From 880a9fcbde2c52697824ab2fbb3f8dd10c1894d8 Mon Sep 17 00:00:00 2001 From: Ruben Nijveld Date: Tue, 1 Oct 2024 16:06:25 +0200 Subject: [PATCH] Initial commit Co-authored-by: Maximilian Pohl Co-authored-by: Tamme Dittrich Co-authored-by: Folkert de Vries Co-authored-by: Marlon Baeten Signed-off-by: Ruben Nijveld --- .dockerignore | 3 + .env | 1 + .github/codecov.yml | 4 + .github/dependabot.yml | 23 + .github/workflows/checks.yml | 200 ++ .gitignore | 3 + ...edd9c637e9d4a4050a006cf6833ed880da967.json | 125 + ...ccd235c01fd53f52db753c7d3f145c4dd37f4.json | 57 + ...4de8f46173d901d58f612b1c6d0367cf3a153.json | 77 + ...2baff4728ff60f5857f48b20fbfccd9208ec4.json | 63 + ...fc0778132ae4f00bb4ee8a45e3c871cddafc3.json | 59 + ...71e5f0254a8af06b60897d278144a3400c49b.json | 15 + ...0fd506264502e76632dfd7c156708e4f2ded6.json | 113 + ...15960712c575ba20d6ede6841714386d9064e.json | 54 + ...3192e6f338d89cf9be41c51a339eb42b3a474.json | 14 + ...45d30c18f4cd62a565cc44e7511cc34008474.json | 55 + ...8c06dfe060616bcafa5ddc38468ed964e8abd.json | 14 + ...b1186846f42d49bc190f07ca94b6a5e558602.json | 16 + ...24d47b3a4d324e74cac449b48a3f9f612afef.json | 15 + ...cc9f690b0daf08ba3540faa18809276cd6236.json | 92 + ...3ad42176388569a4f942bcd0b305fdc6dd332.json | 89 + ...4a4098c1fece8721f35e03b544f1e5ef05afa.json | 73 + ...1c5d0765cc96cafeefa6ff5fe5bd1967a2d23.json | 58 + ...61a23cd37bae62eef2ad9cb70515fb1bf51d0.json | 80 + ...699aa9346c8cea7ecea9088ec5dd1539bc70c.json | 15 + ...c898b5e0bdc24321aeb7b2f3efb1cccbcb941.json | 62 + ...63eebde9e724c67f3d3333a6abd21437e5bb3.json | 22 + ...7db85a07f892774e076b732f7ecba2545d44f.json | 14 + ...b4d160553283d9c8d6339d64cc41d4b1b8920.json | 14 + ...24b5f2b040c0de3d146ede1fb49123e350dfd.json | 15 + ...4808c5e7957b9f907053013aa8147bffc7a8b.json | 15 + ...df79aef9ca9f003c1c8b81878343fcfc05ce8.json | 14 + ...1608f91dcd6b907ca01504737f85cf93f6c03.json | 59 + ...d578e778335955f144a6701dcb4fb3424454b.json | 58 + ...c75e8e552b90a3261e782d16d3b8805e80b98.json | 28 + ...d06db254138d06f28cf0079aabfa9c2d378f4.json | 22 + ...a1e19bba44b3cc7d2d382807b750f29f60dc5.json | 52 + ...321f97c8961774c043dc5ab643b5e6357eb35.json | 23 + ...daa339a54a686f9d7e256fbe5a0cde2f90628.json | 53 + ...a4d9d48b71faa68f00888e2dc11a5c232c94f.json | 119 + ...c1e19f6f2e8463b838456f872e10003eb1355.json | 126 + ...d9a384e8af03558116c343cc70839d7e94613.json | 114 + ...48c54455fb4fdf08c0783ef3e42071a6b9d76.json | 14 + ...95505b9f10392316312567099acacca9bfc2c.json | 14 + ...ae078dfaab4a5b7cd831a9f813386acb52a37.json | 16 + ...5527cf66f5230a73659f8430c47d0b183b00d.json | 14 + ...d3984fe9f4d144a7547484999a9598592f9e4.json | 86 + ...30077c4abb5dcdfeee19f02d22fa7250579c3.json | 82 + ...42671691cbf4d06c6cfec22e6d71cca0f9432.json | 61 + ...3e6a819d6ac15412b4c8bd52611aaa909335e.json | 82 + ...d5da9ce740018582ebaac3663724339e195b6.json | 22 + ...84a0a927c20556f53417ac9072dea64c2d7d0.json | 22 + ...c0c63884f59e8a850980c5c2bc51c9890a7cb.json | 75 + ...3651cb5dcfb45368207cf11f16bd30bd06428.json | 14 + ...6dd9cda1e9a3cb2004a3f3ad330a24f885c1b.json | 71 + ...5a2b3002ebb0989f4d5284880226579d983b3.json | 90 + ...487dd9502099974a9dce8580a528a578469a0.json | 79 + ...e09c45ecec073fd32461071f3687ccd9c7e91.json | 14 + COPYRIGHT | 6 + Cargo.lock | 3036 +++++++++++++++++ Cargo.toml | 59 + LICENSE-APACHE | 176 + LICENSE-MIT | 25 + README.md | 44 + clippy.toml | 1 + deny.toml | 23 + docker-compose.yml | 28 + event.json | 63 + fixtures/business.sql | 9 + fixtures/events.sql | 107 + fixtures/openadr_testsuite_user.sql | 28 + fixtures/programs.sql | 88 + fixtures/reports.sql | 27 + fixtures/resources.sql | 42 + fixtures/users.sql | 20 + fixtures/vens-programs.sql | 11 + fixtures/vens.sql | 34 + generated/mod.rs | 7 + generated/models/mod.rs | 11 + generated/models/notification.rs | 67 + generated/models/notification_object.rs | 30 + generated/models/object_types.rs | 51 + generated/models/point.rs | 23 + generated/models/subscription.rs | 82 + .../subscription_object_operations_inner.rs | 66 + migrations/20240826084440_initial_scheme.sql | 158 + openadr-client/Cargo.toml | 35 + openadr-client/migrations | 1 + openadr-client/src/bin/cli.rs | 60 + openadr-client/src/bin/everest.rs | 310 ++ openadr-client/src/error.rs | 65 + openadr-client/src/event.rs | 165 + openadr-client/src/lib.rs | 617 ++++ openadr-client/src/program.rs | 155 + openadr-client/src/report.rs | 57 + openadr-client/src/target.rs | 93 + openadr-client/src/timeline.rs | 361 ++ openadr-client/tests/basic-read.rs | 19 + openadr-client/tests/common/mod.rs | 60 + openadr-client/tests/event.rs | 303 ++ openadr-client/tests/fixtures | 1 + openadr-client/tests/program.rs | 234 ++ openadr-vtn/Cargo.toml | 50 + openadr-vtn/migrations | 1 + openadr-vtn/src/api/auth.rs | 139 + openadr-vtn/src/api/event.rs | 749 ++++ openadr-vtn/src/api/fixtures | 1 + openadr-vtn/src/api/mod.rs | 180 + openadr-vtn/src/api/program.rs | 678 ++++ openadr-vtn/src/api/report.rs | 104 + openadr-vtn/src/api/resource.rs | 127 + openadr-vtn/src/api/user.rs | 505 +++ openadr-vtn/src/api/ven.rs | 193 ++ openadr-vtn/src/data_source/mod.rs | 257 ++ openadr-vtn/src/data_source/postgres/event.rs | 865 +++++ openadr-vtn/src/data_source/postgres/fixtures | 1 + openadr-vtn/src/data_source/postgres/mod.rs | 151 + .../src/data_source/postgres/program.rs | 888 +++++ .../src/data_source/postgres/report.rs | 303 ++ .../src/data_source/postgres/resource.rs | 355 ++ openadr-vtn/src/data_source/postgres/user.rs | 456 +++ openadr-vtn/src/data_source/postgres/ven.rs | 661 ++++ openadr-vtn/src/error.rs | 349 ++ openadr-vtn/src/jwt.rs | 301 ++ openadr-vtn/src/lib.rs | 5 + openadr-vtn/src/main.rs | 60 + openadr-vtn/src/state.rs | 132 + openadr-wire/Cargo.toml | 24 + openadr-wire/src/event.rs | 439 +++ openadr-wire/src/interval.rs | 55 + openadr-wire/src/lib.rs | 498 +++ openadr-wire/src/oauth.rs | 40 + openadr-wire/src/problem.rs | 64 + openadr-wire/src/program.rs | 251 ++ openadr-wire/src/report.rs | 552 +++ openadr-wire/src/resource.rs | 78 + openadr-wire/src/target.rs | 86 + openadr-wire/src/values_map.rs | 59 + openadr-wire/src/ven.rs | 78 + tests/dyn-price.oadr.yaml | 217 ++ tests/load-sched.oadr.yaml | 18 + tests/schema.yaml | 734 ++++ tests/state-of-charge.oadr.yaml | 42 + vtn.Dockerfile | 15 + 144 files changed, 20503 insertions(+) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .github/codecov.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/checks.yml create mode 100644 .gitignore create mode 100644 .sqlx/query-017d677fa8c669e19f35eb7aafeedd9c637e9d4a4050a006cf6833ed880da967.json create mode 100644 .sqlx/query-01a1621412727d4e11947b6b1cfccd235c01fd53f52db753c7d3f145c4dd37f4.json create mode 100644 .sqlx/query-0dc32444332bd9de7ac55c82cfb4de8f46173d901d58f612b1c6d0367cf3a153.json create mode 100644 .sqlx/query-13c3c1b488e0e58fef68df99fed2baff4728ff60f5857f48b20fbfccd9208ec4.json create mode 100644 .sqlx/query-1ac68dc9329acb4c0f0c60cf27cfc0778132ae4f00bb4ee8a45e3c871cddafc3.json create mode 100644 .sqlx/query-1ca605a616bba3f45ff50f7409571e5f0254a8af06b60897d278144a3400c49b.json create mode 100644 .sqlx/query-1e4dd74f603ad34fe7a3dd7bbc90fd506264502e76632dfd7c156708e4f2ded6.json create mode 100644 .sqlx/query-348aab83a718675aba2850418fe15960712c575ba20d6ede6841714386d9064e.json create mode 100644 .sqlx/query-3c89d5a7b353321d84c8e2177f83192e6f338d89cf9be41c51a339eb42b3a474.json create mode 100644 .sqlx/query-4b23b4975ecdedbadaca1d07d0645d30c18f4cd62a565cc44e7511cc34008474.json create mode 100644 .sqlx/query-4b2ef0b91c7a5653deb318e196f8c06dfe060616bcafa5ddc38468ed964e8abd.json create mode 100644 .sqlx/query-4ccc3142896718d0032b9da549cb1186846f42d49bc190f07ca94b6a5e558602.json create mode 100644 .sqlx/query-528ef377a6005365ec7c8e8a8c324d47b3a4d324e74cac449b48a3f9f612afef.json create mode 100644 .sqlx/query-619995c7ecaeac946891546b832cc9f690b0daf08ba3540faa18809276cd6236.json create mode 100644 .sqlx/query-61a3a672b60c14241fe120eaadd3ad42176388569a4f942bcd0b305fdc6dd332.json create mode 100644 .sqlx/query-6a334f6d28df0d4b8677e35c62d4a4098c1fece8721f35e03b544f1e5ef05afa.json create mode 100644 .sqlx/query-6b352614cd8312ec08e45499f8c1c5d0765cc96cafeefa6ff5fe5bd1967a2d23.json create mode 100644 .sqlx/query-7844ffa81af9be444f088c66ba961a23cd37bae62eef2ad9cb70515fb1bf51d0.json create mode 100644 .sqlx/query-7c9a8981055380d8f02f608d435699aa9346c8cea7ecea9088ec5dd1539bc70c.json create mode 100644 .sqlx/query-7f2002df0f1a690cc4e5ea361e4c898b5e0bdc24321aeb7b2f3efb1cccbcb941.json create mode 100644 .sqlx/query-810c2ed4b2eac78871162f95b5763eebde9e724c67f3d3333a6abd21437e5bb3.json create mode 100644 .sqlx/query-82745fe65e7e130ae370679a8c97db85a07f892774e076b732f7ecba2545d44f.json create mode 100644 .sqlx/query-83c3eb4af09664e0fd86accedf3b4d160553283d9c8d6339d64cc41d4b1b8920.json create mode 100644 .sqlx/query-8776b0b3132a27f3b2d7bcbb60124b5f2b040c0de3d146ede1fb49123e350dfd.json create mode 100644 .sqlx/query-8d40ce7830507cde558f39040094808c5e7957b9f907053013aa8147bffc7a8b.json create mode 100644 .sqlx/query-8eb97945a120e5d67d684307c8ddf79aef9ca9f003c1c8b81878343fcfc05ce8.json create mode 100644 .sqlx/query-9202b711abd9f5e60ea86fe1c5d1608f91dcd6b907ca01504737f85cf93f6c03.json create mode 100644 .sqlx/query-92550886669e9c23dbc97717ba1d578e778335955f144a6701dcb4fb3424454b.json create mode 100644 .sqlx/query-93be606a8174a16168120900a94c75e8e552b90a3261e782d16d3b8805e80b98.json create mode 100644 .sqlx/query-9b30e33e870ad8d002f6dbb62d0d06db254138d06f28cf0079aabfa9c2d378f4.json create mode 100644 .sqlx/query-a240be1eb2af2386d6ea101fa79a1e19bba44b3cc7d2d382807b750f29f60dc5.json create mode 100644 .sqlx/query-aed7cc730ddde8420590b38c674321f97c8961774c043dc5ab643b5e6357eb35.json create mode 100644 .sqlx/query-b2c02b607abacdae18ceeaafeb9daa339a54a686f9d7e256fbe5a0cde2f90628.json create mode 100644 .sqlx/query-b31fb919cc483657a30202f5fa5a4d9d48b71faa68f00888e2dc11a5c232c94f.json create mode 100644 .sqlx/query-b4a20eb4fe41ce59b26f20ee4eac1e19f6f2e8463b838456f872e10003eb1355.json create mode 100644 .sqlx/query-bb8a8aa90eddaea701908f35c7ad9a384e8af03558116c343cc70839d7e94613.json create mode 100644 .sqlx/query-cfbc449106654ab6c245538238648c54455fb4fdf08c0783ef3e42071a6b9d76.json create mode 100644 .sqlx/query-d1245889f5dab872e8d1f9f178895505b9f10392316312567099acacca9bfc2c.json create mode 100644 .sqlx/query-d433cf76e8ec697933389b7d4dbae078dfaab4a5b7cd831a9f813386acb52a37.json create mode 100644 .sqlx/query-d52b6338b7923a007751ed891915527cf66f5230a73659f8430c47d0b183b00d.json create mode 100644 .sqlx/query-d7f75c56b51c161c14e287f60bbd3984fe9f4d144a7547484999a9598592f9e4.json create mode 100644 .sqlx/query-dc7b332e581b7806416ab745b4a30077c4abb5dcdfeee19f02d22fa7250579c3.json create mode 100644 .sqlx/query-e0bcb3d6f5cb5820a4c62cff51742671691cbf4d06c6cfec22e6d71cca0f9432.json create mode 100644 .sqlx/query-e6a59c961d3c5efc2e12760f19b3e6a819d6ac15412b4c8bd52611aaa909335e.json create mode 100644 .sqlx/query-ea3eb886a28adef5ce33230343ad5da9ce740018582ebaac3663724339e195b6.json create mode 100644 .sqlx/query-ec971dec31946e39d4d260ee4ef84a0a927c20556f53417ac9072dea64c2d7d0.json create mode 100644 .sqlx/query-f4a84cdd22d4cde0609b297c2dcc0c63884f59e8a850980c5c2bc51c9890a7cb.json create mode 100644 .sqlx/query-f718be26da8cbaeae4c25baadca3651cb5dcfb45368207cf11f16bd30bd06428.json create mode 100644 .sqlx/query-f947a48b3f9c03686fd0e7abd996dd9cda1e9a3cb2004a3f3ad330a24f885c1b.json create mode 100644 .sqlx/query-faf3290725899dc80b16c2233995a2b3002ebb0989f4d5284880226579d983b3.json create mode 100644 .sqlx/query-fdb427ae3d6ecaa2b3549dabe7c487dd9502099974a9dce8580a528a578469a0.json create mode 100644 .sqlx/query-feaeaa22a9b48b647e30cad8582e09c45ecec073fd32461071f3687ccd9c7e91.json create mode 100644 COPYRIGHT create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 clippy.toml create mode 100644 deny.toml create mode 100644 docker-compose.yml create mode 100644 event.json create mode 100644 fixtures/business.sql create mode 100644 fixtures/events.sql create mode 100644 fixtures/openadr_testsuite_user.sql create mode 100644 fixtures/programs.sql create mode 100644 fixtures/reports.sql create mode 100644 fixtures/resources.sql create mode 100644 fixtures/users.sql create mode 100644 fixtures/vens-programs.sql create mode 100644 fixtures/vens.sql create mode 100644 generated/mod.rs create mode 100644 generated/models/mod.rs create mode 100644 generated/models/notification.rs create mode 100644 generated/models/notification_object.rs create mode 100644 generated/models/object_types.rs create mode 100644 generated/models/point.rs create mode 100644 generated/models/subscription.rs create mode 100644 generated/models/subscription_object_operations_inner.rs create mode 100644 migrations/20240826084440_initial_scheme.sql create mode 100644 openadr-client/Cargo.toml create mode 120000 openadr-client/migrations create mode 100644 openadr-client/src/bin/cli.rs create mode 100644 openadr-client/src/bin/everest.rs create mode 100644 openadr-client/src/error.rs create mode 100644 openadr-client/src/event.rs create mode 100644 openadr-client/src/lib.rs create mode 100644 openadr-client/src/program.rs create mode 100644 openadr-client/src/report.rs create mode 100644 openadr-client/src/target.rs create mode 100644 openadr-client/src/timeline.rs create mode 100644 openadr-client/tests/basic-read.rs create mode 100644 openadr-client/tests/common/mod.rs create mode 100644 openadr-client/tests/event.rs create mode 120000 openadr-client/tests/fixtures create mode 100644 openadr-client/tests/program.rs create mode 100644 openadr-vtn/Cargo.toml create mode 120000 openadr-vtn/migrations create mode 100644 openadr-vtn/src/api/auth.rs create mode 100644 openadr-vtn/src/api/event.rs create mode 120000 openadr-vtn/src/api/fixtures create mode 100644 openadr-vtn/src/api/mod.rs create mode 100644 openadr-vtn/src/api/program.rs create mode 100644 openadr-vtn/src/api/report.rs create mode 100644 openadr-vtn/src/api/resource.rs create mode 100644 openadr-vtn/src/api/user.rs create mode 100644 openadr-vtn/src/api/ven.rs create mode 100644 openadr-vtn/src/data_source/mod.rs create mode 100644 openadr-vtn/src/data_source/postgres/event.rs create mode 120000 openadr-vtn/src/data_source/postgres/fixtures create mode 100644 openadr-vtn/src/data_source/postgres/mod.rs create mode 100644 openadr-vtn/src/data_source/postgres/program.rs create mode 100644 openadr-vtn/src/data_source/postgres/report.rs create mode 100644 openadr-vtn/src/data_source/postgres/resource.rs create mode 100644 openadr-vtn/src/data_source/postgres/user.rs create mode 100644 openadr-vtn/src/data_source/postgres/ven.rs create mode 100644 openadr-vtn/src/error.rs create mode 100644 openadr-vtn/src/jwt.rs create mode 100644 openadr-vtn/src/lib.rs create mode 100644 openadr-vtn/src/main.rs create mode 100644 openadr-vtn/src/state.rs create mode 100644 openadr-wire/Cargo.toml create mode 100644 openadr-wire/src/event.rs create mode 100644 openadr-wire/src/interval.rs create mode 100644 openadr-wire/src/lib.rs create mode 100644 openadr-wire/src/oauth.rs create mode 100644 openadr-wire/src/problem.rs create mode 100644 openadr-wire/src/program.rs create mode 100644 openadr-wire/src/report.rs create mode 100644 openadr-wire/src/resource.rs create mode 100644 openadr-wire/src/target.rs create mode 100644 openadr-wire/src/values_map.rs create mode 100644 openadr-wire/src/ven.rs create mode 100644 tests/dyn-price.oadr.yaml create mode 100644 tests/load-sched.oadr.yaml create mode 100644 tests/schema.yaml create mode 100644 tests/state-of-charge.oadr.yaml create mode 100644 vtn.Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..21ea2af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +target/ +vtn.Dockerfile +docker-compose.yml \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..3f97796 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DATABASE_URL=postgres://openadr@localhost/openadr \ No newline at end of file diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000..7d56722 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,4 @@ +coverage: + status: + patch: off + project: off diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..398042e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + ci-dependencies: + patterns: + - "*" + + - package-ecosystem: cargo + directory: "/" + schedule: + interval: daily + versioning-strategy: lockfile-only + open-pull-requests-limit: 10 + groups: + cargo-dependencies: + patterns: + - "*" \ No newline at end of file diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..3d4d1c2 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,200 @@ +name: Checks + +permissions: + contents: read + +on: + push: + branches: + - main + pull_request: + branches-ignore: + - 'release/**' + merge_group: + branches: + - main + +jobs: + build: + name: Build and test + runs-on: "${{ matrix.os }}" + strategy: + matrix: + include: + - rust: "stable" + target: "x86_64-unknown-linux-gnu" + os: ubuntu-latest + features: "--all-features" + - rust: "msrv" + target: "x86_64-unknown-linux-gnu" + os: ubuntu-latest + features: "--all-features" + - rust: "stable" + target: "x86_64-unknown-linux-musl" + os: ubuntu-latest + features: "--all-features" + - rust: "stable" + target: "aarch64-unknown-linux-gnu" + os: ubuntu-latest + features: "--all-features" + steps: + - name: Checkout sources + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + with: + persist-credentials: false + + - name: Set target rust version + run: echo "TARGET_RUST_VERSION=$(if [ "${{matrix.rust}}" = "msrv" ]; then grep rust-version Cargo.toml | grep MSRV | cut -d'"' -f2; else echo "${{matrix.rust}}"; fi)" >> $GITHUB_ENV + + - name: Install toolchain + uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a + with: + toolchain: "${TARGET_RUST_VERSION}" + targets: "${{ matrix.target }}" + + - name: Install cross-compilation tools + uses: taiki-e/setup-cross-toolchain-action@92417c3484017b78b44195de2e0026e080f1e001 # v1.24.0 + with: + target: ${{ matrix.target }} + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@9bef7e9c3d7c7aa986ef19933b0722880ae377e0 # v2.44.13 + with: + tool: cargo-llvm-cov + + - name: Rust cache + uses: Swatinem/rust-cache@23bce251a8cd2ffc3c1075eaa2367cf899916d84 # v2.7.3 + with: + shared-key: "${{matrix.rust}}-${{matrix.target}}" + + - name: Setup Postgres DB + run: | + docker compose up db --wait && \ + cargo install sqlx-cli --no-default-features --features rustls,postgres && \ + cargo sqlx migrate run --source openadr-vtn/migrations + + - name: cargo build + run: cargo build ${{ matrix.features }} + + - name: cargo test + run: cargo llvm-cov --target ${{matrix.target}} ${{ matrix.features }} --lcov --output-path lcov.info --features live-db-test + env: + RUST_BACKTRACE: 1 + + - name: Bring Postgres DB down + run: docker compose down + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 + with: + files: lcov.info + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + unused: + name: Check unused dependencies + runs-on: ubuntu-latest + env: + SQLX_OFFLINE: true + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + with: + persist-credentials: false + + - name: Install nightly toolchain + uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a + with: + toolchain: nightly + + - name: Install udeps + uses: taiki-e/install-action@9bef7e9c3d7c7aa986ef19933b0722880ae377e0 # v2.44.13 + with: + tool: cargo-udeps + + - name: cargo udeps + run: cargo udeps --workspace --all-targets + + format: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + with: + persist-credentials: false + + - name: Install rust toolchain + uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a + with: + toolchain: nightly + components: rustfmt + + - name: Check formatting + run: cargo +nightly fmt --all --check -- --config imports_granularity="Crate" + + clippy: + name: Clippy + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + use_zig: false + zig_args: "" + - target: aarch64-unknown-linux-gnu + use_zig: true + zig_args: "-target aarch64-linux-gnu -g" + - target: armv7-unknown-linux-gnueabihf + use_zig: true + zig_args: "-target arm-linux-gnueabihf -mcpu=generic+v7a+vfp3-d32+thumb2-neon -g" + - target: x86_64-unknown-linux-musl + use_zig: true + zig_args: "-target x86_64-linux-musl" + runs-on: ubuntu-latest + env: + SQLX_OFFLINE: true + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + with: + persist-credentials: false + + - name: Install rust toolchain + uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a + with: + toolchain: stable + components: clippy + targets: ${{matrix.target}} + + # Use zig as our C compiler for convenient cross-compilation. We run into rustls having a dependency on `ring`. + # This crate uses C and assembly code, and because of its build scripts, `cargo clippy` needs to be able to compile + # that code for our target. + - uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2.2.1 + with: + version: 0.9.0 + if: ${{matrix.use_zig}} + + - name: Install cargo-zigbuild + uses: taiki-e/install-action@9bef7e9c3d7c7aa986ef19933b0722880ae377e0 # v2.44.13 + with: + tool: cargo-zigbuild + if: ${{matrix.use_zig}} + + - name: Set TARGET_CC for zig + run: echo "TARGET_CC=/home/runner/.cargo/bin/cargo-zigbuild zig cc -- ${{matrix.zig_args}}" >> $GITHUB_ENV + if: ${{matrix.use_zig}} + + - name: Rust cache + uses: Swatinem/rust-cache@23bce251a8cd2ffc3c1075eaa2367cf899916d84 # v2.7.3 + with: + shared-key: "stable-${{matrix.target}}" + + - name: Run clippy + run: cargo clippy --target ${{matrix.target}} --workspace --all-targets --all-features -- -D warnings + + audit-dependencies: + name: Audit dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + + - uses: EmbarkStudios/cargo-deny-action@8371184bd11e21dcf8ac82ebf8c9c9f74ebf7268 + with: + arguments: --workspace --all-features diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35baa60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/ +**/target +oadr*.yaml \ No newline at end of file diff --git a/.sqlx/query-017d677fa8c669e19f35eb7aafeedd9c637e9d4a4050a006cf6833ed880da967.json b/.sqlx/query-017d677fa8c669e19f35eb7aafeedd9c637e9d4a4050a006cf6833ed880da967.json new file mode 100644 index 0000000..5bae484 --- /dev/null +++ b/.sqlx/query-017d677fa8c669e19f35eb7aafeedd9c637e9d4a4050a006cf6833ed880da967.json @@ -0,0 +1,125 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO program (id,\n created_date_time,\n modification_date_time,\n program_name,\n program_long_name,\n retailer_name,\n retailer_long_name,\n program_type,\n country,\n principal_subdivision,\n interval_period,\n program_descriptions,\n binding_events,\n local_price,\n payload_descriptors,\n targets,\n business_id)\n VALUES (gen_random_uuid(), now(), now(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n RETURNING id,\n created_date_time,\n modification_date_time,\n program_name,\n program_long_name,\n retailer_name,\n retailer_long_name,\n program_type,\n country,\n principal_subdivision,\n interval_period,\n program_descriptions,\n binding_events,\n local_price,\n payload_descriptors,\n targets\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "program_long_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "retailer_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "retailer_long_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "program_type", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "country", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "principal_subdivision", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "interval_period", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "program_descriptions", + "type_info": "Jsonb" + }, + { + "ordinal": 12, + "name": "binding_events", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "local_price", + "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Jsonb", + "Bool", + "Bool", + "Jsonb", + "Jsonb", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "017d677fa8c669e19f35eb7aafeedd9c637e9d4a4050a006cf6833ed880da967" +} diff --git a/.sqlx/query-01a1621412727d4e11947b6b1cfccd235c01fd53f52db753c7d3f145c4dd37f4.json b/.sqlx/query-01a1621412727d4e11947b6b1cfccd235c01fd53f52db753c7d3f145c4dd37f4.json new file mode 100644 index 0000000..99ce10d --- /dev/null +++ b/.sqlx/query-01a1621412727d4e11947b6b1cfccd235c01fd53f52db753c7d3f145c4dd37f4.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT\n v.id AS \"id!\", \n v.created_date_time AS \"created_date_time!\", \n v.modification_date_time AS \"modification_date_time!\",\n v.ven_name AS \"ven_name!\",\n v.attributes,\n v.targets\n FROM ven v\n LEFT JOIN resource r ON r.ven_id = v.id\n LEFT JOIN LATERAL (\n SELECT v.id as v_id, \n json_array(jsonb_array_elements(v.targets)) <@ $3::jsonb AS target_test )\n ON v.id = v_id\n WHERE ($1::text[] IS NULL OR v.ven_name = ANY($1))\n AND ($2::text[] IS NULL OR r.resource_name = ANY($2))\n AND ($3::jsonb = '[]'::jsonb OR target_test)\n AND ($4::text[] IS NULL OR v.id = ANY($4))\n ORDER BY v.created_date_time DESC\n OFFSET $5 LIMIT $6\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time!", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time!", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "ven_name!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "TextArray", + "TextArray", + "Jsonb", + "TextArray", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "01a1621412727d4e11947b6b1cfccd235c01fd53f52db753c7d3f145c4dd37f4" +} diff --git a/.sqlx/query-0dc32444332bd9de7ac55c82cfb4de8f46173d901d58f612b1c6d0367cf3a153.json b/.sqlx/query-0dc32444332bd9de7ac55c82cfb4de8f46173d901d58f612b1c6d0367cf3a153.json new file mode 100644 index 0000000..bb481a1 --- /dev/null +++ b/.sqlx/query-0dc32444332bd9de7ac55c82cfb4de8f46173d901d58f612b1c6d0367cf3a153.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT r.*\n FROM report r\n JOIN program p ON p.id = r.program_id\n LEFT JOIN ven_program v ON v.program_id = r.program_id\n WHERE ($1::text IS NULL OR $1 like r.program_id)\n AND ($2::text IS NULL OR $2 like r.event_id)\n AND ($3::text IS NULL OR $3 like r.client_name)\n AND (NOT $4 OR v.ven_id IS NULL OR v.ven_id = ANY($5))\n AND ($6::text[] IS NULL OR p.business_id = ANY($6))\n LIMIT $7 OFFSET $8\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "event_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "client_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "report_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "resources", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Bool", + "TextArray", + "TextArray", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "0dc32444332bd9de7ac55c82cfb4de8f46173d901d58f612b1c6d0367cf3a153" +} diff --git a/.sqlx/query-13c3c1b488e0e58fef68df99fed2baff4728ff60f5857f48b20fbfccd9208ec4.json b/.sqlx/query-13c3c1b488e0e58fef68df99fed2baff4728ff60f5857f48b20fbfccd9208ec4.json new file mode 100644 index 0000000..b54327c --- /dev/null +++ b/.sqlx/query-13c3c1b488e0e58fef68df99fed2baff4728ff60f5857f48b20fbfccd9208ec4.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE resource\n SET modification_date_time = now(),\n resource_name = $3,\n ven_id = $4,\n attributes = $5,\n targets = $6\n WHERE id = $1 AND ven_id = $2\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "resource_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "ven_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "13c3c1b488e0e58fef68df99fed2baff4728ff60f5857f48b20fbfccd9208ec4" +} diff --git a/.sqlx/query-1ac68dc9329acb4c0f0c60cf27cfc0778132ae4f00bb4ee8a45e3c871cddafc3.json b/.sqlx/query-1ac68dc9329acb4c0f0c60cf27cfc0778132ae4f00bb4ee8a45e3c871cddafc3.json new file mode 100644 index 0000000..a7151b8 --- /dev/null +++ b/.sqlx/query-1ac68dc9329acb4c0f0c60cf27cfc0778132ae4f00bb4ee8a45e3c871cddafc3.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM resource r\n WHERE r.id = $1 AND r.ven_id = $2\n RETURNING r.*\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "resource_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "ven_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "1ac68dc9329acb4c0f0c60cf27cfc0778132ae4f00bb4ee8a45e3c871cddafc3" +} diff --git a/.sqlx/query-1ca605a616bba3f45ff50f7409571e5f0254a8af06b60897d278144a3400c49b.json b/.sqlx/query-1ca605a616bba3f45ff50f7409571e5f0254a8af06b60897d278144a3400c49b.json new file mode 100644 index 0000000..5a5a7e5 --- /dev/null +++ b/.sqlx/query-1ca605a616bba3f45ff50f7409571e5f0254a8af06b60897d278144a3400c49b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO ven_program (program_id, ven_id)\n (SELECT $1, id FROM ven WHERE ven_name = ANY($2))\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "1ca605a616bba3f45ff50f7409571e5f0254a8af06b60897d278144a3400c49b" +} diff --git a/.sqlx/query-1e4dd74f603ad34fe7a3dd7bbc90fd506264502e76632dfd7c156708e4f2ded6.json b/.sqlx/query-1e4dd74f603ad34fe7a3dd7bbc90fd506264502e76632dfd7c156708e4f2ded6.json new file mode 100644 index 0000000..6791783 --- /dev/null +++ b/.sqlx/query-1e4dd74f603ad34fe7a3dd7bbc90fd506264502e76632dfd7c156708e4f2ded6.json @@ -0,0 +1,113 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM program p\n WHERE id = $1\n AND ($2::text IS NULL OR business_id = $2)\n RETURNING p.id,\n p.created_date_time,\n p.modification_date_time,\n p.program_name,\n p.program_long_name,\n p.retailer_name,\n p.retailer_long_name,\n p.program_type,\n p.country,\n p.principal_subdivision,\n p.interval_period,\n p.program_descriptions,\n p.binding_events,\n p.local_price,\n p.payload_descriptors,\n p.targets\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "program_long_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "retailer_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "retailer_long_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "program_type", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "country", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "principal_subdivision", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "interval_period", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "program_descriptions", + "type_info": "Jsonb" + }, + { + "ordinal": 12, + "name": "binding_events", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "local_price", + "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "1e4dd74f603ad34fe7a3dd7bbc90fd506264502e76632dfd7c156708e4f2ded6" +} diff --git a/.sqlx/query-348aab83a718675aba2850418fe15960712c575ba20d6ede6841714386d9064e.json b/.sqlx/query-348aab83a718675aba2850418fe15960712c575ba20d6ede6841714386d9064e.json new file mode 100644 index 0000000..3c753ed --- /dev/null +++ b/.sqlx/query-348aab83a718675aba2850418fe15960712c575ba20d6ede6841714386d9064e.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO ven (\n id,\n created_date_time,\n modification_date_time,\n ven_name,\n attributes,\n targets\n )\n VALUES (gen_random_uuid(), now(), now(), $1, $2, $3)\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "ven_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "348aab83a718675aba2850418fe15960712c575ba20d6ede6841714386d9064e" +} diff --git a/.sqlx/query-3c89d5a7b353321d84c8e2177f83192e6f338d89cf9be41c51a339eb42b3a474.json b/.sqlx/query-3c89d5a7b353321d84c8e2177f83192e6f338d89cf9be41c51a339eb42b3a474.json new file mode 100644 index 0000000..b9dc7d2 --- /dev/null +++ b/.sqlx/query-3c89d5a7b353321d84c8e2177f83192e6f338d89cf9be41c51a339eb42b3a474.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_manager WHERE user_id = $1 \n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "3c89d5a7b353321d84c8e2177f83192e6f338d89cf9be41c51a339eb42b3a474" +} diff --git a/.sqlx/query-4b23b4975ecdedbadaca1d07d0645d30c18f4cd62a565cc44e7511cc34008474.json b/.sqlx/query-4b23b4975ecdedbadaca1d07d0645d30c18f4cd62a565cc44e7511cc34008474.json new file mode 100644 index 0000000..1e88534 --- /dev/null +++ b/.sqlx/query-4b23b4975ecdedbadaca1d07d0645d30c18f4cd62a565cc44e7511cc34008474.json @@ -0,0 +1,55 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE ven\n SET modification_date_time = now(),\n ven_name = $2,\n attributes = $3,\n targets = $4\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "ven_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "4b23b4975ecdedbadaca1d07d0645d30c18f4cd62a565cc44e7511cc34008474" +} diff --git a/.sqlx/query-4b2ef0b91c7a5653deb318e196f8c06dfe060616bcafa5ddc38468ed964e8abd.json b/.sqlx/query-4b2ef0b91c7a5653deb318e196f8c06dfe060616bcafa5ddc38468ed964e8abd.json new file mode 100644 index 0000000..5cb5585 --- /dev/null +++ b/.sqlx/query-4b2ef0b91c7a5653deb318e196f8c06dfe060616bcafa5ddc38468ed964e8abd.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_ven WHERE user_id = $1 \n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "4b2ef0b91c7a5653deb318e196f8c06dfe060616bcafa5ddc38468ed964e8abd" +} diff --git a/.sqlx/query-4ccc3142896718d0032b9da549cb1186846f42d49bc190f07ca94b6a5e558602.json b/.sqlx/query-4ccc3142896718d0032b9da549cb1186846f42d49bc190f07ca94b6a5e558602.json new file mode 100644 index 0000000..ec0b995 --- /dev/null +++ b/.sqlx/query-4ccc3142896718d0032b9da549cb1186846f42d49bc190f07ca94b6a5e558602.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \"user\" SET\n reference = $2,\n description = $3,\n modified = now()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4ccc3142896718d0032b9da549cb1186846f42d49bc190f07ca94b6a5e558602" +} diff --git a/.sqlx/query-528ef377a6005365ec7c8e8a8c324d47b3a4d324e74cac449b48a3f9f612afef.json b/.sqlx/query-528ef377a6005365ec7c8e8a8c324d47b3a4d324e74cac449b48a3f9f612afef.json new file mode 100644 index 0000000..b2d28d6 --- /dev/null +++ b/.sqlx/query-528ef377a6005365ec7c8e8a8c324d47b3a4d324e74cac449b48a3f9f612afef.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_ven (user_id, ven_id) VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "528ef377a6005365ec7c8e8a8c324d47b3a4d324e74cac449b48a3f9f612afef" +} diff --git a/.sqlx/query-619995c7ecaeac946891546b832cc9f690b0daf08ba3540faa18809276cd6236.json b/.sqlx/query-619995c7ecaeac946891546b832cc9f690b0daf08ba3540faa18809276cd6236.json new file mode 100644 index 0000000..5abf4fa --- /dev/null +++ b/.sqlx/query-619995c7ecaeac946891546b832cc9f690b0daf08ba3540faa18809276cd6236.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT e.*\n FROM event e\n JOIN program p on p.id = e.program_id\n LEFT JOIN ven_program vp ON p.id = vp.program_id\n LEFT JOIN ven v ON v.id = vp.ven_id\n LEFT JOIN LATERAL ( \n SELECT e.id as e_id, \n json_array(jsonb_array_elements(e.targets)) <@ $5::jsonb AS target_test )\n ON e.id = e_id\n WHERE ($1::text IS NULL OR e.program_id like $1)\n AND ($2::text[] IS NULL OR e.event_name = ANY($2))\n AND ($3::text[] IS NULL OR p.program_name = ANY($3))\n AND ($4::text[] IS NULL OR v.ven_name = ANY($4))\n AND ($5::jsonb = '[]'::jsonb OR target_test)\n AND (\n ($6 AND (vp.ven_id IS NULL OR vp.ven_id = ANY($7))) \n OR \n ($8 AND ($9::text[] IS NULL OR p.business_id = ANY ($9)))\n )\n GROUP BY e.id\n OFFSET $10 LIMIT $11\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "event_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "report_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "interval_period", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "intervals", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray", + "TextArray", + "TextArray", + "Jsonb", + "Bool", + "TextArray", + "Bool", + "TextArray", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "619995c7ecaeac946891546b832cc9f690b0daf08ba3540faa18809276cd6236" +} diff --git a/.sqlx/query-61a3a672b60c14241fe120eaadd3ad42176388569a4f942bcd0b305fdc6dd332.json b/.sqlx/query-61a3a672b60c14241fe120eaadd3ad42176388569a4f942bcd0b305fdc6dd332.json new file mode 100644 index 0000000..b22afd5 --- /dev/null +++ b/.sqlx/query-61a3a672b60c14241fe120eaadd3ad42176388569a4f942bcd0b305fdc6dd332.json @@ -0,0 +1,89 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO event (id, created_date_time, modification_date_time, program_id, event_name, priority, targets, report_descriptors, payload_descriptors, interval_period, intervals)\n VALUES (gen_random_uuid(), now(), now(), $1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "event_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "report_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "interval_period", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "intervals", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Jsonb", + "Jsonb", + "Jsonb", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "61a3a672b60c14241fe120eaadd3ad42176388569a4f942bcd0b305fdc6dd332" +} diff --git a/.sqlx/query-6a334f6d28df0d4b8677e35c62d4a4098c1fece8721f35e03b544f1e5ef05afa.json b/.sqlx/query-6a334f6d28df0d4b8677e35c62d4a4098c1fece8721f35e03b544f1e5ef05afa.json new file mode 100644 index 0000000..c2acebb --- /dev/null +++ b/.sqlx/query-6a334f6d28df0d4b8677e35c62d4a4098c1fece8721f35e03b544f1e5ef05afa.json @@ -0,0 +1,73 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT r.* \n FROM report r \n JOIN program p ON p.id = r.program_id \n LEFT JOIN ven_program v ON v.program_id = r.program_id\n WHERE r.id = $1 \n AND (NOT $2 OR v.ven_id IS NULL OR v.ven_id = ANY($3)) \n AND ($4::text[] IS NULL OR p.business_id = ANY($4))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "event_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "client_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "report_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "resources", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Bool", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "6a334f6d28df0d4b8677e35c62d4a4098c1fece8721f35e03b544f1e5ef05afa" +} diff --git a/.sqlx/query-6b352614cd8312ec08e45499f8c1c5d0765cc96cafeefa6ff5fe5bd1967a2d23.json b/.sqlx/query-6b352614cd8312ec08e45499f8c1c5d0765cc96cafeefa6ff5fe5bd1967a2d23.json new file mode 100644 index 0000000..a7b353e --- /dev/null +++ b/.sqlx/query-6b352614cd8312ec08e45499f8c1c5d0765cc96cafeefa6ff5fe5bd1967a2d23.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n created_date_time,\n modification_date_time,\n resource_name,\n ven_id,\n attributes,\n targets\n FROM resource\n WHERE ven_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "resource_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "ven_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "6b352614cd8312ec08e45499f8c1c5d0765cc96cafeefa6ff5fe5bd1967a2d23" +} diff --git a/.sqlx/query-7844ffa81af9be444f088c66ba961a23cd37bae62eef2ad9cb70515fb1bf51d0.json b/.sqlx/query-7844ffa81af9be444f088c66ba961a23cd37bae62eef2ad9cb70515fb1bf51d0.json new file mode 100644 index 0000000..b2ef418 --- /dev/null +++ b/.sqlx/query-7844ffa81af9be444f088c66ba961a23cd37bae62eef2ad9cb70515fb1bf51d0.json @@ -0,0 +1,80 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.*,\n array_agg(DISTINCT c.client_id) FILTER ( WHERE c.client_id IS NOT NULL ) AS client_ids,\n array_agg(DISTINCT b.business_id) FILTER ( WHERE b.business_id IS NOT NULL ) AS business_ids,\n array_agg(DISTINCT ven.ven_id) FILTER ( WHERE ven.ven_id IS NOT NULL ) AS ven_ids,\n ab.user_id IS NOT NULL AS \"is_any_business_user!\",\n um.user_id IS NOT NULL AS \"is_user_manager!\",\n vm.user_id IS NOT NULL AS \"is_ven_manager!\"\n FROM \"user\" u\n LEFT JOIN user_credentials c ON c.user_id = u.id\n LEFT JOIN any_business_user ab ON u.id = ab.user_id\n LEFT JOIN user_business b ON u.id = b.user_id\n LEFT JOIN user_manager um ON u.id = um.user_id\n LEFT JOIN user_ven ven ON u.id = ven.user_id\n LEFT JOIN ven_manager vm ON u.id = vm.user_id\n GROUP BY u.id,\n b.user_id,\n ab.user_id,\n um.user_id,\n ven.user_id,\n vm.user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "reference", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "modified", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "client_ids", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "business_ids", + "type_info": "TextArray" + }, + { + "ordinal": 7, + "name": "ven_ids", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "is_any_business_user!", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "is_user_manager!", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "is_ven_manager!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "7844ffa81af9be444f088c66ba961a23cd37bae62eef2ad9cb70515fb1bf51d0" +} diff --git a/.sqlx/query-7c9a8981055380d8f02f608d435699aa9346c8cea7ecea9088ec5dd1539bc70c.json b/.sqlx/query-7c9a8981055380d8f02f608d435699aa9346c8cea7ecea9088ec5dd1539bc70c.json new file mode 100644 index 0000000..6c3a3f6 --- /dev/null +++ b/.sqlx/query-7c9a8981055380d8f02f608d435699aa9346c8cea7ecea9088ec5dd1539bc70c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_credentials WHERE user_id = $1 AND client_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7c9a8981055380d8f02f608d435699aa9346c8cea7ecea9088ec5dd1539bc70c" +} diff --git a/.sqlx/query-7f2002df0f1a690cc4e5ea361e4c898b5e0bdc24321aeb7b2f3efb1cccbcb941.json b/.sqlx/query-7f2002df0f1a690cc4e5ea361e4c898b5e0bdc24321aeb7b2f3efb1cccbcb941.json new file mode 100644 index 0000000..a993d19 --- /dev/null +++ b/.sqlx/query-7f2002df0f1a690cc4e5ea361e4c898b5e0bdc24321aeb7b2f3efb1cccbcb941.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n r.id AS \"id!\", \n r.created_date_time AS \"created_date_time!\", \n r.modification_date_time AS \"modification_date_time!\",\n r.resource_name AS \"resource_name!\",\n r.ven_id AS \"ven_id!\",\n r.attributes,\n r.targets\n FROM resource r\n LEFT JOIN LATERAL ( \n SELECT r.id as r_id, \n json_array(jsonb_array_elements(r.targets)) <@ $3::jsonb AS target_test )\n ON r.id = r_id\n WHERE r.ven_id = $1\n AND ($2::text[] IS NULL OR r.resource_name = ANY($2))\n AND ($3::jsonb = '[]'::jsonb OR target_test)\n OFFSET $4 LIMIT $5\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time!", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time!", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "resource_name!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "ven_id!", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Jsonb", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "7f2002df0f1a690cc4e5ea361e4c898b5e0bdc24321aeb7b2f3efb1cccbcb941" +} diff --git a/.sqlx/query-810c2ed4b2eac78871162f95b5763eebde9e724c67f3d3333a6abd21437e5bb3.json b/.sqlx/query-810c2ed4b2eac78871162f95b5763eebde9e724c67f3d3333a6abd21437e5bb3.json new file mode 100644 index 0000000..7681e21 --- /dev/null +++ b/.sqlx/query-810c2ed4b2eac78871162f95b5763eebde9e724c67f3d3333a6abd21437e5bb3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT business_id AS id FROM program WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "810c2ed4b2eac78871162f95b5763eebde9e724c67f3d3333a6abd21437e5bb3" +} diff --git a/.sqlx/query-82745fe65e7e130ae370679a8c97db85a07f892774e076b732f7ecba2545d44f.json b/.sqlx/query-82745fe65e7e130ae370679a8c97db85a07f892774e076b732f7ecba2545d44f.json new file mode 100644 index 0000000..add4757 --- /dev/null +++ b/.sqlx/query-82745fe65e7e130ae370679a8c97db85a07f892774e076b732f7ecba2545d44f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM ven_program WHERE program_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "82745fe65e7e130ae370679a8c97db85a07f892774e076b732f7ecba2545d44f" +} diff --git a/.sqlx/query-83c3eb4af09664e0fd86accedf3b4d160553283d9c8d6339d64cc41d4b1b8920.json b/.sqlx/query-83c3eb4af09664e0fd86accedf3b4d160553283d9c8d6339d64cc41d4b1b8920.json new file mode 100644 index 0000000..74d4f34 --- /dev/null +++ b/.sqlx/query-83c3eb4af09664e0fd86accedf3b4d160553283d9c8d6339d64cc41d4b1b8920.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM any_business_user WHERE user_id = $1 \n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "83c3eb4af09664e0fd86accedf3b4d160553283d9c8d6339d64cc41d4b1b8920" +} diff --git a/.sqlx/query-8776b0b3132a27f3b2d7bcbb60124b5f2b040c0de3d146ede1fb49123e350dfd.json b/.sqlx/query-8776b0b3132a27f3b2d7bcbb60124b5f2b040c0de3d146ede1fb49123e350dfd.json new file mode 100644 index 0000000..6eec278 --- /dev/null +++ b/.sqlx/query-8776b0b3132a27f3b2d7bcbb60124b5f2b040c0de3d146ede1fb49123e350dfd.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO ven_program (program_id, ven_id)\n (SELECT $1, id FROM ven WHERE ven_name = ANY ($2))\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "8776b0b3132a27f3b2d7bcbb60124b5f2b040c0de3d146ede1fb49123e350dfd" +} diff --git a/.sqlx/query-8d40ce7830507cde558f39040094808c5e7957b9f907053013aa8147bffc7a8b.json b/.sqlx/query-8d40ce7830507cde558f39040094808c5e7957b9f907053013aa8147bffc7a8b.json new file mode 100644 index 0000000..b556b47 --- /dev/null +++ b/.sqlx/query-8d40ce7830507cde558f39040094808c5e7957b9f907053013aa8147bffc7a8b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_business (user_id, business_id) VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8d40ce7830507cde558f39040094808c5e7957b9f907053013aa8147bffc7a8b" +} diff --git a/.sqlx/query-8eb97945a120e5d67d684307c8ddf79aef9ca9f003c1c8b81878343fcfc05ce8.json b/.sqlx/query-8eb97945a120e5d67d684307c8ddf79aef9ca9f003c1c8b81878343fcfc05ce8.json new file mode 100644 index 0000000..0148d42 --- /dev/null +++ b/.sqlx/query-8eb97945a120e5d67d684307c8ddf79aef9ca9f003c1c8b81878343fcfc05ce8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO any_business_user (user_id) VALUES ($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "8eb97945a120e5d67d684307c8ddf79aef9ca9f003c1c8b81878343fcfc05ce8" +} diff --git a/.sqlx/query-9202b711abd9f5e60ea86fe1c5d1608f91dcd6b907ca01504737f85cf93f6c03.json b/.sqlx/query-9202b711abd9f5e60ea86fe1c5d1608f91dcd6b907ca01504737f85cf93f6c03.json new file mode 100644 index 0000000..62b157f --- /dev/null +++ b/.sqlx/query-9202b711abd9f5e60ea86fe1c5d1608f91dcd6b907ca01504737f85cf93f6c03.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n created_date_time,\n modification_date_time,\n resource_name,\n ven_id,\n attributes,\n targets\n FROM resource\n WHERE id = $1 AND ven_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "resource_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "ven_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "9202b711abd9f5e60ea86fe1c5d1608f91dcd6b907ca01504737f85cf93f6c03" +} diff --git a/.sqlx/query-92550886669e9c23dbc97717ba1d578e778335955f144a6701dcb4fb3424454b.json b/.sqlx/query-92550886669e9c23dbc97717ba1d578e778335955f144a6701dcb4fb3424454b.json new file mode 100644 index 0000000..7d25aeb --- /dev/null +++ b/.sqlx/query-92550886669e9c23dbc97717ba1d578e778335955f144a6701dcb4fb3424454b.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n created_date_time,\n modification_date_time,\n resource_name,\n ven_id,\n attributes,\n targets\n FROM resource\n WHERE ven_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "resource_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "ven_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "92550886669e9c23dbc97717ba1d578e778335955f144a6701dcb4fb3424454b" +} diff --git a/.sqlx/query-93be606a8174a16168120900a94c75e8e552b90a3261e782d16d3b8805e80b98.json b/.sqlx/query-93be606a8174a16168120900a94c75e8e552b90a3261e782d16d3b8805e80b98.json new file mode 100644 index 0000000..cab82dd --- /dev/null +++ b/.sqlx/query-93be606a8174a16168120900a94c75e8e552b90a3261e782d16d3b8805e80b98.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id,\n client_secret\n FROM \"user\"\n JOIN user_credentials ON user_id = id\n WHERE client_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "client_secret", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "93be606a8174a16168120900a94c75e8e552b90a3261e782d16d3b8805e80b98" +} diff --git a/.sqlx/query-9b30e33e870ad8d002f6dbb62d0d06db254138d06f28cf0079aabfa9c2d378f4.json b/.sqlx/query-9b30e33e870ad8d002f6dbb62d0d06db254138d06f28cf0079aabfa9c2d378f4.json new file mode 100644 index 0000000..ea92d02 --- /dev/null +++ b/.sqlx/query-9b30e33e870ad8d002f6dbb62d0d06db254138d06f28cf0079aabfa9c2d378f4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT program_id AS id FROM event WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9b30e33e870ad8d002f6dbb62d0d06db254138d06f28cf0079aabfa9c2d378f4" +} diff --git a/.sqlx/query-a240be1eb2af2386d6ea101fa79a1e19bba44b3cc7d2d382807b750f29f60dc5.json b/.sqlx/query-a240be1eb2af2386d6ea101fa79a1e19bba44b3cc7d2d382807b750f29f60dc5.json new file mode 100644 index 0000000..5c39cbd --- /dev/null +++ b/.sqlx/query-a240be1eb2af2386d6ea101fa79a1e19bba44b3cc7d2d382807b750f29f60dc5.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM ven\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "ven_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "a240be1eb2af2386d6ea101fa79a1e19bba44b3cc7d2d382807b750f29f60dc5" +} diff --git a/.sqlx/query-aed7cc730ddde8420590b38c674321f97c8961774c043dc5ab643b5e6357eb35.json b/.sqlx/query-aed7cc730ddde8420590b38c674321f97c8961774c043dc5ab643b5e6357eb35.json new file mode 100644 index 0000000..7a161b7 --- /dev/null +++ b/.sqlx/query-aed7cc730ddde8420590b38c674321f97c8961774c043dc5ab643b5e6357eb35.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO \"user\" (id, reference, description, created, modified)\n VALUES (gen_random_uuid(), $1, $2, now(), now())\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "aed7cc730ddde8420590b38c674321f97c8961774c043dc5ab643b5e6357eb35" +} diff --git a/.sqlx/query-b2c02b607abacdae18ceeaafeb9daa339a54a686f9d7e256fbe5a0cde2f90628.json b/.sqlx/query-b2c02b607abacdae18ceeaafeb9daa339a54a686f9d7e256fbe5a0cde2f90628.json new file mode 100644 index 0000000..3179fc2 --- /dev/null +++ b/.sqlx/query-b2c02b607abacdae18ceeaafeb9daa339a54a686f9d7e256fbe5a0cde2f90628.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT *\n FROM ven\n WHERE id = $1\n AND ($2::text[] IS NULL OR id = ANY($2))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "ven_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "b2c02b607abacdae18ceeaafeb9daa339a54a686f9d7e256fbe5a0cde2f90628" +} diff --git a/.sqlx/query-b31fb919cc483657a30202f5fa5a4d9d48b71faa68f00888e2dc11a5c232c94f.json b/.sqlx/query-b31fb919cc483657a30202f5fa5a4d9d48b71faa68f00888e2dc11a5c232c94f.json new file mode 100644 index 0000000..756d132 --- /dev/null +++ b/.sqlx/query-b31fb919cc483657a30202f5fa5a4d9d48b71faa68f00888e2dc11a5c232c94f.json @@ -0,0 +1,119 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT p.id AS \"id!\", \n p.created_date_time AS \"created_date_time!\", \n p.modification_date_time AS \"modification_date_time!\",\n p.program_name AS \"program_name!\",\n p.program_long_name,\n p.retailer_name,\n p.retailer_long_name,\n p.program_type,\n p.country,\n p.principal_subdivision,\n p.interval_period,\n p.program_descriptions,\n p.binding_events,\n p.local_price,\n p.payload_descriptors,\n p.targets\n FROM program p\n LEFT JOIN event e ON p.id = e.program_id\n LEFT JOIN ven_program vp ON p.id = vp.program_id\n LEFT JOIN ven v ON v.id = vp.ven_id\n LEFT JOIN LATERAL ( \n SELECT p.id as p_id, \n json_array(jsonb_array_elements(p.targets)) <@ $4::jsonb AS target_test )\n ON p.id = p_id\n WHERE ($1::text[] IS NULL OR e.event_name = ANY($1))\n AND ($2::text[] IS NULL OR p.program_name = ANY($2))\n AND ($3::text[] IS NULL OR v.ven_name = ANY($3))\n AND ($4::jsonb = '[]'::jsonb OR target_test)\n AND (NOT $5 OR v.id IS NULL OR v.id = ANY($6)) -- Filter for VEN ids\n GROUP BY p.id\n OFFSET $7 LIMIT $8\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time!", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time!", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_name!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "program_long_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "retailer_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "retailer_long_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "program_type", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "country", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "principal_subdivision", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "interval_period", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "program_descriptions", + "type_info": "Jsonb" + }, + { + "ordinal": 12, + "name": "binding_events", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "local_price", + "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "TextArray", + "TextArray", + "TextArray", + "Jsonb", + "Bool", + "TextArray", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "b31fb919cc483657a30202f5fa5a4d9d48b71faa68f00888e2dc11a5c232c94f" +} diff --git a/.sqlx/query-b4a20eb4fe41ce59b26f20ee4eac1e19f6f2e8463b838456f872e10003eb1355.json b/.sqlx/query-b4a20eb4fe41ce59b26f20ee4eac1e19f6f2e8463b838456f872e10003eb1355.json new file mode 100644 index 0000000..b4a09f3 --- /dev/null +++ b/.sqlx/query-b4a20eb4fe41ce59b26f20ee4eac1e19f6f2e8463b838456f872e10003eb1355.json @@ -0,0 +1,126 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE program p\n SET modification_date_time = now(),\n program_name = $2,\n program_long_name = $3,\n retailer_name = $4,\n retailer_long_name = $5,\n program_type = $6,\n country = $7,\n principal_subdivision = $8,\n interval_period = $9,\n program_descriptions = $10,\n binding_events = $11,\n local_price = $12,\n payload_descriptors = $13,\n targets = $14\n WHERE id = $1\n AND ($15::text IS NULL OR business_id = $15)\n RETURNING p.id,\n p.created_date_time,\n p.modification_date_time,\n p.program_name,\n p.program_long_name,\n p.retailer_name,\n p.retailer_long_name,\n p.program_type,\n p.country,\n p.principal_subdivision,\n p.interval_period,\n p.program_descriptions,\n p.binding_events,\n p.local_price,\n p.payload_descriptors,\n p.targets\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "program_long_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "retailer_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "retailer_long_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "program_type", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "country", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "principal_subdivision", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "interval_period", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "program_descriptions", + "type_info": "Jsonb" + }, + { + "ordinal": 12, + "name": "binding_events", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "local_price", + "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Jsonb", + "Bool", + "Bool", + "Jsonb", + "Jsonb", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "b4a20eb4fe41ce59b26f20ee4eac1e19f6f2e8463b838456f872e10003eb1355" +} diff --git a/.sqlx/query-bb8a8aa90eddaea701908f35c7ad9a384e8af03558116c343cc70839d7e94613.json b/.sqlx/query-bb8a8aa90eddaea701908f35c7ad9a384e8af03558116c343cc70839d7e94613.json new file mode 100644 index 0000000..85c02c8 --- /dev/null +++ b/.sqlx/query-bb8a8aa90eddaea701908f35c7ad9a384e8af03558116c343cc70839d7e94613.json @@ -0,0 +1,114 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT p.id,\n p.created_date_time,\n p.modification_date_time,\n p.program_name,\n p.program_long_name,\n p.retailer_name,\n p.retailer_long_name,\n p.program_type,\n p.country,\n p.principal_subdivision,\n p.interval_period,\n p.program_descriptions,\n p.binding_events,\n p.local_price,\n p.payload_descriptors,\n p.targets\n FROM program p\n LEFT JOIN ven_program vp ON p.id = vp.program_id\n WHERE id = $1\n AND (NOT $2 OR vp.ven_id IS NULL OR vp.ven_id = ANY($3)) -- Filter for VEN ids\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "program_long_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "retailer_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "retailer_long_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "program_type", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "country", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "principal_subdivision", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "interval_period", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "program_descriptions", + "type_info": "Jsonb" + }, + { + "ordinal": 12, + "name": "binding_events", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "local_price", + "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Bool", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "bb8a8aa90eddaea701908f35c7ad9a384e8af03558116c343cc70839d7e94613" +} diff --git a/.sqlx/query-cfbc449106654ab6c245538238648c54455fb4fdf08c0783ef3e42071a6b9d76.json b/.sqlx/query-cfbc449106654ab6c245538238648c54455fb4fdf08c0783ef3e42071a6b9d76.json new file mode 100644 index 0000000..44cb1f8 --- /dev/null +++ b/.sqlx/query-cfbc449106654ab6c245538238648c54455fb4fdf08c0783ef3e42071a6b9d76.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_manager (user_id) VALUES ($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "cfbc449106654ab6c245538238648c54455fb4fdf08c0783ef3e42071a6b9d76" +} diff --git a/.sqlx/query-d1245889f5dab872e8d1f9f178895505b9f10392316312567099acacca9bfc2c.json b/.sqlx/query-d1245889f5dab872e8d1f9f178895505b9f10392316312567099acacca9bfc2c.json new file mode 100644 index 0000000..eb332b8 --- /dev/null +++ b/.sqlx/query-d1245889f5dab872e8d1f9f178895505b9f10392316312567099acacca9bfc2c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM ven_manager WHERE user_id = $1 \n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "d1245889f5dab872e8d1f9f178895505b9f10392316312567099acacca9bfc2c" +} diff --git a/.sqlx/query-d433cf76e8ec697933389b7d4dbae078dfaab4a5b7cd831a9f813386acb52a37.json b/.sqlx/query-d433cf76e8ec697933389b7d4dbae078dfaab4a5b7cd831a9f813386acb52a37.json new file mode 100644 index 0000000..63cae35 --- /dev/null +++ b/.sqlx/query-d433cf76e8ec697933389b7d4dbae078dfaab4a5b7cd831a9f813386acb52a37.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_credentials \n (user_id, client_id, client_secret) \n VALUES \n ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d433cf76e8ec697933389b7d4dbae078dfaab4a5b7cd831a9f813386acb52a37" +} diff --git a/.sqlx/query-d52b6338b7923a007751ed891915527cf66f5230a73659f8430c47d0b183b00d.json b/.sqlx/query-d52b6338b7923a007751ed891915527cf66f5230a73659f8430c47d0b183b00d.json new file mode 100644 index 0000000..6123775 --- /dev/null +++ b/.sqlx/query-d52b6338b7923a007751ed891915527cf66f5230a73659f8430c47d0b183b00d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO ven_manager (user_id) VALUES ($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "d52b6338b7923a007751ed891915527cf66f5230a73659f8430c47d0b183b00d" +} diff --git a/.sqlx/query-d7f75c56b51c161c14e287f60bbd3984fe9f4d144a7547484999a9598592f9e4.json b/.sqlx/query-d7f75c56b51c161c14e287f60bbd3984fe9f4d144a7547484999a9598592f9e4.json new file mode 100644 index 0000000..d62ef4a --- /dev/null +++ b/.sqlx/query-d7f75c56b51c161c14e287f60bbd3984fe9f4d144a7547484999a9598592f9e4.json @@ -0,0 +1,86 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT e.*\n FROM event e\n JOIN program p ON e.program_id = p.id\n LEFT JOIN ven_program vp ON p.id = vp.program_id\n WHERE e.id = $1\n AND (\n ($2 AND (vp.ven_id IS NULL OR vp.ven_id = ANY($3))) \n OR \n ($4 AND ($5::text[] IS NULL OR p.business_id = ANY ($5)))\n )\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "event_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "report_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "interval_period", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "intervals", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Bool", + "TextArray", + "Bool", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "d7f75c56b51c161c14e287f60bbd3984fe9f4d144a7547484999a9598592f9e4" +} diff --git a/.sqlx/query-dc7b332e581b7806416ab745b4a30077c4abb5dcdfeee19f02d22fa7250579c3.json b/.sqlx/query-dc7b332e581b7806416ab745b4a30077c4abb5dcdfeee19f02d22fa7250579c3.json new file mode 100644 index 0000000..9d58a0f --- /dev/null +++ b/.sqlx/query-dc7b332e581b7806416ab745b4a30077c4abb5dcdfeee19f02d22fa7250579c3.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM event WHERE id = $1 RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "event_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "report_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "interval_period", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "intervals", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "dc7b332e581b7806416ab745b4a30077c4abb5dcdfeee19f02d22fa7250579c3" +} diff --git a/.sqlx/query-e0bcb3d6f5cb5820a4c62cff51742671691cbf4d06c6cfec22e6d71cca0f9432.json b/.sqlx/query-e0bcb3d6f5cb5820a4c62cff51742671691cbf4d06c6cfec22e6d71cca0f9432.json new file mode 100644 index 0000000..0087dbd --- /dev/null +++ b/.sqlx/query-e0bcb3d6f5cb5820a4c62cff51742671691cbf4d06c6cfec22e6d71cca0f9432.json @@ -0,0 +1,61 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO resource (\n id,\n created_date_time,\n modification_date_time,\n resource_name,\n ven_id,\n attributes,\n targets\n )\n VALUES (gen_random_uuid(), now(), now(), $1, $2, $3, $4)\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "resource_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "ven_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "attributes", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "e0bcb3d6f5cb5820a4c62cff51742671691cbf4d06c6cfec22e6d71cca0f9432" +} diff --git a/.sqlx/query-e6a59c961d3c5efc2e12760f19b3e6a819d6ac15412b4c8bd52611aaa909335e.json b/.sqlx/query-e6a59c961d3c5efc2e12760f19b3e6a819d6ac15412b4c8bd52611aaa909335e.json new file mode 100644 index 0000000..35026f9 --- /dev/null +++ b/.sqlx/query-e6a59c961d3c5efc2e12760f19b3e6a819d6ac15412b4c8bd52611aaa909335e.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.*,\n array_agg(DISTINCT c.client_id) FILTER ( WHERE c.client_id IS NOT NULL ) AS client_ids,\n array_agg(DISTINCT b.business_id) FILTER ( WHERE b.business_id IS NOT NULL ) AS business_ids,\n array_agg(DISTINCT ven.ven_id) FILTER ( WHERE ven.ven_id IS NOT NULL ) AS ven_ids,\n ab.user_id IS NOT NULL AS \"is_any_business_user!\",\n um.user_id IS NOT NULL AS \"is_user_manager!\",\n vm.user_id IS NOT NULL AS \"is_ven_manager!\"\n FROM \"user\" u\n LEFT JOIN user_credentials c ON c.user_id = u.id\n LEFT JOIN any_business_user ab ON u.id = ab.user_id\n LEFT JOIN user_business b ON u.id = b.user_id\n LEFT JOIN user_manager um ON u.id = um.user_id\n LEFT JOIN user_ven ven ON u.id = ven.user_id\n LEFT JOIN ven_manager vm ON u.id = vm.user_id\n WHERE u.id = $1\n GROUP BY u.id,\n b.user_id,\n ab.user_id,\n um.user_id,\n ven.user_id,\n vm.user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "reference", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "modified", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "client_ids", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "business_ids", + "type_info": "TextArray" + }, + { + "ordinal": 7, + "name": "ven_ids", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "is_any_business_user!", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "is_user_manager!", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "is_ven_manager!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "e6a59c961d3c5efc2e12760f19b3e6a819d6ac15412b4c8bd52611aaa909335e" +} diff --git a/.sqlx/query-ea3eb886a28adef5ce33230343ad5da9ce740018582ebaac3663724339e195b6.json b/.sqlx/query-ea3eb886a28adef5ce33230343ad5da9ce740018582ebaac3663724339e195b6.json new file mode 100644 index 0000000..02770de --- /dev/null +++ b/.sqlx/query-ea3eb886a28adef5ce33230343ad5da9ce740018582ebaac3663724339e195b6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT program_id AS id FROM event WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ea3eb886a28adef5ce33230343ad5da9ce740018582ebaac3663724339e195b6" +} diff --git a/.sqlx/query-ec971dec31946e39d4d260ee4ef84a0a927c20556f53417ac9072dea64c2d7d0.json b/.sqlx/query-ec971dec31946e39d4d260ee4ef84a0a927c20556f53417ac9072dea64c2d7d0.json new file mode 100644 index 0000000..fabd718 --- /dev/null +++ b/.sqlx/query-ec971dec31946e39d4d260ee4ef84a0a927c20556f53417ac9072dea64c2d7d0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ven_id AS id FROM ven_program WHERE program_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ec971dec31946e39d4d260ee4ef84a0a927c20556f53417ac9072dea64c2d7d0" +} diff --git a/.sqlx/query-f4a84cdd22d4cde0609b297c2dcc0c63884f59e8a850980c5c2bc51c9890a7cb.json b/.sqlx/query-f4a84cdd22d4cde0609b297c2dcc0c63884f59e8a850980c5c2bc51c9890a7cb.json new file mode 100644 index 0000000..abb3438 --- /dev/null +++ b/.sqlx/query-f4a84cdd22d4cde0609b297c2dcc0c63884f59e8a850980c5c2bc51c9890a7cb.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO report (id, created_date_time, modification_date_time, program_id, event_id, client_name, report_name, payload_descriptors, resources)\n VALUES (gen_random_uuid(), now(), now(), $1, $2, $3, $4, $5, $6)\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "event_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "client_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "report_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "resources", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "f4a84cdd22d4cde0609b297c2dcc0c63884f59e8a850980c5c2bc51c9890a7cb" +} diff --git a/.sqlx/query-f718be26da8cbaeae4c25baadca3651cb5dcfb45368207cf11f16bd30bd06428.json b/.sqlx/query-f718be26da8cbaeae4c25baadca3651cb5dcfb45368207cf11f16bd30bd06428.json new file mode 100644 index 0000000..c509720 --- /dev/null +++ b/.sqlx/query-f718be26da8cbaeae4c25baadca3651cb5dcfb45368207cf11f16bd30bd06428.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_business WHERE user_id = $1 \n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "f718be26da8cbaeae4c25baadca3651cb5dcfb45368207cf11f16bd30bd06428" +} diff --git a/.sqlx/query-f947a48b3f9c03686fd0e7abd996dd9cda1e9a3cb2004a3f3ad330a24f885c1b.json b/.sqlx/query-f947a48b3f9c03686fd0e7abd996dd9cda1e9a3cb2004a3f3ad330a24f885c1b.json new file mode 100644 index 0000000..ea17242 --- /dev/null +++ b/.sqlx/query-f947a48b3f9c03686fd0e7abd996dd9cda1e9a3cb2004a3f3ad330a24f885c1b.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM report r \n USING program p \n WHERE r.id = $1 \n AND r.program_id = p.id \n AND ($2::text[] IS NULL OR p.business_id = ANY($2))\n RETURNING r.*\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "event_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "client_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "report_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "resources", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "f947a48b3f9c03686fd0e7abd996dd9cda1e9a3cb2004a3f3ad330a24f885c1b" +} diff --git a/.sqlx/query-faf3290725899dc80b16c2233995a2b3002ebb0989f4d5284880226579d983b3.json b/.sqlx/query-faf3290725899dc80b16c2233995a2b3002ebb0989f4d5284880226579d983b3.json new file mode 100644 index 0000000..e96a707 --- /dev/null +++ b/.sqlx/query-faf3290725899dc80b16c2233995a2b3002ebb0989f4d5284880226579d983b3.json @@ -0,0 +1,90 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE event\n SET modification_date_time = now(),\n program_id = $2,\n event_name = $3,\n priority = $4,\n targets = $5,\n report_descriptors = $6,\n payload_descriptors = $7,\n interval_period = $8,\n intervals = $9\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "event_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "report_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "interval_period", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "intervals", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "targets", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8", + "Jsonb", + "Jsonb", + "Jsonb", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "faf3290725899dc80b16c2233995a2b3002ebb0989f4d5284880226579d983b3" +} diff --git a/.sqlx/query-fdb427ae3d6ecaa2b3549dabe7c487dd9502099974a9dce8580a528a578469a0.json b/.sqlx/query-fdb427ae3d6ecaa2b3549dabe7c487dd9502099974a9dce8580a528a578469a0.json new file mode 100644 index 0000000..55039cd --- /dev/null +++ b/.sqlx/query-fdb427ae3d6ecaa2b3549dabe7c487dd9502099974a9dce8580a528a578469a0.json @@ -0,0 +1,79 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE report r\n SET modification_date_time = now(),\n program_id = $5,\n event_id = $6,\n client_name = $7,\n report_name = $8,\n payload_descriptors = $9,\n resources = $10\n FROM program p\n LEFT JOIN ven_program v ON p.id = v.program_id\n WHERE r.id = $1\n AND (p.id = r.program_id)\n AND (NOT $2 OR v.ven_id IS NULL OR v.ven_id = ANY($3)) \n AND ($4::text[] IS NULL OR p.business_id = ANY($4))\n RETURNING r.*\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "modification_date_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "program_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "event_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "client_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "report_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "payload_descriptors", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "resources", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Bool", + "TextArray", + "TextArray", + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "fdb427ae3d6ecaa2b3549dabe7c487dd9502099974a9dce8580a528a578469a0" +} diff --git a/.sqlx/query-feaeaa22a9b48b647e30cad8582e09c45ecec073fd32461071f3687ccd9c7e91.json b/.sqlx/query-feaeaa22a9b48b647e30cad8582e09c45ecec073fd32461071f3687ccd9c7e91.json new file mode 100644 index 0000000..d4b04f1 --- /dev/null +++ b/.sqlx/query-feaeaa22a9b48b647e30cad8582e09c45ecec073fd32461071f3687ccd9c7e91.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM \"user\" WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "feaeaa22a9b48b647e30cad8582e09c45ecec073fd32461071f3687ccd9c7e91" +} diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 0000000..5516ec4 --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1,6 @@ +Copyright (c) 2024 Stichting ElaadNL and Contributors + +Except as otherwise noted (below and/or in individual files), openadr-rs is +licensed under the Apache License, Version 2.0 or + or the MIT license + or , at your option. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f5a376d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3036 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "axum" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "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 1.0.1", + "tokio", + "tower 0.5.1", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.1", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "serde_html_form", + "tower 0.5.1", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" + +[[package]] +name = "cc" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d74707dde2ba56f86ae90effb3b43ddd369504387e718014de010cec7959800" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.6", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.77", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_logger" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" + +[[package]] +name = "h2" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.5.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da62f120a8a37763efb0cf8fdf264b884c7b8b9ac8660b900c8661030c00e6ba" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower 0.4.13", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +dependencies = [ + "equivalent", + "hashbrown 0.14.5", + "serde", +] + +[[package]] +name = "ipnet" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" + +[[package]] +name = "iso8601-duration" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26adff60a5d3ca10dc271ad37a34ff376595d2a1e5f21d02564929ca888c511" +dependencies = [ + "chrono", + "nom", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" +dependencies = [ + "base64 0.21.7", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "wasi", + "windows-sys 0.52.0", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openadr-client" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "http-body-util", + "mime", + "openadr-vtn", + "openadr-wire", + "rangemap", + "reqwest", + "serde", + "serde_json", + "sqlx", + "tokio", + "tower 0.4.13", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openadr-vtn" +version = "0.1.0" +dependencies = [ + "argon2", + "axum", + "axum-extra", + "chrono", + "dotenvy", + "http-body-util", + "jsonwebtoken", + "mime", + "openadr-wire", + "reqwest", + "serde", + "serde_json", + "serde_with", + "sqlx", + "thiserror", + "tokio", + "tower 0.4.13", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "validator", +] + +[[package]] +name = "openadr-wire" +version = "0.1.0" +dependencies = [ + "chrono", + "http", + "iso8601-duration", + "quickcheck", + "serde", + "serde_json", + "serde_with", + "thiserror", + "validator", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.1", + "serde", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quickcheck" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +dependencies = [ + "env_logger", + "log", + "rand", +] + +[[package]] +name = "quinn" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" +dependencies = [ + "bytes", + "rand", + "ring", + "rustc-hash", + "rustls", + "slab", + "thiserror", + "tinyvec", + "tracing", +] + +[[package]] +name = "quinn-udp" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe68c2e9e1a1234e218683dbdf9f9dfcb094113c5ac2b938dfcb9bab4c4140b" +dependencies = [ + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rangemap" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60fcc7d6849342eff22c4350c8b9a989ee8ceabc4b481253e8946b9fe83d684" + +[[package]] +name = "redox_syscall" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + +[[package]] +name = "reqwest" +version = "0.12.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-registry", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc-hash" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" + +[[package]] +name = "rustix" +version = "0.38.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcaf18a4f2be7326cd874a5fa579fae794320a0f388d365dca7e480e55f83f8a" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" +dependencies = [ + "base64 0.22.1", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "schannel" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "serde_html_form" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de514ef58196f1fc96dcaef80fe6170a1ce6215df9687a93fe8300e773fefc5" +dependencies = [ + "form_urlencoded", + "indexmap 2.5.0", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cecfa94848272156ea67b2b1a53f20fc7bc638c4a46d2f8abde08f05f4b857" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.5.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93334716a037193fac19df402f8571269c84a00852f6a7066b5d2616dcd64d3e" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d8060b456358185f7d50c55d9b5066ad956956fddec42ee2e8567134a8936e" +dependencies = [ + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.14.5", + "hashlink", + "hex", + "indexmap 2.5.0", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cac0692bcc9de3b073e8d747391827297e075c7710ff6276d9f7a1f3d58c6657" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.77", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.77", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "tracing", + "url", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +dependencies = [ + "futures-core", +] + +[[package]] +name = "tempfile" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 0.1.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ea75f83c0137a9b98608359a5f1af8144876eb67bcb1ce837368e906a9f524" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "uuid" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +dependencies = [ + "getrandom", +] + +[[package]] +name = "validator" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db79c75af171630a3148bd3e6d7c4f42b6a9a014c2945bc5ed0020cbb8d9478e" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.77", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" + +[[package]] +name = "web-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "whoami" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +dependencies = [ + "redox_syscall", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0bf320d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,59 @@ +[workspace] +members = [ + "openadr-vtn", + "openadr-client", + "openadr-wire" +] +exclude = [ ] + +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.80" # MSRV +license = "Apache-2.0 OR MIT" +repository = "https://github.com/OpenLEADR/openleadr-rs" +homepage = "https://github.com/OpenLEADR/openleadr-rs" +publish = true +description = "An OpenADR 3.0 VTN/VEN implementation" + +[workspace.dependencies] +openadr-wire = { path = "openadr-wire" } +openadr-vtn = { path = "openadr-vtn" } +openadr-client = { path = "openadr-client" } + +serde = { version = "1.0.203", features = ["derive"] } +serde_json = "1.0.117" +serde_with = { version = "3.8.1", features = ["macros"] } + +reqwest = { version = "0.12.4", default-features = false, features = ["http2", "charset", "rustls-tls-native-roots", "json"] } +tokio = { version = "1.37.0", features = ["full", "test-util"] } +axum = { version = "0.7.5", features = ["macros"] } +axum-extra = { version = "0.9.3", features = ["query", "typed-header"] } +tower = { version = "0.4", features = ["util"] } + +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +tracing-test = "0.2.5" + +chrono = "0.4.38" +iso8601-duration = { version = "0.2.0", features = ["chrono"] } +rangemap = "1.5.1" + +thiserror = "1.0.61" +validator = {version = "0.18.1", features = ["derive"] } +uuid = { version = "1.8.0", features = ["v4"] } +url = "2.5.0" +http = "^1.0.0" +mime = "0.3" +tower-http = { version = "0.5.2" , features = ["trace"]} +http-body-util = "0.1.0" +jsonwebtoken = "9.3.0" +async-trait = "0.1.81" + +quickcheck = "1.0.3" + +sqlx = { version = "0.8.1", features = ["postgres", "runtime-tokio", "chrono", "migrate"] } +argon2 = "0.5.3" +dotenvy = "0.15.7" diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..1b5ec8b --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..73a9eaf --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2024 Stichting ElaadNL and Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a1a0d2 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# OpenADR 3.0 in Rust + +This is a work-in-progress implementation of the OpenADR 3.0 specification. +OpenADR is a protocol for automatic demand-response in electricity grids, like dynamic pricing or load shedding. + +## Limitations + +This repository contains only OpenADR 3.0, older versions are not supported. +Currently, only the `/programs`, `/reports`, `/events` endpoints are supported. +Also no authentication is supported yet. + +## Database setup + +Startup a postgres database. For example, using docker compose: + +```bash +docker compose up db +``` + +Run the [migrations](https://github.com/launchbadge/sqlx/blob/main/sqlx-cli/README.md): + +```bash +cargo sqlx migrate run +``` + +## How to use + +Running the VTN using cargo: + +```bash +RUST_LOG=trace cargo run --bin vtn +``` + +Running the VTN using docker-compose: + +```bash +docker compose up +``` + +Running the client + +```bash +cargo run --bin openadr +``` diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..8bfc90f --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +msrv = "1.80" diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..0283e61 --- /dev/null +++ b/deny.toml @@ -0,0 +1,23 @@ +[graph] +targets = [ + { triple = "x86_64-unknown-linux-musl" }, + { triple = "x86_64-unknown-linux-gnu" }, + { triple = "aarch64-unknown-linux-gnu" }, +] + +[licenses] +version = 2 +private = { ignore = true } +allow = [ + "MIT", + "Apache-2.0", + "Unicode-DFS-2016", + "BSD-3-Clause", + "ISC", + "OpenSSL", +] + +[[licenses.clarify]] +name = "ring" +expression = "ISC AND MIT AND OpenSSL" +license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d6189c1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + vtn: + build: + dockerfile: vtn.Dockerfile + context: . + ports: + - "127.0.0.1:3000:3000" + environment: + RUST_LOG: debug + healthcheck: + test: curl --fail http://127.0.0.1:3000/programs || exit 1 + interval: 60s + timeout: 5s + retries: 3 + + db: + image: ghcr.io/tweedegolf/postgres:16 + environment: + POSTGRES_USER: openadr + POSTGRES_DB: openadr + TZ: Europe/Amsterdam + POSTGRES_HOST_AUTH_METHOD: trust + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U openadr" ] + interval: 5s + timeout: 5s + retries: 5 + ports: [ "127.0.0.1:5432:5432" ] \ No newline at end of file diff --git a/event.json b/event.json new file mode 100644 index 0000000..3b0022b --- /dev/null +++ b/event.json @@ -0,0 +1,63 @@ +{ + "id": 1, + "createdDateTime": "2024-06-19T09:51:12.530567Z", + "objectType": "EVENT", + "programID": "1", + "eventName": "DSO Limitation event", + "priority": 0, + "targets": [ + {} + ], + "payloadDescriptors": [ + { + "payloadType": "IMPORT_CAPACITY_LIMIT", + "units": "KW", + "currency": "EUR" + }, + { + "payloadType": "EXPORT_CAPACITY_LIMIT", + "units": "KW", + "currency": "EUR" + } + ], + "intervalPeriod": { + "start": "2024-06-19T09:51:12.530569Z", + "duration": "PT15M" + }, + "intervals": [ + { + "id": 0, + "intervalPeriod": { + "start": "2024-06-19T09:51:12.530570Z", + "duration": "PT15M" + }, + "payloads": [ + { + "type": "IMPORT_CAPACITY_LIMIT", + "values": 100 + }, + { + "type": "EXPORT_CAPACITY_LIMIT", + "values": 10 + } + ] + }, + { + "id": 1, + "intervalPeriod": { + "start": "2024-06-19T10:06:12.530573Z", + "duration": "PT15M" + }, + "payloads": [ + { + "type": "IMPORT_CAPACITY_LIMIT", + "values": 50 + }, + { + "type": "EXPORT_CAPACITY_LIMIT", + "values": 60 + } + ] + } + ] +} diff --git a/fixtures/business.sql b/fixtures/business.sql new file mode 100644 index 0000000..6673d52 --- /dev/null +++ b/fixtures/business.sql @@ -0,0 +1,9 @@ +INSERT INTO business (id) +VALUES ('business-1'); + +INSERT INTO user_business (user_id, business_id) +VALUES ('user-1', 'business-1'); + +UPDATE program +SET business_id = 'business-1' +WHERE id = 'program-3'; \ No newline at end of file diff --git a/fixtures/events.sql b/fixtures/events.sql new file mode 100644 index 0000000..8a54418 --- /dev/null +++ b/fixtures/events.sql @@ -0,0 +1,107 @@ +INSERT INTO event (id, created_date_time, modification_date_time, program_id, event_name, priority, targets, + report_descriptors, payload_descriptors, interval_period, intervals) +VALUES ('event-1', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'program-1', + 'event-1-name', + '4', + '[ + { + "type": "GROUP", + "values": [ + "group-1" + ] + }, + { + "type": "PRIVATE_LABEL", + "values": [ + "private value" + ] + } + ]'::jsonb, + null, + null, + '{ + "start": "2023-06-15T09:30:00+00:00", + "duration": "P0Y0M0DT1H0M0S", + "randomizeStart": "P0Y0M0DT1H0M0S" + }'::jsonb, + '[ + { + "id": 3, + "payloads": [ + { + "type": "PRICE", + "values": [ + 0.17 + ] + } + ], + "intervalPeriod": { + "start": "2023-06-15T09:30:00+00:00", + "duration": "P0Y0M0DT1H0M0S", + "randomizeStart": "P0Y0M0DT1H0M0S" + } + } + ]'::jsonb), + ('event-2', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'program-2', + 'event-2-name', + null, + '[ + { + "type": "SOME_TARGET", + "values": [ + "target-1" + ] + } + ]'::jsonb, + null, + null, + null, + '[ + { + "id": 3, + "payloads": [ + { + "type": "SOME_PAYLOAD", + "values": [ + "value" + ] + } + ] + } + ]'::jsonb), + ('event-3', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'program-3', + 'event-3-name', + null, + '[ + { + "type": "SOME_TARGET", + "values": [ + "target-1" + ] + } + ]'::jsonb, + null, + null, + null, + '[ + { + "id": 3, + "payloads": [ + { + "type": "SOME_PAYLOAD", + "values": [ + "value" + ] + } + ] + } + ]'::jsonb); \ No newline at end of file diff --git a/fixtures/openadr_testsuite_user.sql b/fixtures/openadr_testsuite_user.sql new file mode 100644 index 0000000..042f147 --- /dev/null +++ b/fixtures/openadr_testsuite_user.sql @@ -0,0 +1,28 @@ + +INSERT INTO "user" (id, reference, description, created, modified) +VALUES ('bl_user', + 'bl_test_user', + null, + now(), + now()); + +-- secret: 1001 +INSERT INTO user_credentials (user_id, client_id, client_secret) +VALUES ('bl_user', 'bl_client', + '$argon2id$v=19$m=16,t=2,p=1$YmJkMTJrU0ptMVprYVJLSQ$mu1Fbbt5PzBsE/dJevKazw'); + +INSERT INTO any_business_user (user_id) VALUES ('bl_user'); + +INSERT INTO "user" (id, reference, description, created, modified) +VALUES ('ven_user', + 'ven_test_user', + null, + now(), + now()); + +-- secret: 999 +INSERT INTO user_credentials (user_id, client_id, client_secret) +VALUES ('ven_user', 'ven_client', + '$argon2id$v=19$m=16,t=2,p=1$RGhDTmVkbEl5cEZDY0Fubg$qPtSCpK6Z5XKQkOLHC/+qg'); + +INSERT INTO user_ven VALUES ('ven-1', 'ven_user'); \ No newline at end of file diff --git a/fixtures/programs.sql b/fixtures/programs.sql new file mode 100644 index 0000000..ede88a0 --- /dev/null +++ b/fixtures/programs.sql @@ -0,0 +1,88 @@ +INSERT INTO program (id, + created_date_time, + modification_date_time, + program_name, + program_long_name, + retailer_name, + retailer_long_name, + program_type, + country, + principal_subdivision, + interval_period, + program_descriptions, + binding_events, + local_price, + payload_descriptors, + targets) +VALUES ('program-1', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'program-1', + 'program long name', + 'retailer name', + 'retailer long name', + 'program type', + 'country', + 'principal-subdivision', + '{ + "start": "2024-07-25T08:31:10.776Z" + }', + '[ + { + "URL": "https://program-description-1.com" + } + ]', + false, + true, + '[ + { + "objectType": "EVENT_PAYLOAD_DESCRIPTOR", + "payloadType": "EXPORT_PRICE" + } + ]', + '[ + { + "type": "GROUP", + "values": [ + "group-1" + ] + }, + { + "type": "PRIVATE_LABEL", + "values": [ + "private value" + ] + } + ]'), + ('program-2', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'program-2', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL), + ('program-3', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'program-3', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL); \ No newline at end of file diff --git a/fixtures/reports.sql b/fixtures/reports.sql new file mode 100644 index 0000000..4aeb147 --- /dev/null +++ b/fixtures/reports.sql @@ -0,0 +1,27 @@ +INSERT INTO report (id, + created_date_time, + modification_date_time, + program_id, + event_id, + client_name, + report_name, + payload_descriptors, + resources) +VALUES ('report-1', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'program-1', + 'event-1', + 'some-client-maybe-vtn-name', + NULL, + NULL, + '{}'), + ('report-2', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'program-2', + 'event-2', + 'some-client-maybe-vtn-name', + NULL, + NULL, + '{}') \ No newline at end of file diff --git a/fixtures/resources.sql b/fixtures/resources.sql new file mode 100644 index 0000000..b529c20 --- /dev/null +++ b/fixtures/resources.sql @@ -0,0 +1,42 @@ +INSERT INTO resource (id, + created_date_time, + modification_date_time, + resource_name, + ven_id, + attributes, + targets) +VALUES ('resource-1', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'resource-1-name', + 'ven-1', + NULL, + NULL), + ('resource-2', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'resource-2-name', + 'ven-2', + NULL, + NULL), + ('resource-3', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'resource-3-name', + 'ven-1', + NULL, + NULL), + ('resource-4', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'resource-4-name', + 'ven-2', + NULL, + NULL), + ('resource-5', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'resource-5-name', + 'ven-2', + NULL, + NULL); diff --git a/fixtures/users.sql b/fixtures/users.sql new file mode 100644 index 0000000..8710b6e --- /dev/null +++ b/fixtures/users.sql @@ -0,0 +1,20 @@ +INSERT INTO "user" (id, reference, description, created, modified) +VALUES ('admin', 'admin-ref', null, '2024-07-25 08:31:10.776000 +00:00', '2024-07-25 08:31:10.776000 +00:00'); + +INSERT INTO any_business_user (user_id) +VALUES ('admin'); +INSERT INTO user_manager (user_id) +VALUES ('admin'); +INSERT INTO ven_manager (user_id) +VALUES ('admin'); + +INSERT INTO user_credentials (user_id, client_id, client_secret) +VALUES ('admin', 'admin', '$argon2id$v=19$m=16,t=2,p=1$QmtwZnBPVnlIYkJTWUtHZg$lMxF0N+CeRa99UmzMaUKeg'); -- secret: admin + +INSERT INTO "user" (id, reference, description, created, modified) +VALUES ('user-1', 'user-1-ref', 'desc', '2024-07-25 08:31:10.776000 +00:00', '2024-07-25 08:31:10.776000 +00:00'); + +INSERT INTO user_credentials (user_id, client_id, client_secret) +VALUES ('user-1', 'user-1-client-id', + '$argon2id$v=19$m=16,t=2,p=1$R04zbWxDNVhtVHB4aVJLag$mRpShTDhgZ9+bVNLa8GBgw'); -- secret: user-1 + diff --git a/fixtures/vens-programs.sql b/fixtures/vens-programs.sql new file mode 100644 index 0000000..63af165 --- /dev/null +++ b/fixtures/vens-programs.sql @@ -0,0 +1,11 @@ +INSERT INTO ven_program (program_id, ven_id) +VALUES ('program-1', 'ven-1'); + +INSERT INTO ven_program (program_id, ven_id) +VALUES ('program-1', 'ven-2'); + +INSERT INTO ven_program (program_id, ven_id) +VALUES ('program-2', 'ven-2'); + +INSERT INTO ven_program (program_id, ven_id) +VALUES ('program-3', 'ven-1'); \ No newline at end of file diff --git a/fixtures/vens.sql b/fixtures/vens.sql new file mode 100644 index 0000000..0112677 --- /dev/null +++ b/fixtures/vens.sql @@ -0,0 +1,34 @@ +INSERT INTO ven (id, + created_date_time, + modification_date_time, + ven_name, + attributes, + targets) +VALUES ('ven-1', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'ven-1-name', + NULL, + '[ + { + "type": "GROUP", + "values": [ + "group-1" + ] + }, + { + "type": "PRIVATE_LABEL", + "values": [ + "private value" + ] + } + ]'), + ('ven-2', + '2024-07-25 08:31:10.776000 +00:00', + '2024-07-25 08:31:10.776000 +00:00', + 'ven-2-name', + NULL, + NULL); + +INSERT INTO user_ven (ven_id, user_id) +VALUES ('ven-1', 'user-1'); diff --git a/generated/mod.rs b/generated/mod.rs new file mode 100644 index 0000000..e411081 --- /dev/null +++ b/generated/mod.rs @@ -0,0 +1,7 @@ +//! Types generated by the [openAPI generator](https://github.com/OpenAPITools/openapi-generator) +//! from the official openAPI specification +//! +//! Since the generated types are not ideal, some types are modified +//! and moved to the [`wire`](super::wire) module + +pub mod models; diff --git a/generated/models/mod.rs b/generated/models/mod.rs new file mode 100644 index 0000000..4fbb74b --- /dev/null +++ b/generated/models/mod.rs @@ -0,0 +1,11 @@ +pub use self::notification_object::NotificationObject; +pub use self::object_types::ObjectTypes; +pub use self::resource::Resource; +pub use self::subscription_object_operations_inner::SubscriptionObjectOperationsInner; + +mod notification; +mod notification_object; +mod object_types; +mod point; +mod subscription; +mod subscription_object_operations_inner; diff --git a/generated/models/notification.rs b/generated/models/notification.rs new file mode 100644 index 0000000..119f2eb --- /dev/null +++ b/generated/models/notification.rs @@ -0,0 +1,67 @@ +/* + * OpenADR 3 API + * + * The OpenADR 3 API supports energy retailer to energy customer Demand Response programs. The API includes the following capabilities and operations: __Manage programs:__ * Create/Update/Delete a program * Search programs __Manage events:__ * Create/Update/Delete an event * Search events __Manage reports:__ * Create/Update/Delete a report * Search reports __Manage subscriptions:__ * Create/Update/Delete subscriptions to programs, events, and reports * Search subscriptions * Subscriptions allows clients to register a callback URL (webhook) to be notified on the change of state of a resource __Manage vens:__ * Create/Update/Delete vens and ven resources * Search ven and ven resources __Manage tokens:__ * Obtain an access token * This endpoint is provided as a convenience and may be neglected in a commercial implementation + * + * The version of the OpenAPI document: 3.0.1 + * Contact: frank@pajaritotech.com + * Generated by: https://openapi-generator.tech + */ + +use serde::{Deserialize, Serialize}; + +use crate::wire::values_map::ValuesMap; + +use super::{NotificationObject, ObjectTypes}; + +/// Notification : VTN generated object included in request to subscription callbackUrl. + +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct Notification { + #[serde(rename = "objectType")] + pub object_type: ObjectTypes, + /// the operation on on object that triggered the notification. + #[serde(rename = "operation")] + pub operation: Operation, + #[serde(rename = "object")] + pub object: Box, + /// A list of valuesMap objects. + #[serde(rename = "targets", skip_serializing_if = "Option::is_none")] + pub targets: Option>, +} + +impl Notification { + /// VTN generated object included in request to subscription callbackUrl. + #[allow(dead_code)] + pub fn new( + object_type: ObjectTypes, + operation: Operation, + object: NotificationObject, + ) -> Notification { + Notification { + object_type, + operation, + object: Box::new(object), + targets: None, + } + } +} + +/// the operation on on object that triggered the notification. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Operation { + #[serde(rename = "GET")] + Get, + #[serde(rename = "POST")] + Post, + #[serde(rename = "PUT")] + Put, + #[serde(rename = "DELETE")] + Delete, +} + +impl Default for Operation { + fn default() -> Operation { + Self::Get + } +} diff --git a/generated/models/notification_object.rs b/generated/models/notification_object.rs new file mode 100644 index 0000000..b6ff20e --- /dev/null +++ b/generated/models/notification_object.rs @@ -0,0 +1,30 @@ +/* + * OpenADR 3 API + * + * The OpenADR 3 API supports energy retailer to energy customer Demand Response programs. The API includes the following capabilities and operations: __Manage programs:__ * Create/Update/Delete a program * Search programs __Manage events:__ * Create/Update/Delete an event * Search events __Manage reports:__ * Create/Update/Delete a report * Search reports __Manage subscriptions:__ * Create/Update/Delete subscriptions to programs, events, and reports * Search subscriptions * Subscriptions allows clients to register a callback URL (webhook) to be notified on the change of state of a resource __Manage vens:__ * Create/Update/Delete vens and ven resources * Search ven and ven resources __Manage tokens:__ * Obtain an access token * This endpoint is provided as a convenience and may be neglected in a commercial implementation + * + * The version of the OpenAPI document: 3.0.1 + * Contact: frank@pajaritotech.com + * Generated by: https://openapi-generator.tech + */ + +use serde::{Deserialize, Serialize}; + +/// NotificationObject : the object that is the subject of the notification. + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "objecttype")] +pub struct NotificationObject {} + +/// Used as discriminator, e.g. notification.object +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum ObjectType { + #[serde(rename = "RESOURCE")] + Resource, +} + +impl Default for ObjectType { + fn default() -> ObjectType { + Self::Resource + } +} diff --git a/generated/models/object_types.rs b/generated/models/object_types.rs new file mode 100644 index 0000000..2178fd1 --- /dev/null +++ b/generated/models/object_types.rs @@ -0,0 +1,51 @@ +/* + * OpenADR 3 API + * + * The OpenADR 3 API supports energy retailer to energy customer Demand Response programs. The API includes the following capabilities and operations: __Manage programs:__ * Create/Update/Delete a program * Search programs __Manage events:__ * Create/Update/Delete an event * Search events __Manage reports:__ * Create/Update/Delete a report * Search reports __Manage subscriptions:__ * Create/Update/Delete subscriptions to programs, events, and reports * Search subscriptions * Subscriptions allows clients to register a callback URL (webhook) to be notified on the change of state of a resource __Manage vens:__ * Create/Update/Delete vens and ven resources * Search ven and ven resources __Manage tokens:__ * Obtain an access token * This endpoint is provided as a convenience and may be neglected in a commercial implementation + * + * The version of the OpenAPI document: 3.0.1 + * Contact: frank@pajaritotech.com + * Generated by: https://openapi-generator.tech + */ + +use serde::{Deserialize, Serialize}; +use std::fmt::Display; + +/// ObjectTypes : Types of objects addressable through API. + +/// Types of objects addressable through API. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum ObjectTypes { + #[serde(rename = "PROGRAM")] + Program, + #[serde(rename = "EVENT")] + Event, + #[serde(rename = "REPORT")] + Report, + #[serde(rename = "SUBSCRIPTION")] + Subscription, + #[serde(rename = "VEN")] + Ven, + #[serde(rename = "RESOURCE")] + Resource, +} + +impl Display for ObjectTypes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match self { + Self::Program => String::from("PROGRAM"), + Self::Event => String::from("EVENT"), + Self::Report => String::from("REPORT"), + Self::Subscription => String::from("SUBSCRIPTION"), + Self::Ven => String::from("VEN"), + Self::Resource => String::from("RESOURCE"), + }; + write!(f, "{}", str) + } +} + +impl Default for ObjectTypes { + fn default() -> ObjectTypes { + Self::Program + } +} diff --git a/generated/models/point.rs b/generated/models/point.rs new file mode 100644 index 0000000..d0e1f62 --- /dev/null +++ b/generated/models/point.rs @@ -0,0 +1,23 @@ +/* + * OpenADR 3 API + * + * The OpenADR 3 API supports energy retailer to energy customer Demand Response programs. The API includes the following capabilities and operations: __Manage programs:__ * Create/Update/Delete a program * Search programs __Manage events:__ * Create/Update/Delete an event * Search events __Manage reports:__ * Create/Update/Delete a report * Search reports __Manage subscriptions:__ * Create/Update/Delete subscriptions to programs, events, and reports * Search subscriptions * Subscriptions allows clients to register a callback URL (webhook) to be notified on the change of state of a resource __Manage vens:__ * Create/Update/Delete vens and ven resources * Search ven and ven resources __Manage tokens:__ * Obtain an access token * This endpoint is provided as a convenience and may be neglected in a commercial implementation + * + * The version of the OpenAPI document: 3.0.1 + * Contact: frank@pajaritotech.com + * Generated by: https://openapi-generator.tech + */ + +use serde::{Deserialize, Serialize}; + +/// Point : A pair of floats typically used as a point on a 2 dimensional grid. + +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct Point { + /// A value on an x axis. + #[serde(rename = "x")] + pub x: f32, + /// A value on a y axis. + #[serde(rename = "y")] + pub y: f32, +} diff --git a/generated/models/subscription.rs b/generated/models/subscription.rs new file mode 100644 index 0000000..12c799e --- /dev/null +++ b/generated/models/subscription.rs @@ -0,0 +1,82 @@ +/* + * OpenADR 3 API + * + * The OpenADR 3 API supports energy retailer to energy customer Demand Response programs. The API includes the following capabilities and operations: __Manage programs:__ * Create/Update/Delete a program * Search programs __Manage events:__ * Create/Update/Delete an event * Search events __Manage reports:__ * Create/Update/Delete a report * Search reports __Manage subscriptions:__ * Create/Update/Delete subscriptions to programs, events, and reports * Search subscriptions * Subscriptions allows clients to register a callback URL (webhook) to be notified on the change of state of a resource __Manage vens:__ * Create/Update/Delete vens and ven resources * Search ven and ven resources __Manage tokens:__ * Obtain an access token * This endpoint is provided as a convenience and may be neglected in a commercial implementation + * + * The version of the OpenAPI document: 3.0.1 + * Contact: frank@pajaritotech.com + * Generated by: https://openapi-generator.tech + */ + +use serde::{Deserialize, Serialize}; + +use crate::generated::models::SubscriptionObjectOperationsInner; +use crate::wire::values_map::ValuesMap; + +/// Subscription : An object created by a client to receive notification of operations on objects. Clients may subscribe to be notified when a type of object is created, updated, or deleted. + +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct Subscription { + /// URL safe VTN assigned object ID. + #[serde(rename = "id", skip_serializing_if = "Option::is_none")] + pub id: Option, + /// datetime in ISO 8601 format + #[serde(rename = "createdDateTime", skip_serializing_if = "Option::is_none")] + pub created_date_time: Option, + /// datetime in ISO 8601 format + #[serde( + rename = "modificationDateTime", + skip_serializing_if = "Option::is_none" + )] + pub modification_date_time: Option, + /// Used as discriminator, e.g. notification.object + #[serde(rename = "objectType", skip_serializing_if = "Option::is_none")] + pub object_type: Option, + /// User generated identifier, may be VEN identifier provisioned during program enrollment. + #[serde(rename = "clientName")] + #[serde(deserialize_with = "crate::wire::string_within_range_inclusive::<1, 128, _>")] + pub client_name: String, + /// URL safe VTN assigned object ID. + #[serde(rename = "programID")] + pub program_id: String, + /// list of objects and operations to subscribe to. + #[serde(rename = "objectOperations")] + pub object_operations: Vec, + /// A list of valuesMap objects. Used by server to filter callbacks. + #[serde(rename = "targets", skip_serializing_if = "Option::is_none")] + pub targets: Option>, +} + +impl Subscription { + /// An object created by a client to receive notification of operations on objects. Clients may subscribe to be notified when a type of object is created, updated, or deleted. + #[allow(dead_code)] + pub fn new( + client_name: String, + program_id: String, + object_operations: Vec, + ) -> Subscription { + Subscription { + id: None, + created_date_time: None, + modification_date_time: None, + object_type: None, + client_name, + program_id, + object_operations, + targets: None, + } + } +} + +/// Used as discriminator, e.g. notification.object +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum ObjectType { + #[serde(rename = "SUBSCRIPTION")] + Subscription, +} + +impl Default for ObjectType { + fn default() -> ObjectType { + Self::Subscription + } +} diff --git a/generated/models/subscription_object_operations_inner.rs b/generated/models/subscription_object_operations_inner.rs new file mode 100644 index 0000000..42dbedf --- /dev/null +++ b/generated/models/subscription_object_operations_inner.rs @@ -0,0 +1,66 @@ +/* + * OpenADR 3 API + * + * The OpenADR 3 API supports energy retailer to energy customer Demand Response programs. The API includes the following capabilities and operations: __Manage programs:__ * Create/Update/Delete a program * Search programs __Manage events:__ * Create/Update/Delete an event * Search events __Manage reports:__ * Create/Update/Delete a report * Search reports __Manage subscriptions:__ * Create/Update/Delete subscriptions to programs, events, and reports * Search subscriptions * Subscriptions allows clients to register a callback URL (webhook) to be notified on the change of state of a resource __Manage vens:__ * Create/Update/Delete vens and ven resources * Search ven and ven resources __Manage tokens:__ * Obtain an access token * This endpoint is provided as a convenience and may be neglected in a commercial implementation + * + * The version of the OpenAPI document: 3.0.1 + * Contact: frank@pajaritotech.com + * Generated by: https://openapi-generator.tech + */ + +use crate::generated::models::ObjectTypes; +use serde::{Deserialize, Serialize}; + +/// SubscriptionObjectOperationsInner : object type, operations, and callbackUrl. + +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct SubscriptionObjectOperationsInner { + /// list of objects to subscribe to. + #[serde(rename = "objects")] + pub objects: Vec, + /// list of operations to subscribe to. + #[serde(rename = "operations")] + pub operations: Vec, + /// User provided webhook URL. + #[serde(rename = "callbackUrl")] + pub callback_url: String, + /// User provided token. To avoid custom integrations, callback endpoints should accept the provided bearer token to authenticate VTN requests. + #[serde(rename = "bearerToken", skip_serializing_if = "Option::is_none")] + pub bearer_token: Option, +} + +impl SubscriptionObjectOperationsInner { + /// object type, operations, and callbackUrl. + #[allow(dead_code)] + pub fn new( + objects: Vec, + operations: Vec, + callback_url: String, + ) -> SubscriptionObjectOperationsInner { + SubscriptionObjectOperationsInner { + objects, + operations, + callback_url, + bearer_token: None, + } + } +} + +/// list of operations to subscribe to. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Operations { + #[serde(rename = "GET")] + Get, + #[serde(rename = "POST")] + Post, + #[serde(rename = "PUT")] + Put, + #[serde(rename = "DELETE")] + Delete, +} + +impl Default for Operations { + fn default() -> Operations { + Self::Get + } +} diff --git a/migrations/20240826084440_initial_scheme.sql b/migrations/20240826084440_initial_scheme.sql new file mode 100644 index 0000000..216bef2 --- /dev/null +++ b/migrations/20240826084440_initial_scheme.sql @@ -0,0 +1,158 @@ +create table business +( + id text not null + constraint business_pk primary key +); + +create table program +( + id text not null + constraint program_pk + primary key, + created_date_time timestamptz not null, + modification_date_time timestamptz not null, + + program_name text not null, + program_long_name text, + retailer_name text, + retailer_long_name text, + program_type text, + country text, + principal_subdivision text, + -- deliberately omitted: time_zone_offset + interval_period jsonb, + program_descriptions jsonb, + binding_events boolean, + local_price boolean, + payload_descriptors jsonb, + targets jsonb, + business_id text references business (id) +); + +create unique index program_program_name_uindex + on program (program_name); + +create table event +( + id text not null + constraint event_pk + primary key, + created_date_time timestamptz not null, + modification_date_time timestamptz not null, + + program_id text not null references program (id), + event_name text, + priority bigint, + report_descriptors jsonb, + payload_descriptors jsonb, + interval_period jsonb, + intervals jsonb not null, + targets jsonb +); + +create index event_event_name_index + on event (event_name); + + +create table report +( + id text not null + constraint report_pk + primary key, + created_date_time timestamptz not null, + modification_date_time timestamptz not null, + + program_id text not null references program (id), + event_id text not null references event (id), + client_name text not null, + report_name text, + payload_descriptors jsonb, + resources jsonb not null +); + +create unique index report_report_name_uindex + on report (report_name); + +create table "user" +( + id text primary key, + reference text not null, + description text, + created timestamptz not null, + modified timestamptz not null +); + +create table user_credentials +( + user_id text not null references "user" (id) on delete cascade, + client_id text primary key, + client_secret text not null + -- TODO maybe the credentials require their own role? +); + +create table ven +( + id text not null + constraint ven_pk + primary key, + created_date_time timestamptz not null, + modification_date_time timestamptz not null, + ven_name text not null, + attributes jsonb, + targets jsonb +); + +create unique index ven_ven_name_uindex + on ven (ven_name); + +create table user_ven +( + ven_id text not null references ven (id) on delete cascade, + user_id text not null references "user" (id) on delete cascade +); + +create table resource +( + id text not null + constraint resource_pk + primary key, + created_date_time timestamptz not null, + modification_date_time timestamptz not null, + resource_name text not null unique, + ven_id text not null references ven (id), -- TODO is this actually 'NOT NULL'? + attributes jsonb, + targets jsonb + +); + +create table ven_program +( + program_id text not null references program (id) on delete cascade, + ven_id text not null references ven (id) on delete cascade, + constraint ven_program_pk primary key (program_id, ven_id) +); + + +create table user_business +( + user_id text not null references "user" (id) on delete cascade, + business_id text not null references business (id) on delete cascade +); + +create unique index uindex_user_business + on user_business (user_id, business_id); + +create table ven_manager +( + user_id text primary key references "user" (id) on delete cascade +); + +create table user_manager +( + user_id text primary key references "user" (id) on delete cascade +); + +create table any_business_user +( + user_id text primary key references "user" (id) on delete cascade +); \ No newline at end of file diff --git a/openadr-client/Cargo.toml b/openadr-client/Cargo.toml new file mode 100644 index 0000000..197c7f1 --- /dev/null +++ b/openadr-client/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "openadr-client" +description = "openadr client" +readme = "../README.md" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish.workspace = true +rust-version.workspace = true + +[dependencies] +openadr-wire.workspace = true + +serde.workspace = true +serde_json.workspace = true + +reqwest.workspace = true +axum.workspace = true +tokio = { workspace = true, features = ["full"] } +tracing.workspace = true +http-body-util.workspace = true +tower.workspace = true + +url.workspace = true +chrono.workspace = true +rangemap.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["full", "test-util"] } +openadr-vtn = { path = "../openadr-vtn", features = ["postgres"] } +mime.workspace = true +sqlx.workspace = true \ No newline at end of file diff --git a/openadr-client/migrations b/openadr-client/migrations new file mode 120000 index 0000000..acd3dd4 --- /dev/null +++ b/openadr-client/migrations @@ -0,0 +1 @@ +../migrations/ \ No newline at end of file diff --git a/openadr-client/src/bin/cli.rs b/openadr-client/src/bin/cli.rs new file mode 100644 index 0000000..dab5d71 --- /dev/null +++ b/openadr-client/src/bin/cli.rs @@ -0,0 +1,60 @@ +use openadr_client::ClientCredentials; +use openadr_wire::program::ProgramContent; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = openadr_client::Client::with_url( + "http://localhost:3000/".try_into()?, + Some(ClientCredentials::admin()), + ); + let _created_program = client.create_program(ProgramContent::new("name")).await?; + // let created_program_1 = client.create_program(ProgramContent::new("name1")).await?; + let program = client.get_program_by_name("name").await?; + // let created_event = program + // .create_event(program.new_event().with_event_name("prices3").with_priority(0)) + // .await?; + let events = program.get_all_events().await?; + // let reports = events[0].get_all_reports().await?; + // let event = program.get_event(Target::Event("prices3")).await?; + dbg!(events); + // dbg!(reports); + + // let programs: Vec = client.get_all_programs()?; + // let programs = client.get_programs(TargetLabel::ProgramName, &["name"])?; + + // let program = client.get_program_by_id("id").await?; + + // let evt = program.send_event(Event { + + // })?; + + // let events = program.get_events(TargetLabel::EventName, &["name"], 0, 10)?; + + // program.get_event_by_name("prices").await?; + + // let events = program.get_all_events().await?; + + // // find the event you want, each event contains all relevant information to reconstruct periods + // let event = events[0]; + + // for interval in event.intervals { + // for payload in interval.payloads { // Iterator(); + // } + // } + + // send a report + // event.send_report(Report { + + // }).await?; + + // program.on_event(|evt| { + + // })?; + + Ok(()) +} diff --git a/openadr-client/src/bin/everest.rs b/openadr-client/src/bin/everest.rs new file mode 100644 index 0000000..ed26ee6 --- /dev/null +++ b/openadr-client/src/bin/everest.rs @@ -0,0 +1,310 @@ +use chrono::{DateTime, Utc}; +use openadr_wire::{ + event::{EventType, EventValuesMap}, + values_map::Value, +}; + +use openadr_client::{ProgramClient, Timeline}; +use std::{error::Error, time::Duration}; +use tokio::{ + select, + sync::{ + mpsc, + mpsc::{Receiver, Sender}, + }, +}; +use uuid::Uuid; + +async fn wait_for_next_start(clock: &impl Clock, timeline: &Timeline) { + let now = clock.now(); + + let Some(next) = timeline.next_update(&now) else { + return std::future::pending().await; // Wait forever + }; + + // if the wait time is negative, return immediately + match (next - now).to_std() { + Err(_) => {} + Ok(wait_time) => tokio::time::sleep(wait_time).await, + } +} + +trait Clock { + fn now(&self) -> DateTime; +} + +struct ChronoClock; + +impl Clock for ChronoClock { + fn now(&self) -> DateTime { + Utc::now() + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = openadr_client::Client::with_url("http://localhost:3000/".try_into()?, None); + let program = client.get_program_by_name("name").await?; + + // channel used to send new timelines + let (sender, receiver) = mpsc::channel(1); + let poll_interval = Duration::from_secs(30); + tokio::spawn(poll_timeline(program, poll_interval, sender)); + + let (output_sender, mut output_receiver) = mpsc::channel(1); + tokio::spawn(update_listener(ChronoClock, receiver, output_sender)); + + tokio::spawn(async move { + while let Some(enforced_limits) = output_receiver.recv().await { + eprintln!("received by mock everest: {:?}", enforced_limits); + } + }); + + Ok(()) +} + +async fn poll_timeline( + mut program: ProgramClient, + poll_interval: Duration, + sender: Sender, +) -> Result<(), openadr_client::Error> { + loop { + tokio::time::sleep(poll_interval).await; + + let timeline = program.get_timeline().await?; + + let Ok(_) = sender.send(timeline).await else { + return Ok(()); + }; + } +} + +async fn update_listener( + clock: impl Clock, + mut receiver: Receiver, + sender: Sender, +) { + let mut timeline = Timeline::new(); + loop { + // wait for the next thing to respond to. That is either: + // + // - the next interval from our timeline is starting + // - the timeline got updated + select! { + result = receiver.recv() => { + match result { + None => break, // sender was dropped + Some(new_timeline) => timeline = new_timeline, + } + } + () = wait_for_next_start(&clock, &timeline) => { + // fall through + } + } + + let now = clock.now(); + + // normally, we expect this to return Some(_), but due to e.g. time synchronization, + // we may wake up without any interval to actually send upstream. + let Some(current) = timeline.at_datetime(&now) else { + continue; + }; + + let mut schedule = Vec::new(); + let mut valid_until = None; + + for (range, interval) in timeline.iter() { + // skip anything that is already complete + if range.end < now { + continue; + } + + valid_until = Ord::max(valid_until, Some(range.end)); + + if let Some(limits_to_root) = LimitsRes::try_from_event_values(interval.value_map) { + let entry = ScheduleResEntry { + timestamp: range.start, + limits_to_root, + }; + + schedule.push(entry); + } + } + + let opt_limits = LimitsRes::try_from_event_values(current.1.value_map); + if let (Some(valid_until), Some(limits_root_side)) = (valid_until, opt_limits) { + let enforced_limits = EnforcedLimits { + uuid: Uuid::new_v4().to_string(), + valid_until, + limits_root_side, + schedule, + }; + + let Ok(()) = sender.send(enforced_limits).await else { + break; + }; + } + } +} + +// https://github.com/tdittr/everest-core/blob/openadr/types/energy.yaml#L213 +#[derive(Debug, Clone, PartialEq)] +struct EnforcedLimits { + uuid: String, + valid_until: DateTime, + limits_root_side: LimitsRes, + schedule: Vec, +} + +// https://github.com/tdittr/everest-core/blob/openadr/types/energy.yaml#L125 +#[derive(Debug, Clone, Copy, PartialEq)] +struct ScheduleResEntry { + timestamp: DateTime, + limits_to_root: LimitsRes, +} + +// https://github.com/tdittr/everest-core/blob/openadr/types/energy.yaml#L46 +#[derive(Debug, Clone, Copy, PartialEq)] +struct LimitsRes { + // NOTE: that W is uppercase if we ever need to serialize this! + total_power_w: f64, +} + +impl LimitsRes { + fn try_from_event_values(values: &[EventValuesMap]) -> Option { + for EventValuesMap { value_type, values } in values { + if let EventType::ImportCapacityLimit = value_type { + let total_power_w = match &values[..] { + [Value::Integer(value)] => *value as f64, + [Value::Number(value)] => *value, + other => panic!("invalid values {other:?}"), + }; + + return Some(Self { total_power_w }); + } + } + + None + } +} + +#[cfg(test)] +mod test { + use super::*; + use openadr_wire::{ + event::{EventContent, EventInterval}, + interval::IntervalPeriod, + program::{ProgramContent, ProgramId}, + }; + use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }; + + struct TestingClock(AtomicU64); + + impl Clock for Arc { + fn now(&self) -> DateTime { + let millis = self.0.load(Ordering::Relaxed); + chrono::DateTime::::from_timestamp_millis(millis as i64).unwrap() + } + } + + impl TestingClock { + pub fn new(now: DateTime) -> Arc { + Arc::new(Self(AtomicU64::new( + now.timestamp_millis().try_into().unwrap(), + ))) + } + + async fn advance(&self, duration: Duration) { + self.0 + .fetch_add(duration.as_millis().try_into().unwrap(), Ordering::Relaxed); + tokio::time::advance(duration).await; + } + } + + const HOUR: chrono::TimeDelta = chrono::TimeDelta::hours(1); + const MINUTE: chrono::TimeDelta = chrono::TimeDelta::minutes(1); + + #[tokio::test(start_paused = true)] + async fn test_everest_update() { + let clock = TestingClock::new(chrono::DateTime::UNIX_EPOCH + (HOUR * 9) + (MINUTE * 42)); + let past = tokio::time::Instant::now(); + + let (input_sender, input_receiver) = mpsc::channel(1); + let (output_sender, mut output_receiver) = mpsc::channel(1); + + let handle = tokio::spawn(update_listener( + Arc::clone(&clock), + input_receiver, + output_sender, + )); + assert!(!handle.is_finished()); + assert!(!output_receiver.is_closed()); + assert!(output_receiver.is_empty()); + + assert_eq!(past, tokio::time::Instant::now()); + clock.advance(Duration::from_secs(60)).await; + assert!(output_receiver.is_empty()); + + let event1_ts = chrono::DateTime::UNIX_EPOCH + (HOUR * 9); + let event2_ts = chrono::DateTime::UNIX_EPOCH + (HOUR * 10); + + let timeline = create_timeline(vec![(event1_ts, 42.0), (event2_ts, 21.0)]); + input_sender.send(timeline).await.unwrap(); + + let output = output_receiver.recv().await.unwrap(); + assert_eq!(output.limits_root_side.total_power_w, 42.0); + assert_eq!( + output.schedule, + vec![ + ScheduleResEntry { + timestamp: event1_ts, + limits_to_root: LimitsRes { + total_power_w: 42.0 + } + }, + ScheduleResEntry { + timestamp: event2_ts, + limits_to_root: LimitsRes { + total_power_w: 21.0 + } + } + ] + ); + + clock.advance(Duration::from_secs(60 * 60)).await; + let output = output_receiver.recv().await.unwrap(); + assert_eq!(output.limits_root_side.total_power_w, 21.0); + assert_eq!( + output.schedule, + vec![ScheduleResEntry { + timestamp: event2_ts, + limits_to_root: LimitsRes { + total_power_w: 21.0 + } + }] + ); + } + + fn create_timeline(entries: Vec<(DateTime, f64)>) -> Timeline { + let intervals = entries + .into_iter() + .map(|(start_time, value)| EventInterval { + id: 0, + interval_period: Some(IntervalPeriod::new(start_time)), + payloads: vec![EventValuesMap { + value_type: EventType::ImportCapacityLimit, + values: vec![Value::Number(value)], + }], + }) + .collect(); + + let program = ProgramContent::new("Limits for Arthur Dent"); + let event = EventContent::new(ProgramId::new("ad").unwrap(), intervals); + let events = vec![&event]; + + Timeline::from_events(&program, events).unwrap() + } +} diff --git a/openadr-client/src/error.rs b/openadr-client/src/error.rs new file mode 100644 index 0000000..233d355 --- /dev/null +++ b/openadr-client/src/error.rs @@ -0,0 +1,65 @@ +/// Errors that can occur using the [`Client`](crate::Client) +#[derive(Debug)] +pub enum Error { + Reqwest(reqwest::Error), + Serde(serde_json::Error), + UrlParseError(url::ParseError), + Problem(openadr_wire::problem::Problem), + AuthProblem(openadr_wire::oauth::OAuthError), + OAuthTokenNotBearer, + ObjectNotFound, + DuplicateObject, + InvalidParentObject, + InvalidInterval, +} + +impl From for Error { + fn from(err: reqwest::Error) -> Self { + Error::Reqwest(err) + } +} + +impl From for Error { + fn from(err: serde_json::Error) -> Self { + Error::Serde(err) + } +} + +impl From for Error { + fn from(err: url::ParseError) -> Self { + Error::UrlParseError(err) + } +} + +impl From for Error { + fn from(err: openadr_wire::problem::Problem) -> Self { + Error::Problem(err) + } +} + +impl From for Error { + fn from(err: openadr_wire::oauth::OAuthError) -> Self { + Error::AuthProblem(err) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::Reqwest(err) => write!(f, "Reqwest error: {}", err), + Error::Serde(err) => write!(f, "Serde error: {}", err), + Error::UrlParseError(err) => write!(f, "URL parse error: {}", err), + Error::Problem(err) => write!(f, "OpenADR Problem: {:?}", err), + Error::AuthProblem(err) => write!(f, "Authentication problem: {:?}", err), + Error::ObjectNotFound => write!(f, "Object not found"), + Error::DuplicateObject => write!(f, "Found more than one object matching the filter"), + Error::InvalidParentObject => write!(f, "Invalid parent object"), + Error::InvalidInterval => write!(f, "Invalid interval specified"), + Error::OAuthTokenNotBearer => write!(f, "OAuth token received is not a Bearer token"), + } + } +} + +impl std::error::Error for Error {} + +pub(crate) type Result = std::result::Result; diff --git a/openadr-client/src/event.rs b/openadr-client/src/event.rs new file mode 100644 index 0000000..c4e7cb4 --- /dev/null +++ b/openadr-client/src/event.rs @@ -0,0 +1,165 @@ +use std::sync::Arc; + +use crate::{ + error::{Error, Result}, + ClientRef, ReportClient, +}; +use openadr_wire::{ + event::EventContent, + report::{ReportContent, ReportObjectType}, + Event, Report, +}; + +#[derive(Debug)] +pub struct EventClient { + client: Arc, + data: Event, +} + +impl EventClient { + pub(super) fn from_event(client: Arc, event: Event) -> Self { + Self { + client, + data: event, + } + } + + pub fn id(&self) -> &openadr_wire::event::EventId { + &self.data.id + } + + pub fn created_date_time(&self) -> chrono::DateTime { + self.data.created_date_time + } + + pub fn modification_date_time(&self) -> chrono::DateTime { + self.data.modification_date_time + } + + pub fn content(&self) -> &EventContent { + &self.data.content + } + + pub fn content_mut(&mut self) -> &mut EventContent { + &mut self.data.content + } + + /// Save any modifications of the event to the VTN + pub async fn update(&mut self) -> Result<()> { + let res = self + .client + .put(&format!("events/{}", self.id()), &self.data.content, &[]) + .await?; + self.data = res; + Ok(()) + } + + /// Delete the event from the VTN + pub async fn delete(self) -> Result { + self.client + .delete(&format!("events/{}", self.id()), &[]) + .await + } + + /// Create a new report object + pub fn new_report(&self) -> ReportContent { + ReportContent { + object_type: Some(ReportObjectType::Report), + program_id: self.content().program_id.clone(), + event_id: self.id().clone(), + client_name: "".to_string(), + report_name: None, + payload_descriptors: None, + resources: vec![], + } + } + + /// Create a new report for the event + pub async fn create_report(&self, report_data: ReportContent) -> Result { + if report_data.program_id != self.content().program_id { + return Err(Error::InvalidParentObject); + } + + if &report_data.event_id != self.id() { + return Err(Error::InvalidParentObject); + } + + let report = self.client.post("events", &report_data, &[]).await?; + Ok(ReportClient::from_report(self.client.clone(), report)) + } + + async fn get_reports_req( + &self, + client_name: Option<&str>, + skip: usize, + limit: usize, + ) -> Result> { + let skip_str = skip.to_string(); + let limit_str = limit.to_string(); + + let mut query = vec![ + ("programID", self.content().program_id.as_str()), + ("eventID", self.id().as_str()), + ("skip", &skip_str), + ("limit", &limit_str), + ]; + + if let Some(client_name) = client_name { + query.push(("clientName", client_name)); + } + + let reports: Vec = self.client.get("reports", &query).await?; + Ok(reports + .into_iter() + .map(|report| ReportClient::from_report(self.client.clone(), report)) + .collect()) + } + + /// Get all reports from the VTN for a specific client, trying to paginate whenever possible + pub async fn get_client_reports(&self, client_name: &str) -> Result> { + let page_size = self.client.default_page_size(); + let mut reports = vec![]; + let mut page = 0; + loop { + let received = self + .get_reports_req(Some(client_name), page * page_size, page_size) + .await?; + let received_all = received.len() < page_size; + for report in received { + reports.push(report); + } + + if received_all { + break; + } else { + page += 1; + } + } + + Ok(reports) + } + + /// Get all reports from the VTN, trying to paginate whenever possible + pub async fn get_all_reports(&self) -> Result> { + let page_size = self.client.default_page_size(); + let mut reports = vec![]; + let mut page = 0; + loop { + let received = self + .get_reports_req(None, page * page_size, page_size) + .await?; + let received_all = received.len() < page_size; + for report in received { + reports.push(report); + } + + if received_all { + break; + } else { + page += 1; + } + } + + Ok(reports) + } +} diff --git a/openadr-client/src/lib.rs b/openadr-client/src/lib.rs new file mode 100644 index 0000000..aaa756d --- /dev/null +++ b/openadr-client/src/lib.rs @@ -0,0 +1,617 @@ +mod error; +mod event; +mod program; +mod report; +mod target; +mod timeline; + +use axum::async_trait; +use openadr_wire::{event::EventId, Event}; +use std::{ + fmt::Debug, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::RwLock; + +use axum::body::Body; +use http_body_util::BodyExt; +use reqwest::{Method, RequestBuilder, Response}; +use tower::{Service, ServiceExt}; +use url::Url; + +pub use error::*; +pub use event::*; +pub use program::*; +pub use report::*; +pub use target::*; +pub use timeline::*; + +use crate::error::Result; +pub(crate) use openadr_wire::{ + event::EventContent, + program::{ProgramContent, ProgramId}, + target::TargetLabel, + Program, +}; + +#[async_trait] +trait HttpClient: Debug { + fn request_builder(&self, method: Method, url: Url) -> RequestBuilder; + async fn send(&self, req: RequestBuilder) -> reqwest::Result; +} + +/// Client used for interaction with a VTN. +/// +/// Can be used to implement both, the VEN and the business logic +#[derive(Debug, Clone)] +pub struct Client { + client_ref: Arc, +} + +pub struct ClientCredentials { + pub client_id: String, + client_secret: String, + pub refresh_margin: Duration, + pub default_credential_expires_in: Duration, +} + +impl Debug for ClientCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct(std::any::type_name::()) + .field("client_id", &self.client_id) + .field("refresh_margin", &self.refresh_margin) + .field( + "default_credential_expires_in", + &self.default_credential_expires_in, + ) + .finish_non_exhaustive() + } +} + +impl ClientCredentials { + pub fn new(client_id: String, client_secret: String) -> Self { + Self { + client_id, + client_secret, + refresh_margin: Duration::from_secs(60), + default_credential_expires_in: Duration::from_secs(3600), + } + } + + pub fn admin() -> Self { + Self::new("admin".to_string(), "admin".to_string()) + } +} + +struct AuthToken { + token: String, + expires_in: Duration, + since: Instant, +} + +impl Debug for AuthToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct(std::any::type_name::()) + .field("expires_in", &self.expires_in) + .field("since", &self.since) + .finish_non_exhaustive() + } +} + +#[derive(Debug)] +pub struct ClientRef { + client: Box, + base_url: Url, + default_page_size: usize, + auth_data: Option, + auth_token: RwLock>, +} + +impl ClientRef { + /// This ensures the client is authenticated. + /// + /// We follow the process according to RFC 6749, section 4.4 (client + /// credentials grant). The client id and secret are by default sent via + /// HTTP Basic Auth. + async fn ensure_auth(&self) -> Result<()> { + // if there is no auth data we don't do any authentication + let Some(auth_data) = &self.auth_data else { + return Ok(()); + }; + + // if there is a token and it is valid long enough, we don't have to do anything + if let Some(token) = self.auth_token.read().await.as_ref() { + if token.since.elapsed() < token.expires_in - auth_data.refresh_margin { + return Ok(()); + } + } + + #[derive(serde::Serialize)] + struct AccessTokenRequest { + grant_type: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + client_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + client_secret: Option, + } + + // we should authenticate + let auth_url = self.base_url.join("auth/token")?; + let request = + self.client + .request_builder(Method::POST, auth_url) + .form(&AccessTokenRequest { + grant_type: "client_credentials", + scope: None, + client_id: None, + client_secret: None, + }); + let request = request.basic_auth(&auth_data.client_id, Some(&auth_data.client_secret)); + let request = request.header("Accept", "application/json"); + let since = Instant::now(); + let res = self.client.send(request).await?; + if !res.status().is_success() { + let problem = res.json::().await?; + return Err(Error::AuthProblem(problem)); + } + + #[derive(Debug, serde::Deserialize)] + struct AuthResult { + access_token: String, + token_type: String, + #[serde(default)] + expires_in: Option, + // Refresh tokens aren't supported currently + // #[serde(default)] + // refresh_token: Option, + // #[serde(default)] + // scope: Option, + // #[serde(flatten)] + // other: std::collections::HashMap, + } + + let auth_result = res.json::().await?; + if auth_result.token_type.to_lowercase() != "bearer" { + return Err(Error::OAuthTokenNotBearer); + } + let token = AuthToken { + token: auth_result.access_token, + expires_in: auth_result + .expires_in + .map(Duration::from_secs) + .unwrap_or(auth_data.default_credential_expires_in), + since, + }; + + *self.auth_token.write().await = Some(token); + Ok(()) + } + + async fn request( + &self, + mut request: RequestBuilder, + query: &[(&str, &str)], + ) -> Result { + self.ensure_auth().await?; + request = request.header("Accept", "application/json"); + if !query.is_empty() { + request = request.query(&query); + } + + // read token and insert in request if available + { + let token = self.auth_token.read().await; + if let Some(token) = token.as_ref() { + request = request.bearer_auth(&token.token); + } + } + let res = self.client.send(request).await?; + + // handle any errors returned by the server + if !res.status().is_success() { + let problem = res.json::().await?; + return Err(crate::error::Error::from(problem)); + } + + Ok(res.json().await?) + } + + async fn get( + &self, + path: &str, + query: &[(&str, &str)], + ) -> Result { + let url = self.base_url.join(path)?; + let request = self.client.request_builder(Method::GET, url); + self.request(request, query).await + } + + async fn post(&self, path: &str, body: &S, query: &[(&str, &str)]) -> Result + where + S: serde::ser::Serialize + Sync, + T: serde::de::DeserializeOwned, + { + let url = self.base_url.join(path)?; + let request = self.client.request_builder(Method::POST, url).json(body); + self.request(request, query).await + } + + async fn put(&self, path: &str, body: &S, query: &[(&str, &str)]) -> Result + where + S: serde::ser::Serialize + Sync, + T: serde::de::DeserializeOwned, + { + let url = self.base_url.join(path)?; + let request = self.client.request_builder(Method::PUT, url).json(body); + self.request(request, query).await + } + + async fn delete(&self, path: &str, query: &[(&str, &str)]) -> Result + where + T: serde::de::DeserializeOwned, + { + let url = self.base_url.join(path)?; + let request = self.client.request_builder(Method::DELETE, url); + self.request(request, query).await + } + + fn default_page_size(&self) -> usize { + self.default_page_size + } +} + +#[derive(Debug)] +pub struct ReqwestClientRef { + client: reqwest::Client, +} + +#[async_trait] +impl HttpClient for ReqwestClientRef { + fn request_builder(&self, method: Method, url: Url) -> RequestBuilder { + self.client.request(method, url) + } + + async fn send(&self, req: RequestBuilder) -> std::result::Result { + req.send().await + } +} + +#[derive(Debug)] +pub struct MockClientRef { + router: Arc>, +} + +impl MockClientRef { + pub fn new(router: axum::Router) -> Self { + MockClientRef { + router: Arc::new(tokio::sync::Mutex::new(router)), + } + } + + pub fn into_client(self, auth: Option) -> Client { + let client = ClientRef { + client: Box::new(self), + base_url: Url::parse("https://example.com/").unwrap(), + default_page_size: 50, + auth_data: auth, + auth_token: RwLock::new(None), + }; + + Client::new(client) + } +} + +#[async_trait] +impl HttpClient for MockClientRef { + fn request_builder(&self, method: Method, url: Url) -> RequestBuilder { + reqwest::Client::new().request(method, url) + } + + async fn send(&self, req: RequestBuilder) -> reqwest::Result { + let request = axum::http::Request::try_from(req.build().unwrap()).unwrap(); + + let response = + ServiceExt::>::ready(&mut *self.router.lock().await) + .await + .unwrap() + .call(request) + .await + .unwrap(); + + let (parts, body) = response.into_parts(); + let body = body.collect().await.unwrap().to_bytes(); + let body = reqwest::Body::from(body); + let response = axum::http::Response::from_parts(parts, body); + + Ok(response.into()) + } +} + +pub struct PaginationOptions { + pub skip: usize, + pub limit: usize, +} + +pub enum Filter<'a> { + None, + By(TargetLabel, &'a [&'a str]), +} + +impl Client { + /// Create a new client for a VTN located at the specified URL + pub fn with_url(base_url: Url, auth: Option) -> Self { + let client = reqwest::Client::new(); + Self::with_reqwest(base_url, client, auth) + } + + /// Create a new client, but use the specific reqwest client instead of + /// the default one. This allows you to configure proxy settings, timeouts, etc. + pub fn with_reqwest( + base_url: Url, + client: reqwest::Client, + auth: Option, + ) -> Self { + let client_ref = ClientRef { + client: Box::new(ReqwestClientRef { client }), + base_url, + default_page_size: 50, + auth_data: auth, + auth_token: RwLock::new(None), + }; + + Self::new(client_ref) + } + + fn new(client_ref: ClientRef) -> Self { + Client { + client_ref: Arc::new(client_ref), + } + } + + /// Create a new program on the VTN + pub async fn create_program(&self, program_content: ProgramContent) -> Result { + let program = self + .client_ref + .post("programs", &program_content, &[]) + .await?; + Ok(ProgramClient::from_program(self.clone(), program)) + } + + /// Lowlevel operation that gets a list of programs from the VTN with the given query parameters + pub async fn get_programs( + &self, + filter: Filter<'_>, + pagination: PaginationOptions, + ) -> Result> { + // convert query params + let skip_str = pagination.skip.to_string(); + let limit_str = pagination.limit.to_string(); + + // insert into query params + let mut query = vec![]; + + if let Filter::By(ref target_label, target_values) = filter { + query.push(("targetType", target_label.as_str())); + + for target_value in target_values { + query.push(("targetValues", *target_value)); + } + } + + query.push(("skip", &skip_str)); + query.push(("limit", &limit_str)); + + // send request and return response + let programs: Vec = self.client_ref.get("programs", &query).await?; + Ok(programs + .into_iter() + .map(|program| ProgramClient::from_program(self.clone(), program)) + .collect()) + } + + /// Get a list of programs from the VTN with the given query parameters + pub async fn get_program_list(&self, target: Target<'_>) -> Result> { + let page_size = self.client_ref.default_page_size(); + let mut programs = vec![]; + let mut page = 0; + loop { + let pagination = PaginationOptions { + skip: page * page_size, + limit: page_size, + }; + + let received = self + .get_programs( + Filter::By(target.target_label(), target.target_values()), + pagination, + ) + .await?; + let received_all = received.len() < page_size; + for program in received { + programs.push(program); + } + + if received_all { + break; + } else { + page += 1; + } + } + + Ok(programs) + } + + /// Get all programs from the VTN, trying to paginate whenever possible + pub async fn get_all_programs(&self) -> Result> { + let page_size = self.client_ref.default_page_size(); + let mut programs = vec![]; + + for page in 0.. { + // TODO: this pagination should really depend on that the server indicated there are more results + let pagination = PaginationOptions { + skip: page * page_size, + limit: page_size, + }; + + let received = self.get_programs(Filter::None, pagination).await?; + let received_all = received.len() < page_size; + for program in received { + programs.push(program); + } + + if received_all { + break; + } + } + + Ok(programs) + } + + /// Get a program by name + pub async fn get_program_by_name(&self, name: &str) -> Result { + let target = Target::Program(name); + + let pagination = PaginationOptions { skip: 0, limit: 2 }; + let mut programs = self + .get_programs( + Filter::By(target.target_label(), target.target_values()), + pagination, + ) + .await?; + + match programs[..] { + [] => Err(crate::Error::ObjectNotFound), + [_] => Ok(programs.remove(0)), + [..] => Err(crate::Error::DuplicateObject), + } + } + + /// Get a program by id + pub async fn get_program_by_id(&self, id: &ProgramId) -> Result { + let program = self + .client_ref + .get(&format!("programs/{}", id.as_str()), &[]) + .await?; + + Ok(ProgramClient::from_program(self.clone(), program)) + } + + /// Create a new event on the VTN + pub async fn create_event(&self, event_data: EventContent) -> Result { + let event = self.client_ref.post("events", &event_data, &[]).await?; + Ok(EventClient::from_event(self.client_ref.clone(), event)) + } + + /// Lowlevel operation that gets a list of events from the VTN with the given query parameters + pub async fn get_events( + &self, + program_id: Option<&ProgramId>, + filter: Filter<'_>, + pagination: PaginationOptions, + ) -> Result> { + let mut query = vec![]; + + if let Filter::By(ref target_label, target_values) = filter { + query.push(("targetType", target_label.as_str())); + + for target_value in target_values { + query.push(("targetValues", *target_value)); + } + } + + if let Some(program_id) = program_id { + query.push(("programID", program_id.as_str())); + } + + let skip_str = pagination.skip.to_string(); + let limit_str = pagination.limit.to_string(); + + query.push(("skip", &skip_str)); + query.push(("limit", &limit_str)); + + // send request and return response + let events: Vec = self.client_ref.get("events", &query).await?; + Ok(events + .into_iter() + .map(|event| EventClient::from_event(self.client_ref.clone(), event)) + .collect()) + } + + /// Get a list of events from the VTN with the given query parameters + pub async fn get_event_list( + &self, + program_id: Option<&ProgramId>, + target: Target<'_>, + ) -> Result> { + let page_size = self.client_ref.default_page_size(); + let mut events = vec![]; + let mut page = 0; + loop { + let pagination = PaginationOptions { + skip: page * page_size, + limit: page_size, + }; + + let received = self + .get_events( + program_id, + Filter::By(target.target_label(), target.target_values()), + pagination, + ) + .await?; + let received_all = received.len() < page_size; + for event in received { + events.push(event); + } + + if received_all { + break; + } else { + page += 1; + } + } + + Ok(events) + } + + /// Get all events from the VTN, trying to paginate whenever possible + pub async fn get_all_events(&self) -> Result> { + let page_size = self.client_ref.default_page_size(); + let mut events = vec![]; + let mut page = 0; + loop { + // TODO: this pagination should really depend on that the server indicated there are more results + let pagination = PaginationOptions { + skip: page * page_size, + limit: page_size, + }; + + let received = self.get_events(None, Filter::None, pagination).await?; + let received_all = received.len() < page_size; + for event in received { + events.push(event); + } + + if received_all { + break; + } else { + page += 1; + } + } + + Ok(events) + } + + /// Get a event by id + pub async fn get_event_by_id(&self, id: &EventId) -> Result { + let event = self + .client_ref + .get(&format!("events/{}", id.as_str()), &[]) + .await?; + + Ok(EventClient::from_event(self.client_ref.clone(), event)) + } +} diff --git a/openadr-client/src/program.rs b/openadr-client/src/program.rs new file mode 100644 index 0000000..6179d34 --- /dev/null +++ b/openadr-client/src/program.rs @@ -0,0 +1,155 @@ +use openadr_wire::{ + event::{EventObjectType, Priority}, + Program, +}; + +use crate::{ + error::{Error, Result}, + Client, EventClient, EventContent, Filter, PaginationOptions, ProgramContent, ProgramId, + Target, Timeline, +}; + +/// A client for interacting with the data in a specific program and the events +/// contained in the program. +#[derive(Debug)] +pub struct ProgramClient { + client: Client, + data: Program, +} + +impl ProgramClient { + pub(super) fn from_program(client: Client, program: Program) -> Self { + Self { + client, + data: program, + } + } + + /// Get the id of the program + pub fn id(&self) -> &ProgramId { + &self.data.id + } + + /// Get the time the program was created on the VTN + pub fn created_date_time(&self) -> chrono::DateTime { + self.data.created_date_time + } + + /// Get the time the program was last modified on the VTN + pub fn modification_date_time(&self) -> chrono::DateTime { + self.data.modification_date_time + } + + /// Read the data of the program + pub fn content(&self) -> &ProgramContent { + &self.data.content + } + + /// Modify the data of the program, make sure to update the program on the + /// VTN once your modifications are complete. + pub fn content_mut(&mut self) -> &mut ProgramContent { + &mut self.data.content + } + + /// Save any modifications of the program to the VTN + pub async fn update(&mut self) -> Result<()> { + let res = self + .client + .client_ref + .put(&format!("programs/{}", self.id()), &self.data.content, &[]) + .await?; + self.data = res; + Ok(()) + } + + /// Delete the program from the VTN + pub async fn delete(self) -> Result { + self.client + .client_ref + .delete(&format!("programs/{}", self.id()), &[]) + .await + } + + /// Create a new event on the VTN + pub async fn create_event(&self, event_data: EventContent) -> Result { + if &event_data.program_id != self.id() { + return Err(Error::InvalidParentObject); + } + let event = self + .client + .client_ref + .post("events", &event_data, &[]) + .await?; + Ok(EventClient::from_event( + self.client.client_ref.clone(), + event, + )) + } + + /// Create a new event object within the program + pub fn new_event(&self) -> EventContent { + EventContent { + object_type: Some(EventObjectType::Event), + program_id: self.id().clone(), + event_name: None, + priority: Priority::UNSPECIFIED, + targets: None, + report_descriptors: None, + payload_descriptors: None, + interval_period: None, + intervals: vec![], + } + } + + pub async fn get_events_request( + &self, + filter: Filter<'_>, + pagination: PaginationOptions, + ) -> Result> { + self.client + .get_events(Some(self.id()), filter, pagination) + .await + } + + /// Get a list of events from the VTN with the given query parameters + pub async fn get_event_list(&self, target: Target<'_>) -> Result> { + self.client.get_event_list(Some(self.id()), target).await + } + + /// Get all events from the VTN, trying to paginate whenever possible + pub async fn get_all_events(&self) -> Result> { + let page_size = self.client.client_ref.default_page_size(); + let mut events = vec![]; + let mut page = 0; + loop { + // TODO: this pagination should really depend on that the server indicated there are more results + let pagination = PaginationOptions { + skip: page * page_size, + limit: page_size, + }; + + let received = self + .client + .get_events(Some(self.id()), Filter::None, pagination) + .await?; + let received_all = received.len() < page_size; + for event in received { + events.push(event); + } + + if received_all { + break; + } else { + page += 1; + } + } + + Ok(events) + } + + pub async fn get_timeline(&mut self) -> Result { + let events = self.get_all_events().await?; + let events = events.iter().map(|e| e.content()).collect(); + Timeline::from_events(self.content(), events).ok_or(Error::InvalidInterval) + } +} diff --git a/openadr-client/src/report.rs b/openadr-client/src/report.rs new file mode 100644 index 0000000..24b2d1f --- /dev/null +++ b/openadr-client/src/report.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use openadr_wire::{report::ReportContent, Report}; + +use crate::{error::Result, ClientRef}; + +#[derive(Debug)] +pub struct ReportClient { + client: Arc, + data: Report, +} + +impl ReportClient { + pub(super) fn from_report(client: Arc, report: Report) -> Self { + Self { + client, + data: report, + } + } + + pub fn id(&self) -> &openadr_wire::report::ReportId { + &self.data.id + } + + pub fn created_date_time(&self) -> &chrono::DateTime { + &self.data.created_date_time + } + + pub fn modification_date_time(&self) -> &chrono::DateTime { + &self.data.modification_date_time + } + + pub fn data(&self) -> &ReportContent { + &self.data.content + } + + pub fn data_mut(&mut self) -> &mut ReportContent { + &mut self.data.content + } + + /// Save any modifications of the report to the VTN + pub async fn update(&mut self) -> Result<()> { + let res = self + .client + .put(&format!("reports/{}", self.id()), &self.data.content, &[]) + .await?; + self.data = res; + Ok(()) + } + + /// Delete the report from the VTN + pub async fn delete(self) -> Result<()> { + self.client + .delete(&format!("reports/{}", self.id()), &[]) + .await + } +} diff --git a/openadr-client/src/target.rs b/openadr-client/src/target.rs new file mode 100644 index 0000000..de8efe1 --- /dev/null +++ b/openadr-client/src/target.rs @@ -0,0 +1,93 @@ +use openadr_wire::target::TargetLabel; + +/// Target for a query to the VTN +#[derive(Copy, Clone, Debug)] +pub enum Target<'a> { + /// Target by a specific program name + Program(&'a str), + + /// Target by a list of program names + Programs(&'a [&'a str]), + + /// Target by a specific event name + Event(&'a str), + + /// Target by a list of event names + Events(&'a [&'a str]), + + /// Target by a specific VEN name + VEN(&'a str), + + /// Target by a list of VEN names + VENs(&'a [&'a str]), + + /// Target by a specific group name + Group(&'a str), + + /// Target by a list of group names + Groups(&'a [&'a str]), + + /// Target by a specific resource name + Resource(&'a str), + + /// Target by a list of resource names + Resources(&'a [&'a str]), + + /// Target by a specific service area + ServiceArea(&'a str), + + /// Target by a list of service areas + ServiceAreas(&'a [&'a str]), + + /// Target by a specific power service location + PowerServiceLocation(&'a str), + + /// Target by a list of power service locations + PowerServiceLocations(&'a [&'a str]), + + /// Target using some other kind of privately defined target type, using a single target value + Other(&'a str, &'a str), + + /// Target using some other kind of privately defined target type, with a list of values + Others(&'a str, &'a [&'a str]), +} + +impl<'a> Target<'a> { + /// Get the target label for this specific target + pub fn target_label(&self) -> TargetLabel { + match self { + Target::Program(_) | Target::Programs(_) => TargetLabel::ProgramName, + Target::Event(_) | Target::Events(_) => TargetLabel::EventName, + Target::VEN(_) | Target::VENs(_) => TargetLabel::VENName, + Target::Group(_) | Target::Groups(_) => TargetLabel::Group, + Target::Resource(_) | Target::Resources(_) => TargetLabel::ResourceName, + Target::ServiceArea(_) | Target::ServiceAreas(_) => TargetLabel::ServiceArea, + Target::PowerServiceLocation(_) | Target::PowerServiceLocations(_) => { + TargetLabel::PowerServiceLocation + } + Target::Other(p, _) | Target::Others(p, _) => TargetLabel::Private(p.to_string()), + } + } + + /// Get the list of target values for this specific target + pub fn target_values(&self) -> &[&str] { + match self { + Target::Program(v) => std::slice::from_ref(v), + Target::Programs(v) => v, + Target::Event(v) => std::slice::from_ref(v), + Target::Events(v) => v, + Target::VEN(v) => std::slice::from_ref(v), + Target::VENs(v) => v, + Target::Group(v) => std::slice::from_ref(v), + Target::Groups(v) => v, + Target::Resource(v) => std::slice::from_ref(v), + Target::Resources(v) => v, + Target::ServiceArea(v) => std::slice::from_ref(v), + Target::ServiceAreas(v) => v, + Target::PowerServiceLocation(v) => std::slice::from_ref(v), + Target::PowerServiceLocations(v) => v, + Target::Other(_, v) => std::slice::from_ref(v), + Target::Others(_, v) => v, + } + } +} diff --git a/openadr-client/src/timeline.rs b/openadr-client/src/timeline.rs new file mode 100644 index 0000000..c577744 --- /dev/null +++ b/openadr-client/src/timeline.rs @@ -0,0 +1,361 @@ +#![allow(dead_code)] +use std::{collections::HashSet, ops::Range}; + +use chrono::{DateTime, Utc}; +use tracing::warn; + +use openadr_wire::{ + event::{EventContent, EventValuesMap, Priority}, + interval::IntervalPeriod, + program::ProgramContent, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InternalInterval { + /// Id so that split itervals with a randomized start don't start randomly twice + id: u32, + /// Relative priority of event + priority: Priority, + /// Indicates a randomization time that may be applied to start. + randomize_start: Option, + /// The actual values that are active during this interval + value_map: Vec, +} + +/// A sequence of ordered, non-overlapping intervals and associated values. +/// +/// Intervals are sorted by their timestamp. The intervals will not overlap, but there may be gaps +/// between intervals. +#[allow(unused)] +#[derive(Clone, Default, Debug)] +pub struct Timeline { + data: rangemap::RangeMap, InternalInterval>, +} + +impl Timeline { + pub fn new() -> Self { + Self { + data: rangemap::RangeMap::new(), + } + } + + /// Returns: + /// + /// - `None` if no interval is specified in the input + /// - `Some(timeline)` otherwise + pub fn from_events(program: &ProgramContent, mut events: Vec<&EventContent>) -> Option { + let mut data = Self::default(); + + events.sort_by_key(|e| e.priority); + + for (id, event) in events.iter().enumerate() { + // SPEC ASSUMPTION: At least one of the following `interval_period`s must be given on the program, + // on the event, or on the interval + let default_period = event + .interval_period + .as_ref() + .or(program.interval_period.as_ref()); + + for event_interval in &event.intervals { + // use the even't interval period when the interval doesn't specify one + let period = event_interval.interval_period.as_ref().or(default_period)?; + + let IntervalPeriod { + start, + duration, + randomize_start, + } = period; + + let range = match duration { + Some(duration) => *start..*start + duration.to_chrono_at_datetime(*start), + None => *start..DateTime::::MAX_UTC, + }; + + let interval = InternalInterval { + id: id as u32, + randomize_start: randomize_start + .as_ref() + .map(|d| d.to_chrono_at_datetime(*start)), + value_map: event_interval.payloads.clone(), + priority: event.priority, + }; + + for (existing_range, existing) in data.data.overlapping(&range) { + if existing.priority == event.priority { + warn!(?existing_range, ?existing, new_range = ?range, new = ?interval, "Overlapping ranges with equal priority"); + } + } + + data.data.insert(range, interval); + } + } + + Some(data) + } + + pub fn iter(&self) -> Iter<'_> { + Iter { + iter: self.data.iter(), + seen: HashSet::default(), + } + } + + pub fn at_datetime( + &self, + datetime: &DateTime, + ) -> Option<(&Range>, Interval)> { + let (range, internal_interval) = self.data.get_key_value(datetime)?; + + let interval = Interval { + randomize_start: internal_interval.randomize_start, + value_map: &internal_interval.value_map, + }; + + Some((range, interval)) + } + + pub fn next_update(&self, datetime: &DateTime) -> Option> { + if let Some((k, _)) = self.at_datetime(datetime) { + return Some(k.end); + } + + let (last_range, _) = self.data.last_range_value()?; + + let (range, _) = self.data.overlapping(*datetime..last_range.end).next()?; + + Some(range.start) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Interval<'a> { + /// Indicates a randomization time that may be applied to start. + pub randomize_start: Option, + /// The actual values that are active during this interval + pub value_map: &'a [EventValuesMap], +} + +pub struct Iter<'a> { + iter: rangemap::map::Iter<'a, DateTime, InternalInterval>, + seen: HashSet, +} + +impl<'a> Iterator for Iter<'a> { + type Item = (&'a Range>, Interval<'a>); + + fn next(&mut self) -> Option { + let (range, internal) = self.iter.next()?; + + let interval = Interval { + // only the first occurence of an id should randomize its start + randomize_start: match self.seen.insert(internal.id) { + true => internal.randomize_start, + false => None, + }, + value_map: &internal.value_map, + }; + + Some((range, interval)) + } +} + +#[cfg(test)] +mod test { + use std::ops::Range; + + use chrono::{DateTime, Duration, Utc}; + + use openadr_wire::{event::EventInterval, program::ProgramId, values_map::Value}; + + use super::*; + + fn test_program_id() -> ProgramId { + ProgramId::new("test-program-id").unwrap() + } + + fn test_event_content(range: Range, value: i64) -> EventContent { + EventContent::new( + test_program_id(), + vec![event_interval_with_value(range, value)], + ) + } + + fn event_interval_with_value(range: Range, value: i64) -> EventInterval { + EventInterval { + id: range.start as _, + interval_period: Some(IntervalPeriod { + start: DateTime::UNIX_EPOCH + Duration::hours(range.start.into()), + duration: Some(openadr_wire::Duration::hours( + (range.end - range.start) as _, + )), + randomize_start: None, + }), + payloads: vec![EventValuesMap { + value_type: openadr_wire::event::EventType::Price, + values: vec![Value::Integer(value)], + }], + } + } + + fn interval_with_value( + id: u32, + range: Range, + value: i64, + priority: Priority, + ) -> (Range>, InternalInterval) { + let start = DateTime::UNIX_EPOCH + Duration::hours(range.start.into()); + let end = DateTime::UNIX_EPOCH + Duration::hours(range.end.into()); + + ( + start..end, + InternalInterval { + id, + randomize_start: None, + value_map: vec![EventValuesMap { + value_type: openadr_wire::event::EventType::Price, + values: vec![Value::Integer(value)], + }], + priority, + }, + ) + } + + // the spec does not specify the behavior when two intervals with the same priority overlap. + // Our current implementation uses `RangeMap`, and its behavior is to overwrite the existing + // range with a new one. In other words: the event that is inserted last wins. + #[test] + fn overlap_same_priority() { + let program = ProgramContent::new("p"); + + let event1 = test_event_content(0..10, 42); + let event2 = test_event_content(5..15, 43); + + // first come, last serve + let tl1 = Timeline::from_events(&program, vec![&event1, &event2]).unwrap(); + assert_eq!( + tl1.data.into_iter().collect::>(), + vec![ + interval_with_value(0, 0..5, 42, Priority::UNSPECIFIED), + interval_with_value(1, 5..15, 43, Priority::UNSPECIFIED), + ] + ); + + // first come, last serve + let tl2 = Timeline::from_events(&program, vec![&event2, &event1]).unwrap(); + assert_eq!( + tl2.data.into_iter().collect::>(), + vec![ + interval_with_value(1, 0..10, 42, Priority::UNSPECIFIED), + interval_with_value(0, 10..15, 43, Priority::UNSPECIFIED), + ] + ); + } + + #[test] + fn overlap_lower_priority() { + let event1 = test_event_content(0..10, 42).with_priority(Priority::new(1)); + let event2 = test_event_content(5..15, 43).with_priority(Priority::new(2)); + + let tl = Timeline::from_events(&ProgramContent::new("p"), vec![&event1, &event2]).unwrap(); + assert_eq!( + tl.data.into_iter().collect::>(), + vec![ + interval_with_value(1, 0..10, 42, Priority::new(1)), + interval_with_value(0, 10..15, 43, Priority::new(2)), + ], + "a lower priority event MUST NOT overwrite a higher priority one", + ); + + let tl = Timeline::from_events(&ProgramContent::new("p"), vec![&event2, &event1]).unwrap(); + assert_eq!( + tl.data.into_iter().collect::>(), + vec![ + interval_with_value(1, 0..10, 42, Priority::new(1)), + interval_with_value(0, 10..15, 43, Priority::new(2)), + ], + "a lower priority event MUST NOT overwrite a higher priority one", + ); + } + + #[test] + fn overlap_higher_priority() { + let event1 = test_event_content(0..10, 42).with_priority(Priority::new(2)); + let event2 = test_event_content(5..15, 43).with_priority(Priority::new(1)); + + let tl = Timeline::from_events(&ProgramContent::new("p"), vec![&event1, &event2]).unwrap(); + assert_eq!( + tl.data.into_iter().collect::>(), + vec![ + interval_with_value(0, 0..5, 42, Priority::new(2)), + interval_with_value(1, 5..15, 43, Priority::new(1)), + ], + "a higher priority event MUST overwrite a lower priority one", + ); + + let tl = Timeline::from_events(&ProgramContent::new("p"), vec![&event2, &event1]).unwrap(); + assert_eq!( + tl.data.into_iter().collect::>(), + vec![ + interval_with_value(0, 0..5, 42, Priority::new(2)), + interval_with_value(1, 5..15, 43, Priority::new(1)), + ], + "a higher priority event MUST overwrite a lower priority one", + ); + } + + #[test] + fn randomize_start_not_duplicated() { + let event1 = test_event_content(5..10, 42).with_priority(Priority::MAX); + + let event2 = { + let range = 0..15; + let value = 43; + EventContent::new( + test_program_id(), + vec![EventInterval { + id: range.start as _, + interval_period: Some(IntervalPeriod { + start: DateTime::UNIX_EPOCH + Duration::hours(range.start.into()), + duration: Some(openadr_wire::Duration::hours( + (range.end - range.start) as _, + )), + randomize_start: Some(openadr_wire::Duration::hours(5.0)), + }), + payloads: vec![EventValuesMap { + value_type: openadr_wire::event::EventType::Price, + values: vec![Value::Integer(value)], + }], + }], + ) + }; + + let tl = Timeline::from_events(&ProgramContent::new("p"), vec![&event1, &event2]).unwrap(); + assert_eq!( + tl.iter().map(|(_, i)| i).collect::>(), + vec![ + Interval { + randomize_start: Some(Duration::hours(5)), + value_map: &[EventValuesMap { + value_type: openadr_wire::event::EventType::Price, + values: vec![Value::Integer(43)], + }], + }, + Interval { + randomize_start: None, + value_map: &[EventValuesMap { + value_type: openadr_wire::event::EventType::Price, + values: vec![Value::Integer(42)], + }], + }, + Interval { + randomize_start: None, + value_map: &[EventValuesMap { + value_type: openadr_wire::event::EventType::Price, + values: vec![Value::Integer(43)], + }], + }, + ], + "when an event is split, only the first interval should retain `randomize_start`", + ); + } +} diff --git a/openadr-client/tests/basic-read.rs b/openadr-client/tests/basic-read.rs new file mode 100644 index 0000000..2bc7423 --- /dev/null +++ b/openadr-client/tests/basic-read.rs @@ -0,0 +1,19 @@ +use openadr_wire::program::ProgramContent; +use sqlx::PgPool; + +mod common; + +#[sqlx::test(fixtures("users"))] +async fn basic_create_read(db: PgPool) -> Result<(), openadr_client::Error> { + let client = common::setup_client(db).await; + + client + .create_program(ProgramContent::new("test-prog")) + .await?; + + let programs = client.get_all_programs().await?; + assert_eq!(programs.len(), 1); + assert_eq!(programs[0].content().program_name, "test-prog"); + + Ok(()) +} diff --git a/openadr-client/tests/common/mod.rs b/openadr-client/tests/common/mod.rs new file mode 100644 index 0000000..dbe783c --- /dev/null +++ b/openadr-client/tests/common/mod.rs @@ -0,0 +1,60 @@ +use openadr_client::{Client, ClientCredentials, MockClientRef, ProgramClient}; +use openadr_wire::program::ProgramContent; +use sqlx::PgPool; +use std::env::VarError; +use url::Url; + +// FIXME make this function independent of the storage backend +pub async fn setup_mock_client(db: PgPool) -> Client { + use openadr_vtn::{data_source::PostgresStorage, jwt::JwtManager, state::AppState}; + + // let auth_info = AuthInfo::bl_admin(); + let client_credentials = ClientCredentials::admin(); + + let storage = PostgresStorage::new(db).unwrap(); + // storage.auth.try_write().unwrap().push(auth_info); + + let app_state = AppState::new(storage, JwtManager::from_secret(b"test")); + + MockClientRef::new(app_state.into_router()).into_client(Some(client_credentials)) +} + +pub fn setup_url_client(url: Url) -> Client { + Client::with_url(url, Some(ClientCredentials::admin())) +} + +pub async fn setup_client(db: PgPool) -> Client { + match std::env::var("OPENADR_RS_VTN_URL") { + Ok(url) => match url.parse() { + Ok(url) => setup_url_client(url), + Err(e) => panic!("Could not parse URL: {e}"), + }, + Err(VarError::NotPresent) => setup_mock_client(db).await, + Err(VarError::NotUnicode(e)) => panic!("Could not parse URL: {e:?}"), + } +} + +#[allow(unused)] +pub async fn setup_program_client(program_name: impl ToString, db: PgPool) -> ProgramClient { + let client = setup_client(db).await; + + let program_content = ProgramContent { + object_type: None, + program_name: program_name.to_string(), + program_long_name: Some("program_long_name".to_string()), + retailer_name: Some("retailer_name".to_string()), + retailer_long_name: Some("retailer_long_name".to_string()), + program_type: None, + country: None, + principal_subdivision: None, + time_zone_offset: None, + interval_period: None, + program_descriptions: None, + binding_events: None, + local_price: None, + payload_descriptors: None, + targets: None, + }; + + client.create_program(program_content).await.unwrap() +} diff --git a/openadr-client/tests/event.rs b/openadr-client/tests/event.rs new file mode 100644 index 0000000..916f66f --- /dev/null +++ b/openadr-client/tests/event.rs @@ -0,0 +1,303 @@ +use axum::http::StatusCode; +use openadr_client::{Error, Filter, PaginationOptions}; +use openadr_wire::{ + event::{EventContent, Priority}, + program::{ProgramContent, ProgramId}, + target::TargetLabel, +}; +use sqlx::PgPool; + +mod common; + +fn default_content(program_id: &ProgramId) -> EventContent { + EventContent { + object_type: None, + program_id: program_id.clone(), + event_name: Some("event_name".to_string()), + priority: Priority::MAX, + report_descriptors: None, + interval_period: None, + intervals: vec![], + payload_descriptors: None, + targets: None, + } +} + +#[sqlx::test(fixtures("users"))] +async fn get(db: PgPool) { + let client = common::setup_program_client("program", db).await; + let event_content = default_content(client.id()); + let event_client = client.create_event(event_content.clone()).await.unwrap(); + + assert_eq!(event_client.content(), &event_content); +} + +#[sqlx::test(fixtures("users"))] +async fn delete(db: PgPool) { + let client = common::setup_program_client("program", db).await; + + let event1 = EventContent { + event_name: Some("event1".to_string()), + ..default_content(client.id()) + }; + let event2 = EventContent { + event_name: Some("event2".to_string()), + ..default_content(client.id()) + }; + let event3 = EventContent { + event_name: Some("event3".to_string()), + ..default_content(client.id()) + }; + + for content in [event1, event2.clone(), event3] { + client.create_event(content).await.unwrap(); + } + + let pagination = PaginationOptions { skip: 0, limit: 2 }; + let filter = Filter::By(TargetLabel::EventName, &["event2"]); + let mut events = client.get_events_request(filter, pagination).await.unwrap(); + assert_eq!(events.len(), 1); + let event = events.pop().unwrap(); + assert_eq!(event.content(), &event2); + + let removed = event.delete().await.unwrap(); + assert_eq!(removed.content, event2); + + let events = client.get_all_events().await.unwrap(); + assert_eq!(events.len(), 2); +} + +#[sqlx::test(fixtures("users"))] +async fn update(db: PgPool) { + let client = common::setup_program_client("program", db).await; + + let event1 = EventContent { + event_name: Some("event1".to_string()), + ..default_content(client.id()) + }; + + let mut event = client.create_event(event1).await.unwrap(); + let creation_date_time = event.modification_date_time(); + + let event2 = EventContent { + event_name: Some("event1".to_string()), + priority: Priority::MIN, + ..default_content(client.id()) + }; + + *event.content_mut() = event2.clone(); + event.update().await.unwrap(); + + assert_eq!(event.content(), &event2); + assert!(event.modification_date_time() > creation_date_time); +} + +#[sqlx::test(fixtures("users"))] +async fn update_same_name(db: PgPool) { + let client = common::setup_program_client("program", db).await; + + let event1 = EventContent { + event_name: Some("event1".to_string()), + ..default_content(client.id()) + }; + + let event2 = EventContent { + event_name: Some("event2".to_string()), + ..default_content(client.id()) + }; + + let _event1 = client.create_event(event1).await.unwrap(); + let mut event2 = client.create_event(event2).await.unwrap(); + let creation_date_time = event2.modification_date_time(); + + let content = EventContent { + event_name: Some("event1".to_string()), + priority: Priority::MIN, + ..default_content(client.id()) + }; + + // duplicate event names are fine + *event2.content_mut() = content; + event2.update().await.unwrap(); + + assert!(event2.modification_date_time() > creation_date_time); +} + +#[sqlx::test(fixtures("users"))] +async fn create_same_name(db: PgPool) { + let client = common::setup_program_client("program", db).await; + + let event1 = EventContent { + event_name: Some("event1".to_string()), + ..default_content(client.id()) + }; + + // duplicate event names are fine + let _ = client.create_event(event1.clone()).await.unwrap(); + let _ = client.create_event(event1).await.unwrap(); +} + +#[sqlx::test(fixtures("users"))] +async fn retrieve_all_with_filter(db: PgPool) { + let client = common::setup_program_client("program1", db).await; + + let event1 = EventContent { + program_id: client.id().clone(), + event_name: Some("event1".to_string()), + ..default_content(client.id()) + }; + let event2 = EventContent { + program_id: client.id().clone(), + event_name: Some("event2".to_string()), + ..default_content(client.id()) + }; + let event3 = EventContent { + program_id: client.id().clone(), + event_name: Some("event3".to_string()), + ..default_content(client.id()) + }; + + for content in [event1, event2, event3] { + let _ = client.create_event(content).await.unwrap(); + } + + let events = client + .get_events_request(Filter::None, PaginationOptions { skip: 0, limit: 50 }) + .await + .unwrap(); + assert_eq!(events.len(), 3); + + // skip + let events = client + .get_events_request(Filter::None, PaginationOptions { skip: 1, limit: 50 }) + .await + .unwrap(); + assert_eq!(events.len(), 2); + + // limit + let events = client + .get_events_request(Filter::None, PaginationOptions { skip: 0, limit: 2 }) + .await + .unwrap(); + assert_eq!(events.len(), 2); + + // event name + let events = client + .get_events_request( + Filter::By(TargetLabel::Private("NONSENSE".to_string()), &["test"]), + PaginationOptions { skip: 0, limit: 2 }, + ) + .await + .unwrap(); + assert_eq!(events.len(), 0); + + let err = client + .get_events_request( + Filter::By(TargetLabel::Private("NONSENSE".to_string()), &[""]), + PaginationOptions { skip: 0, limit: 2 }, + ) + .await + .unwrap_err(); + let Error::Problem(problem) = err else { + unreachable!() + }; + assert_eq!( + problem.status, + StatusCode::BAD_REQUEST, + "Do return BAD_REQUEST on empty targetValue" + ); + + let err = client + .get_events_request( + Filter::By(TargetLabel::Private("NONSENSE".to_string()), &[]), + PaginationOptions { skip: 0, limit: 2 }, + ) + .await + .unwrap_err(); + let Error::Problem(problem) = err else { + unreachable!() + }; + assert_eq!( + problem.status, + StatusCode::BAD_REQUEST, + "Do return BAD_REQUEST on empty targetValue" + ); + + let events = client + .get_events_request( + Filter::By(TargetLabel::ProgramName, &["program1", "program2"]), + PaginationOptions { skip: 0, limit: 50 }, + ) + .await + .unwrap(); + assert_eq!(events.len(), 3); + + let events = client + .get_events_request( + Filter::By(TargetLabel::ProgramName, &["program2"]), + PaginationOptions { skip: 0, limit: 50 }, + ) + .await + .unwrap(); + assert_eq!(events.len(), 0); +} + +#[sqlx::test(fixtures("users"))] +async fn get_program_events(db: PgPool) { + let client = common::setup_client(db).await; + + let program1 = client + .create_program(ProgramContent::new("program1")) + .await + .unwrap(); + let program2 = client + .create_program(ProgramContent::new("program2")) + .await + .unwrap(); + + let event1 = EventContent { + event_name: Some("event".to_string()), + priority: Priority::MAX, + ..default_content(program1.id()) + }; + let event2 = EventContent { + event_name: Some("event".to_string()), + priority: Priority::MIN, + ..default_content(program2.id()) + }; + + program1.create_event(event1.clone()).await.unwrap(); + program2.create_event(event2.clone()).await.unwrap(); + + let events1 = program1.get_all_events().await.unwrap(); + let events2 = program2.get_all_events().await.unwrap(); + + assert_eq!(events1.len(), 1); + assert_eq!(events2.len(), 1); + + assert_eq!(events1[0].content(), &event1); + assert_eq!(events2[0].content(), &event2); +} + +#[sqlx::test(fixtures("users"))] +async fn filter_constraint_violation(db: PgPool) { + let client = common::setup_client(db).await; + + let err = client + .get_events(None, Filter::None, PaginationOptions { skip: 0, limit: 51 }) + .await + .unwrap_err(); + let Error::Problem(problem) = err else { + unreachable!() + }; + assert_eq!(problem.status, StatusCode::BAD_REQUEST); + + let err = client + .get_events(None, Filter::None, PaginationOptions { skip: 0, limit: 0 }) + .await + .unwrap_err(); + let Error::Problem(problem) = err else { + unreachable!() + }; + assert_eq!(problem.status, StatusCode::BAD_REQUEST); +} diff --git a/openadr-client/tests/fixtures b/openadr-client/tests/fixtures new file mode 120000 index 0000000..3f752ae --- /dev/null +++ b/openadr-client/tests/fixtures @@ -0,0 +1 @@ +../../fixtures/ \ No newline at end of file diff --git a/openadr-client/tests/program.rs b/openadr-client/tests/program.rs new file mode 100644 index 0000000..876c862 --- /dev/null +++ b/openadr-client/tests/program.rs @@ -0,0 +1,234 @@ +use axum::http::StatusCode; +use openadr_client::{Error, Filter, PaginationOptions}; +use openadr_wire::{program::ProgramContent, target::TargetLabel}; +use sqlx::PgPool; + +mod common; + +fn default_content() -> ProgramContent { + ProgramContent { + object_type: None, + program_name: "program_name".to_string(), + program_long_name: Some("program_long_name".to_string()), + retailer_name: Some("retailer_name".to_string()), + retailer_long_name: Some("retailer_long_name".to_string()), + program_type: None, + country: None, + principal_subdivision: None, + time_zone_offset: None, + interval_period: None, + program_descriptions: None, + binding_events: None, + local_price: None, + payload_descriptors: None, + targets: None, + } +} + +#[sqlx::test(fixtures("users"))] +async fn get(db: PgPool) { + let client = common::setup_client(db).await; + let program_client = client.create_program(default_content()).await.unwrap(); + + assert_eq!(program_client.content(), &default_content()); +} + +#[sqlx::test(fixtures("users"))] +async fn delete(db: PgPool) { + let client = common::setup_client(db).await; + + let program1 = ProgramContent { + program_name: "program1".to_string(), + ..default_content() + }; + let program2 = ProgramContent { + program_name: "program2".to_string(), + ..default_content() + }; + let program3 = ProgramContent { + program_name: "program3".to_string(), + ..default_content() + }; + + for content in [program1, program2.clone(), program3] { + client.create_program(content).await.unwrap(); + } + + let program = client.get_program_by_name("program2").await.unwrap(); + assert_eq!(program.content(), &program2); + + let removed = program.delete().await.unwrap(); + assert_eq!(removed.content, program2); + + let programs = client.get_all_programs().await.unwrap(); + assert_eq!(programs.len(), 2); +} + +#[sqlx::test(fixtures("users"))] +async fn update(db: PgPool) { + let client = common::setup_client(db).await; + + let program1 = ProgramContent { + program_name: "program1".to_string(), + ..default_content() + }; + + let mut program = client.create_program(program1).await.unwrap(); + let creation_date_time = program.modification_date_time(); + + let program2 = ProgramContent { + program_name: "program1".to_string(), + country: Some("NO".to_string()), + ..default_content() + }; + + *program.content_mut() = program2.clone(); + program.update().await.unwrap(); + + assert_eq!(program.content(), &program2); + assert!(program.modification_date_time() > creation_date_time); +} + +#[sqlx::test(fixtures("users"))] +async fn update_same_name(db: PgPool) { + let client = common::setup_client(db).await; + + let program1 = ProgramContent { + program_name: "program1".to_string(), + ..default_content() + }; + + let program2 = ProgramContent { + program_name: "program2".to_string(), + ..default_content() + }; + + let _program1 = client.create_program(program1).await.unwrap(); + let mut program2 = client.create_program(program2).await.unwrap(); + let creation_date_time = program2.modification_date_time(); + + let content = ProgramContent { + program_name: "program1".to_string(), + country: Some("NO".to_string()), + ..default_content() + }; + + *program2.content_mut() = content; + + let Error::Problem(problem) = program2.update().await.unwrap_err() else { + unreachable!() + }; + + assert_eq!(problem.status, StatusCode::CONFLICT); + assert_eq!(program2.modification_date_time(), creation_date_time); +} + +#[sqlx::test(fixtures("users"))] +async fn create_same_name(db: PgPool) { + let client = common::setup_client(db).await; + + let program1 = ProgramContent { + program_name: "program1".to_string(), + ..default_content() + }; + + let _ = client.create_program(program1.clone()).await.unwrap(); + let Error::Problem(problem) = client.create_program(program1).await.unwrap_err() else { + unreachable!() + }; + + assert_eq!(problem.status, StatusCode::CONFLICT); +} + +#[sqlx::test(fixtures("users"))] +async fn retrieve_all_with_filter(db: PgPool) { + let client = common::setup_client(db).await; + + let program1 = ProgramContent { + program_name: "program1".to_string(), + ..default_content() + }; + let program2 = ProgramContent { + program_name: "program2".to_string(), + ..default_content() + }; + let program3 = ProgramContent { + program_name: "program3".to_string(), + ..default_content() + }; + + for content in [program1, program2, program3] { + let _ = client.create_program(content).await.unwrap(); + } + + let programs = client + .get_programs(Filter::None, PaginationOptions { skip: 0, limit: 50 }) + .await + .unwrap(); + assert_eq!(programs.len(), 3); + + // skip + let programs = client + .get_programs(Filter::None, PaginationOptions { skip: 1, limit: 50 }) + .await + .unwrap(); + assert_eq!(programs.len(), 2); + + // limit + let programs = client + .get_programs(Filter::None, PaginationOptions { skip: 0, limit: 2 }) + .await + .unwrap(); + assert_eq!(programs.len(), 2); + + // program name + let err = client + .get_programs( + Filter::By(TargetLabel::Private("NONSENSE".to_string()), &[]), + PaginationOptions { skip: 0, limit: 2 }, + ) + .await + .unwrap_err(); + let Error::Problem(problem) = err else { + unreachable!() + }; + assert_eq!( + problem.status, + StatusCode::BAD_REQUEST, + "Do return BAD_REQUEST on empty targetValue" + ); + + let err = client + .get_programs( + Filter::By(TargetLabel::Private("NONSENSE".to_string()), &[""]), + PaginationOptions { skip: 0, limit: 2 }, + ) + .await + .unwrap_err(); + let Error::Problem(problem) = err else { + unreachable!() + }; + assert_eq!( + problem.status, + StatusCode::BAD_REQUEST, + "Do return BAD_REQUEST on empty targetValue" + ); + + let programs = client + .get_programs( + Filter::By(TargetLabel::Private("NONSENSE".to_string()), &["test"]), + PaginationOptions { skip: 0, limit: 50 }, + ) + .await + .unwrap(); + assert_eq!(programs.len(), 0); + + let programs = client + .get_programs( + Filter::By(TargetLabel::ProgramName, &["program1", "program2"]), + PaginationOptions { skip: 0, limit: 50 }, + ) + .await + .unwrap(); + assert_eq!(programs.len(), 2); +} diff --git a/openadr-vtn/Cargo.toml b/openadr-vtn/Cargo.toml new file mode 100644 index 0000000..157720d --- /dev/null +++ b/openadr-vtn/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "openadr-vtn" +description = "openadr VTN server" +readme = "../README.md" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish.workspace = true +rust-version.workspace = true + +[dependencies] +openadr-wire.workspace = true + +serde.workspace = true +serde_json.workspace = true +serde_with.workspace = true + +reqwest.workspace = true +axum.workspace = true +axum-extra.workspace = true +tokio = { workspace = true, features = ["full"] } +tower-http.workspace = true +tower.workspace = true + +tracing.workspace = true +tracing-subscriber.workspace = true + +url.workspace = true +uuid.workspace = true +jsonwebtoken.workspace = true +validator.workspace = true +mime.workspace = true +http-body-util.workspace = true + +chrono.workspace = true +thiserror.workspace = true + +sqlx = {workspace = true, optional = true} +argon2 = {workspace = true, optional = true} +dotenvy = {workspace = true, optional = true} + +[dev-dependencies] +tokio = { workspace = true, features = ["full", "test-util"] } + +[features] +default = ["postgres", "live-db-test"] +live-db-test = ["postgres"] +postgres = ["sqlx/postgres", "dep:dotenvy", "dep:argon2"] \ No newline at end of file diff --git a/openadr-vtn/migrations b/openadr-vtn/migrations new file mode 120000 index 0000000..acd3dd4 --- /dev/null +++ b/openadr-vtn/migrations @@ -0,0 +1 @@ +../migrations/ \ No newline at end of file diff --git a/openadr-vtn/src/api/auth.rs b/openadr-vtn/src/api/auth.rs new file mode 100644 index 0000000..643b91c --- /dev/null +++ b/openadr-vtn/src/api/auth.rs @@ -0,0 +1,139 @@ +use std::sync::Arc; + +use crate::{api::ValidatedForm, data_source::AuthSource, jwt::JwtManager}; +use axum::{ + extract::State, + http::{Response, StatusCode}, + response::IntoResponse, + Json, +}; +use axum_extra::{ + headers::{authorization::Basic, Authorization}, + TypedHeader, +}; +use openadr_wire::oauth::{OAuthError, OAuthErrorType}; +use reqwest::header; +use serde::Deserialize; +use validator::Validate; + +#[derive(Debug, Deserialize, Validate)] +pub struct AccessTokenRequest { + grant_type: String, + // TODO: handle scope + // scope: Option, + client_id: Option, + client_secret: Option, +} + +pub struct ResponseOAuthError(pub OAuthError); + +impl IntoResponse for ResponseOAuthError { + fn into_response(self) -> Response { + match self.0.error { + OAuthErrorType::InvalidClient => ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, r#"Basic realm="VTN""#)], + Json(self.0), + ) + .into_response(), + OAuthErrorType::ServerError => { + (StatusCode::INTERNAL_SERVER_ERROR, Json(self.0)).into_response() + } + _ => (StatusCode::BAD_REQUEST, Json(self.0)).into_response(), + } + } +} + +impl From for ResponseOAuthError { + fn from(_: jsonwebtoken::errors::Error) -> Self { + ResponseOAuthError( + OAuthError::new(OAuthErrorType::ServerError) + .with_description("Could not issue a new token".to_string()), + ) + } +} + +impl From for ResponseOAuthError { + fn from(err: OAuthError) -> Self { + ResponseOAuthError(err) + } +} + +#[derive(Debug, serde::Serialize)] +pub struct AccessTokenResponse { + access_token: String, + token_type: &'static str, + expires_in: u64, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, +} + +impl IntoResponse for AccessTokenResponse { + fn into_response(self) -> Response { + IntoResponse::into_response((StatusCode::OK, Json(self))) + } +} + +/// RFC 6749 client credentials grant flow +pub(crate) async fn token( + State(auth_source): State>, + State(jwt_manager): State>, + authorization: Option>>, + ValidatedForm(request): ValidatedForm, +) -> Result { + if request.grant_type != "client_credentials" { + return Err(OAuthError::new(OAuthErrorType::UnsupportedGrantType) + .with_description("Only client_credentials grant type is supported".to_string()) + .into()); + } + + let auth_header = authorization + .as_ref() + .map(|TypedHeader(auth)| (auth.username(), auth.password())); + + let auth_body = request + .client_id + .as_ref() + .map(|client_id| { + ( + client_id.as_str(), + request.client_secret.as_deref().unwrap_or(""), + ) + }) + .or_else(|| request.client_secret.as_ref().map(|cr| ("", cr.as_str()))); + + if auth_header.is_some() && auth_body.is_some() { + return Err(OAuthError::new(OAuthErrorType::InvalidRequest) + .with_description("Both header and body authentication provided".to_string()) + .into()); + } + + let Some((client_id, client_secret)) = auth_body.or(auth_header) else { + return Err(OAuthError::new(OAuthErrorType::InvalidClient) + .with_description( + "No valid authentication data provided, client_id and client_secret required" + .to_string(), + ) + .into()); + }; + + // check that the client_id and client_secret are valid + let Some(user) = auth_source + .check_credentials(client_id, client_secret) + .await + else { + return Err(OAuthError::new(OAuthErrorType::InvalidClient) + .with_description("Invalid client_id or client_secret".to_string()) + .into()); + }; + + let expiration = std::time::Duration::from_secs(3600 * 24 * 30); + let token = jwt_manager.create(expiration, user.client_id, user.roles)?; + + Ok(AccessTokenResponse { + access_token: token, + token_type: "bearer", + expires_in: expiration.as_secs(), + scope: None, + }) +} diff --git a/openadr-vtn/src/api/event.rs b/openadr-vtn/src/api/event.rs new file mode 100644 index 0000000..6d61f20 --- /dev/null +++ b/openadr-vtn/src/api/event.rs @@ -0,0 +1,749 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use serde::Deserialize; +use tracing::{info, trace}; +use validator::{Validate, ValidationError}; + +use openadr_wire::{ + event::{EventContent, EventId}, + program::ProgramId, + target::TargetLabel, + Event, +}; + +use crate::{ + api::{AppResponse, ValidatedJson, ValidatedQuery}, + data_source::EventCrud, + error::AppError, + jwt::{BusinessUser, User}, +}; + +pub async fn get_all( + State(event_source): State>, + ValidatedQuery(query_params): ValidatedQuery, + User(user): User, +) -> AppResponse> { + trace!(?query_params); + + let events = event_source.retrieve_all(&query_params, &user).await?; + + Ok(Json(events)) +} + +pub async fn get( + State(event_source): State>, + Path(id): Path, + User(user): User, +) -> AppResponse { + let event = event_source.retrieve(&id, &user).await?; + Ok(Json(event)) +} + +pub async fn add( + State(event_source): State>, + BusinessUser(user): BusinessUser, + ValidatedJson(new_event): ValidatedJson, +) -> Result<(StatusCode, Json), AppError> { + let event = event_source.create(new_event, &user).await?; + + info!(%event.id, event_name=?event.content.event_name, "event created"); + + Ok((StatusCode::CREATED, Json(event))) +} + +pub async fn edit( + State(event_source): State>, + Path(id): Path, + BusinessUser(user): BusinessUser, + ValidatedJson(content): ValidatedJson, +) -> AppResponse { + let event = event_source.update(&id, content, &user).await?; + + info!(%event.id, event_name=?event.content.event_name, "event updated"); + + Ok(Json(event)) +} + +pub async fn delete( + State(event_source): State>, + Path(id): Path, + BusinessUser(user): BusinessUser, +) -> AppResponse { + let event = event_source.delete(&id, &user).await?; + info!(%id, "deleted event"); + Ok(Json(event)) +} + +#[derive(Deserialize, Validate, Debug)] +#[validate(schema(function = "validate_target_type_value_pair"))] +#[serde(rename_all = "camelCase")] +pub struct QueryParams { + #[serde(rename = "programID")] + pub(crate) program_id: Option, + pub(crate) target_type: Option, + pub(crate) target_values: Option>, + #[serde(default)] + #[validate(range(min = 0))] + pub(crate) skip: i64, + // TODO how to interpret limit = 0 and what is the default? + #[validate(range(min = 1, max = 50))] + #[serde(default = "get_50")] + pub(crate) limit: i64, +} + +fn validate_target_type_value_pair(query: &QueryParams) -> Result<(), ValidationError> { + if query.target_type.is_some() == query.target_values.is_some() { + Ok(()) + } else { + Err(ValidationError::new("targetType and targetValues query parameter must either both be set or not set at the same time.")) + } +} + +fn get_50() -> i64 { + 50 +} + +#[cfg(test)] +#[cfg(feature = "live-db-test")] +mod test { + use crate::{data_source::PostgresStorage, jwt::JwtManager, state::AppState}; + + use super::*; + use crate::api::test::*; + // for `call`, `oneshot`, and `ready` + use crate::data_source::DataSource; + // for `collect` + use crate::jwt::{AuthRole, Claims}; + use axum::{ + body::Body, + http::{self, Request, Response, StatusCode}, + Router, + }; + use http_body_util::BodyExt; + use openadr_wire::event::Priority; + use sqlx::PgPool; + use tower::{Service, ServiceExt}; + + fn default_event_content() -> EventContent { + EventContent { + object_type: None, + program_id: ProgramId::new("program-1").unwrap(), + event_name: Some("event_name".to_string()), + priority: Priority::MAX, + report_descriptors: None, + interval_period: None, + intervals: vec![], + payload_descriptors: None, + targets: None, + } + } + + fn event_request(method: http::Method, event: Event, token: &str) -> Request { + Request::builder() + .method(method) + .uri(format!("/events/{}", event.id)) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(&event).unwrap())) + .unwrap() + } + + async fn state_with_events( + new_events: Vec, + db: PgPool, + ) -> (AppState, Vec) { + let store = PostgresStorage::new(db).unwrap(); + let mut events = Vec::new(); + + for event in new_events { + events.push( + store + .events() + .create(event.clone(), &Claims::any_business_user()) + .await + .unwrap(), + ); + assert_eq!(events[events.len() - 1].content, event) + } + + ( + AppState::new(store, JwtManager::from_base64_secret("test").unwrap()), + events, + ) + } + + async fn get_help(id: &str, token: &str, app: &mut Router) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::GET) + .uri(format!("/events/{}", id)) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + #[sqlx::test(fixtures("programs"))] + async fn get(db: PgPool) { + let (state, mut events) = state_with_events(vec![default_event_content()], db).await; + let event = events.remove(0); + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let mut app = state.into_router(); + + let response = get_help(event.id.as_str(), &token, &mut app).await; + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let db_event: Event = serde_json::from_slice(&body).unwrap(); + + assert_eq!(event, db_event); + } + + #[sqlx::test(fixtures("programs"))] + async fn delete(db: PgPool) { + let event1 = EventContent { + program_id: ProgramId::new("program-1").unwrap(), + event_name: Some("event1".to_string()), + ..default_event_content() + }; + let event2 = EventContent { + program_id: ProgramId::new("program-2").unwrap(), + event_name: Some("event2".to_string()), + ..default_event_content() + }; + let event3 = EventContent { + program_id: ProgramId::new("program-2").unwrap(), + event_name: Some("event3".to_string()), + ..default_event_content() + }; + + let (state, events) = state_with_events(vec![event1, event2.clone(), event3], db).await; + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let mut app = state.into_router(); + + let event_id = events[1].id.clone(); + + let request = Request::builder() + .method(http::Method::DELETE) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .uri(format!("/events/{event_id}")) + .body(Body::empty()) + .unwrap(); + + let response = ServiceExt::>::ready(&mut app) + .await + .unwrap() + .call(request) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let db_event: Event = serde_json::from_slice(&body).unwrap(); + + assert_eq!(event2, db_event.content); + + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 2); + } + + #[sqlx::test(fixtures("programs"))] + async fn update(db: PgPool) { + let (state, mut events) = state_with_events(vec![default_event_content()], db).await; + let event = events.remove(0); + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let app = state.into_router(); + + let response = app + .oneshot(event_request(http::Method::PUT, event.clone(), &token)) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let db_program: Event = serde_json::from_slice(&body).unwrap(); + + assert_eq!(event.content, db_program.content); + assert!(event.modification_date_time < db_program.modification_date_time); + } + + async fn help_create_event( + mut app: &mut Router, + content: &EventContent, + token: &str, + ) -> Response { + let request = Request::builder() + .method(http::Method::POST) + .uri("/events") + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(content).unwrap())) + .unwrap(); + + ServiceExt::>::ready(&mut app) + .await + .unwrap() + .call(request) + .await + .unwrap() + } + + #[sqlx::test(fixtures("programs"))] + async fn create_same_name(db: PgPool) { + let (state, _) = state_with_events(vec![], db).await; + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let mut app = state.into_router(); + + let content = default_event_content(); + + let response = help_create_event(&mut app, &content, &token).await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = help_create_event(&mut app, &content, &token).await; + assert_eq!(response.status(), StatusCode::CREATED); + } + + async fn retrieve_all_with_filter_help( + app: &mut Router, + query_params: &str, + token: &str, + ) -> Response { + let request = Request::builder() + .method(http::Method::GET) + .uri(format!("/events?{query_params}")) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::empty()) + .unwrap(); + + ServiceExt::>::ready(app) + .await + .unwrap() + .call(request) + .await + .unwrap() + } + + #[sqlx::test(fixtures("programs"))] + async fn retrieve_all_with_filter(db: PgPool) { + let event1 = EventContent { + program_id: ProgramId::new("program-1").unwrap(), + event_name: Some("event1".to_string()), + ..default_event_content() + }; + let event2 = EventContent { + program_id: ProgramId::new("program-2").unwrap(), + event_name: Some("event2".to_string()), + ..default_event_content() + }; + let event3 = EventContent { + program_id: ProgramId::new("program-2").unwrap(), + event_name: Some("event3".to_string()), + ..default_event_content() + }; + + let (state, _) = state_with_events(vec![event1, event2, event3], db).await; + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let mut app = state.into_router(); + + // no query params + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 3); + + // skip + let response = retrieve_all_with_filter_help(&mut app, "skip=1", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 2); + + let response = retrieve_all_with_filter_help(&mut app, "skip=-1", &token).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = retrieve_all_with_filter_help(&mut app, "skip=0", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + // limit + let response = retrieve_all_with_filter_help(&mut app, "limit=2", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 2); + + let response = retrieve_all_with_filter_help(&mut app, "limit=-1", &token).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = retrieve_all_with_filter_help(&mut app, "limit=0", &token).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // program name + let response = retrieve_all_with_filter_help(&mut app, "targetType=NONSENSE", &token).await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "Do return BAD_REQUEST on empty targetValue" + ); + + let response = + retrieve_all_with_filter_help(&mut app, "targetType=NONSENSE&targetValues", &token) + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "Do return BAD_REQUEST on empty targetValue" + ); + + let response = retrieve_all_with_filter_help( + &mut app, + "targetType=NONSENSE&targetValues=test", + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 0); + + let response = retrieve_all_with_filter_help( + &mut app, + "targetType=PROGRAM_NAME&targetValues=program-1&targetValues=program-2", + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 3); + + let response = retrieve_all_with_filter_help( + &mut app, + "targetType=PROGRAM_NAME&targetValues=program-1", + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 1); + + let response = retrieve_all_with_filter_help(&mut app, "programID=program-1", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 1); + } + + mod permissions { + use super::*; + + #[sqlx::test(fixtures("users", "programs", "business", "events"))] + async fn business_can_write_event_in_own_program_only(db: PgPool) { + let (state, _) = state_with_events(vec![], db).await; + let mut app = state.clone().into_router(); + + let content = EventContent { + program_id: "program-3".parse().unwrap(), + ..default_event_content() + }; + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let response = help_create_event(&mut app, &content, &token).await; + assert_eq!(response.status(), StatusCode::CREATED); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-2".to_string())]); + let response = help_create_event(&mut app, &content, &token).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let token = jwt_test_token( + &state, + vec![ + AuthRole::AnyBusiness, + AuthRole::Business("business-2".to_string()), + ], + ); + let response = help_create_event(&mut app, &content, &token).await; + assert_eq!(response.status(), StatusCode::CREATED); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-2".to_string())]); + let response = app + .clone() + .oneshot( + Request::builder() + .method(http::Method::DELETE) + .uri(format!("/events/{}", "event-3")) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-2".to_string())]); + let response = app + .clone() + .oneshot( + Request::builder() + .method(http::Method::PUT) + .uri(format!("/events/{}", "event-3")) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(&content).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let response = app + .clone() + .oneshot( + Request::builder() + .method(http::Method::PUT) + .uri(format!("/events/{}", "event-3")) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(&content).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let response = app + .clone() + .oneshot( + Request::builder() + .method(http::Method::DELETE) + .uri(format!("/events/{}", "event-3")) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[sqlx::test(fixtures("users", "programs", "business", "events"))] + async fn business_can_read_event_in_own_program_only(db: PgPool) { + let (state, _) = state_with_events(vec![], db).await; + let mut app = state.clone().into_router(); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let response = get_help("event-3", &token, &mut app).await; + assert_eq!(response.status(), StatusCode::OK); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let response = get_help("event-2", &token, &mut app).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-2".to_string())]); + let response = get_help("event-3", &token, &mut app).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let token = jwt_test_token( + &state, + vec![ + AuthRole::VEN("ven-1".parse().unwrap()), + AuthRole::Business("business-2".to_string()), + ], + ); + let response = get_help("event-3", &token, &mut app).await; + assert_eq!(response.status(), StatusCode::OK); + } + + #[sqlx::test(fixtures("users", "programs", "business", "events", "vens", "vens-programs"))] + async fn vens_can_read_event_in_assigned_program_only(db: PgPool) { + let (state, _) = state_with_events(vec![], db).await; + let mut app = state.clone().into_router(); + + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-1".parse().unwrap())]); + let response = get_help("event-3", &token, &mut app).await; + assert_eq!(response.status(), StatusCode::OK); + + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-2".parse().unwrap())]); + let response = get_help("event-3", &token, &mut app).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let token = jwt_test_token( + &state, + vec![ + AuthRole::VEN("ven-2".parse().unwrap()), + AuthRole::VEN("ven-1".parse().unwrap()), + ], + ); + let response = get_help("event-3", &token, &mut app).await; + assert_eq!(response.status(), StatusCode::OK); + + let token = jwt_test_token( + &state, + vec![ + AuthRole::VEN("ven-2".parse().unwrap()), + AuthRole::Business("business-2".to_string()), + ], + ); + let response = get_help("event-3", &token, &mut app).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[sqlx::test(fixtures("users", "programs", "business", "events", "vens", "vens-programs"))] + async fn vens_event_list_assigned_program_only(db: PgPool) { + let (state, _) = state_with_events(vec![], db).await; + let mut app = state.clone().into_router(); + + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-1".parse().unwrap())]); + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(events.len(), 2); + + let token = jwt_test_token( + &state, + vec![ + AuthRole::VEN("ven-1".parse().unwrap()), + AuthRole::VEN("ven-2".parse().unwrap()), + ], + ); + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(events.len(), 3); + + // VEN should not be able to filter on other ven names, + // even if they have a common set of events, + // as this would leak information about which events the VENs have in common. + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-1".parse().unwrap())]); + let response = retrieve_all_with_filter_help( + &mut app, + "targetType=VEN_NAME&targetValues=ven-2-name", + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(events.len(), 0); + } + + #[sqlx::test(fixtures("users", "programs", "business", "events", "vens", "vens-programs"))] + async fn business_can_list_events_in_own_program_only(db: PgPool) { + let (state, _) = state_with_events(vec![], db).await; + let mut app = state.clone().into_router(); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(events.len(), 1); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let response = retrieve_all_with_filter_help( + &mut app, + "targetType=VEN_NAME&targetValues=ven-1-name", + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(events.len(), 1); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let response = retrieve_all_with_filter_help( + &mut app, + "targetType=VEN_NAME&targetValues=ven-2-name", + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(events.len(), 0); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-2".to_string())]); + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(events.len(), 0); + + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(events.len(), 3); + } + + #[sqlx::test(fixtures("users", "programs", "events", "vens", "vens-programs"))] + async fn ven_cannot_write_event(db: PgPool) { + let (state, _) = state_with_events(vec![], db).await; + let mut app = state.clone().into_router(); + + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-1".parse().unwrap())]); + let response = help_create_event(&mut app, &default_event_content(), &token).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = app + .clone() + .oneshot( + Request::builder() + .method(http::Method::DELETE) + .uri(format!("/events/{}", "event-3")) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = app + .clone() + .oneshot( + Request::builder() + .method(http::Method::PUT) + .uri(format!("/events/{}", "event-3")) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from( + serde_json::to_vec(&default_event_content()).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + } +} diff --git a/openadr-vtn/src/api/fixtures b/openadr-vtn/src/api/fixtures new file mode 120000 index 0000000..0e342a1 --- /dev/null +++ b/openadr-vtn/src/api/fixtures @@ -0,0 +1 @@ +../../../fixtures/ \ No newline at end of file diff --git a/openadr-vtn/src/api/mod.rs b/openadr-vtn/src/api/mod.rs new file mode 100644 index 0000000..641ba87 --- /dev/null +++ b/openadr-vtn/src/api/mod.rs @@ -0,0 +1,180 @@ +use crate::error::AppError; +use axum::{ + async_trait, + extract::{ + rejection::{FormRejection, JsonRejection}, + FromRequest, FromRequestParts, Request, + }, + Form, Json, +}; +use axum_extra::extract::{Query, QueryRejection}; +use serde::de::DeserializeOwned; +use validator::Validate; + +pub mod auth; +pub mod event; +pub mod program; +pub mod report; +pub mod resource; +pub mod user; +pub mod ven; + +pub type AppResponse = Result, AppError>; + +#[derive(Debug, Clone)] +pub struct ValidatedForm(T); + +#[derive(Debug, Clone)] +pub struct ValidatedQuery(pub T); + +#[derive(Debug, Clone)] +pub struct ValidatedJson(pub T); + +#[async_trait] +impl FromRequest for ValidatedJson +where + T: DeserializeOwned + Validate, + S: Send + Sync, + Json: FromRequest, +{ + type Rejection = AppError; + + async fn from_request(req: Request, state: &S) -> Result { + let Json(value) = Json::::from_request(req, state).await?; + value.validate()?; + Ok(ValidatedJson(value)) + } +} + +#[async_trait] +impl FromRequestParts for ValidatedQuery +where + T: DeserializeOwned + Validate, + S: Send + Sync, + Query: FromRequestParts, +{ + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &S, + ) -> Result { + let Query(value) = Query::::from_request_parts(parts, state).await?; + value.validate()?; + Ok(ValidatedQuery(value)) + } +} + +#[async_trait] +impl FromRequest for ValidatedForm +where + T: DeserializeOwned + Validate, + S: Send + Sync, + Form: FromRequest, +{ + type Rejection = AppError; + + async fn from_request(req: Request, state: &S) -> Result { + let Form(value) = Form::::from_request(req, state).await?; + value.validate()?; + Ok(ValidatedForm(value)) + } +} + +#[cfg(test)] +#[cfg(feature = "live-db-test")] +mod test { + use crate::{ + data_source::PostgresStorage, + jwt::{AuthRole, JwtManager}, + state::AppState, + }; + use axum::{ + body::Body, + http, + http::{Request, StatusCode}, + response::Response, + }; + use http_body_util::BodyExt; + use openadr_wire::problem::Problem; + use sqlx::PgPool; + use tower::ServiceExt; + + pub(crate) fn jwt_test_token(state: &AppState, roles: Vec) -> String { + state + .jwt_manager + .create( + std::time::Duration::from_secs(60), + "test_admin".to_string(), + roles, + ) + .unwrap() + } + + pub(crate) async fn state(db: PgPool) -> AppState { + let store = PostgresStorage::new(db).unwrap(); + AppState::new(store, JwtManager::from_base64_secret("test").unwrap()) + } + + async fn into_problem(response: Response) -> Problem { + let body = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() + } + + #[sqlx::test] + async fn unsupported_media_type(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness, AuthRole::UserManager]); + let mut app = state.into_router(); + + let response = (&mut app) + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/programs") + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + into_problem(response).await; + + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/auth/token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + into_problem(response).await; + } + + #[sqlx::test] + async fn method_not_allowed(db: PgPool) { + let state = state(db).await; + let app = state.into_router(); + + let response = app + .oneshot( + Request::builder() + .method(http::Method::DELETE) + .uri("/programs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + + into_problem(response).await; + } +} diff --git a/openadr-vtn/src/api/program.rs b/openadr-vtn/src/api/program.rs new file mode 100644 index 0000000..55e45c8 --- /dev/null +++ b/openadr-vtn/src/api/program.rs @@ -0,0 +1,678 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + Json, +}; +use reqwest::StatusCode; +use serde::Deserialize; +use tracing::{info, trace}; +use validator::{Validate, ValidationError}; + +use openadr_wire::{ + program::{ProgramContent, ProgramId}, + target::TargetLabel, + Program, +}; + +use crate::{ + api::{AppResponse, ValidatedJson, ValidatedQuery}, + data_source::ProgramCrud, + error::AppError, + jwt::{BusinessUser, User}, +}; +pub async fn get_all( + State(program_source): State>, + ValidatedQuery(query_params): ValidatedQuery, + User(user): User, +) -> AppResponse> { + trace!(?query_params); + + let programs = program_source.retrieve_all(&query_params, &user).await?; + + Ok(Json(programs)) +} + +pub async fn get( + State(program_source): State>, + Path(id): Path, + User(user): User, +) -> AppResponse { + let program = program_source.retrieve(&id, &user).await?; + Ok(Json(program)) +} + +pub async fn add( + State(program_source): State>, + BusinessUser(user): BusinessUser, + ValidatedJson(new_program): ValidatedJson, +) -> Result<(StatusCode, Json), AppError> { + let program = program_source.create(new_program, &user).await?; + + Ok((StatusCode::CREATED, Json(program))) +} + +pub async fn edit( + State(program_source): State>, + Path(id): Path, + BusinessUser(user): BusinessUser, + ValidatedJson(content): ValidatedJson, +) -> AppResponse { + let program = program_source.update(&id, content, &user).await?; + + info!(%program.id, program.program_name=program.content.program_name, "program updated"); + + Ok(Json(program)) +} + +pub async fn delete( + State(program_source): State>, + Path(id): Path, + BusinessUser(user): BusinessUser, +) -> AppResponse { + let program = program_source.delete(&id, &user).await?; + info!(%id, "deleted program"); + Ok(Json(program)) +} + +#[derive(Deserialize, Validate, Debug)] +#[validate(schema(function = "validate_target_type_value_pair"))] +#[serde(rename_all = "camelCase")] +pub struct QueryParams { + pub(crate) target_type: Option, + pub(crate) target_values: Option>, + #[serde(default)] + #[validate(range(min = 0))] + pub(crate) skip: i64, + // TODO how to interpret limit = 0 and what is the default? + #[validate(range(min = 1, max = 50))] + #[serde(default = "get_50")] + pub(crate) limit: i64, +} + +fn validate_target_type_value_pair(query: &QueryParams) -> Result<(), ValidationError> { + if query.target_type.is_some() == query.target_values.is_some() { + Ok(()) + } else { + Err(ValidationError::new("targetType and targetValues query parameter must either both be set or not set at the same time.")) + } +} + +fn get_50() -> i64 { + 50 +} + +#[cfg(test)] +#[cfg(feature = "live-db-test")] +mod test { + use crate::{data_source::PostgresStorage, jwt::JwtManager, state::AppState}; + + use crate::api::test::*; + + use super::*; + // for `collect` + use crate::{ + data_source::DataSource, + jwt::{AuthRole, Claims}, + }; + use axum::{ + body::Body, + http::{self, Request, Response, StatusCode}, + Router, + }; + use http_body_util::BodyExt; + use openadr_wire::Event; + use sqlx::PgPool; + use tower::{Service, ServiceExt}; + // for `call`, `oneshot`, and `ready` + + fn default_content() -> ProgramContent { + ProgramContent { + object_type: None, + program_name: "program_name".to_string(), + program_long_name: Some("program_long_name".to_string()), + retailer_name: Some("retailer_name".to_string()), + retailer_long_name: Some("retailer_long_name".to_string()), + program_type: None, + country: None, + principal_subdivision: None, + time_zone_offset: None, + interval_period: None, + program_descriptions: None, + binding_events: None, + local_price: None, + payload_descriptors: None, + targets: None, + } + } + + fn program_request( + method: http::Method, + program: ProgramContent, + id: &str, + token: &str, + ) -> Request { + Request::builder() + .method(method) + .uri(format!("/programs/{}", id)) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(&program).unwrap())) + .unwrap() + } + + async fn state_with_programs( + new_programs: Vec, + db: PgPool, + ) -> (AppState, Vec) { + let store = PostgresStorage::new(db).unwrap(); + let mut programs = Vec::new(); + + for program in new_programs { + let p = store + .programs() + .create(program.clone(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(p.content, program); + programs.push(p); + } + + ( + AppState::new(store, JwtManager::from_base64_secret("test").unwrap()), + programs, + ) + } + + async fn get_help(app: &mut Router, token: &str, id: &str) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::GET) + .uri(format!("/programs/{}", id)) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + #[sqlx::test(fixtures("users"))] + async fn get(db: PgPool) { + let (state, mut programs) = state_with_programs(vec![default_content()], db).await; + let program = programs.remove(0); + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let mut app = state.into_router(); + + let response = get_help(&mut app, &token, program.id.as_str()).await; + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let db_program: Program = serde_json::from_slice(&body).unwrap(); + + assert_eq!(program, db_program); + } + + #[sqlx::test(fixtures("users"))] + async fn delete(db: PgPool) { + let program1 = ProgramContent { + program_name: "program1".to_string(), + ..default_content() + }; + let program2 = ProgramContent { + program_name: "program2".to_string(), + ..default_content() + }; + let program3 = ProgramContent { + program_name: "program3".to_string(), + ..default_content() + }; + + let (state, programs) = + state_with_programs(vec![program1, program2.clone(), program3], db).await; + let program_id = programs[1].id.clone(); + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let mut app = state.into_router(); + + let request = Request::builder() + .method(http::Method::DELETE) + .uri(format!("/programs/{program_id}")) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(); + + let response = ServiceExt::>::ready(&mut app) + .await + .unwrap() + .call(request) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let db_program: Program = serde_json::from_slice(&body).unwrap(); + + assert_eq!(program2, db_program.content); + + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 2); + } + + #[sqlx::test(fixtures("users"))] + async fn update(db: PgPool) { + let (state, mut programs) = state_with_programs(vec![default_content()], db).await; + let program = programs.remove(0); + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let app = state.into_router(); + + let response = app + .oneshot(program_request( + http::Method::PUT, + program.content.clone(), + program.id.as_str(), + &token, + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let db_program: Program = serde_json::from_slice(&body).unwrap(); + + assert_eq!(program.content, db_program.content); + assert!(program.modification_date_time < db_program.modification_date_time); + } + + #[sqlx::test(fixtures("users"))] + async fn update_same_name(db: PgPool) { + let program1 = ProgramContent { + program_name: "program1".to_string(), + ..default_content() + }; + let program2 = ProgramContent { + program_name: "program2".to_string(), + ..default_content() + }; + + let (state, mut programs) = state_with_programs(vec![program1, program2], db).await; + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let app = state.into_router(); + + let mut updated = programs.remove(0); + updated.content.program_name = "program2".to_string(); + + // different id, same name + let response = app + .oneshot(program_request( + http::Method::PUT, + updated.content, + updated.id.as_str(), + &token, + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + async fn help_create_program( + mut app: &mut Router, + token: &str, + body: &ProgramContent, + ) -> Response { + let request = Request::builder() + .method(http::Method::POST) + .uri("/programs") + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap(); + + ServiceExt::>::ready(&mut app) + .await + .unwrap() + .call(request) + .await + .unwrap() + } + + #[sqlx::test(fixtures("users"))] + async fn create_same_name(db: PgPool) { + let (state, _) = state_with_programs(vec![], db).await; + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let mut app = state.into_router(); + + let response = help_create_program(&mut app, &token, &default_content()).await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = help_create_program(&mut app, &token, &default_content()).await; + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + async fn retrieve_all_with_filter_help( + app: &mut Router, + query_params: &str, + token: &str, + ) -> Response { + let request = Request::builder() + .method(http::Method::GET) + .uri(format!("/programs?{query_params}")) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::empty()) + .unwrap(); + + ServiceExt::>::ready(app) + .await + .unwrap() + .call(request) + .await + .unwrap() + } + + #[sqlx::test(fixtures("users"))] + async fn retrieve_all_with_filter(db: PgPool) { + let program1 = ProgramContent { + program_name: "program1".to_string(), + ..default_content() + }; + let program2 = ProgramContent { + program_name: "program2".to_string(), + ..default_content() + }; + let program3 = ProgramContent { + program_name: "program3".to_string(), + ..default_content() + }; + + let (state, _) = state_with_programs(vec![program1, program2, program3], db).await; + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let mut app = state.into_router(); + + // no query params + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 3); + + // skip + let response = retrieve_all_with_filter_help(&mut app, "skip=1", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 2); + + let response = retrieve_all_with_filter_help(&mut app, "skip=-1", &token).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = retrieve_all_with_filter_help(&mut app, "skip=0", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + // limit + let response = retrieve_all_with_filter_help(&mut app, "limit=2", &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 2); + + let response = retrieve_all_with_filter_help(&mut app, "limit=-1", &token).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = retrieve_all_with_filter_help(&mut app, "limit=0", &token).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // program name + let response = retrieve_all_with_filter_help(&mut app, "targetType=NONSENSE", &token).await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "Do return BAD_REQUEST on empty targetValue" + ); + + let response = + retrieve_all_with_filter_help(&mut app, "targetType=NONSENSE&targetValues", &token) + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "Do return BAD_REQUEST on empty targetValue" + ); + + let response = retrieve_all_with_filter_help( + &mut app, + "targetType=NONSENSE&targetValues=test", + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 0); + + let response = retrieve_all_with_filter_help( + &mut app, + "targetType=PROGRAM_NAME&targetValues=program1&targetValues=program2", + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 2); + } + + mod permissions { + use super::*; + use openadr_wire::target::{TargetEntry, TargetMap}; + + #[sqlx::test(fixtures("users", "business"))] + async fn business_can_create_program(db: PgPool) { + let (state, _) = state_with_programs(vec![], db).await; + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let mut app = state.into_router(); + + let response = help_create_program(&mut app, &token, &default_content()).await; + assert_eq!(response.status(), StatusCode::CREATED); + } + + #[sqlx::test(fixtures("users", "business"))] + async fn business_id_must_must_be_unambiguous_create_program(db: PgPool) { + let (state, _) = state_with_programs(vec![], db.clone()).await; + let token = jwt_test_token( + &state, + vec![ + AuthRole::Business("business-1".to_string()), + AuthRole::Business("business-2".to_string()), + ], + ); + let mut app = state.into_router(); + + let response = help_create_program(&mut app, &token, &default_content()).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[sqlx::test(fixtures("users", "business"))] + async fn businesses_can_read_any_program(db: PgPool) { + let (state, _) = state_with_programs(vec![], db).await; + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let mut app = state.clone().into_router(); + + let response = help_create_program(&mut app, &token, &default_content()).await; + assert_eq!(response.status(), StatusCode::CREATED); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let program: Program = serde_json::from_slice(&body).unwrap(); + + let response = get_help(&mut app, &token, program.id.as_str()).await; + assert_eq!(response.status(), StatusCode::OK); + + let token = jwt_test_token(&state, vec![AuthRole::Business("business-2".to_string())]); + let response = get_help(&mut app, &token, program.id.as_str()).await; + assert_eq!(response.status(), StatusCode::OK); + + let token = jwt_test_token( + &state, + vec![ + AuthRole::Business("business-2".to_string()), + AuthRole::Business("business-1".to_string()), + ], + ); + let response = get_help(&mut app, &token, program.id.as_str()).await; + assert_eq!(response.status(), StatusCode::OK); + + let token = jwt_test_token(&state, vec![AuthRole::AnyBusiness]); + let response = get_help(&mut app, &token, program.id.as_str()).await; + assert_eq!(response.status(), StatusCode::OK); + } + + #[sqlx::test(fixtures("users", "business", "programs", "vens"))] + async fn vens_can_read_assigned_programs_only(db: PgPool) { + let (state, _) = state_with_programs(vec![], db).await; + let token = jwt_test_token(&state, vec![AuthRole::Business("business-1".to_string())]); + let mut app = state.clone().into_router(); + + let content = ProgramContent { + targets: Some(TargetMap(vec![TargetEntry { + label: TargetLabel::VENName, + values: ["ven-1-name".to_string()], + }])), + ..default_content() + }; + + let response = help_create_program(&mut app, &token, &content).await; + assert_eq!(response.status(), StatusCode::CREATED); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let program: Program = serde_json::from_slice(&body).unwrap(); + + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-1".parse().unwrap())]); + let response = get_help(&mut app, &token, program.id.as_str()).await; + assert_eq!(response.status(), StatusCode::OK); + + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-2".parse().unwrap())]); + let response = get_help(&mut app, &token, program.id.as_str()).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let token = jwt_test_token( + &state, + vec![ + AuthRole::VEN("ven-2".parse().unwrap()), + AuthRole::VEN("ven-1".parse().unwrap()), + ], + ); + let response = get_help(&mut app, &token, program.id.as_str()).await; + assert_eq!(response.status(), StatusCode::OK); + } + + #[sqlx::test(fixtures("users", "business", "programs", "vens", "vens-programs"))] + async fn retrieve_all_returns_ven_assigned_programs_only(db: PgPool) { + let (state, _) = state_with_programs(vec![], db).await; + let mut app = state.clone().into_router(); + + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-1".parse().unwrap())]); + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 2); + let mut names = programs + .into_iter() + .map(|p| p.content.program_name) + .collect::>(); + names.sort(); + assert_eq!(names, vec!["program-1", "program-3"]); + + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-2".parse().unwrap())]); + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 2); + let mut names = programs + .into_iter() + .map(|p| p.content.program_name) + .collect::>(); + names.sort(); + assert_eq!(names, vec!["program-1", "program-2"]); + + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-2".parse().unwrap())]); + let response = retrieve_all_with_filter_help( + &mut app, + "targetType=VEN_NAME&targetValues=ven-1", + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert!(programs.is_empty()); + + let token = jwt_test_token( + &state, + vec![ + AuthRole::VEN("ven-2".parse().unwrap()), + AuthRole::VEN("ven-1".parse().unwrap()), + ], + ); + let response = retrieve_all_with_filter_help(&mut app, "", &token).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let programs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(programs.len(), 3); + let mut names = programs + .into_iter() + .map(|p| p.content.program_name) + .collect::>(); + names.sort(); + assert_eq!(names, vec!["program-1", "program-2", "program-3"]); + } + + #[sqlx::test(fixtures("users", "business", "programs", "vens"))] + async fn ven_cannot_write_program(db: PgPool) { + let (state, _) = state_with_programs(vec![], db).await; + let mut app = state.clone().into_router(); + + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-1".parse().unwrap())]); + let response = help_create_program(&mut app, &token, &default_content()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = app + .clone() + .oneshot(program_request( + http::Method::PUT, + default_content(), + "program-1", + &token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + app.clone() + .oneshot( + Request::builder() + .method(http::Method::DELETE) + .uri(format!("/programs/{}", "program-1")) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + } +} diff --git a/openadr-vtn/src/api/report.rs b/openadr-vtn/src/api/report.rs new file mode 100644 index 0000000..5942fdb --- /dev/null +++ b/openadr-vtn/src/api/report.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use tracing::{info, instrument}; +use validator::Validate; + +use openadr_wire::{ + event::EventId, + program::ProgramId, + report::{ReportContent, ReportId}, + Report, +}; + +use crate::{ + api::{AppResponse, ValidatedJson, ValidatedQuery}, + data_source::ReportCrud, + error::AppError, + jwt::{BusinessUser, User, VENUser}, +}; + +#[instrument(skip(user, report_source))] +pub async fn get_all( + State(report_source): State>, + ValidatedQuery(query_params): ValidatedQuery, + User(user): User, +) -> AppResponse> { + let reports = report_source.retrieve_all(&query_params, &user).await?; + + Ok(Json(reports)) +} + +#[instrument(skip(user, report_source))] +pub async fn get( + State(report_source): State>, + Path(id): Path, + User(user): User, +) -> AppResponse { + let report: Report = report_source.retrieve(&id, &user).await?; + Ok(Json(report)) +} + +#[instrument(skip(user, report_source))] +pub async fn add( + State(report_source): State>, + VENUser(user): VENUser, + ValidatedJson(new_report): ValidatedJson, +) -> Result<(StatusCode, Json), AppError> { + let report = report_source.create(new_report, &user).await?; + + info!(%report.id, report_name=?report.content.report_name, "report created"); + + Ok((StatusCode::CREATED, Json(report))) +} + +#[instrument(skip(user, report_source))] +pub async fn edit( + State(report_source): State>, + Path(id): Path, + VENUser(user): VENUser, + ValidatedJson(content): ValidatedJson, +) -> AppResponse { + let report = report_source.update(&id, content, &user).await?; + + info!(%report.id, report_name=?report.content.report_name, "report updated"); + + Ok(Json(report)) +} + +#[instrument(skip(user, report_source))] +pub async fn delete( + State(report_source): State>, + // TODO this contradicts the spec, which says that only VENs have write access + BusinessUser(user): BusinessUser, + Path(id): Path, +) -> AppResponse { + let report = report_source.delete(&id, &user).await?; + info!(%id, "deleted report"); + Ok(Json(report)) +} + +#[derive(Serialize, Deserialize, Validate, Debug)] +#[serde(rename_all = "camelCase")] +pub struct QueryParams { + #[serde(rename = "programID")] + pub(crate) program_id: Option, + #[serde(rename = "eventID")] + pub(crate) event_id: Option, + pub(crate) client_name: Option, + #[serde(default)] + pub(crate) skip: i64, + // TODO how to interpret limit = 0 and what is the default? + #[validate(range(max = 50))] + #[serde(default = "get_50")] + pub(crate) limit: i64, +} + +fn get_50() -> i64 { + 50 +} diff --git a/openadr-vtn/src/api/resource.rs b/openadr-vtn/src/api/resource.rs new file mode 100644 index 0000000..4bd5989 --- /dev/null +++ b/openadr-vtn/src/api/resource.rs @@ -0,0 +1,127 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + Json, +}; +use openadr_wire::ven::VenId; +use reqwest::StatusCode; +use serde::Deserialize; +use tracing::{info, trace}; +use validator::{Validate, ValidationError}; + +use openadr_wire::{ + resource::{Resource, ResourceContent, ResourceId}, + target::TargetLabel, +}; + +use crate::{ + api::{AppResponse, ValidatedJson, ValidatedQuery}, + data_source::ResourceCrud, + error::AppError, + jwt::{Claims, User}, +}; + +fn has_write_permission(user_claims: &Claims, ven_id: &VenId) -> Result<(), AppError> { + if user_claims.is_ven_manager() { + return Ok(()); + } + + if user_claims.is_ven() && user_claims.ven_ids().contains(ven_id) { + return Ok(()); + } + + Err(AppError::Forbidden( + "User not authorized to access this resource", + )) +} + +pub async fn get_all( + State(resource_source): State>, + Path(ven_id): Path, + ValidatedQuery(query_params): ValidatedQuery, + User(user): User, +) -> AppResponse> { + has_write_permission(&user, &ven_id)?; + trace!(?query_params); + + let resources = resource_source + .retrieve_all(ven_id, &query_params, &user) + .await?; + + Ok(Json(resources)) +} + +pub async fn get( + State(resource_source): State>, + Path((ven_id, id)): Path<(VenId, ResourceId)>, + User(user): User, +) -> AppResponse { + has_write_permission(&user, &ven_id)?; + let ven = resource_source.retrieve(&id, ven_id, &user).await?; + + Ok(Json(ven)) +} + +pub async fn add( + State(resource_source): State>, + User(user): User, + Path(ven_id): Path, + ValidatedJson(new_resource): ValidatedJson, +) -> Result<(StatusCode, Json), AppError> { + has_write_permission(&user, &ven_id)?; + let ven = resource_source.create(new_resource, ven_id, &user).await?; + + Ok((StatusCode::CREATED, Json(ven))) +} + +pub async fn edit( + State(resource_source): State>, + Path((ven_id, id)): Path<(VenId, ResourceId)>, + User(user): User, + ValidatedJson(content): ValidatedJson, +) -> AppResponse { + has_write_permission(&user, &ven_id)?; + let resource = resource_source.update(&id, ven_id, content, &user).await?; + + info!(%resource.id, resource.resource_name=resource.content.resource_name, "resource updated"); + + Ok(Json(resource)) +} + +pub async fn delete( + State(resource_source): State>, + Path((ven_id, id)): Path<(VenId, ResourceId)>, + User(user): User, +) -> AppResponse { + has_write_permission(&user, &ven_id)?; + let resource = resource_source.delete(&id, ven_id, &user).await?; + info!(%id, "deleted resource"); + Ok(Json(resource)) +} + +#[derive(Deserialize, Validate, Debug)] +#[validate(schema(function = "validate_target_type_value_pair"))] +#[serde(rename_all = "camelCase")] +pub struct QueryParams { + pub(crate) target_type: Option, + pub(crate) target_values: Option>, + #[serde(default)] + #[validate(range(min = 0))] + pub(crate) skip: i64, + #[validate(range(min = 1, max = 50))] + #[serde(default = "get_50")] + pub(crate) limit: i64, +} + +fn validate_target_type_value_pair(query: &QueryParams) -> Result<(), ValidationError> { + if query.target_type.is_some() == query.target_values.is_some() { + Ok(()) + } else { + Err(ValidationError::new("targetType and targetValues query parameter must either both be set or not set at the same time.")) + } +} + +fn get_50() -> i64 { + 50 +} diff --git a/openadr-vtn/src/api/user.rs b/openadr-vtn/src/api/user.rs new file mode 100644 index 0000000..528cb30 --- /dev/null +++ b/openadr-vtn/src/api/user.rs @@ -0,0 +1,505 @@ +use crate::{ + api::{AppResponse, ValidatedJson}, + data_source::{AuthSource, UserDetails}, + error::AppError, + jwt::{AuthRole, UserManagerUser}, +}; +use axum::{ + extract::{Path, State}, + Json, +}; +use reqwest::StatusCode; +#[cfg(test)] +use serde::Serialize; +use serde_with::serde_derive::Deserialize; +use std::sync::Arc; +use tracing::{info, trace}; +use validator::Validate; + +#[derive(Deserialize, Debug, Validate)] +#[cfg_attr(test, derive(Serialize))] +pub struct NewUser { + reference: String, + description: Option, + roles: Vec, +} + +#[derive(Deserialize, Validate)] +#[cfg_attr(test, derive(Serialize, Default))] +pub struct NewCredential { + client_id: String, + client_secret: String, +} + +pub async fn get_all( + State(auth_source): State>, + UserManagerUser(_): UserManagerUser, +) -> AppResponse> { + let users = auth_source.get_all_users().await?; + + trace!("received {} users", users.len()); + Ok(Json(users)) +} + +pub async fn get( + State(auth_source): State>, + Path(id): Path, + UserManagerUser(_): UserManagerUser, +) -> AppResponse { + let user = auth_source.get_user(&id).await?; + trace!(user_id = user.id(), "received user"); + Ok(Json(user)) +} + +pub async fn add_user( + State(auth_source): State>, + UserManagerUser(_): UserManagerUser, + ValidatedJson(new_user): ValidatedJson, +) -> Result<(StatusCode, Json), AppError> { + let user = auth_source + .add_user( + &new_user.reference, + new_user.description.as_deref(), + &new_user.roles, + ) + .await?; + info!(user_id = user.id(), "created new user"); + Ok((StatusCode::CREATED, Json(user))) +} + +pub async fn add_credential( + State(auth_source): State>, + Path(id): Path, + UserManagerUser(_): UserManagerUser, + ValidatedJson(new): ValidatedJson, +) -> AppResponse { + let user = auth_source + .add_credential(&id, &new.client_id, &new.client_secret) + .await?; + info!( + user_id = id, + client_id = new.client_id, + "created new credential for user" + ); + Ok(Json(user)) +} + +pub async fn edit( + State(auth_source): State>, + Path(id): Path, + UserManagerUser(_): UserManagerUser, + ValidatedJson(modified): ValidatedJson, +) -> AppResponse { + let user = auth_source + .edit_user( + &id, + &modified.reference, + modified.description.as_deref(), + &modified.roles, + ) + .await?; + + info!(user_id = user.id(), "updated user"); + Ok(Json(user)) +} + +pub async fn delete_user( + State(auth_source): State>, + Path(id): Path, + UserManagerUser(_): UserManagerUser, +) -> AppResponse { + let user = auth_source.remove_user(&id).await?; + info!(user_id = user.id(), "deleted user"); + Ok(Json(user)) +} + +pub async fn delete_credential( + State(auth_source): State>, + Path((user_id, client_id)): Path<(String, String)>, + UserManagerUser(_): UserManagerUser, +) -> AppResponse { + let user = auth_source.remove_credentials(&user_id, &client_id).await?; + info!(user_id = user.id(), client_id, "deleted credential"); + Ok(Json(user)) +} + +#[cfg(test)] +#[cfg(feature = "live-db-test")] +mod test { + use super::*; + use crate::api::test::{jwt_test_token, state}; + use axum::{ + body::Body, + http, + http::{Request, Response, StatusCode}, + Router, + }; + use http_body_util::BodyExt; + use sqlx::PgPool; + use tower::ServiceExt; + + fn user_1() -> UserDetails { + UserDetails { + id: "user-1".to_string(), + reference: "user-1-ref".to_string(), + description: Some("desc".to_string()), + roles: vec![], + client_ids: vec!["user-1-client-id".to_string()], + created: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + modified: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + } + } + + fn admin() -> UserDetails { + UserDetails { + id: "admin".to_string(), + reference: "admin-ref".to_string(), + description: None, + roles: vec![ + AuthRole::UserManager, + AuthRole::VenManager, + AuthRole::AnyBusiness, + ], + client_ids: vec!["admin".to_string()], + created: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + modified: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + } + } + + fn new_user() -> NewUser { + NewUser { + reference: "new user reference".to_string(), + description: Some("Some description".to_string()), + roles: vec![ + AuthRole::UserManager, + AuthRole::VenManager, + AuthRole::AnyBusiness, + ], + } + } + + fn all_roles() -> Vec { + vec![ + AuthRole::VEN("ven-1".parse().unwrap()), + AuthRole::AnyBusiness, + AuthRole::Business("business-1".parse().unwrap()), + AuthRole::VenManager, + AuthRole::UserManager, + ] + } + + impl PartialEq for NewUser { + fn eq(&self, other: &UserDetails) -> bool { + let mut self_roles = self.roles.clone(); + self_roles.sort(); + + let mut other_roles = other.roles.clone(); + other_roles.sort(); + + self.reference == other.reference + && self.description == other.description + && self_roles == other_roles + } + } + + async fn help_get(app: &mut Router, token: &str, id: &str) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::GET) + .uri(format!("/users/{}", id)) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + async fn help_get_all(app: &mut Router, token: &str) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/users") + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + async fn help_post( + app: &mut Router, + token: &str, + path: &str, + body: &T, + ) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::POST) + .uri(path) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap(), + ) + .await + .unwrap() + } + + async fn help_add_user(app: &mut Router, token: &str, user: &NewUser) -> Response { + help_post(app, token, "/users", user).await + } + + async fn help_edit_user( + app: &mut Router, + token: &str, + id: &str, + user: &NewUser, + ) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::PUT) + .uri(format!("/users/{}", id)) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from(serde_json::to_vec(&user).unwrap())) + .unwrap(), + ) + .await + .unwrap() + } + + async fn help_add_credential( + app: &mut Router, + token: &str, + user_id: &str, + credential: &NewCredential, + ) -> Response { + help_post(app, token, &format!("/users/{user_id}"), credential).await + } + + async fn help_delete(app: &mut Router, token: &str, path: &str) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::DELETE) + .uri(path) + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + async fn help_login(app: &mut Router, client_id: &str, client_secret: &str) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/auth/token") + .header( + http::header::CONTENT_TYPE, + mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), + ) + .body(Body::from(format!( + "client_id={client_id}&client_secret={client_secret}&grant_type=client_credentials", + ))) + .unwrap(), + ) + .await + .unwrap() + } + + impl UserDetails { + async fn from(response: Response) -> Self { + let body = response.into_body().collect().await.unwrap().to_bytes(); + let mut user: UserDetails = serde_json::from_slice(&body).unwrap(); + user.roles.sort(); + user + } + } + + #[sqlx::test(fixtures("users"))] + async fn get(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let response = help_get(&mut app, &token, "admin").await; + assert_eq!(response.status(), StatusCode::OK); + + let user = UserDetails::from(response).await; + assert_eq!(user, admin()); + + let response = help_get(&mut app, &token, "user-1").await; + assert_eq!(response.status(), StatusCode::OK); + + let user = UserDetails::from(response).await; + assert_eq!(user, user_1()); + } + + #[sqlx::test(fixtures("users"))] + async fn all_routes_only_allowed_for_user_manager(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token( + &state, + vec![ + AuthRole::VEN("123".parse().unwrap()), + AuthRole::AnyBusiness, + AuthRole::Business("1234".parse().unwrap()), + AuthRole::VenManager, + ], + ); + let mut app = state.into_router(); + let response = help_get(&mut app, &token, "admin").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_get_all(&mut app, &token).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_add_user(&mut app, &token, &new_user()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_edit_user(&mut app, &token, "admin", &new_user()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_add_credential(&mut app, &token, "admin", &Default::default()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_delete(&mut app, &token, "/users/admin/admin").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = help_delete(&mut app, &token, "/users/admin").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[sqlx::test(fixtures("users"))] + async fn get_all(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let response = help_get_all(&mut app, &token).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let mut users: Vec = serde_json::from_slice(&body).unwrap(); + users.iter_mut().for_each(|user| user.roles.sort()); + users.sort_by(|a, b| a.id.cmp(&b.id)); + + assert_eq!(users, vec![admin(), user_1()]); + } + + #[sqlx::test(fixtures("users", "vens", "business"))] + pub async fn add(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let new_user = NewUser { + roles: all_roles(), + ..new_user() + }; + + let response = help_add_user(&mut app, &token, &new_user).await; + assert_eq!(response.status(), StatusCode::CREATED); + + let user = UserDetails::from(response).await; + assert_eq!(new_user, user); + + let response = help_get(&mut app, &token, user.id()).await; + assert_eq!(response.status(), StatusCode::OK); + + let user2 = UserDetails::from(response).await; + assert_eq!(user2, user); + } + + #[sqlx::test(fixtures("users", "vens", "business"))] + async fn edit(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let new_users = [ + NewUser { + roles: vec![], + ..new_user() + }, + NewUser { + roles: all_roles(), + ..new_user() + }, + ]; + for new_user in new_users { + let response = help_edit_user(&mut app, &token, "admin", &new_user).await; + assert_eq!(response.status(), StatusCode::OK); + + let user = UserDetails::from(response).await; + assert_eq!(new_user, user); + + let response = help_get(&mut app, &token, user.id()).await; + assert_eq!(response.status(), StatusCode::OK); + + let user2 = UserDetails::from(response).await; + assert_eq!(user2, user); + } + } + + #[sqlx::test(fixtures("users"))] + async fn add_credential(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let new_credential = NewCredential { + client_id: "test".to_string(), + client_secret: "test".to_string(), + }; + + let response = help_add_credential(&mut app, &token, "admin", &new_credential).await; + assert_eq!(response.status(), StatusCode::OK); + + let user = UserDetails::from(response).await; + assert!(user.client_ids.contains(&"test".to_string())); + + let response = help_login( + &mut app, + &new_credential.client_id, + &new_credential.client_secret, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + } + + #[sqlx::test(fixtures("users"))] + async fn delete_credential(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let response = help_login(&mut app, "admin", "admin").await; + assert_eq!(response.status(), StatusCode::OK); + + let response = help_delete(&mut app, &token, "/users/admin/admin").await; + assert_eq!(response.status(), StatusCode::OK); + + let response = help_login(&mut app, "admin", "admin").await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[sqlx::test(fixtures("users"))] + async fn delete_user(db: PgPool) { + let state = state(db).await; + let token = jwt_test_token(&state, vec![AuthRole::UserManager]); + let mut app = state.into_router(); + + let response = help_login(&mut app, "admin", "admin").await; + assert_eq!(response.status(), StatusCode::OK); + + let response = help_delete(&mut app, &token, "/users/admin").await; + assert_eq!(response.status(), StatusCode::OK); + + let response = help_login(&mut app, "admin", "admin").await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/openadr-vtn/src/api/ven.rs b/openadr-vtn/src/api/ven.rs new file mode 100644 index 0000000..2f04306 --- /dev/null +++ b/openadr-vtn/src/api/ven.rs @@ -0,0 +1,193 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + Json, +}; +use reqwest::StatusCode; +use serde::Deserialize; +use tracing::{info, trace}; +use validator::{Validate, ValidationError}; + +use openadr_wire::{ + target::TargetLabel, + ven::{Ven, VenContent, VenId}, +}; + +use crate::{ + api::{AppResponse, ValidatedJson, ValidatedQuery}, + data_source::VenCrud, + error::AppError, + jwt::{User, VenManagerUser}, +}; + +pub async fn get_all( + State(ven_source): State>, + ValidatedQuery(query_params): ValidatedQuery, + User(user): User, +) -> AppResponse> { + trace!(?query_params); + + let vens = ven_source + .retrieve_all(&query_params, &user.try_into()?) + .await?; + + Ok(Json(vens)) +} + +pub async fn get( + State(ven_source): State>, + Path(id): Path, + User(user): User, +) -> AppResponse { + if user.is_ven() { + if !user.ven_ids().iter().any(|vid| *vid == id) { + return Err(AppError::Forbidden("User does not have access to this VEN")); + } + } else if !user.is_ven_manager() { + return Err(AppError::Forbidden("User is not a VEN or VEN Manager")); + } + + let ven = ven_source.retrieve(&id, &user.try_into()?).await?; + + Ok(Json(ven)) +} + +pub async fn add( + State(ven_source): State>, + VenManagerUser(user): VenManagerUser, + ValidatedJson(new_ven): ValidatedJson, +) -> Result<(StatusCode, Json), AppError> { + let ven = ven_source.create(new_ven, &user.try_into()?).await?; + + Ok((StatusCode::CREATED, Json(ven))) +} + +pub async fn edit( + State(ven_source): State>, + Path(id): Path, + VenManagerUser(user): VenManagerUser, + ValidatedJson(content): ValidatedJson, +) -> AppResponse { + let ven = ven_source.update(&id, content, &user.try_into()?).await?; + + info!(%ven.id, ven.ven_name=ven.content.ven_name, "ven updated"); + + Ok(Json(ven)) +} + +pub async fn delete( + State(ven_source): State>, + Path(id): Path, + VenManagerUser(user): VenManagerUser, +) -> AppResponse { + let ven = ven_source.delete(&id, &user.try_into()?).await?; + info!(%id, "deleted ven"); + Ok(Json(ven)) +} + +#[derive(Deserialize, Validate, Debug)] +#[validate(schema(function = "validate_target_type_value_pair"))] +#[serde(rename_all = "camelCase")] +pub struct QueryParams { + pub(crate) target_type: Option, + pub(crate) target_values: Option>, + #[serde(default)] + #[validate(range(min = 0))] + pub(crate) skip: i64, + #[validate(range(min = 1, max = 50))] + #[serde(default = "get_50")] + pub(crate) limit: i64, +} + +fn validate_target_type_value_pair(query: &QueryParams) -> Result<(), ValidationError> { + if query.target_type.is_some() == query.target_values.is_some() { + Ok(()) + } else { + Err(ValidationError::new("targetType and targetValues query parameter must either both be set or not set at the same time.")) + } +} + +fn get_50() -> i64 { + 50 +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{self, Request, Response}, + Router, + }; + use http_body_util::BodyExt; + use openadr_wire::Ven; + use serde::de::DeserializeOwned; + use sqlx::PgPool; + use tower::ServiceExt; + + use crate::{ + api::test::jwt_test_token, + data_source::PostgresStorage, + jwt::{AuthRole, JwtManager}, + state::AppState, + }; + + async fn request_all(app: Router, token: &str) -> Response { + app.oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/vens") + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + async fn get_response_json(response: Response) -> T { + let body = response.into_body().collect().await.unwrap().to_bytes(); + + serde_json::from_slice(&body).unwrap() + } + + fn test_state(db: PgPool) -> AppState { + let store = PostgresStorage::new(db).unwrap(); + let jwt_manager = JwtManager::from_base64_secret("test").unwrap(); + + AppState::new(store, jwt_manager) + } + + #[sqlx::test(fixtures("users", "vens"))] + async fn get_all_unfiletred(db: PgPool) { + let state = test_state(db); + let token = jwt_test_token(&state, vec![AuthRole::VenManager]); + let app = state.into_router(); + + let resp = request_all(app, &token).await; + + assert_eq!(resp.status(), http::StatusCode::OK); + let mut vens: Vec = get_response_json(resp).await; + + assert_eq!(vens.len(), 2); + vens.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str())); + assert_eq!(vens[0].id.as_str(), "ven-1"); + assert_eq!(vens[1].id.as_str(), "ven-2"); + } + + #[sqlx::test(fixtures("users", "vens"))] + async fn get_all_ven_user(db: PgPool) { + let state = test_state(db); + let token = jwt_test_token(&state, vec![AuthRole::VEN("ven-1".parse().unwrap())]); + let app = state.into_router(); + + let resp = request_all(app, &token).await; + + assert_eq!(resp.status(), http::StatusCode::OK); + let vens: Vec = get_response_json(resp).await; + + assert_eq!(vens.len(), 1); + assert_eq!(vens[0].id.as_str(), "ven-1"); + } +} diff --git a/openadr-vtn/src/data_source/mod.rs b/openadr-vtn/src/data_source/mod.rs new file mode 100644 index 0000000..f2c13a3 --- /dev/null +++ b/openadr-vtn/src/data_source/mod.rs @@ -0,0 +1,257 @@ +#[cfg(feature = "postgres")] +mod postgres; + +use axum::async_trait; +use chrono::{DateTime, Utc}; +use openadr_wire::{ + event::{EventContent, EventId}, + program::{ProgramContent, ProgramId}, + report::{ReportContent, ReportId}, + resource::{Resource, ResourceContent, ResourceId}, + ven::{Ven, VenContent, VenId}, + Event, Program, Report, +}; +#[cfg(feature = "postgres")] +pub use postgres::PostgresStorage; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::{ + error::AppError, + jwt::{AuthRole, Claims}, +}; + +#[async_trait] +pub trait Crud: Send + Sync + 'static { + type Type; + type Id; + type NewType; + type Error; + type Filter; + type PermissionFilter; + + async fn create( + &self, + new: Self::NewType, + permission_filter: &Self::PermissionFilter, + ) -> Result; + async fn retrieve( + &self, + id: &Self::Id, + permission_filter: &Self::PermissionFilter, + ) -> Result; + async fn retrieve_all( + &self, + filter: &Self::Filter, + permission_filter: &Self::PermissionFilter, + ) -> Result, Self::Error>; + async fn update( + &self, + id: &Self::Id, + new: Self::NewType, + permission_filter: &Self::PermissionFilter, + ) -> Result; + async fn delete( + &self, + id: &Self::Id, + permission_filter: &Self::PermissionFilter, + ) -> Result; +} + +#[async_trait] +pub trait VenScopedCrud: Send + Sync + 'static { + type Type; + type Id; + type NewType; + type Error; + type Filter; + type PermissionFilter; + + async fn create( + &self, + new: Self::NewType, + ven_id: VenId, + permission_filter: &Self::PermissionFilter, + ) -> Result; + async fn retrieve( + &self, + id: &Self::Id, + ven_id: VenId, + permission_filter: &Self::PermissionFilter, + ) -> Result; + async fn retrieve_all( + &self, + ven_id: VenId, + filter: &Self::Filter, + permission_filter: &Self::PermissionFilter, + ) -> Result, Self::Error>; + async fn update( + &self, + id: &Self::Id, + ven_id: VenId, + new: Self::NewType, + permission_filter: &Self::PermissionFilter, + ) -> Result; + async fn delete( + &self, + id: &Self::Id, + ven_id: VenId, + permission_filter: &Self::PermissionFilter, + ) -> Result; +} + +pub trait ProgramCrud: + Crud< + Type = Program, + Id = ProgramId, + NewType = ProgramContent, + Error = AppError, + Filter = crate::api::program::QueryParams, + PermissionFilter = Claims, +> +{ +} +pub trait ReportCrud: + Crud< + Type = Report, + Id = ReportId, + NewType = ReportContent, + Error = AppError, + Filter = crate::api::report::QueryParams, + PermissionFilter = Claims, +> +{ +} +pub trait EventCrud: + Crud< + Type = Event, + Id = EventId, + NewType = EventContent, + Error = AppError, + Filter = crate::api::event::QueryParams, + PermissionFilter = Claims, +> +{ +} + +pub enum VenPermissions { + AllAllowed, + Specific(Vec), +} + +impl VenPermissions { + pub fn as_value(&self) -> Option> { + match self { + VenPermissions::AllAllowed => None, + VenPermissions::Specific(ids) => { + Some(ids.iter().map(|id| id.to_string()).collect::>()) + } + } + } +} + +impl TryFrom for VenPermissions { + type Error = AppError; + + fn try_from(claims: Claims) -> Result { + if claims.is_ven_manager() { + Ok(VenPermissions::AllAllowed) + } else if claims.is_ven() { + Ok(VenPermissions::Specific(claims.ven_ids())) + } else { + Err(AppError::Forbidden( + "User not authorized to access this vens", + )) + } + } +} + +pub trait VenCrud: + Crud< + Type = Ven, + Id = VenId, + NewType = VenContent, + Error = AppError, + Filter = crate::api::ven::QueryParams, + PermissionFilter = VenPermissions, +> +{ +} + +pub trait ResourceCrud: + VenScopedCrud< + Type = Resource, + Id = ResourceId, + NewType = ResourceContent, + Error = AppError, + Filter = crate::api::resource::QueryParams, + PermissionFilter = Claims, +> +{ +} + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub struct UserDetails { + pub(crate) id: String, + pub(crate) reference: String, + pub(crate) description: Option, + pub(crate) roles: Vec, + pub(crate) client_ids: Vec, + #[serde(with = "openadr_wire::serde_rfc3339")] + pub(crate) created: DateTime, + #[serde(with = "openadr_wire::serde_rfc3339")] + pub(crate) modified: DateTime, +} + +impl UserDetails { + pub fn id(&self) -> &str { + &self.id + } +} + +#[async_trait] +pub trait AuthSource: Send + Sync + 'static { + async fn check_credentials(&self, client_id: &str, client_secret: &str) -> Option; + async fn get_user(&self, user_id: &str) -> Result; + async fn get_all_users(&self) -> Result, AppError>; + async fn add_user( + &self, + reference: &str, + description: Option<&str>, + roles: &[AuthRole], + ) -> Result; + async fn add_credential( + &self, + user_id: &str, + client_id: &str, + client_secret: &str, + ) -> Result; + async fn remove_credentials( + &self, + user_id: &str, + client_id: &str, + ) -> Result; + async fn remove_user(&self, user_id: &str) -> Result; + async fn edit_user( + &self, + user_id: &str, + reference: &str, + description: Option<&str>, + roles: &[AuthRole], + ) -> Result; +} + +pub trait DataSource: Send + Sync + 'static { + fn programs(&self) -> Arc; + fn reports(&self) -> Arc; + fn events(&self) -> Arc; + fn vens(&self) -> Arc; + fn resources(&self) -> Arc; + fn auth(&self) -> Arc; +} + +#[derive(Debug, Clone)] +pub struct AuthInfo { + pub client_id: String, + pub roles: Vec, +} diff --git a/openadr-vtn/src/data_source/postgres/event.rs b/openadr-vtn/src/data_source/postgres/event.rs new file mode 100644 index 0000000..a01600a --- /dev/null +++ b/openadr-vtn/src/data_source/postgres/event.rs @@ -0,0 +1,865 @@ +use crate::{ + api::event::QueryParams, + data_source::{ + postgres::{extract_business_ids, to_json_value, PgId, PgTargetsFilter}, + Crud, EventCrud, + }, + error::AppError, + jwt::{BusinessIds, Claims}, +}; +use axum::async_trait; +use chrono::{DateTime, Utc}; +use openadr_wire::{ + event::{EventContent, EventId, Priority}, + target::TargetLabel, + Event, +}; +use sqlx::PgPool; +use std::str::FromStr; +use tracing::{error, trace}; + +#[async_trait] +impl EventCrud for PgEventStorage {} + +pub(crate) struct PgEventStorage { + db: PgPool, +} + +impl From for PgEventStorage { + fn from(db: PgPool) -> Self { + Self { db } + } +} + +#[derive(Debug)] +struct PostgresEvent { + id: String, + created_date_time: DateTime, + modification_date_time: DateTime, + program_id: String, + event_name: Option, + priority: Priority, + targets: Option, + report_descriptors: Option, + payload_descriptors: Option, + interval_period: Option, + intervals: serde_json::Value, +} + +impl TryFrom for Event { + type Error = AppError; + + #[tracing::instrument(name = "TryFrom for Event")] + fn try_from(value: PostgresEvent) -> Result { + let targets = match value.targets { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!(?err, "Failed to deserialize JSON from DB to `TargetMap`") + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + + let report_descriptors = match value.report_descriptors { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!( + ?err, + "Failed to deserialize JSON from DB to `Vec`" + ) + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + + let payload_descriptors = match value.payload_descriptors { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!( + ?err, + "Failed to deserialize JSON from DB to `Vec`" + ) + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + + let interval_period = match value.interval_period { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!( + ?err, + "Failed to deserialize JSON from DB to `IntervalPeriod`" + ) + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + + Ok(Self { + id: EventId::from_str(&value.id)?, + created_date_time: value.created_date_time, + modification_date_time: value.modification_date_time, + content: EventContent { + object_type: Default::default(), + program_id: value.program_id.parse()?, + event_name: value.event_name, + priority: value.priority, + targets, + report_descriptors, + payload_descriptors, + interval_period, + intervals: serde_json::from_value(value.intervals) + .map_err(AppError::SerdeJsonInternalServerError)?, + }, + }) + } +} + +#[derive(Default, Debug)] +struct PostgresFilter<'a> { + program_id: Option<&'a str>, + ven_names: Option<&'a [String]>, + event_names: Option<&'a [String]>, + program_names: Option<&'a [String]>, + // TODO check whether we also need to extract `PowerServiceLocation`, `ServiceArea`, + // `ResourceNames`, and `Group`, i.e., only leave the `Private` + targets: Vec>, + + skip: i64, + limit: i64, +} + +impl<'a> From<&'a QueryParams> for PostgresFilter<'a> { + fn from(query: &'a QueryParams) -> Self { + let mut filter = Self { + program_id: query.program_id.as_ref().map(|id| id.as_str()), + skip: query.skip, + limit: query.limit, + ..Default::default() + }; + match query.target_type { + Some(TargetLabel::VENName) => filter.ven_names = query.target_values.as_deref(), + Some(TargetLabel::EventName) => filter.event_names = query.target_values.as_deref(), + Some(TargetLabel::ProgramName) => filter.program_names = query.target_values.as_deref(), + Some(ref label) => { + if let Some(values) = query.target_values.as_ref() { + filter.targets = values + .iter() + .map(|value| PgTargetsFilter { + label: label.as_str(), + value: [value.clone()], + }) + .collect() + } + } + None => {} + }; + + filter + } +} + +struct MaybePgId { + id: Option, +} + +async fn check_write_permission( + program_id: &str, + user: &Claims, + db: &PgPool, +) -> Result<(), AppError> { + if let Some(business_ids) = extract_business_ids(user) { + let MaybePgId { id } = sqlx::query_as!( + MaybePgId, + r#" + SELECT business_id AS id FROM program WHERE id = $1 + "#, + program_id + ) + .fetch_one(db) + .await?; + + // If no business is connected, anyone may write + if let Some(id) = id { + if !business_ids.contains(&id) { + Err(AppError::Auth("You do not have write permissions for events belonging to a program that belongs to another business logic".to_string()))?; + } + } + }; + Ok(()) +} + +#[async_trait] +impl Crud for PgEventStorage { + type Type = Event; + type Id = EventId; + type NewType = EventContent; + type Error = AppError; + type Filter = QueryParams; + type PermissionFilter = Claims; + + async fn create( + &self, + new: Self::NewType, + user: &Self::PermissionFilter, + ) -> Result { + check_write_permission(new.program_id.as_str(), user, &self.db).await?; + + Ok(sqlx::query_as!( + PostgresEvent, + r#" + INSERT INTO event (id, created_date_time, modification_date_time, program_id, event_name, priority, targets, report_descriptors, payload_descriptors, interval_period, intervals) + VALUES (gen_random_uuid(), now(), now(), $1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + "#, + new.program_id.as_str(), + new.event_name, + Into::>::into(new.priority), + to_json_value(new.targets)?, + to_json_value(new.report_descriptors)?, + to_json_value(new.payload_descriptors)?, + to_json_value(new.interval_period)?, + serde_json::to_value(&new.intervals).map_err(AppError::SerdeJsonBadRequest)?, + ) + .fetch_one(&self.db) + .await? + .try_into()? + ) + } + + async fn retrieve( + &self, + id: &Self::Id, + user: &Self::PermissionFilter, + ) -> Result { + let business_ids = match user.business_ids() { + BusinessIds::Specific(ids) => Some(ids), + BusinessIds::Any => None, + }; + + Ok(sqlx::query_as!( + PostgresEvent, + r#" + SELECT e.* + FROM event e + JOIN program p ON e.program_id = p.id + LEFT JOIN ven_program vp ON p.id = vp.program_id + WHERE e.id = $1 + AND ( + ($2 AND (vp.ven_id IS NULL OR vp.ven_id = ANY($3))) + OR + ($4 AND ($5::text[] IS NULL OR p.business_id = ANY ($5))) + ) + "#, + id.as_str(), + user.is_ven(), + &user.ven_ids_string(), + user.is_business(), + business_ids.as_deref(), + ) + .fetch_one(&self.db) + .await? + .try_into()?) + } + + async fn retrieve_all( + &self, + filter: &Self::Filter, + user: &Self::PermissionFilter, + ) -> Result, Self::Error> { + let pg_filter: PostgresFilter = filter.into(); + trace!(?pg_filter); + + let business_ids = match user.business_ids() { + BusinessIds::Specific(ids) => Some(ids), + BusinessIds::Any => None, + }; + + Ok(sqlx::query_as!( + PostgresEvent, + r#" + SELECT e.* + FROM event e + JOIN program p on p.id = e.program_id + LEFT JOIN ven_program vp ON p.id = vp.program_id + LEFT JOIN ven v ON v.id = vp.ven_id + LEFT JOIN LATERAL ( + SELECT e.id as e_id, + json_array(jsonb_array_elements(e.targets)) <@ $5::jsonb AS target_test ) + ON e.id = e_id + WHERE ($1::text IS NULL OR e.program_id like $1) + AND ($2::text[] IS NULL OR e.event_name = ANY($2)) + AND ($3::text[] IS NULL OR p.program_name = ANY($3)) + AND ($4::text[] IS NULL OR v.ven_name = ANY($4)) + AND ($5::jsonb = '[]'::jsonb OR target_test) + AND ( + ($6 AND (vp.ven_id IS NULL OR vp.ven_id = ANY($7))) + OR + ($8 AND ($9::text[] IS NULL OR p.business_id = ANY ($9))) + ) + GROUP BY e.id + OFFSET $10 LIMIT $11 + "#, + pg_filter.program_id, + pg_filter.event_names, + pg_filter.program_names, + pg_filter.ven_names, + serde_json::to_value(pg_filter.targets) + .map_err(AppError::SerdeJsonInternalServerError)?, + user.is_ven(), + &user.ven_ids_string(), + user.is_business(), + business_ids.as_deref(), + pg_filter.skip, + pg_filter.limit + ) + .fetch_all(&self.db) + .await? + .into_iter() + .map(TryInto::try_into) + .collect::>()?) + } + + async fn update( + &self, + id: &Self::Id, + new: Self::NewType, + user: &Self::PermissionFilter, + ) -> Result { + check_write_permission(new.program_id.as_str(), user, &self.db).await?; + + let previous_program_id = sqlx::query_as!( + PgId, + r#"SELECT program_id AS id FROM event WHERE id = $1"#, + id.as_str() + ) + .fetch_one(&self.db) + .await?; + + // make sure, you cannot 'steal' an event from another business + if previous_program_id.id != new.program_id.as_str() { + check_write_permission(&previous_program_id.id, user, &self.db).await?; + } + + Ok(sqlx::query_as!( + PostgresEvent, + r#" + UPDATE event + SET modification_date_time = now(), + program_id = $2, + event_name = $3, + priority = $4, + targets = $5, + report_descriptors = $6, + payload_descriptors = $7, + interval_period = $8, + intervals = $9 + WHERE id = $1 + RETURNING * + "#, + id.as_str(), + new.program_id.as_str(), + new.event_name, + Into::>::into(new.priority), + to_json_value(new.targets)?, + to_json_value(new.report_descriptors)?, + to_json_value(new.payload_descriptors)?, + to_json_value(new.interval_period)?, + serde_json::to_value(&new.intervals).map_err(AppError::SerdeJsonBadRequest)?, + ) + .fetch_one(&self.db) + .await? + .try_into()?) + } + + async fn delete( + &self, + id: &Self::Id, + user: &Self::PermissionFilter, + ) -> Result { + let program_id = sqlx::query_as!( + PgId, + r#"SELECT program_id AS id FROM event WHERE id = $1"#, + id.as_str() + ) + .fetch_one(&self.db) + .await?; + + check_write_permission(&program_id.id, user, &self.db).await?; + + Ok(sqlx::query_as!( + PostgresEvent, + r#" + DELETE FROM event WHERE id = $1 RETURNING * + "#, + id.as_str() + ) + .fetch_one(&self.db) + .await? + .try_into()?) + } +} + +#[cfg(test)] +#[cfg(feature = "live-db-test")] +mod tests { + use sqlx::PgPool; + + use crate::{ + api::event::QueryParams, + data_source::{postgres::event::PgEventStorage, Crud}, + error::AppError, + jwt::Claims, + }; + use chrono::{DateTime, Duration, Utc}; + use openadr_wire::{ + event::{EventContent, EventInterval, EventType, EventValuesMap}, + interval::IntervalPeriod, + target::{TargetEntry, TargetLabel, TargetMap}, + values_map::Value, + Event, + }; + + impl Default for QueryParams { + fn default() -> Self { + Self { + program_id: None, + target_type: None, + target_values: None, + skip: 0, + limit: 50, + } + } + } + + fn event_1() -> Event { + Event { + id: "event-1".parse().unwrap(), + created_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + modification_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + content: EventContent { + object_type: Default::default(), + program_id: "program-1".parse().unwrap(), + event_name: Some("event-1-name".to_string()), + priority: Some(4).into(), + targets: Some(TargetMap(vec![ + TargetEntry { + label: TargetLabel::Group, + values: ["group-1".to_string()], + }, + TargetEntry { + label: TargetLabel::Private("PRIVATE_LABEL".to_string()), + values: ["private value".to_string()], + }, + ])), + report_descriptors: None, + payload_descriptors: None, + interval_period: Some(IntervalPeriod { + start: "2023-06-15T09:30:00+00:00".parse().unwrap(), + duration: Some("P0Y0M0DT1H0M0S".parse().unwrap()), + randomize_start: Some("P0Y0M0DT1H0M0S".parse().unwrap()), + }), + intervals: vec![EventInterval { + id: 3, + interval_period: Some(IntervalPeriod { + start: "2023-06-15T09:30:00+00:00".parse().unwrap(), + duration: Some("P0Y0M0DT1H0M0S".parse().unwrap()), + randomize_start: Some("P0Y0M0DT1H0M0S".parse().unwrap()), + }), + payloads: vec![EventValuesMap { + value_type: EventType::Price, + values: vec![Value::Number(0.17)], + }], + }], + }, + } + } + + fn event_2() -> Event { + Event { + id: "event-2".parse().unwrap(), + created_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + modification_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + content: EventContent { + object_type: Default::default(), + program_id: "program-2".parse().unwrap(), + event_name: Some("event-2-name".to_string()), + priority: None.into(), + targets: Some(TargetMap(vec![TargetEntry { + label: TargetLabel::Private("SOME_TARGET".to_string()), + values: ["target-1".to_string()], + }])), + report_descriptors: None, + payload_descriptors: None, + interval_period: None, + intervals: vec![EventInterval { + id: 3, + interval_period: None, + payloads: vec![EventValuesMap { + value_type: EventType::Private("SOME_PAYLOAD".to_string()), + values: vec![Value::String("value".to_string())], + }], + }], + }, + } + } + + fn event_3() -> Event { + Event { + id: "event-3".parse().unwrap(), + content: EventContent { + program_id: "program-3".parse().unwrap(), + event_name: Some("event-3-name".to_string()), + ..event_2().content + }, + ..event_2() + } + } + + mod get_all { + use super::*; + + #[sqlx::test(fixtures("programs", "events"))] + async fn default_get_all(db: PgPool) { + let repo: PgEventStorage = db.into(); + let mut events = repo + .retrieve_all(&Default::default(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(events.len(), 3); + events.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str())); + assert_eq!(events, vec![event_1(), event_2(), event_3()]); + } + + #[sqlx::test(fixtures("programs", "events"))] + async fn limit_get_all(db: PgPool) { + let repo: PgEventStorage = db.into(); + let events = repo + .retrieve_all( + &QueryParams { + limit: 1, + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events, vec![event_1()]); + } + + #[sqlx::test(fixtures("programs", "events"))] + async fn skip_get_all(db: PgPool) { + let repo: PgEventStorage = db.into(); + let events = repo + .retrieve_all( + &QueryParams { + skip: 1, + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 2); + + let events = repo + .retrieve_all( + &QueryParams { + skip: 20, + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 0); + } + + #[sqlx::test(fixtures("programs", "events"))] + async fn filter_target_type_and_value_get_all(db: PgPool) { + let repo: PgEventStorage = db.into(); + + let events = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Group), + target_values: Some(vec!["group-1".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events, vec![event_1()]); + + let mut events = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Private("SOME_TARGET".to_string())), + target_values: Some(vec!["target-1".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 2); + events.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str())); + assert_eq!(events, vec![event_2(), event_3()]); + + let events = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Group), + target_values: Some(vec!["not-existent".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 0); + + let events = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Private("NOT_EXISTENT".to_string())), + target_values: Some(vec!["target-1".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 0); + + let events = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Group), + target_values: Some(vec!["target-1".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 0); + } + + #[sqlx::test(fixtures("programs"))] + async fn filter_multiple_targets(db: PgPool) { + let repo: PgEventStorage = db.into(); + + let events = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Group), + target_values: Some(vec!["private value".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 0); + } + + #[sqlx::test(fixtures("programs", "events"))] + async fn filter_program_id_get_all(db: PgPool) { + let repo: PgEventStorage = db.into(); + + let events = repo + .retrieve_all( + &QueryParams { + program_id: Some("program-1".parse().unwrap()), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events, vec![event_1()]); + + let events = repo + .retrieve_all( + &QueryParams { + program_id: Some("program-1".parse().unwrap()), + target_type: Some(TargetLabel::Group), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events, vec![event_1()]); + + let events = repo + .retrieve_all( + &QueryParams { + program_id: Some("not-existent".parse().unwrap()), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(events.len(), 0); + } + } + + mod get { + use super::*; + + #[sqlx::test(fixtures("programs", "events"))] + async fn get_existing(db: PgPool) { + let repo: PgEventStorage = db.into(); + let event = repo + .retrieve(&"event-1".parse().unwrap(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(event, event_1()); + } + + #[sqlx::test(fixtures("programs", "events"))] + async fn get_not_existing(db: PgPool) { + let repo: PgEventStorage = db.into(); + let event = repo + .retrieve( + &"not-existent".parse().unwrap(), + &Claims::any_business_user(), + ) + .await; + assert!(matches!(event, Err(AppError::NotFound))); + } + } + + mod add { + use super::*; + + #[sqlx::test(fixtures("programs"))] + async fn add(db: PgPool) { + let repo: PgEventStorage = db.into(); + let event = repo + .create(event_1().content, &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(event.content, event_1().content); + assert!(event.created_date_time < Utc::now() + Duration::minutes(10)); + assert!(event.created_date_time > Utc::now() - Duration::minutes(10)); + assert!(event.modification_date_time < Utc::now() + Duration::minutes(10)); + assert!(event.modification_date_time > Utc::now() - Duration::minutes(10)); + } + + #[sqlx::test(fixtures("programs", "events"))] + async fn add_existing_conflict_name(db: PgPool) { + let repo: PgEventStorage = db.into(); + let event = repo + .create(event_1().content, &Claims::any_business_user()) + .await; + assert!(event.is_ok()); + } + } + + mod modify { + use super::*; + + #[sqlx::test(fixtures("programs", "events"))] + async fn updates_modify_time(db: PgPool) { + let repo: PgEventStorage = db.into(); + let event = repo + .update( + &"event-1".parse().unwrap(), + event_1().content, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(event.content, event_1().content); + assert_eq!( + event.created_date_time, + "2024-07-25 08:31:10.776000 +00:00" + .parse::>() + .unwrap() + ); + assert!(event.modification_date_time < Utc::now() + Duration::minutes(10)); + assert!(event.modification_date_time > Utc::now() - Duration::minutes(10)); + } + + #[sqlx::test(fixtures("programs", "events"))] + async fn update(db: PgPool) { + let repo: PgEventStorage = db.into(); + let mut updated = event_2().content; + updated.event_name = Some("updated-name".to_string()); + let event = repo + .update( + &"event-1".parse().unwrap(), + updated.clone(), + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(event.content, updated); + let event = repo + .retrieve(&"event-1".parse().unwrap(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(event.content, updated); + } + + #[sqlx::test(fixtures("programs", "events"))] + async fn update_name_conflict(db: PgPool) { + let repo: PgEventStorage = db.into(); + let event = repo + .update( + &"event-1".parse().unwrap(), + event_2().content, + &Claims::any_business_user(), + ) + .await; + assert!(event.is_ok()); + } + } + + mod delete { + use super::*; + + #[sqlx::test(fixtures("programs", "events"))] + async fn delete_existing(db: PgPool) { + let repo: PgEventStorage = db.into(); + let event = repo + .delete(&"event-1".parse().unwrap(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(event, event_1()); + + let event = repo + .retrieve(&"event-1".parse().unwrap(), &Claims::any_business_user()) + .await; + assert!(matches!(event, Err(AppError::NotFound))); + + let event = repo + .retrieve(&"event-2".parse().unwrap(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(event, event_2()); + } + + #[sqlx::test(fixtures("programs", "events"))] + async fn delete_not_existing(db: PgPool) { + let repo: PgEventStorage = db.into(); + let event = repo + .delete( + &"not-existent".parse().unwrap(), + &Claims::any_business_user(), + ) + .await; + assert!(matches!(event, Err(AppError::NotFound))); + } + } +} diff --git a/openadr-vtn/src/data_source/postgres/fixtures b/openadr-vtn/src/data_source/postgres/fixtures new file mode 120000 index 0000000..934caca --- /dev/null +++ b/openadr-vtn/src/data_source/postgres/fixtures @@ -0,0 +1 @@ +../../../../fixtures/ \ No newline at end of file diff --git a/openadr-vtn/src/data_source/postgres/mod.rs b/openadr-vtn/src/data_source/postgres/mod.rs new file mode 100644 index 0000000..7815546 --- /dev/null +++ b/openadr-vtn/src/data_source/postgres/mod.rs @@ -0,0 +1,151 @@ +use crate::{ + data_source::{ + postgres::{ + event::PgEventStorage, program::PgProgramStorage, report::PgReportStorage, + user::PgAuthSource, ven::PgVenStorage, + }, + AuthSource, DataSource, EventCrud, ProgramCrud, ReportCrud, ResourceCrud, VenCrud, + }, + error::AppError, + jwt::{BusinessIds, Claims}, +}; +use dotenvy::dotenv; +use openadr_wire::target::{TargetLabel, TargetMap}; +use resource::PgResourceStorage; +use serde::Serialize; +use sqlx::PgPool; +use std::sync::Arc; +use tracing::{error, info, trace}; + +mod event; +mod program; +mod report; +mod resource; +mod user; +mod ven; + +#[derive(Clone)] +pub struct PostgresStorage { + db: PgPool, +} + +impl DataSource for PostgresStorage { + fn programs(&self) -> Arc { + Arc::::new(self.db.clone().into()) + } + + fn reports(&self) -> Arc { + Arc::::new(self.db.clone().into()) + } + + fn events(&self) -> Arc { + Arc::::new(self.db.clone().into()) + } + + fn vens(&self) -> Arc { + Arc::::new(self.db.clone().into()) + } + + fn resources(&self) -> Arc { + Arc::::new(self.db.clone().into()) + } + + fn auth(&self) -> Arc { + Arc::::new(self.db.clone().into()) + } +} + +impl PostgresStorage { + pub fn new(db: PgPool) -> Result { + Ok(Self { db }) + } + + pub async fn from_env() -> Result { + dotenv().unwrap(); + let db_url = std::env::var("DATABASE_URL") + .expect("Missing DATABASE_URL env var even though the 'postgres' feature is active"); + + let db = PgPool::connect(&db_url).await?; + + let connect_options = db.connect_options(); + let safe_db_url = format!( + "{}:{}/{}", + connect_options.get_host(), + connect_options.get_port(), + connect_options.get_database().unwrap_or_default() + ); + + Self::new(db) + .inspect_err(|err| error!(?err, "could not connect to Postgres database")) + .inspect(|_| { + info!( + "Successfully connected to Postgres backend at {}", + safe_db_url + ) + }) + } +} + +fn to_json_value(v: Option) -> Result, AppError> { + v.map(|v| serde_json::to_value(v).map_err(AppError::SerdeJsonBadRequest)) + .transpose() +} + +#[derive(Serialize, Debug)] +struct PgTargetsFilter<'a> { + #[serde(rename = "type")] + label: &'a str, + #[serde(rename = "values")] + value: [String; 1], +} + +#[tracing::instrument(level = "trace")] +fn extract_vens(targets: Option) -> (Option, Option>) { + if let Some(TargetMap(targets)) = targets { + let (vens, targets): (Vec<_>, Vec<_>) = targets + .into_iter() + .partition(|t| t.label == TargetLabel::VENName); + + let vens = vens + .into_iter() + .map(|t| t.values[0].clone()) + .collect::>(); + + let targets = if targets.is_empty() { + None + } else { + Some(TargetMap(targets)) + }; + let vens = if vens.is_empty() { None } else { Some(vens) }; + + trace!(?targets, ?vens); + (targets, vens) + } else { + (None, None) + } +} + +fn extract_business_id(user: &Claims) -> Result, AppError> { + match user.business_ids() { + BusinessIds::Specific(ids) => { + if ids.len() == 1 { + Ok(Some(ids[0].clone())) + } else { + Err(AppError::BadRequest("Cannot infer business id from user"))? + } + } + BusinessIds::Any => Ok(None), + } +} + +fn extract_business_ids(user: &Claims) -> Option> { + match user.business_ids() { + BusinessIds::Specific(ids) => Some(ids), + BusinessIds::Any => None, + } +} + +#[derive(Debug)] +struct PgId { + id: String, +} diff --git a/openadr-vtn/src/data_source/postgres/program.rs b/openadr-vtn/src/data_source/postgres/program.rs new file mode 100644 index 0000000..cfda91b --- /dev/null +++ b/openadr-vtn/src/data_source/postgres/program.rs @@ -0,0 +1,888 @@ +use crate::{ + api::program::QueryParams, + data_source::{ + postgres::{extract_business_id, extract_vens, to_json_value, PgTargetsFilter}, + Crud, ProgramCrud, + }, + error::AppError, + jwt::Claims, +}; +use axum::async_trait; +use chrono::{DateTime, Utc}; +use openadr_wire::{ + program::{ProgramContent, ProgramId}, + target::TargetLabel, + Program, +}; +use sqlx::PgPool; +use tracing::{error, trace}; + +#[async_trait] +impl ProgramCrud for PgProgramStorage {} + +pub(crate) struct PgProgramStorage { + db: PgPool, +} + +impl From for PgProgramStorage { + fn from(db: PgPool) -> Self { + Self { db } + } +} + +#[derive(Debug)] +struct PostgresProgram { + id: String, + created_date_time: DateTime, + modification_date_time: DateTime, + program_name: String, + program_long_name: Option, + retailer_name: Option, + retailer_long_name: Option, + program_type: Option, + country: Option, + principal_subdivision: Option, + interval_period: Option, + program_descriptions: Option, + binding_events: Option, + local_price: Option, + payload_descriptors: Option, + targets: Option, +} + +impl TryFrom for Program { + type Error = AppError; + + #[tracing::instrument(name = "TryFrom for Program")] + fn try_from(value: PostgresProgram) -> Result { + let interval_period = match value.interval_period { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!( + ?err, + "Failed to deserialize JSON from DB to `IntervalPeriod`" + ) + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + let program_descriptions = match value.program_descriptions { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!( + ?err, + "Failed to deserialize JSON from DB to `Vec`" + ) + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + let payload_descriptors = match value.payload_descriptors { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!( + ?err, + "Failed to deserialize JSON from DB to `Vec`" + ) + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + let targets = match value.targets { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!(?err, "Failed to deserialize JSON from DB to `TargetMap`") + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + + Ok(Self { + id: value.id.parse()?, + created_date_time: value.created_date_time, + modification_date_time: value.modification_date_time, + content: ProgramContent { + object_type: Default::default(), + program_name: value.program_name, + program_long_name: value.program_long_name, + retailer_name: value.retailer_name, + retailer_long_name: value.retailer_long_name, + program_type: value.program_type, + country: value.country, + principal_subdivision: value.principal_subdivision, + time_zone_offset: None, + interval_period, + program_descriptions, + binding_events: value.binding_events, + local_price: value.local_price, + payload_descriptors, + targets, + }, + }) + } +} + +#[derive(Debug, Default)] +struct PostgresFilter<'a> { + ven_names: Option<&'a [String]>, + event_names: Option<&'a [String]>, + program_names: Option<&'a [String]>, + // TODO check whether we also need to extract `PowerServiceLocation`, `ServiceArea`, + // `ResourceNames`, and `Group`, i.e., only leave the `Private` + targets: Vec>, + + skip: i64, + limit: i64, +} + +impl<'a> From<&'a QueryParams> for PostgresFilter<'a> { + fn from(query: &'a QueryParams) -> Self { + let mut filter = Self { + skip: query.skip, + limit: query.limit, + ..Default::default() + }; + match query.target_type { + Some(TargetLabel::VENName) => filter.ven_names = query.target_values.as_deref(), + Some(TargetLabel::EventName) => filter.event_names = query.target_values.as_deref(), + Some(TargetLabel::ProgramName) => filter.program_names = query.target_values.as_deref(), + Some(ref label) => { + if let Some(values) = query.target_values.as_ref() { + filter.targets = values + .iter() + .map(|value| PgTargetsFilter { + label: label.as_str(), + value: [value.clone()], + }) + .collect() + } + } + None => {} + }; + + filter + } +} + +#[async_trait] +impl Crud for PgProgramStorage { + type Type = Program; + type Id = ProgramId; + type NewType = ProgramContent; + type Error = AppError; + type Filter = QueryParams; + type PermissionFilter = Claims; + + async fn create( + &self, + new: Self::NewType, + user: &Self::PermissionFilter, + ) -> Result { + let (targets, vens) = extract_vens(new.targets); + let business_id = extract_business_id(user)?; + + let mut tx = self.db.begin().await?; + + let program: Program = sqlx::query_as!( + PostgresProgram, + r#" + INSERT INTO program (id, + created_date_time, + modification_date_time, + program_name, + program_long_name, + retailer_name, + retailer_long_name, + program_type, + country, + principal_subdivision, + interval_period, + program_descriptions, + binding_events, + local_price, + payload_descriptors, + targets, + business_id) + VALUES (gen_random_uuid(), now(), now(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id, + created_date_time, + modification_date_time, + program_name, + program_long_name, + retailer_name, + retailer_long_name, + program_type, + country, + principal_subdivision, + interval_period, + program_descriptions, + binding_events, + local_price, + payload_descriptors, + targets + "#, + new.program_name, + new.program_long_name, + new.retailer_name, + new.retailer_long_name, + new.program_type, + new.country, + new.principal_subdivision, + to_json_value(new.interval_period)?, + to_json_value(new.program_descriptions)?, + new.binding_events, + new.local_price, + to_json_value(new.payload_descriptors)?, + to_json_value(targets)?, + business_id, + ) + .fetch_one(&mut *tx) + .await? + .try_into()?; + + if let Some(vens) = vens { + let rows_affected = sqlx::query!( + r#" + INSERT INTO ven_program (program_id, ven_id) + (SELECT $1, id FROM ven WHERE ven_name = ANY ($2)) + "#, + program.id.as_str(), + &vens + ) + .execute(&mut *tx) + .await? + .rows_affected(); + if rows_affected as usize != vens.len() { + Err(AppError::Conflict( + "One or multiple VEN names linked in the program do not exist".to_string(), + None, + ))? + } + }; + tx.commit().await?; + Ok(program) + } + + async fn retrieve( + &self, + id: &Self::Id, + user: &Self::PermissionFilter, + ) -> Result { + Ok(sqlx::query_as!( + PostgresProgram, + r#" + SELECT p.id, + p.created_date_time, + p.modification_date_time, + p.program_name, + p.program_long_name, + p.retailer_name, + p.retailer_long_name, + p.program_type, + p.country, + p.principal_subdivision, + p.interval_period, + p.program_descriptions, + p.binding_events, + p.local_price, + p.payload_descriptors, + p.targets + FROM program p + LEFT JOIN ven_program vp ON p.id = vp.program_id + WHERE id = $1 + AND (NOT $2 OR vp.ven_id IS NULL OR vp.ven_id = ANY($3)) -- Filter for VEN ids + "#, + id.as_str(), + user.is_ven(), + &user.ven_ids_string() + ) + .fetch_one(&self.db) + .await? + .try_into()?) + } + + async fn retrieve_all( + &self, + filter: &Self::Filter, + user: &Self::PermissionFilter, + ) -> Result, Self::Error> { + let pg_filter: PostgresFilter = filter.into(); + trace!(?pg_filter); + + Ok(sqlx::query_as!( + PostgresProgram, + r#" + SELECT p.id AS "id!", + p.created_date_time AS "created_date_time!", + p.modification_date_time AS "modification_date_time!", + p.program_name AS "program_name!", + p.program_long_name, + p.retailer_name, + p.retailer_long_name, + p.program_type, + p.country, + p.principal_subdivision, + p.interval_period, + p.program_descriptions, + p.binding_events, + p.local_price, + p.payload_descriptors, + p.targets + FROM program p + LEFT JOIN event e ON p.id = e.program_id + LEFT JOIN ven_program vp ON p.id = vp.program_id + LEFT JOIN ven v ON v.id = vp.ven_id + LEFT JOIN LATERAL ( + SELECT p.id as p_id, + json_array(jsonb_array_elements(p.targets)) <@ $4::jsonb AS target_test ) + ON p.id = p_id + WHERE ($1::text[] IS NULL OR e.event_name = ANY($1)) + AND ($2::text[] IS NULL OR p.program_name = ANY($2)) + AND ($3::text[] IS NULL OR v.ven_name = ANY($3)) + AND ($4::jsonb = '[]'::jsonb OR target_test) + AND (NOT $5 OR v.id IS NULL OR v.id = ANY($6)) -- Filter for VEN ids + GROUP BY p.id + OFFSET $7 LIMIT $8 + "#, + pg_filter.event_names, + pg_filter.program_names, + pg_filter.ven_names, + serde_json::to_value(pg_filter.targets) + .map_err(AppError::SerdeJsonInternalServerError)?, + user.is_ven(), + &user.ven_ids_string(), + pg_filter.skip, + pg_filter.limit, + ) + .fetch_all(&self.db) + .await? + .into_iter() + .map(TryInto::try_into) + .collect::>()?) + } + + async fn update( + &self, + id: &Self::Id, + new: Self::NewType, + user: &Self::PermissionFilter, + ) -> Result { + let (targets, vens) = extract_vens(new.targets); + let business_id = extract_business_id(user)?; + + let mut tx = self.db.begin().await?; + + let program: Program = sqlx::query_as!( + PostgresProgram, + r#" + UPDATE program p + SET modification_date_time = now(), + program_name = $2, + program_long_name = $3, + retailer_name = $4, + retailer_long_name = $5, + program_type = $6, + country = $7, + principal_subdivision = $8, + interval_period = $9, + program_descriptions = $10, + binding_events = $11, + local_price = $12, + payload_descriptors = $13, + targets = $14 + WHERE id = $1 + AND ($15::text IS NULL OR business_id = $15) + RETURNING p.id, + p.created_date_time, + p.modification_date_time, + p.program_name, + p.program_long_name, + p.retailer_name, + p.retailer_long_name, + p.program_type, + p.country, + p.principal_subdivision, + p.interval_period, + p.program_descriptions, + p.binding_events, + p.local_price, + p.payload_descriptors, + p.targets + "#, + id.as_str(), + new.program_name, + new.program_long_name, + new.retailer_name, + new.retailer_long_name, + new.program_type, + new.country, + new.principal_subdivision, + to_json_value(new.interval_period)?, + to_json_value(new.program_descriptions)?, + new.binding_events, + new.local_price, + to_json_value(new.payload_descriptors)?, + to_json_value(targets)?, + business_id + ) + .fetch_one(&mut *tx) + .await? + .try_into()?; + + if let Some(vens) = vens { + sqlx::query!( + r#" + DELETE FROM ven_program WHERE program_id = $1 + "#, + program.id.as_str() + ) + .execute(&mut *tx) + .await?; + + let rows_affected = sqlx::query!( + r#" + INSERT INTO ven_program (program_id, ven_id) + (SELECT $1, id FROM ven WHERE ven_name = ANY($2)) + "#, + program.id.as_str(), + &vens + ) + .execute(&mut *tx) + .await? + .rows_affected(); + if rows_affected as usize != vens.len() { + Err(AppError::BadRequest( + "One or multiple VEN names linked in the program do not exist", + ))? + } + }; + tx.commit().await?; + Ok(program) + } + + async fn delete( + &self, + id: &Self::Id, + user: &Self::PermissionFilter, + ) -> Result { + let business_id = extract_business_id(user)?; + + Ok(sqlx::query_as!( + PostgresProgram, + r#" + DELETE FROM program p + WHERE id = $1 + AND ($2::text IS NULL OR business_id = $2) + RETURNING p.id, + p.created_date_time, + p.modification_date_time, + p.program_name, + p.program_long_name, + p.retailer_name, + p.retailer_long_name, + p.program_type, + p.country, + p.principal_subdivision, + p.interval_period, + p.program_descriptions, + p.binding_events, + p.local_price, + p.payload_descriptors, + p.targets + "#, + id.as_str(), + business_id, + ) + .fetch_one(&self.db) + .await? + .try_into()?) + } +} + +#[cfg(test)] +#[cfg(feature = "live-db-test")] +mod tests { + use crate::{ + api::program::QueryParams, + data_source::{postgres::program::PgProgramStorage, Crud}, + error::AppError, + jwt::Claims, + }; + use openadr_wire::{ + event::{EventPayloadDescriptor, EventType}, + interval::IntervalPeriod, + program::{PayloadDescriptor, ProgramContent, ProgramDescription}, + target::{TargetEntry, TargetLabel, TargetMap}, + Program, + }; + use sqlx::PgPool; + + impl Default for QueryParams { + fn default() -> Self { + Self { + target_type: None, + target_values: None, + skip: 0, + limit: 50, + } + } + } + + fn program_1() -> Program { + Program { + id: "program-1".parse().unwrap(), + created_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + modification_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + content: ProgramContent { + object_type: Default::default(), + program_name: "program-1".to_string(), + program_long_name: Some("program long name".to_string()), + retailer_name: Some("retailer name".to_string()), + retailer_long_name: Some("retailer long name".to_string()), + program_type: Some("program type".to_string()), + country: Some("country".to_string()), + principal_subdivision: Some("principal-subdivision".to_string()), + time_zone_offset: None, + interval_period: Some(IntervalPeriod::new( + "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + )), + program_descriptions: Some(vec![ProgramDescription { + url: "https://program-description-1.com".to_string(), + }]), + binding_events: Some(false), + local_price: Some(true), + payload_descriptors: Some(vec![PayloadDescriptor::EventPayloadDescriptor( + EventPayloadDescriptor::new(EventType::ExportPrice), + )]), + targets: Some(TargetMap(vec![ + TargetEntry { + label: TargetLabel::Group, + values: ["group-1".to_string()], + }, + TargetEntry { + label: TargetLabel::Private("PRIVATE_LABEL".to_string()), + values: ["private value".to_string()], + }, + ])), + }, + } + } + + fn program_2() -> Program { + Program { + id: "program-2".parse().unwrap(), + created_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + modification_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + content: ProgramContent { + object_type: Default::default(), + program_name: "program-2".to_string(), + program_long_name: None, + retailer_name: None, + retailer_long_name: None, + program_type: None, + country: None, + principal_subdivision: None, + time_zone_offset: None, + interval_period: None, + program_descriptions: None, + binding_events: None, + local_price: None, + payload_descriptors: None, + targets: None, + }, + } + } + + fn program_3() -> Program { + Program { + id: "program-3".parse().unwrap(), + content: ProgramContent { + program_name: "program-3".to_string(), + ..program_2().content + }, + ..program_2() + } + } + + mod get_all { + use super::*; + use openadr_wire::target::TargetLabel; + + #[sqlx::test(fixtures("programs"))] + async fn default_get_all(db: PgPool) { + let repo: PgProgramStorage = db.into(); + let mut programs = repo + .retrieve_all(&Default::default(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(programs.len(), 3); + programs.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str())); + assert_eq!(programs, vec![program_1(), program_2(), program_3()]); + } + + #[sqlx::test(fixtures("programs"))] + async fn limit_get_all(db: PgPool) { + let repo: PgProgramStorage = db.into(); + let programs = repo + .retrieve_all( + &QueryParams { + limit: 1, + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(programs.len(), 1); + } + + #[sqlx::test(fixtures("programs"))] + async fn skip_get_all(db: PgPool) { + let repo: PgProgramStorage = db.into(); + let programs = repo + .retrieve_all( + &QueryParams { + skip: 1, + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(programs.len(), 2); + + let programs = repo + .retrieve_all( + &QueryParams { + skip: 3, + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(programs.len(), 0); + } + + #[sqlx::test(fixtures("programs"))] + async fn filter_target_get_all(db: PgPool) { + let repo: PgProgramStorage = db.into(); + + let programs = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Group), + target_values: Some(vec!["group-1".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(programs.len(), 1); + + let programs = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Group), + target_values: Some(vec!["not-existent".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(programs.len(), 0); + + let programs = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::ProgramName), + target_values: Some(vec!["program-2".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(programs.len(), 1); + assert_eq!(programs, vec![program_2()]); + + let programs = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::ProgramName), + target_values: Some(vec!["program-not-existent".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(programs.len(), 0); + } + + #[sqlx::test(fixtures("programs"))] + async fn filter_multiple_targets(db: PgPool) { + let repo: PgProgramStorage = db.into(); + + let programs = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Group), + target_values: Some(vec!["private value".to_string()]), + ..Default::default() + }, + &Claims::any_business_user(), + ) + .await + .unwrap(); + assert_eq!(programs.len(), 0); + } + } + + mod get { + use super::*; + + #[sqlx::test(fixtures("programs"))] + async fn get_existing(db: PgPool) { + let repo: PgProgramStorage = db.into(); + + let program = repo + .retrieve(&"program-1".parse().unwrap(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(program, program_1()); + } + + #[sqlx::test(fixtures("programs"))] + async fn get_not_existent(db: PgPool) { + let repo: PgProgramStorage = db.into(); + let program = repo + .retrieve( + &"program-not-existent".parse().unwrap(), + &Claims::any_business_user(), + ) + .await; + + assert!(matches!(program, Err(AppError::NotFound))); + } + } + + mod add { + use super::*; + use chrono::{Duration, Utc}; + + #[sqlx::test] + async fn add(db: PgPool) { + let repo: PgProgramStorage = db.into(); + + let program = repo + .create(program_1().content, &Claims::any_business_user()) + .await + .unwrap(); + assert!(program.created_date_time < Utc::now() + Duration::minutes(10)); + assert!(program.created_date_time > Utc::now() - Duration::minutes(10)); + assert!(program.modification_date_time < Utc::now() + Duration::minutes(10)); + assert!(program.modification_date_time > Utc::now() - Duration::minutes(10)); + } + + #[sqlx::test(fixtures("programs"))] + async fn add_existing_name(db: PgPool) { + let repo: PgProgramStorage = db.into(); + + let program = repo + .create(program_1().content, &Claims::any_business_user()) + .await; + assert!(matches!(program, Err(AppError::Conflict(_, _)))); + } + } + + mod modify { + use super::*; + use chrono::{DateTime, Duration, Utc}; + + #[sqlx::test(fixtures("programs"))] + async fn updates_modify_time(db: PgPool) { + let repo: PgProgramStorage = db.into(); + let program = repo + .update( + &"program-1".parse().unwrap(), + program_1().content, + &Claims::any_business_user(), + ) + .await + .unwrap(); + + assert_eq!(program.content, program_1().content); + assert_eq!( + program.created_date_time, + "2024-07-25 08:31:10.776000 +00:00" + .parse::>() + .unwrap() + ); + assert!(program.modification_date_time < Utc::now() + Duration::minutes(10)); + assert!(program.modification_date_time > Utc::now() - Duration::minutes(10)); + } + + #[sqlx::test(fixtures("programs"))] + async fn update(db: PgPool) { + let repo: PgProgramStorage = db.into(); + let mut updated = program_2().content; + updated.program_name = "updated_name".parse().unwrap(); + + let program = repo + .update( + &"program-1".parse().unwrap(), + updated.clone(), + &Claims::any_business_user(), + ) + .await + .unwrap(); + + assert_eq!(program.content, updated); + let program = repo + .retrieve(&"program-1".parse().unwrap(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(program.content, updated); + } + } + + mod delete { + use super::*; + + #[sqlx::test(fixtures("programs"))] + async fn delete_existing(db: PgPool) { + let repo: PgProgramStorage = db.into(); + let program = repo + .delete(&"program-1".parse().unwrap(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(program, program_1()); + + let program = repo + .retrieve(&"program-1".parse().unwrap(), &Claims::any_business_user()) + .await; + assert!(matches!(program, Err(AppError::NotFound))); + + let program = repo + .retrieve(&"program-2".parse().unwrap(), &Claims::any_business_user()) + .await + .unwrap(); + assert_eq!(program, program_2()); + } + + #[sqlx::test(fixtures("programs"))] + async fn delete_not_existing(db: PgPool) { + let repo: PgProgramStorage = db.into(); + let program = repo + .delete( + &"program-not-existing".parse().unwrap(), + &Claims::any_business_user(), + ) + .await; + assert!(matches!(program, Err(AppError::NotFound))); + } + } +} diff --git a/openadr-vtn/src/data_source/postgres/report.rs b/openadr-vtn/src/data_source/postgres/report.rs new file mode 100644 index 0000000..58b8633 --- /dev/null +++ b/openadr-vtn/src/data_source/postgres/report.rs @@ -0,0 +1,303 @@ +use crate::{ + api::report::QueryParams, + data_source::{ + postgres::{extract_business_ids, to_json_value, PgId}, + Crud, ReportCrud, + }, + error::AppError, + jwt::Claims, +}; +use axum::async_trait; +use chrono::{DateTime, Utc}; +use openadr_wire::{ + report::{ReportContent, ReportId}, + Report, +}; +use sqlx::PgPool; +use tracing::{error, info, trace}; + +#[async_trait] +impl ReportCrud for PgReportStorage {} + +pub(crate) struct PgReportStorage { + db: PgPool, +} +impl From for PgReportStorage { + fn from(db: PgPool) -> Self { + Self { db } + } +} + +#[derive(Debug)] +struct PostgresReport { + id: String, + created_date_time: DateTime, + modification_date_time: DateTime, + program_id: String, + event_id: String, + client_name: String, + report_name: Option, + payload_descriptors: Option, + resources: serde_json::Value, +} + +impl TryFrom for Report { + type Error = AppError; + + #[tracing::instrument(name = "TryFrom for Report")] + fn try_from(value: PostgresReport) -> Result { + let payload_descriptors = match value.payload_descriptors { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!( + ?err, + "Failed to deserialize JSON from DB to `Vec`" + ) + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + let resources = serde_json::from_value(value.resources) + .inspect_err(|err| error!(?err, "Failed to deserialize JSON from DB to `TargetMap`")) + .map_err(AppError::SerdeJsonInternalServerError)?; + + Ok(Self { + id: value.id.parse()?, + created_date_time: value.created_date_time, + modification_date_time: value.modification_date_time, + content: ReportContent { + object_type: Default::default(), + program_id: value.program_id.parse()?, + event_id: value.event_id.parse()?, + client_name: value.client_name, + report_name: value.report_name, + payload_descriptors, + resources, + }, + }) + } +} + +#[async_trait] +impl Crud for PgReportStorage { + type Type = Report; + type Id = ReportId; + type NewType = ReportContent; + type Error = AppError; + type Filter = QueryParams; + type PermissionFilter = Claims; + + async fn create( + &self, + new: Self::NewType, + user: &Self::PermissionFilter, + ) -> Result { + let permitted_vens = sqlx::query_as!( + PgId, + r#" + SELECT ven_id AS id FROM ven_program WHERE program_id = $1 + "#, + new.program_id.as_str() + ) + .fetch_all(&self.db) + .await? + .into_iter() + .map(|id| id.id) + .collect::>(); + + if !permitted_vens.is_empty() + && !user + .ven_ids() + .into_iter() + .any(|user_ven| permitted_vens.contains(&user_ven.to_string())) + { + Err(AppError::NotFound)? + } + + let program_id = sqlx::query_as!( + PgId, + r#" + SELECT program_id AS id FROM event WHERE id = $1 + "#, + new.event_id.as_str(), + ) + .fetch_one(&self.db) + .await?; + + if program_id.id != new.program_id.as_str() { + return Err(AppError::BadRequest( + "event_id and program_id have to point to the same program", + )); + } + + let report: Report = sqlx::query_as!( + PostgresReport, + r#" + INSERT INTO report (id, created_date_time, modification_date_time, program_id, event_id, client_name, report_name, payload_descriptors, resources) + VALUES (gen_random_uuid(), now(), now(), $1, $2, $3, $4, $5, $6) + RETURNING * + "#, + new.program_id.as_str(), + new.event_id.as_str(), + new.client_name, + new.report_name, + to_json_value(new.payload_descriptors)?, + serde_json::to_value(new.resources).map_err(AppError::SerdeJsonBadRequest)?, + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + info!(report_id = report.id.as_str(), "created report"); + + Ok(report) + } + + async fn retrieve( + &self, + id: &Self::Id, + user: &Self::PermissionFilter, + ) -> Result { + let business_ids = extract_business_ids(user); + + let report: Report = sqlx::query_as!( + PostgresReport, + r#" + SELECT r.* + FROM report r + JOIN program p ON p.id = r.program_id + LEFT JOIN ven_program v ON v.program_id = r.program_id + WHERE r.id = $1 + AND (NOT $2 OR v.ven_id IS NULL OR v.ven_id = ANY($3)) + AND ($4::text[] IS NULL OR p.business_id = ANY($4)) + "#, + id.as_str(), + user.is_ven(), + &user.ven_ids_string(), + business_ids.as_deref() + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + trace!(report_id = report.id.as_str(), "retrieved report"); + + Ok(report) + } + + async fn retrieve_all( + &self, + filter: &Self::Filter, + user: &Self::PermissionFilter, + ) -> Result, Self::Error> { + let business_ids = extract_business_ids(user); + + let reports = sqlx::query_as!( + PostgresReport, + r#" + SELECT r.* + FROM report r + JOIN program p ON p.id = r.program_id + LEFT JOIN ven_program v ON v.program_id = r.program_id + WHERE ($1::text IS NULL OR $1 like r.program_id) + AND ($2::text IS NULL OR $2 like r.event_id) + AND ($3::text IS NULL OR $3 like r.client_name) + AND (NOT $4 OR v.ven_id IS NULL OR v.ven_id = ANY($5)) + AND ($6::text[] IS NULL OR p.business_id = ANY($6)) + LIMIT $7 OFFSET $8 + "#, + filter.program_id.clone().map(|x| x.to_string()), + filter.event_id.clone().map(|x| x.to_string()), + filter.client_name, + user.is_ven(), + &user.ven_ids_string(), + business_ids.as_deref(), + filter.skip, + filter.limit, + ) + .fetch_all(&self.db) + .await? + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?; + + trace!("retrieved {} reports", reports.len()); + + Ok(reports) + } + + async fn update( + &self, + id: &Self::Id, + new: Self::NewType, + user: &Self::PermissionFilter, + ) -> Result { + let business_ids = extract_business_ids(user); + let report: Report = sqlx::query_as!( + PostgresReport, + r#" + UPDATE report r + SET modification_date_time = now(), + program_id = $5, + event_id = $6, + client_name = $7, + report_name = $8, + payload_descriptors = $9, + resources = $10 + FROM program p + LEFT JOIN ven_program v ON p.id = v.program_id + WHERE r.id = $1 + AND (p.id = r.program_id) + AND (NOT $2 OR v.ven_id IS NULL OR v.ven_id = ANY($3)) + AND ($4::text[] IS NULL OR p.business_id = ANY($4)) + RETURNING r.* + "#, + id.as_str(), + user.is_ven(), + &user.ven_ids_string(), + business_ids.as_deref(), + new.program_id.as_str(), + new.event_id.as_str(), + new.client_name, + new.report_name, + to_json_value(new.payload_descriptors)?, + serde_json::to_value(new.resources).map_err(AppError::SerdeJsonBadRequest)?, + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + info!(report_id = report.id.as_str(), "updated report"); + + Ok(report) + } + + async fn delete( + &self, + id: &Self::Id, + user: &Self::PermissionFilter, + ) -> Result { + let business_ids = extract_business_ids(user); + + let report: Report = sqlx::query_as!( + PostgresReport, + r#" + DELETE FROM report r + USING program p + WHERE r.id = $1 + AND r.program_id = p.id + AND ($2::text[] IS NULL OR p.business_id = ANY($2)) + RETURNING r.* + "#, + id.as_str(), + business_ids.as_deref(), + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + info!(report_id = report.id.as_str(), "deleted report"); + + Ok(report) + } +} diff --git a/openadr-vtn/src/data_source/postgres/resource.rs b/openadr-vtn/src/data_source/postgres/resource.rs new file mode 100644 index 0000000..3d5e6b6 --- /dev/null +++ b/openadr-vtn/src/data_source/postgres/resource.rs @@ -0,0 +1,355 @@ +use crate::{ + api::resource::QueryParams, + data_source::{ + postgres::{to_json_value, PgTargetsFilter}, + ResourceCrud, VenScopedCrud, + }, + error::AppError, + jwt::Claims, +}; +use axum::async_trait; +use chrono::{DateTime, Utc}; +use openadr_wire::{ + resource::{Resource, ResourceContent, ResourceId}, + target::TargetLabel, + ven::VenId, +}; +use sqlx::PgPool; +use tracing::{error, trace}; + +#[async_trait] +impl ResourceCrud for PgResourceStorage {} + +pub(crate) struct PgResourceStorage { + db: PgPool, +} + +impl From for PgResourceStorage { + fn from(db: PgPool) -> Self { + Self { db } + } +} + +#[derive(Debug)] +pub(crate) struct PostgresResource { + id: String, + created_date_time: DateTime, + modification_date_time: DateTime, + resource_name: String, + ven_id: String, + attributes: Option, + targets: Option, +} + +impl TryFrom for Resource { + type Error = AppError; + + #[tracing::instrument(name = "TryFrom for Resource")] + fn try_from(value: PostgresResource) -> Result { + let attributes = match value.attributes { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!( + ?err, + "Failed to deserialize JSON from DB to `Vec`" + ) + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + let targets = match value.targets { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!(?err, "Failed to deserialize JSON from DB to `TargetMap`") + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + + Ok(Self { + id: value.id.parse()?, + created_date_time: value.created_date_time, + modification_date_time: value.modification_date_time, + ven_id: value.ven_id.parse()?, + content: ResourceContent { + object_type: Default::default(), + resource_name: value.resource_name, + targets, + attributes, + }, + }) + } +} + +#[derive(Debug, Default)] +struct PostgresFilter<'a> { + resource_names: Option<&'a [String]>, + targets: Vec>, + skip: i64, + limit: i64, +} + +impl<'a> From<&'a QueryParams> for PostgresFilter<'a> { + fn from(query: &'a QueryParams) -> Self { + let mut filter = Self { + skip: query.skip, + limit: query.limit, + ..Default::default() + }; + match query.target_type { + Some(TargetLabel::VENName) => filter.resource_names = query.target_values.as_deref(), + Some(TargetLabel::ResourceName) => { + filter.resource_names = query.target_values.as_deref() + } + Some(ref label) => { + if let Some(values) = query.target_values.as_ref() { + filter.targets = values + .iter() + .map(|value| PgTargetsFilter { + label: label.as_str(), + value: [value.clone()], + }) + .collect() + } + } + None => {} + }; + + filter + } +} + +#[async_trait] +impl VenScopedCrud for PgResourceStorage { + type Type = Resource; + type Id = ResourceId; + type NewType = ResourceContent; + type Error = AppError; + type Filter = QueryParams; + type PermissionFilter = Claims; + + async fn create( + &self, + new: Self::NewType, + ven_id: VenId, + _user: &Self::PermissionFilter, + ) -> Result { + let resource: Resource = sqlx::query_as!( + PostgresResource, + r#" + INSERT INTO resource ( + id, + created_date_time, + modification_date_time, + resource_name, + ven_id, + attributes, + targets + ) + VALUES (gen_random_uuid(), now(), now(), $1, $2, $3, $4) + RETURNING * + "#, + new.resource_name, + ven_id.as_str(), + to_json_value(new.attributes)?, + to_json_value(new.targets)?, + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + Ok(resource) + } + + async fn retrieve( + &self, + id: &Self::Id, + ven_id: VenId, + _user: &Self::PermissionFilter, + ) -> Result { + let resource = sqlx::query_as!( + PostgresResource, + r#" + SELECT + id, + created_date_time, + modification_date_time, + resource_name, + ven_id, + attributes, + targets + FROM resource + WHERE id = $1 AND ven_id = $2 + "#, + id.as_str(), + ven_id.as_str(), + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + Ok(resource) + } + + async fn retrieve_all( + &self, + ven_id: VenId, + filter: &Self::Filter, + _user: &Self::PermissionFilter, + ) -> Result, Self::Error> { + let pg_filter: PostgresFilter = filter.into(); + trace!(?pg_filter); + + let res = sqlx::query_as!( + PostgresResource, + r#" + SELECT + r.id AS "id!", + r.created_date_time AS "created_date_time!", + r.modification_date_time AS "modification_date_time!", + r.resource_name AS "resource_name!", + r.ven_id AS "ven_id!", + r.attributes, + r.targets + FROM resource r + LEFT JOIN LATERAL ( + SELECT r.id as r_id, + json_array(jsonb_array_elements(r.targets)) <@ $3::jsonb AS target_test ) + ON r.id = r_id + WHERE r.ven_id = $1 + AND ($2::text[] IS NULL OR r.resource_name = ANY($2)) + AND ($3::jsonb = '[]'::jsonb OR target_test) + OFFSET $4 LIMIT $5 + "#, + ven_id.as_str(), + pg_filter.resource_names, + serde_json::to_value(pg_filter.targets) + .map_err(AppError::SerdeJsonInternalServerError)?, + pg_filter.skip, + pg_filter.limit, + ) + .fetch_all(&self.db) + .await? + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?; + + trace!( + ven_id = ven_id.as_str(), + "retrieved {} resources", + res.len() + ); + + Ok(res) + } + + async fn update( + &self, + id: &Self::Id, + ven_id: VenId, + new: Self::NewType, + _user: &Self::PermissionFilter, + ) -> Result { + let resource: Resource = sqlx::query_as!( + PostgresResource, + r#" + UPDATE resource + SET modification_date_time = now(), + resource_name = $3, + ven_id = $4, + attributes = $5, + targets = $6 + WHERE id = $1 AND ven_id = $2 + RETURNING * + "#, + id.as_str(), + ven_id.as_str(), + new.resource_name, + ven_id.as_str(), + to_json_value(new.attributes)?, + to_json_value(new.targets)? + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + Ok(resource) + } + + async fn delete( + &self, + id: &Self::Id, + ven_id: VenId, + _user: &Self::PermissionFilter, + ) -> Result { + Ok(sqlx::query_as!( + PostgresResource, + r#" + DELETE FROM resource r + WHERE r.id = $1 AND r.ven_id = $2 + RETURNING r.* + "#, + id.as_str(), + ven_id.as_str(), + ) + .fetch_one(&self.db) + .await? + .try_into()?) + } +} + +impl PgResourceStorage { + pub(crate) async fn retrieve_by_ven( + db: &PgPool, + ven_id: &VenId, + ) -> Result, AppError> { + sqlx::query_as!( + PostgresResource, + r#" + SELECT + id, + created_date_time, + modification_date_time, + resource_name, + ven_id, + attributes, + targets + FROM resource + WHERE ven_id = $1 + "#, + ven_id.as_str(), + ) + .fetch_all(db) + .await? + .into_iter() + .map(TryInto::try_into) + .collect::>() + } + + pub(crate) async fn retrieve_by_vens( + db: &PgPool, + ven_ids: &[String], + ) -> Result, AppError> { + sqlx::query_as!( + PostgresResource, + r#" + SELECT + id, + created_date_time, + modification_date_time, + resource_name, + ven_id, + attributes, + targets + FROM resource + WHERE ven_id = ANY($1) + "#, + ven_ids, + ) + .fetch_all(db) + .await? + .into_iter() + .map(TryInto::try_into) + .collect::>() + } +} diff --git a/openadr-vtn/src/data_source/postgres/user.rs b/openadr-vtn/src/data_source/postgres/user.rs new file mode 100644 index 0000000..4387581 --- /dev/null +++ b/openadr-vtn/src/data_source/postgres/user.rs @@ -0,0 +1,456 @@ +use crate::{ + data_source::{postgres::PgId, AuthInfo, AuthSource, UserDetails}, + error::AppError, + jwt::AuthRole, +}; +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, +}; +use axum::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::{PgConnection, PgPool}; +use tracing::warn; + +pub struct PgAuthSource { + db: PgPool, +} + +impl From for PgAuthSource { + fn from(db: PgPool) -> Self { + Self { db } + } +} + +#[derive(Debug)] +struct IntermediateUser { + id: String, + reference: String, + description: Option, + client_ids: Option>, + created: DateTime, + modified: DateTime, + business_ids: Option>, + ven_ids: Option>, + is_any_business_user: bool, + is_user_manager: bool, + is_ven_manager: bool, +} + +impl TryFrom for UserDetails { + type Error = AppError; + + fn try_from(u: IntermediateUser) -> Result { + let mut roles = Vec::new(); + if let Some(business_ids) = u.business_ids { + roles.append( + &mut business_ids + .into_iter() + .map(|id| Ok(AuthRole::Business(id.to_string()))) + .collect::, AppError>>()?, + ) + } + + if let Some(ven_ids) = u.ven_ids { + roles.append( + &mut ven_ids + .into_iter() + .map(|id| Ok(AuthRole::VEN(id.parse()?))) + .collect::, AppError>>()?, + ) + } + + if u.is_user_manager { + roles.push(AuthRole::UserManager); + } + + if u.is_ven_manager { + roles.push(AuthRole::VenManager) + } + + if u.is_any_business_user { + roles.push(AuthRole::AnyBusiness) + } + + Ok(Self { + id: u.id, + reference: u.reference, + description: u.description, + roles, + client_ids: u.client_ids.unwrap_or_default(), + created: u.created, + modified: u.modified, + }) + } +} + +struct IdAndSecret { + id: String, + client_secret: String, +} + +#[async_trait] +impl AuthSource for PgAuthSource { + async fn check_credentials(&self, client_id: &str, client_secret: &str) -> Option { + let mut tx = self + .db + .begin() + .await + .inspect_err(|err| warn!(client_id, "failed to open transaction: {err}")) + .ok()?; + + let db_entry = sqlx::query_as!( + IdAndSecret, + r#" + SELECT id, + client_secret + FROM "user" + JOIN user_credentials ON user_id = id + WHERE client_id = $1 + "#, + client_id, + ) + .fetch_one(&mut *tx) + .await + .ok()?; + + let parsed_hash = PasswordHash::new(&db_entry.client_secret) + .inspect_err(|err| warn!("Failed to parse client_secret_hash in DB: {}", err)) + .ok()?; + + Argon2::default() + .verify_password(client_secret.as_bytes(), &parsed_hash) + .ok()?; + + let user = Self::get_user(&mut tx, &db_entry.id) + .await + .inspect_err(|err| warn!(client_id, "error fetching user: {err}")) + .ok()?; + + Some(AuthInfo { + client_id: client_id.to_string(), + roles: user.roles, + }) + } + + async fn get_user(&self, user_id: &str) -> Result { + let mut tx = self.db.begin().await?; + Self::get_user(&mut tx, user_id).await + } + + async fn get_all_users(&self) -> Result, AppError> { + sqlx::query_as!( + IntermediateUser, + r#" + SELECT u.*, + array_agg(DISTINCT c.client_id) FILTER ( WHERE c.client_id IS NOT NULL ) AS client_ids, + array_agg(DISTINCT b.business_id) FILTER ( WHERE b.business_id IS NOT NULL ) AS business_ids, + array_agg(DISTINCT ven.ven_id) FILTER ( WHERE ven.ven_id IS NOT NULL ) AS ven_ids, + ab.user_id IS NOT NULL AS "is_any_business_user!", + um.user_id IS NOT NULL AS "is_user_manager!", + vm.user_id IS NOT NULL AS "is_ven_manager!" + FROM "user" u + LEFT JOIN user_credentials c ON c.user_id = u.id + LEFT JOIN any_business_user ab ON u.id = ab.user_id + LEFT JOIN user_business b ON u.id = b.user_id + LEFT JOIN user_manager um ON u.id = um.user_id + LEFT JOIN user_ven ven ON u.id = ven.user_id + LEFT JOIN ven_manager vm ON u.id = vm.user_id + GROUP BY u.id, + b.user_id, + ab.user_id, + um.user_id, + ven.user_id, + vm.user_id + "#, + ) + .fetch_all(&self.db) + .await? + .into_iter() + .map(TryInto::try_into) + .collect() + } + + async fn add_user( + &self, + reference: &str, + description: Option<&str>, + roles: &[AuthRole], + ) -> Result { + let mut tx = self.db.begin().await?; + + let user = sqlx::query_as!( + PgId, + r#" + INSERT INTO "user" (id, reference, description, created, modified) + VALUES (gen_random_uuid(), $1, $2, now(), now()) + RETURNING id + "#, + reference, + description + ) + .fetch_one(&mut *tx) + .await?; + + for role in roles { + Self::add_role(&mut tx, &user.id, role) + .await + .inspect_err(|err| { + warn!( + "Failed to add role {:?} for new user {:?}: {}", + role, user, err + ) + })?; + } + + let user = Self::get_user(&mut tx, &user.id) + .await + .inspect_err(|err| warn!("cannot find user just created: {}", err))?; + + tx.commit().await?; + Ok(user) + } + + async fn add_credential( + &self, + user_id: &str, + client_id: &str, + client_secret: &str, + ) -> Result { + let salt = SaltString::generate(&mut OsRng); + + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(client_secret.as_bytes(), &salt)? + .to_string(); + + let mut tx = self.db.begin().await?; + + sqlx::query!( + r#" + INSERT INTO user_credentials + (user_id, client_id, client_secret) + VALUES + ($1, $2, $3) + "#, + user_id, + client_id, + &hash + ) + .execute(&mut *tx) + .await?; + let user = Self::get_user(&mut tx, user_id).await?; + tx.commit().await?; + + Ok(user) + } + + async fn remove_credentials( + &self, + user_id: &str, + client_id: &str, + ) -> Result { + let mut tx = self.db.begin().await?; + sqlx::query!( + r#" + DELETE FROM user_credentials WHERE user_id = $1 AND client_id = $2 + "#, + user_id, + client_id + ) + .execute(&mut *tx) + .await?; + let user = Self::get_user(&mut tx, user_id).await?; + tx.commit().await?; + Ok(user) + } + + async fn remove_user(&self, user_id: &str) -> Result { + let mut tx = self.db.begin().await?; + let user = Self::get_user(&mut tx, user_id).await?; + sqlx::query!( + r#" + DELETE FROM "user" WHERE id = $1 + "#, + user_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(user) + } + + async fn edit_user( + &self, + user_id: &str, + reference: &str, + description: Option<&str>, + roles: &[AuthRole], + ) -> Result { + let mut tx = self.db.begin().await?; + + sqlx::query!( + r#" + UPDATE "user" SET + reference = $2, + description = $3, + modified = now() + WHERE id = $1 + "#, + user_id, + reference, + description + ) + .execute(&mut *tx) + .await?; + + Self::delete_all_roles(&mut tx, user_id).await?; + + for role in roles { + Self::add_role(&mut tx, user_id, role) + .await + .inspect_err(|err| { + warn!( + "Failed to add role {:?} for updated user {:?}: {}", + role, user_id, err + ) + })?; + } + let user = Self::get_user(&mut tx, user_id) + .await + .inspect_err(|err| warn!("cannot find user just updated: {}", err))?; + + tx.commit().await?; + Ok(user) + } +} + +impl PgAuthSource { + async fn delete_all_roles(db: &mut PgConnection, user_id: &str) -> Result<(), AppError> { + sqlx::query!( + r#" + DELETE FROM user_ven WHERE user_id = $1 + "#, + user_id + ) + .execute(&mut *db) + .await?; + + sqlx::query!( + r#" + DELETE FROM user_business WHERE user_id = $1 + "#, + user_id + ) + .execute(&mut *db) + .await?; + + sqlx::query!( + r#" + DELETE FROM any_business_user WHERE user_id = $1 + "#, + user_id + ) + .execute(&mut *db) + .await?; + + sqlx::query!( + r#" + DELETE FROM ven_manager WHERE user_id = $1 + "#, + user_id + ) + .execute(&mut *db) + .await?; + + sqlx::query!( + r#" + DELETE FROM user_manager WHERE user_id = $1 + "#, + user_id + ) + .execute(&mut *db) + .await?; + + Ok(()) + } + + async fn add_role( + tx: &mut PgConnection, + user_id: &str, + role: &AuthRole, + ) -> Result<(), AppError> { + match role { + AuthRole::Business(b_id) => sqlx::query!( + r#" + INSERT INTO user_business (user_id, business_id) VALUES ($1, $2) + "#, + user_id, + b_id + ), + AuthRole::AnyBusiness => sqlx::query!( + r#" + INSERT INTO any_business_user (user_id) VALUES ($1) + "#, + user_id + ), + AuthRole::VEN(v_id) => sqlx::query!( + r#" + INSERT INTO user_ven (user_id, ven_id) VALUES ($1, $2) + "#, + user_id, + v_id.as_str() + ), + AuthRole::VenManager => sqlx::query!( + r#" + INSERT INTO ven_manager (user_id) VALUES ($1) + "#, + user_id + ), + AuthRole::UserManager => sqlx::query!( + r#" + INSERT INTO user_manager (user_id) VALUES ($1) + "#, + user_id + ), + } + .execute(&mut *tx) + .await?; + + Ok(()) + } + + async fn get_user(tx: &mut PgConnection, user_id: &str) -> Result { + sqlx::query_as!( + IntermediateUser, + r#" + SELECT u.*, + array_agg(DISTINCT c.client_id) FILTER ( WHERE c.client_id IS NOT NULL ) AS client_ids, + array_agg(DISTINCT b.business_id) FILTER ( WHERE b.business_id IS NOT NULL ) AS business_ids, + array_agg(DISTINCT ven.ven_id) FILTER ( WHERE ven.ven_id IS NOT NULL ) AS ven_ids, + ab.user_id IS NOT NULL AS "is_any_business_user!", + um.user_id IS NOT NULL AS "is_user_manager!", + vm.user_id IS NOT NULL AS "is_ven_manager!" + FROM "user" u + LEFT JOIN user_credentials c ON c.user_id = u.id + LEFT JOIN any_business_user ab ON u.id = ab.user_id + LEFT JOIN user_business b ON u.id = b.user_id + LEFT JOIN user_manager um ON u.id = um.user_id + LEFT JOIN user_ven ven ON u.id = ven.user_id + LEFT JOIN ven_manager vm ON u.id = vm.user_id + WHERE u.id = $1 + GROUP BY u.id, + b.user_id, + ab.user_id, + um.user_id, + ven.user_id, + vm.user_id + "#, + user_id + ) + .fetch_one(&mut *tx) + .await? + .try_into() + } +} diff --git a/openadr-vtn/src/data_source/postgres/ven.rs b/openadr-vtn/src/data_source/postgres/ven.rs new file mode 100644 index 0000000..ef97228 --- /dev/null +++ b/openadr-vtn/src/data_source/postgres/ven.rs @@ -0,0 +1,661 @@ +use crate::{ + api::ven::QueryParams, + data_source::{ + postgres::{to_json_value, PgTargetsFilter}, + Crud, VenCrud, VenPermissions, + }, + error::AppError, +}; +use axum::async_trait; +use chrono::{DateTime, Utc}; +use openadr_wire::{ + target::TargetLabel, + ven::{Ven, VenContent, VenId}, +}; +use sqlx::PgPool; +use tracing::{error, trace}; + +use super::resource::PgResourceStorage; + +#[async_trait] +impl VenCrud for PgVenStorage {} + +pub(crate) struct PgVenStorage { + db: PgPool, +} + +impl From for PgVenStorage { + fn from(db: PgPool) -> Self { + Self { db } + } +} + +#[derive(Debug)] +struct PostgresVen { + id: String, + created_date_time: DateTime, + modification_date_time: DateTime, + ven_name: String, + attributes: Option, + targets: Option, +} + +impl TryFrom for Ven { + type Error = AppError; + + #[tracing::instrument(name = "TryFrom for Ven")] + fn try_from(value: PostgresVen) -> Result { + let attributes = match value.attributes { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!( + ?err, + "Failed to deserialize JSON from DB to `Vec`" + ) + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + let targets = match value.targets { + None => None, + Some(t) => serde_json::from_value(t) + .inspect_err(|err| { + error!(?err, "Failed to deserialize JSON from DB to `TargetMap`") + }) + .map_err(AppError::SerdeJsonInternalServerError)?, + }; + + Ok(Self { + id: value.id.parse()?, + created_date_time: value.created_date_time, + modification_date_time: value.modification_date_time, + content: VenContent { + object_type: Default::default(), + ven_name: value.ven_name, + targets, + attributes, + resources: Default::default(), + }, + }) + } +} + +#[derive(Debug, Default)] +struct PostgresFilter<'a> { + ven_names: Option<&'a [String]>, + resource_names: Option<&'a [String]>, + targets: Vec>, + skip: i64, + limit: i64, +} + +impl<'a> From<&'a QueryParams> for PostgresFilter<'a> { + fn from(query: &'a QueryParams) -> Self { + let mut filter = Self { + skip: query.skip, + limit: query.limit, + ..Default::default() + }; + match query.target_type { + Some(TargetLabel::VENName) => filter.ven_names = query.target_values.as_deref(), + Some(TargetLabel::ResourceName) => { + filter.resource_names = query.target_values.as_deref() + } + Some(ref label) => { + if let Some(values) = query.target_values.as_ref() { + filter.targets = values + .iter() + .map(|value| PgTargetsFilter { + label: label.as_str(), + value: [value.clone()], + }) + .collect() + } + } + None => {} + }; + + filter + } +} + +#[async_trait] +impl Crud for PgVenStorage { + type Type = Ven; + type Id = VenId; + type NewType = VenContent; + type Error = AppError; + type Filter = QueryParams; + type PermissionFilter = VenPermissions; + + async fn create( + &self, + new: Self::NewType, + _user: &Self::PermissionFilter, + ) -> Result { + let ven: Ven = sqlx::query_as!( + PostgresVen, + r#" + INSERT INTO ven ( + id, + created_date_time, + modification_date_time, + ven_name, + attributes, + targets + ) + VALUES (gen_random_uuid(), now(), now(), $1, $2, $3) + RETURNING * + "#, + new.ven_name, + to_json_value(new.attributes)?, + to_json_value(new.targets)?, + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + trace!(ven_id = ven.id.as_str(), "created ven"); + + Ok(ven) + } + + async fn retrieve( + &self, + id: &Self::Id, + permissions: &Self::PermissionFilter, + ) -> Result { + let ids = permissions.as_value(); + + let mut ven: Ven = sqlx::query_as!( + PostgresVen, + r#" + SELECT * + FROM ven + WHERE id = $1 + AND ($2::text[] IS NULL OR id = ANY($2)) + "#, + id.as_str(), + ids.as_deref(), + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + ven.content.resources = Some(PgResourceStorage::retrieve_by_ven(&self.db, id).await?); + trace!(ven_id = ven.id.as_str(), "retrieved ven"); + + Ok(ven) + } + + async fn retrieve_all( + &self, + filter: &Self::Filter, + permissions: &Self::PermissionFilter, + ) -> Result, Self::Error> { + let pg_filter: PostgresFilter = filter.into(); + trace!(?pg_filter); + + let ids = permissions.as_value(); + + let mut vens: Vec = sqlx::query_as!( + PostgresVen, + r#" + SELECT DISTINCT + v.id AS "id!", + v.created_date_time AS "created_date_time!", + v.modification_date_time AS "modification_date_time!", + v.ven_name AS "ven_name!", + v.attributes, + v.targets + FROM ven v + LEFT JOIN resource r ON r.ven_id = v.id + LEFT JOIN LATERAL ( + SELECT v.id as v_id, + json_array(jsonb_array_elements(v.targets)) <@ $3::jsonb AS target_test ) + ON v.id = v_id + WHERE ($1::text[] IS NULL OR v.ven_name = ANY($1)) + AND ($2::text[] IS NULL OR r.resource_name = ANY($2)) + AND ($3::jsonb = '[]'::jsonb OR target_test) + AND ($4::text[] IS NULL OR v.id = ANY($4)) + ORDER BY v.created_date_time DESC + OFFSET $5 LIMIT $6 + "#, + pg_filter.ven_names, + pg_filter.resource_names, + serde_json::to_value(pg_filter.targets) + .map_err(AppError::SerdeJsonInternalServerError)?, + ids.as_deref(), + pg_filter.skip, + pg_filter.limit, + ) + .fetch_all(&self.db) + .await? + .into_iter() + .map(TryInto::try_into) + .collect::>()?; + + let ven_ids: Vec = vens.iter().map(|v| v.id.to_string()).collect(); + let resources = PgResourceStorage::retrieve_by_vens(&self.db, &ven_ids).await?; + + for ven in &mut vens { + ven.content.resources = Some(vec![]); + + for resource in &resources { + if resource.ven_id == ven.id { + ven.content + .resources + .get_or_insert_with(Vec::new) + .push(resource.clone()); + } + } + } + trace!("retrieved {} ven(s)", vens.len()); + + Ok(vens) + } + + async fn update( + &self, + id: &Self::Id, + new: Self::NewType, + _user: &Self::PermissionFilter, + ) -> Result { + let mut ven: Ven = sqlx::query_as!( + PostgresVen, + r#" + UPDATE ven + SET modification_date_time = now(), + ven_name = $2, + attributes = $3, + targets = $4 + WHERE id = $1 + RETURNING * + "#, + id.as_str(), + new.ven_name, + to_json_value(new.attributes)?, + to_json_value(new.targets)? + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + ven.content.resources = Some(PgResourceStorage::retrieve_by_ven(&self.db, id).await?); + trace!(ven_id = id.as_str(), "updated ven"); + + Ok(ven) + } + + async fn delete( + &self, + id: &Self::Id, + _user: &Self::PermissionFilter, + ) -> Result { + if !PgResourceStorage::retrieve_by_ven(&self.db, id) + .await? + .is_empty() + { + Err(AppError::Forbidden( + "Cannot delete VEN with associated resources", + ))? + } + + let mut ven: Ven = sqlx::query_as!( + PostgresVen, + r#" + DELETE FROM ven + WHERE id = $1 + RETURNING * + "#, + id.as_str(), + ) + .fetch_one(&self.db) + .await? + .try_into()?; + + ven.content.resources = Some(vec![]); + trace!(ven_id = id.as_str(), "deleted ven"); + + Ok(ven) + } +} + +#[cfg(test)] +#[cfg(feature = "live-db-test")] +mod tests { + use crate::{ + api::ven::QueryParams, + data_source::{postgres::ven::PgVenStorage, Crud}, + error::AppError, + }; + use openadr_wire::{ + values_map::{Value, ValueType, ValuesMap}, + ven::{Ven, VenContent}, + }; + use sqlx::PgPool; + + impl Default for QueryParams { + fn default() -> Self { + Self { + target_type: None, + target_values: None, + skip: 0, + limit: 50, + } + } + } + + fn ven_1() -> Ven { + Ven { + id: "ven-1".parse().unwrap(), + created_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + modification_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + content: VenContent { + object_type: Default::default(), + ven_name: "ven-1-name".to_string(), + targets: Some(vec![ + ValuesMap { + value_type: ValueType("GROUP".into()), + values: vec![Value::String("group-1".into())], + }, + ValuesMap { + value_type: ValueType("PRIVATE_LABEL".into()), + values: vec![Value::String("private value".into())], + }, + ]), + attributes: None, + resources: Some(vec![]), + }, + } + } + + fn ven_2() -> Ven { + Ven { + id: "ven-2".parse().unwrap(), + created_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + modification_date_time: "2024-07-25 08:31:10.776000 +00:00".parse().unwrap(), + content: VenContent { + object_type: Default::default(), + ven_name: "ven-2-name".to_string(), + targets: None, + attributes: None, + resources: Some(vec![]), + }, + } + } + + mod get_all { + use crate::data_source::postgres::ven::{PgVenStorage, VenPermissions}; + + use super::*; + use openadr_wire::target::TargetLabel; + + #[sqlx::test(fixtures("users", "vens"))] + async fn default_get_all(db: PgPool) { + let repo: PgVenStorage = db.into(); + let mut vens = repo + .retrieve_all(&Default::default(), &VenPermissions::AllAllowed) + .await + .unwrap(); + assert_eq!(vens.len(), 2); + vens.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str())); + assert_eq!(vens, vec![ven_1(), ven_2()]); + } + + #[sqlx::test(fixtures("users", "vens"))] + async fn limit_get_all(db: PgPool) { + let repo: PgVenStorage = db.into(); + let vens = repo + .retrieve_all( + &QueryParams { + limit: 1, + ..Default::default() + }, + &VenPermissions::AllAllowed, + ) + .await + .unwrap(); + assert_eq!(vens.len(), 1); + } + + #[sqlx::test(fixtures("users", "vens"))] + async fn skip_get_all(db: PgPool) { + let repo: PgVenStorage = db.into(); + let vens = repo + .retrieve_all( + &QueryParams { + skip: 1, + ..Default::default() + }, + &VenPermissions::AllAllowed, + ) + .await + .unwrap(); + assert_eq!(vens.len(), 1); + + let vens = repo + .retrieve_all( + &QueryParams { + skip: 2, + ..Default::default() + }, + &VenPermissions::AllAllowed, + ) + .await + .unwrap(); + assert_eq!(vens.len(), 0); + } + + #[sqlx::test(fixtures("users", "vens"))] + async fn filter_target_get_all(db: PgPool) { + let repo: PgVenStorage = db.into(); + + let vens = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Group), + target_values: Some(vec!["group-1".to_string()]), + ..Default::default() + }, + &VenPermissions::AllAllowed, + ) + .await + .unwrap(); + assert_eq!(vens.len(), 1); + + let vens = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::Group), + target_values: Some(vec!["not-existent".to_string()]), + ..Default::default() + }, + &VenPermissions::AllAllowed, + ) + .await + .unwrap(); + assert_eq!(vens.len(), 0); + + let vens = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::VENName), + target_values: Some(vec!["ven-2-name".to_string()]), + ..Default::default() + }, + &VenPermissions::AllAllowed, + ) + .await + .unwrap(); + assert_eq!(vens.len(), 1); + assert_eq!(vens, vec![ven_2()]); + + let vens = repo + .retrieve_all( + &QueryParams { + target_type: Some(TargetLabel::VENName), + target_values: Some(vec!["ven-not-existent".to_string()]), + ..Default::default() + }, + &VenPermissions::AllAllowed, + ) + .await + .unwrap(); + assert_eq!(vens.len(), 0); + } + } + + mod get { + use crate::data_source::postgres::ven::VenPermissions; + + use super::*; + + #[sqlx::test(fixtures("users", "vens"))] + async fn get_existing(db: PgPool) { + let repo: PgVenStorage = db.into(); + + let ven = repo + .retrieve(&"ven-1".parse().unwrap(), &VenPermissions::AllAllowed) + .await + .unwrap(); + assert_eq!(ven, ven_1()); + } + + #[sqlx::test(fixtures("users", "vens"))] + async fn get_not_existent(db: PgPool) { + let repo: PgVenStorage = db.into(); + let ven = repo + .retrieve( + &"ven-not-existent".parse().unwrap(), + &VenPermissions::AllAllowed, + ) + .await; + + assert!(matches!(ven, Err(AppError::NotFound))); + } + } + + mod add { + use crate::data_source::postgres::ven::VenPermissions; + + use super::*; + use chrono::{Duration, Utc}; + + #[sqlx::test] + async fn add(db: PgPool) { + let repo: PgVenStorage = db.into(); + + let ven = repo + .create(ven_1().content, &VenPermissions::AllAllowed) + .await + .unwrap(); + assert!(ven.created_date_time < Utc::now() + Duration::minutes(10)); + assert!(ven.created_date_time > Utc::now() - Duration::minutes(10)); + assert!(ven.modification_date_time < Utc::now() + Duration::minutes(10)); + assert!(ven.modification_date_time > Utc::now() - Duration::minutes(10)); + } + + #[sqlx::test(fixtures("users", "vens"))] + async fn add_existing_name(db: PgPool) { + let repo: PgVenStorage = db.into(); + + let ven = repo + .create(ven_1().content, &VenPermissions::AllAllowed) + .await; + assert!(matches!(ven, Err(AppError::Conflict(_, _)))); + } + } + + mod modify { + use crate::data_source::postgres::ven::VenPermissions; + + use super::*; + use chrono::{DateTime, Duration, Utc}; + + #[sqlx::test(fixtures("users", "vens"))] + async fn updates_modify_time(db: PgPool) { + let repo: PgVenStorage = db.into(); + let ven = repo + .update( + &"ven-1".parse().unwrap(), + ven_1().content, + &VenPermissions::AllAllowed, + ) + .await + .unwrap(); + + assert_eq!(ven.content, ven_1().content); + assert_eq!( + ven.created_date_time, + "2024-07-25 08:31:10.776000 +00:00" + .parse::>() + .unwrap() + ); + assert!(ven.modification_date_time < Utc::now() + Duration::minutes(10)); + assert!(ven.modification_date_time > Utc::now() - Duration::minutes(10)); + } + + #[sqlx::test(fixtures("users", "vens"))] + async fn update(db: PgPool) { + let repo: PgVenStorage = db.into(); + let mut updated = ven_2().content; + updated.ven_name = "updated_name".parse().unwrap(); + + let ven = repo + .update( + &"ven-1".parse().unwrap(), + updated.clone(), + &VenPermissions::AllAllowed, + ) + .await + .unwrap(); + + assert_eq!(ven.content, updated); + let ven = repo + .retrieve(&"ven-1".parse().unwrap(), &VenPermissions::AllAllowed) + .await + .unwrap(); + assert_eq!(ven.content, updated); + } + } + + mod delete { + use crate::data_source::postgres::ven::VenPermissions; + + use super::*; + + #[sqlx::test(fixtures("users", "vens"))] + async fn delete_existing(db: PgPool) { + let repo: PgVenStorage = db.into(); + let ven = repo + .delete(&"ven-1".parse().unwrap(), &VenPermissions::AllAllowed) + .await + .unwrap(); + assert_eq!(ven, ven_1()); + + let ven = repo + .retrieve(&"ven-1".parse().unwrap(), &VenPermissions::AllAllowed) + .await; + assert!(matches!(ven, Err(AppError::NotFound))); + + let ven = repo + .retrieve(&"ven-2".parse().unwrap(), &VenPermissions::AllAllowed) + .await + .unwrap(); + assert_eq!(ven, ven_2()); + } + + #[sqlx::test(fixtures("users", "vens"))] + async fn delete_not_existing(db: PgPool) { + let repo: PgVenStorage = db.into(); + let ven = repo + .delete( + &"ven-not-existing".parse().unwrap(), + &VenPermissions::AllAllowed, + ) + .await; + assert!(matches!(ven, Err(AppError::NotFound))); + } + } +} diff --git a/openadr-vtn/src/error.rs b/openadr-vtn/src/error.rs new file mode 100644 index 0000000..70e7836 --- /dev/null +++ b/openadr-vtn/src/error.rs @@ -0,0 +1,349 @@ +use argon2::password_hash; +use axum::{ + extract::rejection::{FormRejection, JsonRejection}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use axum_extra::extract::QueryRejection; +use openadr_wire::{problem::Problem, IdentifierError}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "sqlx")] +use sqlx::error::DatabaseError; +use tracing::{error, info, trace, warn}; +use uuid::Uuid; + +#[derive(thiserror::Error, Debug)] +pub enum AppError { + #[error("Invalid request: {0}")] + Validation(#[from] validator::ValidationErrors), + #[error("Invalid request: {0}")] + Json(JsonRejection), + #[error("Invalid request: {0}")] + Form(FormRejection), + #[error("Invalid request: {0}")] + QueryParams(#[from] QueryRejection), + #[error("Object not found")] + NotFound, + #[error("Bad request: {0}")] + BadRequest(&'static str), + #[error("Forbidden: {0}")] + Forbidden(&'static str), + #[error("Not implemented {0}")] + NotImplemented(&'static str), + #[cfg(feature = "sqlx")] + #[error("Conflict: {0}")] + Conflict(String, Option>), + #[cfg(feature = "sqlx")] + #[error("Unprocessable Content: {0}")] + ForeignKeyConstraintViolated(String, Option>), + #[error("Authentication error: {0}")] + Auth(String), + #[cfg(feature = "sqlx")] + #[error("Database error: {0}")] + Sql(sqlx::Error), + #[cfg(feature = "sqlx")] + #[error("Json (de)serialization error : {0}")] + SerdeJsonInternalServerError(serde_json::Error), + #[cfg(feature = "sqlx")] + #[error("Json (de)serialization error : {0}")] + SerdeJsonBadRequest(serde_json::Error), + #[error("Malformed Identifier")] + Identifier(#[from] IdentifierError), + #[error("Method not allowed")] + MethodNotAllowed, + #[cfg(feature = "sqlx")] + #[error("Password Hash error: {0}")] + PasswordHashError(password_hash::Error), + #[error("Unsupported Media Type: {0}")] + UnsupportedMediaType(String), +} + +#[cfg(feature = "sqlx")] +impl From for AppError { + fn from(err: sqlx::Error) -> Self { + match err { + sqlx::Error::RowNotFound => Self::NotFound, + sqlx::Error::Database(err) if err.is_unique_violation() => { + Self::Conflict("Conflict".to_string(), Some(err)) + } + sqlx::Error::Database(err) if err.is_foreign_key_violation() => { + Self::ForeignKeyConstraintViolated( + "A foreign key constraint is violated".to_string(), + Some(err), + ) + } + _ => Self::Sql(err), + } + } +} + +impl From for AppError { + fn from(rejection: JsonRejection) -> Self { + match rejection { + JsonRejection::MissingJsonContentType(text) => { + AppError::UnsupportedMediaType(text.to_string()) + } + _ => AppError::Json(rejection), + } + } +} + +impl From for AppError { + fn from(rejection: FormRejection) -> Self { + match rejection { + FormRejection::InvalidFormContentType(text) => { + AppError::UnsupportedMediaType(text.to_string()) + } + _ => AppError::Form(rejection), + } + } +} + +impl From for AppError { + fn from(hash_err: password_hash::Error) -> Self { + Self::PasswordHashError(hash_err) + } +} + +impl AppError { + fn into_problem(self) -> Problem { + let reference = Uuid::new_v4(); + + match self { + AppError::Validation(err) => { + trace!(%reference, + "Received invalid request: {}", + err + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::BAD_REQUEST.to_string()), + status: StatusCode::BAD_REQUEST, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + AppError::Json(err) => { + trace!(%reference, + "Received invalid JSON in request: {}", + err + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::BAD_REQUEST.to_string()), + status: StatusCode::BAD_REQUEST, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + AppError::Form(err) => { + trace!(%reference, + "Received invalid form data: {}", + err + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::BAD_REQUEST.to_string()), + status: StatusCode::BAD_REQUEST, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + AppError::QueryParams(err) => { + trace!(%reference, + "Received invalid query parameters: {}", + err + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::BAD_REQUEST.to_string()), + status: StatusCode::BAD_REQUEST, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + AppError::NotFound => { + trace!(%reference, "Object not found"); + Problem { + r#type: Default::default(), + title: Some(StatusCode::NOT_FOUND.to_string()), + status: StatusCode::NOT_FOUND, + detail: None, + instance: Some(reference.to_string()), + } + } + AppError::BadRequest(err) => { + trace!(%reference, + "Received invalid request: {}", + err + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::BAD_REQUEST.to_string()), + status: StatusCode::BAD_REQUEST, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + AppError::Forbidden(err) => { + trace!(%reference, + "Forbidden: {}", + err + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::FORBIDDEN.to_string()), + status: StatusCode::FORBIDDEN, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + AppError::NotImplemented(err) => { + error!(%reference, "Not implemented: {}", err); + Problem { + r#type: Default::default(), + title: Some(StatusCode::NOT_IMPLEMENTED.to_string()), + status: StatusCode::NOT_IMPLEMENTED, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + #[cfg(feature = "sqlx")] + AppError::Conflict(err, db_err) => { + warn!(%reference, "Conflict: {}, DB err: {:?}", err, db_err); + Problem { + r#type: Default::default(), + title: Some(StatusCode::CONFLICT.to_string()), + status: StatusCode::CONFLICT, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + AppError::Auth(err) => { + trace!(%reference, + "Authentication error: {}", + err + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::UNAUTHORIZED.to_string()), + status: StatusCode::UNAUTHORIZED, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + #[cfg(feature = "sqlx")] + AppError::Sql(err) => { + error!(%reference, "SQL error: {}", err); + Problem { + r#type: Default::default(), + title: Some(StatusCode::INTERNAL_SERVER_ERROR.to_string()), + status: StatusCode::INTERNAL_SERVER_ERROR, + detail: Some("A database error occurred".to_string()), + instance: Some(reference.to_string()), + } + } + #[cfg(feature = "sqlx")] + AppError::SerdeJsonInternalServerError(err) => { + trace!(%reference, "serde json error: {}", err); + Problem { + r#type: Default::default(), + title: Some(StatusCode::INTERNAL_SERVER_ERROR.to_string()), + status: StatusCode::INTERNAL_SERVER_ERROR, + detail: None, + instance: Some(reference.to_string()), + } + } + #[cfg(feature = "sqlx")] + AppError::SerdeJsonBadRequest(err) => { + trace!(%reference, "serde json error: {}", err); + Problem { + r#type: Default::default(), + title: Some(StatusCode::BAD_REQUEST.to_string()), + status: StatusCode::BAD_REQUEST, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + AppError::Identifier(err) => { + trace!(%reference, + "Malformed identifier: {}", + err + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::BAD_REQUEST.to_string()), + status: StatusCode::BAD_REQUEST, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + #[cfg(feature = "sqlx")] + AppError::ForeignKeyConstraintViolated(err, db_err) => { + trace!(%reference, + "Unprocessable Content: {}, DB details: {:?}", + err, + db_err + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::BAD_REQUEST.to_string()), + status: StatusCode::BAD_REQUEST, + detail: Some(err.to_string()), + instance: Some(reference.to_string()), + } + } + AppError::MethodNotAllowed => { + trace!(%reference, + "Method not allowed" + ); + Problem { + r#type: Default::default(), + title: Some(StatusCode::METHOD_NOT_ALLOWED.to_string()), + status: StatusCode::METHOD_NOT_ALLOWED, + detail: Some("See allow headers for allowed methods".to_string()), + instance: Some(reference.to_string()), + } + } + AppError::PasswordHashError(err) => { + warn!(%reference, + "Password hash error: {}", + err); + Problem { + r#type: Default::default(), + title: Some(StatusCode::INTERNAL_SERVER_ERROR.to_string()), + status: StatusCode::INTERNAL_SERVER_ERROR, + detail: Some("An internal error occurred".to_string()), + instance: Some(reference.to_string()), + } + } + AppError::UnsupportedMediaType(err) => { + info!(%reference, "Unsupported media type: {}", err); + Problem { + r#type: Default::default(), + title: Some(StatusCode::UNSUPPORTED_MEDIA_TYPE.to_string()), + status: StatusCode::UNSUPPORTED_MEDIA_TYPE, + detail: Some(err), + instance: Some(reference.to_string()), + } + } + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let problem = self.into_problem(); + (problem.status, Json(problem)).into_response() + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct ProblemUri(String); + +impl Default for ProblemUri { + fn default() -> Self { + Self("about:blank".to_string()) + } +} diff --git a/openadr-vtn/src/jwt.rs b/openadr-vtn/src/jwt.rs new file mode 100644 index 0000000..7de54e6 --- /dev/null +++ b/openadr-vtn/src/jwt.rs @@ -0,0 +1,301 @@ +use std::sync::Arc; + +use axum::{ + async_trait, + extract::{FromRef, FromRequestParts}, + http::request::Parts, +}; +use axum_extra::{ + headers::{authorization::Bearer, Authorization}, + TypedHeader, +}; +use jsonwebtoken::{encode, DecodingKey, EncodingKey, Header}; +use openadr_wire::ven::VenId; +use tracing::trace; + +use crate::error::AppError; + +pub struct JwtManager { + encoding_key: EncodingKey, + decoding_key: DecodingKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(test, derive(PartialOrd, Ord))] +#[serde(tag = "role", content = "id")] +pub enum AuthRole { + UserManager, + VenManager, + Business(String), + AnyBusiness, + VEN(VenId), +} + +impl AuthRole { + pub fn is_business(&self) -> bool { + matches!(self, AuthRole::Business(_) | AuthRole::AnyBusiness) + } + + pub fn is_ven(&self) -> bool { + matches!(self, AuthRole::VEN(_)) + } + + pub fn is_user_manager(&self) -> bool { + matches!(self, AuthRole::UserManager) + } + + pub fn is_ven_manager(&self) -> bool { + matches!(self, AuthRole::VenManager) + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct Claims { + exp: usize, + nbf: usize, + pub sub: String, + pub roles: Vec, +} + +#[cfg(test)] +#[cfg(feature = "live-db-test")] +impl Claims { + pub(crate) fn new(roles: Vec) -> Self { + Self { + exp: 0, + nbf: 0, + sub: "".to_string(), + roles, + } + } + + pub(crate) fn any_business_user() -> Claims { + Claims::new(vec![AuthRole::AnyBusiness]) + } +} + +#[derive(Debug)] +pub enum BusinessIds { + Specific(Vec), + Any, +} + +impl Claims { + pub fn ven_ids(&self) -> Vec { + self.roles + .iter() + .filter_map(|role| { + if let AuthRole::VEN(id) = role { + Some(id.clone()) + } else { + None + } + }) + .collect() + } + + pub fn ven_ids_string(&self) -> Vec { + self.roles + .iter() + .filter_map(|role| { + if let AuthRole::VEN(id) = role { + Some(id.to_string()) + } else { + None + } + }) + .collect() + } + + pub fn business_ids(&self) -> BusinessIds { + let mut ids = vec![]; + + for role in &self.roles { + match role { + AuthRole::Business(id) => ids.push(id.clone()), + AuthRole::AnyBusiness => return BusinessIds::Any, + _ => {} + } + } + + BusinessIds::Specific(ids) + } + + pub fn is_ven(&self) -> bool { + self.roles.iter().any(AuthRole::is_ven) + } + + pub fn is_business(&self) -> bool { + self.roles.iter().any(AuthRole::is_business) + } + + pub fn is_user_manager(&self) -> bool { + self.roles.iter().any(AuthRole::is_user_manager) + } + + pub fn is_ven_manager(&self) -> bool { + self.roles.iter().any(AuthRole::is_ven_manager) + } +} + +impl JwtManager { + /// Create a new JWT manager from a base64 encoded secret + pub fn from_base64_secret(secret: &str) -> Result { + let encoding_key = EncodingKey::from_base64_secret(secret)?; + let decoding_key = DecodingKey::from_base64_secret(secret)?; + Ok(Self::new(encoding_key, decoding_key)) + } + + /// Create a new JWT manager from some secret bytes + pub fn from_secret(secret: &[u8]) -> Self { + let encoding_key = EncodingKey::from_secret(secret); + let decoding_key = DecodingKey::from_secret(secret); + Self::new(encoding_key, decoding_key) + } + + /// Create a new JWT manager with a specific encoding and decoding key + pub fn new(encoding_key: EncodingKey, decoding_key: DecodingKey) -> Self { + Self { + encoding_key, + decoding_key, + } + } + + /// Create a new JWT token with the given claims and expiration time + pub fn create( + &self, + expires_in: std::time::Duration, + client_id: String, + roles: Vec, + ) -> Result { + let now = chrono::Utc::now(); + let exp = now + expires_in; + + let claims = Claims { + exp: exp.timestamp() as usize, + nbf: now.timestamp() as usize, + sub: client_id, + roles, + }; + + let token = encode(&Header::default(), &claims, &self.encoding_key)?; + + Ok(token) + } + + /// Decode and validate a given JWT token, returning the validated claims + pub fn decode_and_validate(&self, token: &str) -> Result { + let validation = jsonwebtoken::Validation::default(); + let token_data = jsonwebtoken::decode::(token, &self.decoding_key, &validation)?; + Ok(token_data.claims) + } +} + +/// User claims extracted from the request +pub struct User(pub Claims); + +/// User claims extracted from the request, with the requirement that the user is a business user +pub struct BusinessUser(pub Claims); + +/// User claims extracted from the request, with the requirement that the user is a VEN user +pub struct VENUser(pub Claims); + +/// User claims extracted from the request, with the requirement that the user is a user manager +pub struct UserManagerUser(pub Claims); + +/// User claims extracted from the request, with the requirement that the user is a VEN manager +pub struct VenManagerUser(pub Claims); + +#[async_trait] +impl FromRequestParts for User +where + Arc: FromRef, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let Ok(bearer) = + TypedHeader::>::from_request_parts(parts, state).await + else { + return Err(AppError::Auth( + "Authorization via Bearer token in Authorization header required".to_string(), + )); + }; + + let jwt_manager = Arc::::from_ref(state); + + let Ok(claims) = jwt_manager.decode_and_validate(bearer.0.token()) else { + return Err(AppError::Forbidden("Invalid authentication token provided")); + }; + + trace!(user = ?claims, "Extracted User from request"); + + Ok(User(claims)) + } +} + +#[async_trait] +impl FromRequestParts for BusinessUser +where + Arc: FromRef, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let User(user) = User::from_request_parts(parts, state).await?; + if !user.is_business() { + return Err(AppError::Forbidden("User does not have the required role")); + } + Ok(BusinessUser(user)) + } +} + +#[async_trait] +impl FromRequestParts for VENUser +where + Arc: FromRef, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let User(user) = User::from_request_parts(parts, state).await?; + if !user.is_ven() { + return Err(AppError::Forbidden("User does not have the required role")); + } + Ok(VENUser(user)) + } +} + +#[async_trait] +impl FromRequestParts for UserManagerUser +where + Arc: FromRef, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let User(user) = User::from_request_parts(parts, state).await?; + if !user.is_user_manager() { + return Err(AppError::Forbidden("User does not have the required role")); + } + Ok(UserManagerUser(user)) + } +} + +#[async_trait] +impl FromRequestParts for VenManagerUser +where + Arc: FromRef, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let User(user) = User::from_request_parts(parts, state).await?; + if !user.is_ven_manager() { + return Err(AppError::Auth( + "User does not have the required role".to_string(), + )); + } + Ok(VenManagerUser(user)) + } +} diff --git a/openadr-vtn/src/lib.rs b/openadr-vtn/src/lib.rs new file mode 100644 index 0000000..1105340 --- /dev/null +++ b/openadr-vtn/src/lib.rs @@ -0,0 +1,5 @@ +pub mod api; +pub mod data_source; +mod error; +pub mod jwt; +pub mod state; diff --git a/openadr-vtn/src/main.rs b/openadr-vtn/src/main.rs new file mode 100644 index 0000000..8de0a3b --- /dev/null +++ b/openadr-vtn/src/main.rs @@ -0,0 +1,60 @@ +use tokio::{net::TcpListener, signal}; +use tracing::{error, info}; +use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +#[cfg(feature = "postgres")] +use openadr_vtn::data_source::PostgresStorage; +use openadr_vtn::{jwt::JwtManager, state::AppState}; + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(fmt::layer().with_file(true).with_line_number(true)) + .with(EnvFilter::from_default_env()) + .init(); + + let addr = "0.0.0.0:3000"; + let listener = TcpListener::bind(addr).await.unwrap(); + info!("listening on http://{}", listener.local_addr().unwrap()); + + #[cfg(feature = "postgres")] + let storage = PostgresStorage::from_env().await.unwrap(); + + #[cfg(not(feature = "postgres"))] + compile_error!( + "No storage backend selected. Please enable the `postgres` feature flag during compilation" + ); + + // TODO make the JWT secret secure and configurable + let state = AppState::new(storage, JwtManager::from_base64_secret("test").unwrap()); + if let Err(e) = axum::serve(listener, state.into_router()) + .with_graceful_shutdown(shutdown_signal()) + .await + { + error!("webserver crashed: {}", e); + } +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} diff --git a/openadr-vtn/src/state.rs b/openadr-vtn/src/state.rs new file mode 100644 index 0000000..bcd546f --- /dev/null +++ b/openadr-vtn/src/state.rs @@ -0,0 +1,132 @@ +use crate::{ + data_source::{ + AuthSource, DataSource, EventCrud, ProgramCrud, ReportCrud, ResourceCrud, VenCrud, + }, + error::AppError, + jwt::JwtManager, +}; +use axum::{ + extract::{FromRef, Request}, + middleware, + middleware::Next, + response::IntoResponse, + routing::{delete, get, post}, +}; +use reqwest::StatusCode; +use std::sync::Arc; +use tower_http::trace::TraceLayer; + +use crate::api::{auth, event, program, report, resource, user, ven}; + +#[derive(Clone, FromRef)] +pub struct AppState { + pub storage: Arc, + pub jwt_manager: Arc, +} + +impl AppState { + pub fn new(storage: S, jwt_manager: JwtManager) -> Self { + Self { + storage: Arc::new(storage), + jwt_manager: Arc::new(jwt_manager), + } + } + + fn router_without_state() -> axum::Router { + axum::Router::new() + .route("/programs", get(program::get_all).post(program::add)) + .route( + "/programs/:id", + get(program::get).put(program::edit).delete(program::delete), + ) + .route("/reports", get(report::get_all).post(report::add)) + .route( + "/reports/:id", + get(report::get).put(report::edit).delete(report::delete), + ) + .route("/events", get(event::get_all).post(event::add)) + .route( + "/events/:id", + get(event::get).put(event::edit).delete(event::delete), + ) + .route("/vens", get(ven::get_all).post(ven::add)) + .route( + "/vens/:id", + get(ven::get).put(ven::edit).delete(ven::delete), + ) + .route( + "/vens/:ven_id/resources", + get(resource::get_all).post(resource::add), + ) + .route( + "/vens/:ven_id/resources/:id", + get(resource::get) + .put(resource::edit) + .delete(resource::delete), + ) + .route("/auth/token", post(auth::token)) + .route("/users", get(user::get_all).post(user::add_user)) + .route( + "/users/:id", + get(user::get) + .put(user::edit) + .delete(user::delete_user) + .post(user::add_credential), + ) + .route( + "/users/:user_id/:client_id", + delete(user::delete_credential), + ) + .layer(middleware::from_fn(method_not_allowed)) + .layer(TraceLayer::new_for_http()) + } + + pub fn into_router(self) -> axum::Router { + Self::router_without_state().with_state(self) + } +} + +pub async fn method_not_allowed(req: Request, next: Next) -> impl IntoResponse { + let resp = next.run(req).await; + let status = resp.status(); + match status { + StatusCode::METHOD_NOT_ALLOWED => Err(AppError::MethodNotAllowed), + _ => Ok(resp), + } +} + +impl FromRef for Arc { + fn from_ref(state: &AppState) -> Arc { + state.storage.auth() + } +} + +impl FromRef for Arc { + fn from_ref(state: &AppState) -> Arc { + state.storage.programs() + } +} + +impl FromRef for Arc { + fn from_ref(state: &AppState) -> Arc { + state.storage.events() + } +} + +impl FromRef for Arc { + fn from_ref(state: &AppState) -> Arc { + state.storage.reports() + } +} + +impl FromRef for Arc { + fn from_ref(state: &AppState) -> Arc { + state.storage.vens() + } +} + +impl FromRef for Arc { + fn from_ref(state: &AppState) -> Arc { + state.storage.resources() + } +} diff --git a/openadr-wire/Cargo.toml b/openadr-wire/Cargo.toml new file mode 100644 index 0000000..a085df7 --- /dev/null +++ b/openadr-wire/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "openadr-wire" +description = "encode and decode openadr messages that go over the wire" +readme = "../README.md" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish.workspace = true +rust-version.workspace = true + +[dependencies] +serde.workspace = true +chrono.workspace = true +serde_with.workspace = true +iso8601-duration.workspace = true +thiserror.workspace = true +http.workspace = true +validator.workspace = true + +[dev-dependencies] +serde_json.workspace = true +quickcheck.workspace = true diff --git a/openadr-wire/src/event.rs b/openadr-wire/src/event.rs new file mode 100644 index 0000000..f7c25f6 --- /dev/null +++ b/openadr-wire/src/event.rs @@ -0,0 +1,439 @@ +//! Types used for the `event/` endpoint + +use crate::{ + interval::IntervalPeriod, program::ProgramId, report::ReportDescriptor, target::TargetMap, + values_map::Value, Identifier, IdentifierError, Unit, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::{ + fmt::{Display, Formatter}, + str::FromStr, +}; +use validator::Validate; + +/// Event object to communicate a Demand Response request to VEN. If intervalPeriod is present, sets +/// start time and duration of intervals. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct Event { + /// URL safe VTN assigned object ID. + pub id: EventId, + /// datetime in ISO 8601 format + #[serde(with = "crate::serde_rfc3339")] + pub created_date_time: DateTime, + /// datetime in ISO 8601 format + #[serde(with = "crate::serde_rfc3339")] + pub modification_date_time: DateTime, + #[serde(flatten)] + #[validate(nested)] + pub content: EventContent, +} + +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct EventContent { + /// Used as discriminator, e.g. notification.object + // TODO: remove this? + pub object_type: Option, + /// URL safe VTN assigned object ID. + #[serde(rename = "programID")] + pub program_id: ProgramId, + /// User defined string for use in debugging or User Interface. + pub event_name: Option, + /// Relative priority of event. A lower number is a higher priority. + pub priority: Priority, + /// A list of valuesMap objects. + pub targets: Option, + /// A list of reportDescriptor objects. Used to request reports from VEN. + pub report_descriptors: Option>, + /// A list of payloadDescriptor objects. + pub payload_descriptors: Option>, + /// Defines default start and durations of intervals. + pub interval_period: Option, + /// A list of interval objects. + pub intervals: Vec, +} + +impl EventContent { + pub fn new(program_id: ProgramId, intervals: Vec) -> Self { + assert!( + !intervals.is_empty(), + "`EventContent::new` called with no intervals!" + ); + + Self { + object_type: None, + program_id, + event_name: None, + priority: Priority::UNSPECIFIED, + targets: None, + report_descriptors: None, + payload_descriptors: None, + interval_period: None, + intervals, + } + } + + pub fn with_event_name(mut self, event_name: impl ToString) -> Self { + self.event_name = Some(event_name.to_string()); + self + } + + pub fn with_priority(self, priority: Priority) -> Self { + Self { priority, ..self } + } + + pub fn with_targets(mut self, targets: TargetMap) -> Self { + self.targets = Some(targets); + self + } + + pub fn with_report_descriptors(mut self, report_descriptors: Vec) -> Self { + self.report_descriptors = Some(report_descriptors); + self + } + + pub fn with_payload_descriptors( + mut self, + payload_descriptors: Vec, + ) -> Self { + self.payload_descriptors = Some(payload_descriptors); + self + } + + pub fn with_interval_period(mut self, interval_period: IntervalPeriod) -> Self { + self.interval_period = Some(interval_period); + self + } + + pub fn with_intervals(mut self, intervals: Vec) -> Self { + self.intervals = intervals; + self + } +} + +/// URL safe VTN assigned object ID +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Hash, Eq)] +pub struct EventId(pub(crate) Identifier); + +impl Display for EventId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl EventId { + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl FromStr for EventId { + type Err = IdentifierError; + + fn from_str(s: &str) -> Result { + Ok(Self(s.parse()?)) + } +} + +/// Used as discriminator, e.g. notification.object +#[derive( + Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EventObjectType { + #[default] + Event, +} + +/// Relative priority of an event +/// +/// 0 indicates the highest priority. +/// +/// SPEC ASSUMPTION: [`Self::UNSPECIFIED`] has lower priority then any other value. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Priority(Option); + +impl Priority { + pub const UNSPECIFIED: Self = Self(None); + + pub const MAX: Self = Self(Some(0)); + pub const MIN: Self = Self::UNSPECIFIED; + + pub const fn new(val: u32) -> Self { + Self(Some(val)) + } +} + +impl PartialOrd for Priority { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Priority { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + use std::cmp::Ordering; + + match (self.0, other.0) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (Some(s), Some(o)) => s.cmp(&o).reverse(), + } + } +} + +impl From> for Priority { + fn from(value: Option) -> Self { + Self(value.and_then(|i| i.unsigned_abs().try_into().ok())) + } +} + +impl From for Option { + fn from(value: Priority) -> Self { + value.0.map(|u| u.into()) + } +} + +/// Contextual information used to interpret event valuesMap values. E.g. a PRICE payload simply +/// contains a price value, an associated descriptor provides necessary context such as units and +/// currency. +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventPayloadDescriptor { + /// Enumerated or private string signifying the nature of values. + pub payload_type: EventType, + /// Units of measure. + pub units: Option, + /// Currency of price payload. + pub currency: Option, +} + +impl EventPayloadDescriptor { + pub fn new(payload_type: EventType) -> Self { + Self { + payload_type, + units: None, + currency: None, + } + } +} + +// TODO: Find a nice ISO 4217 crate +/// A currency described as listed in ISO 4217 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Currency { + Todo, +} + +/// An object defining a temporal window and a list of valuesMaps. if intervalPeriod present may set +/// temporal aspects of interval or override event.intervalPeriod. +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventInterval { + /// A client generated number assigned an interval object. Not a sequence number. + pub id: i32, + /// Defines default start and durations of intervals. + pub interval_period: Option, + /// A list of valuesMap objects. + pub payloads: Vec, +} + +impl EventInterval { + pub fn new(id: i32, payloads: Vec) -> Self { + Self { + id, + interval_period: None, + payloads, + } + } +} + +/// Represents one or more values associated with a type. E.g. a type of PRICE contains a single float value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventValuesMap { + /// Enumerated or private string signifying the nature of values. E.G. \"PRICE\" indicates value is to be interpreted as a currency. + #[serde(rename = "type")] + pub value_type: EventType, + /// A list of data points. Most often a singular value such as a price. + // TODO: The type of Value is actually defined by value_type + pub values: Vec, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EventType { + Simple, + Price, + ChargeStateSetpoint, + DispatchSetpoint, + DispatchSetpointRelative, + ControlSetpoint, + ExportPrice, + #[serde(rename = "GHG")] + GHG, + Curve, + #[serde(rename = "OLS")] + OLS, + ImportCapacitySubscription, + ImportCapacityReservation, + ImportCapacityReservationFee, + ImportCapacityAvailable, + ImportCapacityAvailablePrice, + ExportCapacitySubscription, + ExportCapacityReservation, + ExportCapacityReservationFee, + ExportCapacityAvailable, + ExportCapacityAvailablePrice, + ImportCapacityLimit, + ExportCapacityLimit, + AlertGridEmergency, + AlertBlackStart, + AlertPossibleOutage, + AlertFlexAlert, + AlertFire, + AlertFreezing, + AlertWind, + AlertTsunami, + AlertAirQuality, + AlertOther, + #[serde(rename = "CTA2045_REBOOT")] + CTA2045Reboot, + #[serde(rename = "CTA2045_SET_OVERRIDE_STATUS")] + CTA2045SetOverrideStatus, + #[serde(untagged)] + #[serde(deserialize_with = "crate::string_within_range_inclusive::<1, 128, _>")] + Private(String), +} + +#[cfg(test)] +mod tests { + use crate::{values_map::Value, Duration}; + + use super::*; + + #[test] + fn test_event_serialization() { + assert_eq!( + serde_json::to_string(&EventType::Simple).unwrap(), + r#""SIMPLE""# + ); + assert_eq!( + serde_json::to_string(&EventType::CTA2045Reboot).unwrap(), + r#""CTA2045_REBOOT""# + ); + assert_eq!( + serde_json::from_str::(r#""GHG""#).unwrap(), + EventType::GHG + ); + assert_eq!( + serde_json::from_str::(r#""something else""#).unwrap(), + EventType::Private(String::from("something else")) + ); + + assert!(serde_json::from_str::(r#""""#).is_err()); + assert!(serde_json::from_str::(&format!("\"{}\"", "x".repeat(129))).is_err()); + } + + #[test] + fn parse_minimal() { + let example = r#"{"programID":"foo","intervals":[]}"#; + assert_eq!( + serde_json::from_str::(example).unwrap(), + EventContent { + object_type: None, + program_id: ProgramId("foo".parse().unwrap()), + event_name: None, + priority: Priority::MIN, + targets: None, + report_descriptors: None, + payload_descriptors: None, + interval_period: None, + intervals: vec![], + } + ); + } + + #[test] + fn example_parses() { + let example = r#"[{ + "id": "object-999-foo", + "createdDateTime": "2023-06-15T09:30:00Z", + "modificationDateTime": "2023-06-15T09:30:00Z", + "objectType": "EVENT", + "programID": "object-999", + "eventName": "price event 11-18-2022", + "priority": 0, + "targets": null, + "reportDescriptors": null, + "payloadDescriptors": null, + "intervalPeriod": { + "start": "2023-06-15T09:30:00Z", + "duration": "PT1H", + "randomizeStart": "PT1H" + }, + "intervals": [ + { + "id": 0, + "intervalPeriod": { + "start": "2023-06-15T09:30:00Z", + "duration": "PT1H", + "randomizeStart": "PT1H" + }, + "payloads": [ + { + "type": "PRICE", + "values": [ + 0.17 + ] + } + ] + } + ] + }]"#; + + let expected = Event { + id: EventId("object-999-foo".parse().unwrap()), + created_date_time: "2023-06-15T09:30:00Z".parse().unwrap(), + modification_date_time: "2023-06-15T09:30:00Z".parse().unwrap(), + content: EventContent { + object_type: Some(EventObjectType::Event), + program_id: ProgramId("object-999".parse().unwrap()), + event_name: Some("price event 11-18-2022".into()), + priority: Priority::MAX, + targets: Default::default(), + report_descriptors: None, + payload_descriptors: None, + interval_period: Some(IntervalPeriod { + start: "2023-06-15T09:30:00Z".parse().unwrap(), + duration: Some(Duration::PT1H), + randomize_start: Some(Duration::PT1H), + }), + intervals: vec![EventInterval { + id: 0, + interval_period: Some(IntervalPeriod { + start: "2023-06-15T09:30:00Z".parse().unwrap(), + duration: Some(Duration::PT1H), + randomize_start: Some(Duration::PT1H), + }), + payloads: vec![EventValuesMap { + value_type: EventType::Price, + values: vec![Value::Number(0.17)], + }], + }], + }, + }; + + assert_eq!( + serde_json::from_str::>(example).unwrap()[0], + expected + ); + } +} diff --git a/openadr-wire/src/interval.rs b/openadr-wire/src/interval.rs new file mode 100644 index 0000000..ec536e2 --- /dev/null +++ b/openadr-wire/src/interval.rs @@ -0,0 +1,55 @@ +//! Descriptions of temporal periods + +use crate::{values_map::ValuesMap, Duration}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// An object defining a temporal window and a list of valuesMaps. if intervalPeriod present may set +/// temporal aspects of interval or override event.intervalPeriod. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Interval { + /// A client generated number assigned an interval object. Not a sequence number. + pub id: i32, + /// Defines default start and durations of intervals. + #[serde(skip_serializing_if = "Option::is_none")] + pub interval_period: Option, + /// A list of valuesMap objects. + pub payloads: Vec, +} + +impl Interval { + pub fn new(id: i32, payloads: Vec) -> Self { + Self { + id, + interval_period: None, + payloads, + } + } +} + +/// Defines temporal aspects of intervals. A duration of default null indicates infinity. A +/// randomizeStart of default null indicates no randomization. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IntervalPeriod { + /// The start time of an interval or set of intervals. + #[serde(with = "crate::serde_rfc3339")] + pub start: DateTime, + /// The duration of an interval or set of intervals. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + /// Indicates a randomization time that may be applied to start. + #[serde(skip_serializing_if = "Option::is_none")] + pub randomize_start: Option, +} + +impl IntervalPeriod { + pub fn new(start: DateTime) -> Self { + Self { + start, + duration: None, + randomize_start: None, + } + } +} diff --git a/openadr-wire/src/lib.rs b/openadr-wire/src/lib.rs new file mode 100644 index 0000000..adef222 --- /dev/null +++ b/openadr-wire/src/lib.rs @@ -0,0 +1,498 @@ +//! Wire format definitions for OpenADR endpoints +//! +//! The types in this module model the messages sent over the wire in OpenADR 3.0. +//! Most types are originally generated from the OpenAPI specification of OpenADR +//! and manually modified to be more idiomatic. + +use std::fmt::Display; + +pub use event::Event; +pub use program::Program; +pub use report::Report; +use serde::{de::Unexpected, Deserialize, Deserializer, Serialize, Serializer}; +pub use ven::Ven; + +pub mod event; +pub mod interval; +pub mod oauth; +pub mod problem; +pub mod program; +pub mod report; +pub mod resource; +pub mod target; +pub mod values_map; +pub mod ven; + +pub mod serde_rfc3339 { + use super::*; + + use chrono::{DateTime, TimeZone, Utc}; + + pub fn serialize(time: &DateTime, serializer: S) -> Result + where + S: Serializer, + Tz: TimeZone, + { + serializer.serialize_str(&time.to_rfc3339()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let rfc_str = ::deserialize(deserializer)?; + + match DateTime::parse_from_rfc3339(&rfc_str) { + Ok(datetime) => Ok(datetime.into()), + Err(_) => Err(serde::de::Error::invalid_value( + Unexpected::Str(&rfc_str), + &"Invalid RFC3339 string", + )), + } + } +} + +pub fn string_within_range_inclusive<'de, const MIN: usize, const MAX: usize, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let string = ::deserialize(deserializer)?; + let len = string.len(); + + if (MIN..=MAX).contains(&len) { + Ok(string.to_string()) + } else { + Err(serde::de::Error::invalid_value( + Unexpected::Str(&string), + &IdentifierError::InvalidLength(len).to_string().as_str(), + )) + } +} + +/// A string that matches `/^[a-zA-Z0-9_-]*$/` with length in 1..=128 +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Identifier(#[serde(deserialize_with = "identifier")] String); + +impl<'de> Deserialize<'de> for Identifier { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let borrowed_str = <&str as Deserialize>::deserialize(deserializer)?; + + borrowed_str.parse::().map_err(|e| { + serde::de::Error::invalid_value(Unexpected::Str(borrowed_str), &e.to_string().as_str()) + }) + } +} + +#[derive(thiserror::Error, Debug)] +pub enum IdentifierError { + #[error("string length {0} outside of allowed range 1..=128")] + InvalidLength(usize), + #[error("identifier contains characters besides [a-zA-Z0-9_-]")] + InvalidCharacter, + #[error("this identifier name is not allowed: {0}")] + ForbiddenName(String), +} + +const FORBIDDEN_NAMES: &[&str] = &["null"]; + +impl std::str::FromStr for Identifier { + type Err = IdentifierError; + + fn from_str(s: &str) -> Result { + let is_valid_character = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'; + + if !(1..=128).contains(&s.len()) { + Err(IdentifierError::InvalidLength(s.len())) + } else if !s.bytes().all(is_valid_character) { + Err(IdentifierError::InvalidCharacter) + } else if FORBIDDEN_NAMES.contains(&s.to_ascii_lowercase().as_str()) { + Err(IdentifierError::ForbiddenName(s.to_string())) + } else { + Ok(Identifier(s.to_string())) + } + } +} + +impl Identifier { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Display for Identifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// An ISO 8601 formatted duration +#[derive(Clone, Debug, PartialEq)] +pub struct Duration(iso8601_duration::Duration); + +impl<'de> Deserialize<'de> for Duration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + let duration = raw + .parse::() + .map_err(|_| "iso8601_duration::ParseDurationError") + .map_err(serde::de::Error::custom)?; + + Ok(Self(duration)) + } +} + +impl Serialize for Duration { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.to_string().serialize(serializer) + } +} + +impl Duration { + /// because iso8601 durations can include months and years, they don't independently have a + /// fixed duration. Their real duration (in real units like seconds) can only be determined + /// when a starting time is given. + /// + /// NOTE: does not consider leap seconds! + pub fn to_chrono_at_datetime( + &self, + at: chrono::DateTime, + ) -> chrono::Duration { + self.0.to_chrono_at_datetime(at) + } + + /// One (1) hour + pub const PT1H: Self = Self(iso8601_duration::Duration { + year: 0.0, + month: 0.0, + day: 0.0, + hour: 1.0, + minute: 0.0, + second: 0.0, + }); + + /// Indicates that an event's intervals continue indefinitely into the future until the event is + /// deleted or modified. This effectively represents an infinite duration. + pub const P999Y: Self = Self(iso8601_duration::Duration { + year: 9999.0, + month: 0.0, + day: 0.0, + hour: 0.0, + minute: 0.0, + second: 0.0, + }); + + pub const fn hours(hour: f32) -> Self { + Self(iso8601_duration::Duration { + year: 0.0, + month: 0.0, + day: 0.0, + hour, + minute: 0.0, + second: 0.0, + }) + } +} + +impl std::str::FromStr for Duration { + type Err = iso8601_duration::ParseDurationError; + + fn from_str(s: &str) -> Result { + let duration = s.parse::()?; + Ok(Self(duration)) + } +} + +impl Display for Duration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let iso8601_duration::Duration { + year, + month, + day, + hour, + minute, + second, + } = self.0; + + f.write_fmt(format_args!( + "P{}Y{}M{}DT{}H{}M{}S", + year, month, day, hour, minute, second + )) + } +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum OperatingState { + Normal, + Error, + IdleNormal, + RunningNormal, + RunningCurtailed, + RunningHeightened, + IdleCurtailed, + #[serde(rename = "SGD_ERROR_CONDITION")] + SGDErrorCondition, + IdleHeightened, + IdleOptedOut, + RunningOptedOut, + #[serde(untagged)] + Private(String), +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DataQuality { + /// No known reasons to doubt the data. + Ok, + /// The data item is currently unavailable. + Missing, + /// The data item has been estimated from other available information. + Estimated, + /// The data item is suspected to be bad or is known to be. + Bad, + /// An application specific privately defined data quality setting. + #[serde(untagged)] + Private(String), +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Attribute { + /// Describes a single geographic point. Values contains 2 floats, generally + /// representing longitude and latitude. Demand Response programs may define + /// their own use of these fields. + Location, + /// Describes a geographic area. Application specific data. Demand Response + /// programs may define their own use of these fields, such as GeoJSON + /// polygon data. + Area, + /// The maximum consumption as a float, in kiloWatts. + MaxPowerConsumption, + /// The maximum power the device can export as a float, in kiloWatts. + MaxPowerExport, + /// A free-form short description of a VEN or resource. + Description, + /// An application specific privately defined attribute. + #[serde(untagged)] + Private(String), +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Unit { + /// Kilowatt-hours (kWh) + #[serde(rename = "KWH")] + KWH, + /// Greenhouse gas emissions (g/kWh) + #[serde(rename = "GHG")] + GHG, + /// Voltage (V) + Volts, + /// Current (A) + Amps, + /// Temperature (C) + Celcius, + /// Temperature (F) + Fahrenheit, + /// Percentage (%) + Percent, + /// Kilowatts + #[serde(rename = "KW")] + KW, + /// Kilovolt-ampere hours (kVAh) + #[serde(rename = "KVAH")] + KVAH, + /// Kilovolt-amperes reactive hours (kVARh) + #[serde(rename = "KVARH")] + KVARH, + /// Kilovolt-amperes (kVA) + #[serde(rename = "KVA")] + KVA, + /// Kilovolt-amperes reactive (kVAR) + #[serde(rename = "KVAR")] + KVAR, + /// An application specific privately defined unit. + #[serde(untagged)] + Private(String), +} + +#[cfg(test)] +mod tests { + use crate::{Attribute, DataQuality, Identifier, OperatingState, Unit}; + + #[test] + fn test_operating_state_serialization() { + assert_eq!( + serde_json::to_string(&OperatingState::SGDErrorCondition).unwrap(), + r#""SGD_ERROR_CONDITION""# + ); + assert_eq!( + serde_json::to_string(&OperatingState::Error).unwrap(), + r#""ERROR""# + ); + assert_eq!( + serde_json::to_string(&OperatingState::Private(String::from("something else"))) + .unwrap(), + r#""something else""# + ); + assert_eq!( + serde_json::from_str::(r#""NORMAL""#).unwrap(), + OperatingState::Normal + ); + assert_eq!( + serde_json::from_str::(r#""something else""#).unwrap(), + OperatingState::Private(String::from("something else")) + ); + } + + #[test] + fn test_data_quality_serialization() { + assert_eq!(serde_json::to_string(&DataQuality::Ok).unwrap(), r#""OK""#); + assert_eq!( + serde_json::to_string(&DataQuality::Private(String::from("something else"))).unwrap(), + r#""something else""# + ); + assert_eq!( + serde_json::from_str::(r#""MISSING""#).unwrap(), + DataQuality::Missing + ); + assert_eq!( + serde_json::from_str::(r#""something else""#).unwrap(), + DataQuality::Private(String::from("something else")) + ); + } + + #[test] + fn test_attribute_serialization() { + assert_eq!( + serde_json::to_string(&Attribute::Area).unwrap(), + r#""AREA""# + ); + assert_eq!( + serde_json::to_string(&Attribute::Private(String::from("something else"))).unwrap(), + r#""something else""# + ); + assert_eq!( + serde_json::from_str::(r#""MAX_POWER_EXPORT""#).unwrap(), + Attribute::MaxPowerExport + ); + assert_eq!( + serde_json::from_str::(r#""something else""#).unwrap(), + Attribute::Private(String::from("something else")) + ); + } + + #[test] + fn test_unit_serialization() { + assert_eq!(serde_json::to_string(&Unit::KVARH).unwrap(), r#""KVARH""#); + assert_eq!( + serde_json::to_string(&Unit::Private(String::from("something else"))).unwrap(), + r#""something else""# + ); + assert_eq!( + serde_json::from_str::(r#""CELCIUS""#).unwrap(), + Unit::Celcius + ); + assert_eq!( + serde_json::from_str::(r#""something else""#).unwrap(), + Unit::Private(String::from("something else")) + ); + } + + impl quickcheck::Arbitrary for super::Duration { + fn arbitrary(g: &mut quickcheck::Gen) -> Self { + // the iso8601_duration library uses an f32 to store the values, which starts losing + // precision at 24-bit integers. + super::Duration(iso8601_duration::Duration { + year: (::arbitrary(g) & 0x00FF_FFFF) as f32, + month: (::arbitrary(g) & 0x00FF_FFFF) as f32, + day: (::arbitrary(g) & 0x00FF_FFFF) as f32, + hour: (::arbitrary(g) & 0x00FF_FFFF) as f32, + minute: (::arbitrary(g) & 0x00FF_FFFF) as f32, + second: (::arbitrary(g) & 0x00FF_FFFF) as f32, + }) + } + } + + #[test] + fn duration_to_string_from_str_roundtrip() { + quickcheck::quickcheck(test as fn(_) -> bool); + + fn test(input: super::Duration) -> bool { + let roundtrip = input.to_string().parse::().unwrap(); + + assert_eq!(input.0, roundtrip.0); + + input.0 == roundtrip.0 + } + } + + #[test] + fn deserialize_identifier() { + assert_eq!( + serde_json::from_str::(r#""example-999""#).unwrap(), + Identifier("example-999".to_string()) + ); + assert!(serde_json::from_str::(r#""þingvellir-999""#) + .unwrap_err() + .to_string() + .contains("identifier contains characters besides")); + + let long = "x".repeat(128); + assert_eq!( + serde_json::from_str::(&format!("\"{long}\"")).unwrap(), + Identifier(long) + ); + + let too_long = "x".repeat(129); + assert!( + serde_json::from_str::(&format!("\"{too_long}\"")) + .unwrap_err() + .to_string() + .contains("string length 129 outside of allowed range 1..=128") + ); + + assert!(serde_json::from_str::("\"\"") + .unwrap_err() + .to_string() + .contains("string length 0 outside of allowed range 1..=128")); + } + + #[test] + fn deserialize_string_within_range_inclusive() { + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct Test( + #[serde(deserialize_with = "super::string_within_range_inclusive::<1, 128, _>")] String, + ); + + let long = "x".repeat(128); + assert_eq!( + serde_json::from_str::(&format!("\"{long}\"")).unwrap(), + Test(long) + ); + + let too_long = "x".repeat(129); + assert!(serde_json::from_str::(&format!("\"{too_long}\"")) + .unwrap_err() + .to_string() + .contains("string length 129 outside of allowed range 1..=128")); + + assert!(serde_json::from_str::("\"\"") + .unwrap_err() + .to_string() + .contains("string length 0 outside of allowed range 1..=128")); + } +} diff --git a/openadr-wire/src/oauth.rs b/openadr-wire/src/oauth.rs new file mode 100644 index 0000000..7d95ae4 --- /dev/null +++ b/openadr-wire/src/oauth.rs @@ -0,0 +1,40 @@ +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OAuthErrorType { + InvalidRequest, + InvalidClient, + // InvalidGrant, + // UnauthorizedClient, + UnsupportedGrantType, + // InvalidScope, + ServerError, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct OAuthError { + pub error: OAuthErrorType, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_uri: Option, +} + +impl OAuthError { + pub fn new(error: OAuthErrorType) -> Self { + Self { + error, + error_description: None, + error_uri: None, + } + } + + pub fn with_description(mut self, description: String) -> Self { + self.error_description = Some(description); + self + } + + pub fn with_uri(mut self, uri: String) -> Self { + self.error_uri = Some(uri); + self + } +} diff --git a/openadr-wire/src/problem.rs b/openadr-wire/src/problem.rs new file mode 100644 index 0000000..0d47e25 --- /dev/null +++ b/openadr-wire/src/problem.rs @@ -0,0 +1,64 @@ +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct ProblemUri(String); + +impl Default for ProblemUri { + fn default() -> Self { + Self("about:blank".to_string()) + } +} + +/// Reusable error response. From . +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Problem { + /// An absolute URI that identifies the problem type. + /// When dereferenced, it SHOULD provide human-readable documentation for the problem type + /// (e.g., using HTML). + #[serde(default)] + pub r#type: ProblemUri, + /// A short, summary of the problem type. + /// Written in english and readable for engineers + /// (usually not suited for non-technical stakeholders and not localized); + /// example: Service Unavailable. + pub title: Option, + /// The HTTP status code generated by the origin server for this occurrence of the problem. + #[serde(with = "status_code_serialization")] + pub status: StatusCode, + /// A human-readable explanation specific to this occurrence of the problem. + pub detail: Option, + /// An absolute URI that identifies the specific occurrence of the problem. + /// It may or may not yield further information if dereferenced. + pub instance: Option, +} + +mod status_code_serialization { + use super::*; + + use serde::{de::Unexpected, Deserializer, Serializer}; + + pub fn serialize(code: &StatusCode, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u16(code.as_u16()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + u16::deserialize(deserializer).and_then(|code| { + StatusCode::from_u16(code).map_err(|_| { + serde::de::Error::invalid_value( + Unexpected::Unsigned(code as u64), + &"Valid http status code", + ) + }) + }) + } +} diff --git a/openadr-wire/src/program.rs b/openadr-wire/src/program.rs new file mode 100644 index 0000000..4aeae1a --- /dev/null +++ b/openadr-wire/src/program.rs @@ -0,0 +1,251 @@ +//! Types used for the `program/` endpoint + +use crate::{ + event::EventPayloadDescriptor, interval::IntervalPeriod, report::ReportPayloadDescriptor, + target::TargetMap, Duration, IdentifierError, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::{fmt::Display, str::FromStr}; +use validator::Validate; + +use super::Identifier; + +pub type Programs = Vec; + +/// Provides program specific metadata from VTN to VEN. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct Program { + /// VTN provisioned on object creation. + /// + /// URL safe VTN assigned object ID. + pub id: ProgramId, + + /// VTN provisioned on object creation. + /// + /// datetime in ISO 8601 format + #[serde(with = "crate::serde_rfc3339")] + pub created_date_time: DateTime, + + /// VTN provisioned on object modification. + /// + /// datetime in ISO 8601 format + #[serde(with = "crate::serde_rfc3339")] + pub modification_date_time: DateTime, + + #[serde(flatten)] + #[validate(nested)] + pub content: ProgramContent, +} + +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct ProgramContent { + /// Used as discriminator, e.g. notification.object + /// + /// VTN provisioned on object creation. + // TODO: Maybe remove this? It is more part of the enum containing this + pub object_type: Option, + /// Short name to uniquely identify program. + #[serde(deserialize_with = "crate::string_within_range_inclusive::<1, 128, _>")] + pub program_name: String, + /// Long name of program for human readability. + pub program_long_name: Option, + /// Short name of energy retailer providing the program. + pub retailer_name: Option, + /// Long name of energy retailer for human readability. + pub retailer_long_name: Option, + /// A program defined categorization. + pub program_type: Option, + /// Alpha-2 code per ISO 3166-1. + pub country: Option, + /// Coding per ISO 3166-2. E.g. state in US. + pub principal_subdivision: Option, + /// duration in ISO 8601 format + /// + /// Number of hours different from UTC for the standard time applicable to the program. + // TODO: aaaaaah why??? + pub time_zone_offset: Option, + pub interval_period: Option, + /// A list of programDescriptions + #[validate(nested)] + pub program_descriptions: Option>, + /// True if events are fixed once transmitted. + pub binding_events: Option, + /// True if events have been adapted from a grid event. + pub local_price: Option, + /// A list of payloadDescriptors. + pub payload_descriptors: Option>, + /// A list of valuesMap objects. + pub targets: Option, +} + +impl ProgramContent { + pub fn new(name: impl ToString) -> ProgramContent { + ProgramContent { + object_type: Some(ProgramObjectType::Program), + program_name: name.to_string(), + program_long_name: Default::default(), + retailer_name: Default::default(), + retailer_long_name: Default::default(), + program_type: Default::default(), + country: Default::default(), + principal_subdivision: Default::default(), + time_zone_offset: Default::default(), + interval_period: Default::default(), + program_descriptions: Default::default(), + binding_events: Default::default(), + local_price: Default::default(), + payload_descriptors: Default::default(), + targets: Default::default(), + } + } +} + +// example: object-999 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Hash, Eq)] +pub struct ProgramId(pub(crate) Identifier); + +impl Display for ProgramId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl ProgramId { + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn new(identifier: &str) -> Option { + Some(Self(identifier.parse().ok()?)) + } +} + +impl FromStr for ProgramId { + type Err = IdentifierError; + + fn from_str(s: &str) -> Result { + Ok(Self(s.parse()?)) + } +} + +/// Used as discriminator, e.g. notification.object +#[derive( + Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ProgramObjectType { + #[default] + Program, +} + +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, Validate)] +pub struct ProgramDescription { + /// A human or machine readable program description + #[serde(rename = "URL")] + #[validate(url)] + pub url: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "objectType", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayloadDescriptor { + EventPayloadDescriptor(EventPayloadDescriptor), + ReportPayloadDescriptor(ReportPayloadDescriptor), +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn example_parses() { + let example = r#"[ + { + "id": "object-999", + "createdDateTime": "2023-06-15T09:30:00Z", + "modificationDateTime": "2023-06-15T09:30:00Z", + "objectType": "PROGRAM", + "programName": "ResTOU", + "programLongName": "Residential Time of Use-A", + "retailerName": "ACME", + "retailerLongName": "ACME Electric Inc.", + "programType": "PRICING_TARIFF", + "country": "US", + "principalSubdivision": "CO", + "timeZoneOffset": "PT1H", + "intervalPeriod": { + "start": "2023-06-15T09:30:00Z", + "duration": "PT1H", + "randomizeStart": "PT1H" + }, + "programDescriptions": null, + "bindingEvents": false, + "localPrice": false, + "payloadDescriptors": null, + "targets": null + } + ]"#; + + let parsed = serde_json::from_str::(example).unwrap(); + + let expected = vec![Program { + id: ProgramId("object-999".parse().unwrap()), + created_date_time: "2023-06-15T09:30:00Z".parse().unwrap(), + modification_date_time: "2023-06-15T09:30:00Z".parse().unwrap(), + content: ProgramContent { + object_type: Some(ProgramObjectType::Program), + program_name: "ResTOU".into(), + program_long_name: Some("Residential Time of Use-A".into()), + retailer_name: Some("ACME".into()), + retailer_long_name: Some("ACME Electric Inc.".into()), + program_type: Some("PRICING_TARIFF".into()), + country: Some("US".into()), + principal_subdivision: Some("CO".into()), + time_zone_offset: Some(Duration::PT1H), + interval_period: Some(IntervalPeriod { + start: "2023-06-15T09:30:00Z".parse().unwrap(), + duration: Some(Duration::PT1H), + randomize_start: Some(Duration::PT1H), + }), + program_descriptions: None, + binding_events: Some(false), + local_price: Some(false), + payload_descriptors: None, + targets: None, + }, + }]; + + assert_eq!(expected, parsed); + } + + #[test] + fn parses_minimal() { + let example = r#"{"programName":"test"}"#; + + assert_eq!( + serde_json::from_str::(example).unwrap(), + ProgramContent { + object_type: None, + program_name: "test".to_string(), + program_long_name: None, + retailer_name: None, + retailer_long_name: None, + program_type: None, + country: None, + principal_subdivision: None, + time_zone_offset: None, + interval_period: None, + program_descriptions: None, + binding_events: None, + local_price: None, + payload_descriptors: None, + targets: None, + } + ); + } +} diff --git a/openadr-wire/src/report.rs b/openadr-wire/src/report.rs new file mode 100644 index 0000000..378e0e7 --- /dev/null +++ b/openadr-wire/src/report.rs @@ -0,0 +1,552 @@ +//! Types used for the `report/` endpoint + +use crate::{ + event::EventId, + interval::{Interval, IntervalPeriod}, + program::ProgramId, + target::TargetMap, + values_map::Value, + Identifier, IdentifierError, Unit, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::{ + fmt::{Display, Formatter}, + str::FromStr, +}; +use validator::{Validate, ValidateRange}; + +/// report object. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct Report { + /// URL safe VTN assigned object ID. + pub id: ReportId, + /// datetime in ISO 8601 format + #[serde(with = "crate::serde_rfc3339")] + pub created_date_time: DateTime, + /// datetime in ISO 8601 format + #[serde(with = "crate::serde_rfc3339")] + pub modification_date_time: DateTime, + #[serde(flatten)] + #[validate(nested)] + pub content: ReportContent, +} + +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct ReportContent { + /// Used as discriminator, e.g. notification.object + pub object_type: Option, + // FIXME Must likely be EITHER a programID OR an eventID + /// ID attribute of the program object this report is associated with. + #[serde(rename = "programID")] + pub program_id: ProgramId, + /// ID attribute of the event object this report is associated with. + #[serde(rename = "eventID")] + pub event_id: EventId, + /// User generated identifier; may be VEN ID provisioned during program enrollment. + #[serde(deserialize_with = "crate::string_within_range_inclusive::<1, 128, _>")] + pub client_name: String, + /// User defined string for use in debugging or User Interface. + pub report_name: Option, + /// A list of reportPayloadDescriptors. + /// + /// An optional list of objects that provide context to payload types. + #[validate(nested)] + pub payload_descriptors: Option>, + /// A list of objects containing report data for a set of resources. + pub resources: Vec, +} + +impl ReportContent { + pub fn with_client_name(mut self, client_name: &str) -> Self { + self.client_name = client_name.to_string(); + self + } + + pub fn with_name(mut self, name: &str) -> Self { + self.report_name = Some(name.to_string()); + self + } + + pub fn with_payload_descriptors(mut self, descriptors: Vec) -> Self { + self.payload_descriptors = Some(descriptors); + self + } + + pub fn with_resources(mut self, resources: Vec) -> Self { + self.resources = resources; + self + } +} + +/// URL safe VTN assigned object ID +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Hash, Eq)] +pub struct ReportId(pub(crate) Identifier); + +impl ReportId { + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl Display for ReportId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for ReportId { + type Err = IdentifierError; + + fn from_str(s: &str) -> Result { + Ok(Self(s.parse()?)) + } +} + +/// Used as discriminator, e.g. notification.object +#[derive( + Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ReportObjectType { + #[default] + Report, +} + +/// Report data associated with a resource. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Resource { + /// User generated identifier. A value of AGGREGATED_REPORT indicates an aggregation of more + /// that one resource's data + pub resource_name: ResourceName, + /// Defines default start and durations of intervals. + #[serde(skip_serializing_if = "Option::is_none")] + pub interval_period: Option, + /// A list of interval objects. + pub intervals: Vec, +} + +impl Resource { + /// Report data associated with a resource. + pub fn new(resource_name: ResourceName, intervals: Vec) -> Resource { + Resource { + resource_name, + interval_period: None, + intervals, + } + } +} + +/// An object that may be used to request a report from a VEN. See OpenADR REST User Guide for +/// detailed description of how configure a report request. +// TODO: replace "-1 means" with proper enum +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportDescriptor { + /// Enumerated or private string signifying the nature of values. + pub payload_type: ReportType, + /// Enumerated or private string signifying the type of reading. + #[serde(default)] + pub reading_type: ReadingType, + /// Units of measure. + pub units: Option, + /// A list of valuesMap objects. + pub targets: Option, + /// True if report should aggregate results from all targeted resources. False if report includes results for each resource. + #[serde(default = "bool_false")] + pub aggregate: bool, + /// The interval on which to generate a report. -1 indicates generate report at end of last interval. + #[serde(default = "neg_one")] + pub start_interval: i32, + /// The number of intervals to include in a report. -1 indicates that all intervals are to be included. + #[serde(default = "neg_one")] + pub num_intervals: i32, + /// True indicates report on intervals preceding startInterval. False indicates report on intervals following startInterval (e.g. forecast). + #[serde(default = "bool_true")] + pub historical: bool, + /// Number of intervals that elapse between reports. -1 indicates same as numIntervals. + #[serde(default = "neg_one")] + pub frequency: i32, + /// Number of times to repeat report. 1 indicates generate one report. -1 indicates repeat indefinitely. + #[serde(default = "pos_one")] + pub repeat: i32, +} + +impl ReportDescriptor { + /// An object that may be used to request a report from a VEN. See OpenADR REST User Guide for detailed description of how configure a report request. + pub fn new(payload_type: ReportType) -> Self { + Self { + payload_type, + reading_type: ReadingType::default(), + units: None, + targets: None, + aggregate: false, + start_interval: -1, + num_intervals: -1, + historical: true, + frequency: -1, + repeat: 1, + } + } +} + +fn bool_false() -> bool { + false +} + +fn bool_true() -> bool { + true +} + +fn neg_one() -> i32 { + -1 +} + +fn pos_one() -> i32 { + 1 +} + +/// Contextual information used to interpret report payload values. E.g. a USAGE payload simply +/// contains a usage value, an associated descriptor provides necessary context such as units and +/// data quality. +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct ReportPayloadDescriptor { + /// Enumerated or private string signifying the nature of values. + pub payload_type: ReportType, + /// Enumerated or private string signifying the type of reading. + #[serde(skip_serializing_if = "ReadingType::is_default", default)] + pub reading_type: ReadingType, + /// Units of measure. + pub units: Option, + /// A quantification of the accuracy of a set of payload values. + pub accuracy: Option, + /// A quantification of the confidence in a set of payload values. + #[validate(range(min = Confidence(0), max = Confidence(100)))] + pub confidence: Option, +} + +impl ReportPayloadDescriptor { + pub fn new(payload_type: ReportType) -> Self { + Self { + payload_type, + reading_type: Default::default(), + units: None, + accuracy: None, + confidence: None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, PartialOrd)] +pub struct Confidence(u8); + +impl ValidateRange for Confidence { + fn greater_than(&self, _: Confidence) -> Option { + None + } + + fn less_than(&self, _: Confidence) -> Option { + None + } +} + +/// An object defining a temporal window and a list of valuesMaps. if intervalPeriod present may set +/// temporal aspects of interval or override event.intervalPeriod. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[skip_serializing_none] +#[serde(rename_all = "camelCase")] +pub struct ReportInterval { + /// A client generated number assigned an interval object. Not a sequence number. + pub id: i32, + /// Defines default start and durations of intervals. + pub interval_period: Option, + /// A list of valuesMap objects. + pub payloads: Vec, +} + +impl ReportInterval { + pub fn new(id: i32, payloads: Vec) -> Self { + Self { + id, + interval_period: None, + payloads, + } + } +} + +/// Represents one or more values associated with a type. E.g. a type of PRICE contains a single float value. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ReportValuesMap { + /// Enumerated or private string signifying the nature of values. E.G. \"PRICE\" indicates value is to be interpreted as a currency. + #[serde(rename = "type")] + pub value_type: ReportType, + /// A list of data points. Most often a singular value such as a price. + // TODO: The type of Value is actually defined by value_type + pub values: Vec, +} + +#[cfg(test)] +mod tests { + use crate::{ + values_map::{Value, ValueType, ValuesMap}, + Duration, + }; + + use super::*; + + #[test] + fn test_report_type_serialization() { + assert_eq!( + serde_json::to_string(&ReportType::Baseline).unwrap(), + r#""BASELINE""# + ); + assert_eq!( + serde_json::to_string(&ReportType::RegulationSetpoint).unwrap(), + r#""REGULATION_SETPOINT""# + ); + assert_eq!( + serde_json::to_string(&ReportType::Private(String::from("something else"))).unwrap(), + r#""something else""# + ); + assert_eq!( + serde_json::from_str::(r#""DEMAND""#).unwrap(), + ReportType::Demand + ); + assert_eq!( + serde_json::from_str::(r#""EXPORT_RESERVATION_FEE""#).unwrap(), + ReportType::ExportReservationFee + ); + assert_eq!( + serde_json::from_str::(r#""something else""#).unwrap(), + ReportType::Private(String::from("something else")) + ); + + assert!(serde_json::from_str::(r#""""#).is_err()); + assert!(serde_json::from_str::(&format!("\"{}\"", "x".repeat(129))).is_err()); + } + + #[test] + fn test_reading_type_serialization() { + assert_eq!( + serde_json::to_string(&ReadingType::DirectRead).unwrap(), + r#""DIRECT_READ""# + ); + assert_eq!( + serde_json::to_string(&ReadingType::Private(String::from("something else"))).unwrap(), + r#""something else""# + ); + assert_eq!( + serde_json::from_str::(r#""AVERAGE""#).unwrap(), + ReadingType::Average + ); + assert_eq!( + serde_json::from_str::(r#""something else""#).unwrap(), + ReadingType::Private(String::from("something else")) + ); + } + + #[test] + fn descriptor_parses_minimal() { + let json = r#"{"payloadType":"hello"}"#; + let expected = ReportDescriptor::new(ReportType::Private("hello".into())); + + assert_eq!( + serde_json::from_str::(json).unwrap(), + expected + ); + } + + #[test] + fn parses_minimal_report() { + let example = r#"{"programID":"p1","eventID":"e1","clientName":"c","resources":[]}"#; + let expected = ReportContent { + object_type: None, + program_id: ProgramId("p1".parse().unwrap()), + event_id: EventId("e1".parse().unwrap()), + client_name: "c".to_string(), + report_name: None, + payload_descriptors: None, + resources: vec![], + }; + + assert_eq!( + serde_json::from_str::(example).unwrap(), + expected + ); + } + + #[test] + fn test_resource_name_serialization() { + assert_eq!( + serde_json::to_string(&ResourceName::AggregatedReport).unwrap(), + r#""AGGREGATED_REPORT""# + ); + assert_eq!( + serde_json::to_string(&ResourceName::Private(String::from("something else"))).unwrap(), + r#""something else""# + ); + assert_eq!( + serde_json::from_str::(r#""AGGREGATED_REPORT""#).unwrap(), + ResourceName::AggregatedReport + ); + assert_eq!( + serde_json::from_str::(r#""something else""#).unwrap(), + ResourceName::Private(String::from("something else")) + ); + + assert!(serde_json::from_str::(r#""""#).is_err()); + assert!(serde_json::from_str::(&format!("\"{}\"", "x".repeat(129))).is_err()); + } + + #[test] + fn parses_example() { + let example = r#"[{ + "id": "object-999", + "createdDateTime": "2023-06-15T09:30:00Z", + "modificationDateTime": "2023-06-15T09:30:00Z", + "objectType": "REPORT", + "programID": "object-999", + "eventID": "object-999", + "clientName": "VEN-999", + "reportName": "Battery_usage_04112023", + "payloadDescriptors": null, + "resources": [ + { + "resourceName": "RESOURCE-999", + "intervalPeriod": { + "start": "2023-06-15T09:30:00Z", + "duration": "PT1H", + "randomizeStart": "PT1H" + }, + "intervals": [ + { + "id": 0, + "intervalPeriod": { + "start": "2023-06-15T09:30:00Z", + "duration": "PT1H", + "randomizeStart": "PT1H" + }, + "payloads": [ + { + "type": "PRICE", + "values": [0.17] + } + ] + } + ] + } + ] + }]"#; + + let expected = Report { + id: ReportId("object-999".parse().unwrap()), + created_date_time: "2023-06-15T09:30:00Z".parse().unwrap(), + modification_date_time: "2023-06-15T09:30:00Z".parse().unwrap(), + content: ReportContent { + object_type: Some(ReportObjectType::Report), + program_id: ProgramId("object-999".parse().unwrap()), + event_id: EventId("object-999".parse().unwrap()), + client_name: "VEN-999".into(), + report_name: Some("Battery_usage_04112023".into()), + payload_descriptors: None, + resources: vec![Resource { + resource_name: ResourceName::Private("RESOURCE-999".into()), + interval_period: Some(IntervalPeriod { + start: "2023-06-15T09:30:00Z".parse().unwrap(), + duration: Some(Duration::PT1H), + randomize_start: Some(Duration::PT1H), + }), + intervals: vec![Interval { + id: 0, + interval_period: Some(IntervalPeriod { + start: "2023-06-15T09:30:00Z".parse().unwrap(), + duration: Some(Duration::PT1H), + randomize_start: Some(Duration::PT1H), + }), + payloads: vec![ValuesMap { + value_type: ValueType("PRICE".into()), + values: vec![Value::Number(0.17)], + }], + }], + }], + }, + }; + + assert_eq!( + serde_json::from_str::>(example).unwrap()[0], + expected + ); + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ReportType { + Reading, + Usage, + Demand, + Setpoint, + DeltaUsage, + Baseline, + OperatingState, + UpRegulationAvailable, + DownRegulationAvailable, + RegulationSetpoint, + StorageUsableCapacity, + StorageChargeLevel, + StorageMaxDischargePower, + StorageMaxChargePower, + SimpleLevel, + UsageForecast, + StorageDispatchForecast, + LoadShedDeltaAvailable, + GenerationDeltaAvailable, + DataQuality, + ImportReservationCapacity, + ImportReservationFee, + ExportReservationCapacity, + ExportReservationFee, + #[serde(untagged)] + Private( + #[serde(deserialize_with = "crate::string_within_range_inclusive::<1, 128, _>")] String, + ), +} + +#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ReadingType { + #[default] + DirectRead, + Estimated, + Summed, + Mean, + Peak, + Forecast, + Average, + #[serde(untagged)] + Private(String), +} + +impl ReadingType { + fn is_default(&self) -> bool { + *self == Self::default() + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ResourceName { + AggregatedReport, + #[serde(untagged)] + Private( + #[serde(deserialize_with = "crate::string_within_range_inclusive::<1, 128, _>")] String, + ), +} diff --git a/openadr-wire/src/resource.rs b/openadr-wire/src/resource.rs new file mode 100644 index 0000000..ccea722 --- /dev/null +++ b/openadr-wire/src/resource.rs @@ -0,0 +1,78 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::{fmt::Display, str::FromStr}; +use validator::Validate; + +use crate::{values_map::ValuesMap, ven::VenId, Identifier, IdentifierError}; + +/// A resource is an energy device or system subject to control by a VEN. +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct Resource { + /// URL safe VTN assigned object ID. + pub id: ResourceId, + /// datetime in ISO 8601 format + #[serde(with = "crate::serde_rfc3339")] + pub created_date_time: DateTime, + /// datetime in ISO 8601 format + #[serde(with = "crate::serde_rfc3339")] + pub modification_date_time: DateTime, + /// URL safe VTN assigned object ID. + #[serde(rename = "venID")] + pub ven_id: VenId, + #[serde(flatten)] + #[validate(nested)] + pub content: ResourceContent, +} + +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct ResourceContent { + /// Used as discriminator, e.g. notification.object + pub object_type: Option, + /// User generated identifier, resource may be configured with identifier out-of-band. + #[serde(deserialize_with = "crate::string_within_range_inclusive::<1, 128, _>")] + pub resource_name: String, + /// A list of valuesMap objects describing attributes. + pub attributes: Option>, + /// A list of valuesMap objects describing target criteria. + pub targets: Option>, +} + +/// Used as discriminator, e.g. notification.object +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ObjectType { + #[default] + Resource, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Hash, Eq)] +pub struct ResourceId(pub(crate) Identifier); + +impl Display for ResourceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl ResourceId { + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn new(identifier: &str) -> Option { + Some(Self(identifier.parse().ok()?)) + } +} + +impl FromStr for ResourceId { + type Err = IdentifierError; + + fn from_str(s: &str) -> Result { + Ok(Self(s.parse()?)) + } +} diff --git a/openadr-wire/src/target.rs b/openadr-wire/src/target.rs new file mode 100644 index 0000000..56197c7 --- /dev/null +++ b/openadr-wire/src/target.rs @@ -0,0 +1,86 @@ +//! Types to filter resources + +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct TargetMap(pub Vec); + +// TODO: Handle strong typing of values +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TargetEntry { + #[serde(rename = "type")] + pub label: TargetLabel, + pub values: [String; 1], +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum TargetLabel { + /// A Power Service Location is a utility named specific location in + /// geography or the distribution system, usually the point of service to a + /// customer site. + PowerServiceLocation, + /// A Service Area is a utility named geographic region. + ServiceArea, + /// Targeting a specific group (string). + Group, + /// Targeting a specific resource (string). + ResourceName, + /// Targeting a specific VEN (string). + #[serde(rename = "VEN_NAME")] + VENName, + /// Targeting a specific event (string). + EventName, + /// Targeting a specific program (string). + ProgramName, + /// An application specific privately defined target. + #[serde(untagged)] + Private(String), +} + +impl TargetLabel { + pub fn as_str(&self) -> &str { + match self { + TargetLabel::PowerServiceLocation => "POWER_SERVICE_LOCATION", + TargetLabel::ServiceArea => "SERVICE_AREA", + TargetLabel::Group => "GROUP", + TargetLabel::ResourceName => "RESOURCE_NAME", + TargetLabel::VENName => "VEN_NAME", + TargetLabel::EventName => "EVENT_NAME", + TargetLabel::ProgramName => "PROGRAM_NAME", + TargetLabel::Private(s) => s.as_str(), + } + } +} + +impl Display for TargetLabel { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_target_serialization() { + assert_eq!( + serde_json::to_string(&TargetLabel::EventName).unwrap(), + r#""EVENT_NAME""# + ); + assert_eq!( + serde_json::to_string(&TargetLabel::Private(String::from("something else"))).unwrap(), + r#""something else""# + ); + assert_eq!( + serde_json::from_str::(r#""VEN_NAME""#).unwrap(), + TargetLabel::VENName + ); + assert_eq!( + serde_json::from_str::(r#""something else""#).unwrap(), + TargetLabel::Private(String::from("something else")) + ); + } +} diff --git a/openadr-wire/src/values_map.rs b/openadr-wire/src/values_map.rs new file mode 100644 index 0000000..4c67bef --- /dev/null +++ b/openadr-wire/src/values_map.rs @@ -0,0 +1,59 @@ +//! Helper types to realize type values relations + +use serde::{Deserialize, Serialize}; + +/// ValuesMap : Represents one or more values associated with a type. E.g. a type of PRICE contains a single float value. + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ValuesMap { + /// Enumerated or private string signifying the nature of values. E.G. \"PRICE\" indicates value is to be interpreted as a currency. + #[serde(rename = "type")] + pub value_type: ValueType, + /// A list of data points. Most often a singular value such as a price. + pub values: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ValueType( + #[serde(deserialize_with = "crate::string_within_range_inclusive::<1, 128, _>")] pub String, +); + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Value { + Integer(i64), + Number(f64), + Boolean(bool), + Point(Point), + String(String), +} + +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Integer(s), Self::Integer(o)) => s == o, + (Self::Boolean(s), Self::Boolean(o)) => s == o, + (Self::Point(s), Self::Point(o)) => s == o, + (Self::String(s), Self::String(o)) => s == o, + (Self::Number(s), Self::Number(o)) if s.is_nan() && o.is_nan() => true, + (Self::Number(s), Self::Number(o)) => s == o, + _ => false, + } + } +} + +impl Eq for Value {} + +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct Point { + /// A value on an x axis. + pub x: f32, + /// A value on a y axis. + pub y: f32, +} + +impl Point { + pub fn new(x: f32, y: f32) -> Self { + Self { x, y } + } +} diff --git a/openadr-wire/src/ven.rs b/openadr-wire/src/ven.rs new file mode 100644 index 0000000..9568cbc --- /dev/null +++ b/openadr-wire/src/ven.rs @@ -0,0 +1,78 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::{fmt::Display, str::FromStr}; +use validator::Validate; + +use crate::{resource::Resource, values_map::ValuesMap, Identifier, IdentifierError}; + +/// Ven represents a client with the ven role. +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct Ven { + /// URL safe VTN assigned object ID. + pub id: VenId, + /// datetime in ISO 8601 format + #[serde(with = "crate::serde_rfc3339")] + pub created_date_time: DateTime, + /// datetime in ISO 8601 format + #[serde(with = "crate::serde_rfc3339")] + pub modification_date_time: DateTime, + + #[serde(flatten)] + #[validate(nested)] + pub content: VenContent, +} + +#[skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct VenContent { + /// Used as discriminator, e.g. notification.object. + pub object_type: Option, + /// User generated identifier, may be VEN identifier provisioned during program enrollment. + #[serde(deserialize_with = "crate::string_within_range_inclusive::<1, 128, _>")] + pub ven_name: String, + /// A list of valuesMap objects describing attributes. + pub attributes: Option>, + /// A list of valuesMap objects describing target criteria. + pub targets: Option>, + /// A list of resource objects representing end-devices or systems. + pub resources: Option>, +} + +/// Used as discriminator, e.g. notification.object. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ObjectType { + #[default] + Ven, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Hash, Eq, PartialOrd, Ord)] +pub struct VenId(pub(crate) Identifier); + +impl Display for VenId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl VenId { + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn new(identifier: &str) -> Option { + Some(Self(identifier.parse().ok()?)) + } +} + +impl FromStr for VenId { + type Err = IdentifierError; + + fn from_str(s: &str) -> Result { + Ok(Self(s.parse()?)) + } +} diff --git a/tests/dyn-price.oadr.yaml b/tests/dyn-price.oadr.yaml new file mode 100644 index 0000000..a48165f --- /dev/null +++ b/tests/dyn-price.oadr.yaml @@ -0,0 +1,217 @@ +programs: + - id: dp101 + programName: Dynamic Pricing 101 + +events: + - id: dp101-e0 + programID: dp101 + payloadDescriptors: + - payloadType: PRICE + currency: EUR + units: KWH + intervalPeriod: + start: 2024-01-01T00:00Z + duration: PT1H + intervals: + - id: 0 + payloads: + - type: PRICE + values: [ 0.42 ] + - id: 1 + payloads: + - type: PRICE + values: [ 0.43 ] + - id: 2 + payloads: + - type: PRICE + values: [ 0.44 ] + - id: 3 + payloads: + - type: PRICE + values: [ 0.45 ] + - id: 4 + payloads: + - type: PRICE + values: [ 0.46 ] + - id: 5 + payloads: + - type: PRICE + values: [ 0.47 ] + - id: 6 + payloads: + - type: PRICE + values: [ 0.48 ] + - id: 7 + payloads: + - type: PRICE + values: [ 0.49 ] + - id: 8 + payloads: + - type: PRICE + values: [ 0.50 ] + - id: 9 + payloads: + - type: PRICE + values: [ 0.49 ] + - id: 10 + payloads: + - type: PRICE + values: [ 0.48 ] + - id: 11 + payloads: + - type: PRICE + values: [ 0.47 ] + - id: 12 + payloads: + - type: PRICE + values: [ 0.46 ] + - id: 13 + payloads: + - type: PRICE + values: [ 0.45 ] + - id: 14 + payloads: + - type: PRICE + values: [ 0.44 ] + - id: 15 + payloads: + - type: PRICE + values: [ 0.43 ] + - id: 16 + payloads: + - type: PRICE + values: [ 0.42 ] + - id: 17 + payloads: + - type: PRICE + values: [ 0.41 ] + - id: 18 + payloads: + - type: PRICE + values: [ 0.40 ] + - id: 19 + payloads: + - type: PRICE + values: [ 0.41 ] + - id: 20 + payloads: + - type: PRICE + values: [ 0.42 ] + - id: 21 + payloads: + - type: PRICE + values: [ 0.43 ] + - id: 22 + payloads: + - type: PRICE + values: [ 0.44 ] + - id: 23 + payloads: + - type: PRICE + values: [ 0.45 ] + - id: dp101-e1 + programID: dp101 + payloadDescriptors: + - payloadType: PRICE + currency: EUR + units: KWH + intervalPeriod: + start: 2024-01-02T00:00Z + duration: PT1H + intervals: + - id: 0 + payloads: + - type: PRICE + values: [ 0.42 ] + - id: 1 + payloads: + - type: PRICE + values: [ 0.43 ] + - id: 2 + payloads: + - type: PRICE + values: [ 0.44 ] + - id: 3 + payloads: + - type: PRICE + values: [ 0.45 ] + - id: 4 + payloads: + - type: PRICE + values: [ 0.46 ] + - id: 5 + payloads: + - type: PRICE + values: [ 0.47 ] + - id: 6 + payloads: + - type: PRICE + values: [ 0.48 ] + - id: 7 + payloads: + - type: PRICE + values: [ 0.49 ] + - id: 8 + payloads: + - type: PRICE + values: [ 0.50 ] + - id: 9 + payloads: + - type: PRICE + values: [ 0.49 ] + - id: 10 + payloads: + - type: PRICE + values: [ 0.48 ] + - id: 11 + payloads: + - type: PRICE + values: [ 0.47 ] + - id: 12 + payloads: + - type: PRICE + values: [ 0.46 ] + - id: 13 + payloads: + - type: PRICE + values: [ 0.45 ] + - id: 14 + payloads: + - type: PRICE + values: [ 0.44 ] + - id: 15 + payloads: + - type: PRICE + values: [ 0.43 ] + - id: 16 + payloads: + - type: PRICE + values: [ 0.42 ] + - id: 17 + payloads: + - type: PRICE + values: [ 0.41 ] + - id: 18 + payloads: + - type: PRICE + values: [ 0.40 ] + - id: 19 + payloads: + - type: PRICE + values: [ 0.41 ] + - id: 20 + payloads: + - type: PRICE + values: [ 0.42 ] + - id: 21 + payloads: + - type: PRICE + values: [ 0.43 ] + - id: 22 + payloads: + - type: PRICE + values: [ 0.44 ] + - id: 23 + payloads: + - type: PRICE + values: [ 0.45 ] \ No newline at end of file diff --git a/tests/load-sched.oadr.yaml b/tests/load-sched.oadr.yaml new file mode 100644 index 0000000..4e9271e --- /dev/null +++ b/tests/load-sched.oadr.yaml @@ -0,0 +1,18 @@ +# Based on User Guide 8.2 + +programs: + - id: ls101 + programName: Load Shedding 101 +events: + - id: ls101-e0 + programID: ls101 + intervalPeriod: + start: 2024-01-01T13:37Z + duration: PT4H + payloadDescriptors: + - payloadType: SIMPLE + intervals: + - id: 0 + payloads: + - type: SIMPLE + values: [ 1 ] diff --git a/tests/schema.yaml b/tests/schema.yaml new file mode 100644 index 0000000..5f932a9 --- /dev/null +++ b/tests/schema.yaml @@ -0,0 +1,734 @@ +$id: "https://example.com/person.schema.json" +$schema: "https://json-schema.org/draft/2020-12/schema" +type: object +properties: + programs: + type: array + items: { $ref: '#/components/schemas/program' } + events: + type: array + items: { $ref: '#/components/schemas/event' } + reports: + type: array + items: { $ref: '#/components/schemas/report' } + +components: + schemas: + program: + type: object + description: Provides program specific metadata from VTN to VEN. + required: + - programName + properties: + id: + $ref: '#/components/schemas/objectID' + # VTN provisioned on object creation. + createdDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object creation. + modificationDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object modification. + objectType: + type: string + description: Used as discriminator, e.g. notification.object + enum: [ PROGRAM ] + # VTN provisioned on object creation. + programName: + type: string + description: Short name to uniquely identify program. + minLength: 1 + maxLength: 128 + example: ResTOU + programLongName: + type: string + description: Long name of program for human readability. + example: Residential Time of Use-A + nullable: true + default: null + retailerName: + type: string + description: Short name of energy retailer providing the program. + example: ACME + nullable: true + default: null + retailerLongName: + type: string + description: Long name of energy retailer for human readability. + example: ACME Electric Inc. + nullable: true + default: null + programType: + type: string + description: A program defined categorization. + example: PRICING_TARIFF + nullable: true + default: null + country: + type: string + description: Alpha-2 code per ISO 3166-1. + example: US + nullable: true + default: null + principalSubdivision: + type: string + description: Coding per ISO 3166-2. E.g. state in US. + example: CO + nullable: true + default: null + timeZoneOffset: + $ref: '#/components/schemas/duration' + # Number of hours different from UTC for the standard time applicable to the program. + intervalPeriod: + $ref: '#/components/schemas/intervalPeriod' + # The temporal span of the program, could be years long. + programDescriptions: + type: array + description: A list of programDescriptions + nullable: true + default: null + items: + required: + - URL + properties: + URL: + type: string + format: uri + description: A human or machine readable program description + example: www.myCorporation.com/myProgramDescription + bindingEvents: + type: boolean + description: True if events are fixed once transmitted. + example: false + default: false + localPrice: + type: boolean + description: True if events have been adapted from a grid event. + example: false + default: false + payloadDescriptors: + type: array + description: A list of payloadDescriptors. + nullable: true + default: null + items: + anyOf: + - $ref: '#/components/schemas/eventPayloadDescriptor' + - $ref: '#/components/schemas/reportPayloadDescriptor' + discriminator: + propertyName: objectType + targets: + type: array + description: A list of valuesMap objects. + nullable: true + default: null + items: + $ref: '#/components/schemas/valuesMap' + report: + type: object + description: report object. + required: + - programID + - eventID + - clientName + - resources + properties: + id: + $ref: '#/components/schemas/objectID' + # VTN provisioned on object creation. + createdDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object creation. + modificationDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object modification. + objectType: + type: string + description: Used as discriminator, e.g. notification.object + enum: [ REPORT ] + # VTN provisioned on object creation. + programID: + $ref: '#/components/schemas/objectID' + # ID attribute of program object this report is associated with. + eventID: + $ref: '#/components/schemas/objectID' + # ID attribute of event object this report is associated with. + clientName: + type: string + description: User generated identifier; may be VEN ID provisioned during program enrollment. + minLength: 1 + maxLength: 128 + example: VEN-999 + reportName: + type: string + description: User defined string for use in debugging or User Interface. + example: Battery_usage_04112023 + nullable: true + default: null + payloadDescriptors: + type: array + description: A list of reportPayloadDescriptors. + nullable: true + default: null + items: + $ref: '#/components/schemas/reportPayloadDescriptor' + # An optional list of objects that provide context to payload types. + resources: + type: array + description: A list of objects containing report data for a set of resources. + items: + type: object + description: Report data associated with a resource. + required: + - resourceName + - intervals + properties: + resourceName: + type: string + minLength: 1 + maxLength: 128 + description: User generated identifier. A value of AGGREGATED_REPORT indicates an aggregation of more that one resource's data + example: RESOURCE-999 + intervalPeriod: + $ref: '#/components/schemas/intervalPeriod' + # Defines default start and durations of intervals. + intervals: + type: array + description: A list of interval objects. + items: + $ref: '#/components/schemas/interval' + event: + type: object + description: | + Event object to communicate a Demand Response request to VEN. + If intervalPeriod is present, sets start time and duration of intervals. + required: + - programID + - intervals + properties: + id: + $ref: '#/components/schemas/objectID' + # VTN provisioned on object creation. + createdDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object creation. + modificationDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object modification. + objectType: + type: string + description: Used as discriminator, e.g. notification.object + enum: [ EVENT ] + # VTN provisioned on object creation. + programID: + $ref: '#/components/schemas/objectID' + # ID attribute of program object this event is associated with. + eventName: + type: string + description: User defined string for use in debugging or User Interface. + example: price event 11-18-2022 + nullable: true + default: null + priority: + type: integer + minimum: 0 + description: Relative priority of event. A lower number is a higher priority. + example: 0 + nullable: true + default: null + targets: + type: array + description: A list of valuesMap objects. + nullable: true + default: null + items: + $ref: '#/components/schemas/valuesMap' + reportDescriptors: + type: array + description: A list of reportDescriptor objects. Used to request reports from VEN. + nullable: true + default: null + items: + $ref: '#/components/schemas/reportDescriptor' + payloadDescriptors: + type: array + description: A list of payloadDescriptor objects. + nullable: true + default: null + items: + $ref: '#/components/schemas/eventPayloadDescriptor' + intervalPeriod: + $ref: '#/components/schemas/intervalPeriod' + # Defines default start and durations of intervals. + intervals: + type: array + description: A list of interval objects. + items: + $ref: '#/components/schemas/interval' + subscription: + type: object + description: | + An object created by a client to receive notification of operations on objects. + Clients may subscribe to be notified when a type of object is created, + updated, or deleted. + required: + - clientName + - programID + - objectOperations + properties: + id: + $ref: '#/components/schemas/objectID' + # VTN provisioned on object creation. + createdDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object creation. + modificationDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object modification. + objectType: + type: string + description: Used as discriminator, e.g. notification.object + enum: [ SUBSCRIPTION ] + # VTN provisioned on object creation. + clientName: + type: string + description: User generated identifier, may be VEN identifier provisioned during program enrollment. + minLength: 1 + maxLength: 128 + example: VEN-999 + programID: + $ref: '#/components/schemas/objectID' + # ID attribute of program object this subscription is associated with. + objectOperations: + type: array + description: list of objects and operations to subscribe to. + items: + type: object + description: object type, operations, and callbackUrl. + required: + - objects + - operations + - callbackUrl + properties: + objects: + type: array + description: list of objects to subscribe to. + items: + $ref: '#/components/schemas/objectTypes' + operations: + type: array + description: list of operations to subscribe to. + items: + type: string + description: object operation to subscribe to. + example: POST + enum: [ GET, POST, PUT, DELETE ] + callbackUrl: + type: string + format: uri + description: User provided webhook URL. + example: https://myserver.com/send/callback/here + bearerToken: + type: string + description: | + User provided token. + To avoid custom integrations, callback endpoints + should accept the provided bearer token to authenticate VTN requests. + example: NCEJGI9E8ER9802UT9HUG + nullable: true + default: null + targets: + type: array + description: A list of valuesMap objects. Used by server to filter callbacks. + nullable: true + default: null + items: + $ref: '#/components/schemas/valuesMap' + ven: + type: object + description: Ven represents a client with the ven role. + required: + - venName + properties: + id: + $ref: '#/components/schemas/objectID' + # VTN provisioned on object creation. + createdDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object creation. + modificationDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object modification. + objectType: + type: string + description: Used as discriminator, e.g. notification.object. + enum: [ VEN ] + # VTN provisioned on object creation. + venName: + type: string + description: User generated identifier, may be VEN identifier provisioned during program enrollment. + minLength: 1 + maxLength: 128 + example: VEN-999 + attributes: + type: array + description: A list of valuesMap objects describing attributes. + items: + $ref: '#/components/schemas/valuesMap' + targets: + type: array + description: A list of valuesMap objects describing target criteria. + items: + $ref: '#/components/schemas/valuesMap' + resources: + type: array + description: A list of resource objects representing end-devices or systems. + nullable: true + default: null + items: + $ref: '#/components/schemas/resource' + resource: + type: object + description: | + A resource is an energy device or system subject to control by a VEN. + required: + - resourceName + properties: + id: + $ref: '#/components/schemas/objectID' + # VTN provisioned on object creation. + createdDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object creation. + modificationDateTime: + $ref: '#/components/schemas/dateTime' + # VTN provisioned on object modification. + objectType: + type: string + description: Used as discriminator, e.g. notification.object + enum: [ RESOURCE ] + # VTN provisioned on object creation. + resourceName: + type: string + description: User generated identifier, resource may be configured with identifier out-of-band. + minLength: 1 + maxLength: 128 + example: RESOURCE-999 + venID: + $ref: '#/components/schemas/objectID' + # VTN provisioned on object creation based on path, e.g. POST <>/ven/{venID}/resources. + attributes: + type: array + description: A list of valuesMap objects describing attributes. + items: + $ref: '#/components/schemas/valuesMap' + targets: + type: array + description: A list of valuesMap objects describing target criteria. + items: + $ref: '#/components/schemas/valuesMap' + interval: + type: object + description: | + An object defining a temporal window and a list of valuesMaps. + if intervalPeriod present may set temporal aspects of interval or override event.intervalPeriod. + required: + - id + - payloads + properties: + id: + type: number + format: int32 + description: A client generated number assigned an interval object. Not a sequence number. + example: 0 + intervalPeriod: + $ref: '#/components/schemas/intervalPeriod' + # Defines default start and durations of intervals. + payloads: + type: array + description: A list of valuesMap objects. + items: + $ref: '#/components/schemas/valuesMap' + intervalPeriod: + type: object + description: | + Defines temporal aspects of intervals. + A duration of default null indicates infinity. + A randomizeStart of default null indicates no randomization. + required: + - start + properties: + start: + $ref: '#/components/schemas/dateTime' + # The start time of an interval or set of intervals. + duration: + $ref: '#/components/schemas/duration' + # The duration of an interval or set of intervals. + randomizeStart: + $ref: '#/components/schemas/duration' + # Indicates a randomization time that may be applied to start. + valuesMap: + type: object + description: | + Represents one or more values associated with a type. + E.g. a type of PRICE contains a single float value. + required: + - type + - values + properties: + type: + type: string + minLength: 1 + maxLength: 128 + description: | + Enumerated or private string signifying the nature of values. + E.G. "PRICE" indicates value is to be interpreted as a currency. + example: PRICE + values: + type: array + description: A list of data points. Most often a singular value such as a price. + example: [ 0.17 ] + items: + anyOf: + - type: number + - type: integer + - type: string + - type: boolean + - $ref: '#/components/schemas/point' + point: + type: object + description: A pair of floats typically used as a point on a 2 dimensional grid. + required: + - x + - y + properties: + x: + type: number + format: float + description: A value on an x axis. + example: 1.0 + nullable: true + default: null + y: + type: number + format: float + description: A value on a y axis. + example: 2.0 + nullable: true + default: null + eventPayloadDescriptor: + type: object + description: | + Contextual information used to interpret event valuesMap values. + E.g. a PRICE payload simply contains a price value, an + associated descriptor provides necessary context such as units and currency. + required: + - payloadType + properties: + objectType: + type: string + description: Used as discriminator, e.g. program.payloadDescriptors + default: EVENT_PAYLOAD_DESCRIPTOR + payloadType: + type: string + description: Enumerated or private string signifying the nature of values. + minLength: 1 + maxLength: 128 + example: PRICE + units: + type: string + description: Units of measure. + example: KWH + nullable: true + default: null + currency: + type: string + description: Currency of price payload. + example: USD + nullable: true + default: null + reportPayloadDescriptor: + type: object + description: | + Contextual information used to interpret report payload values. + E.g. a USAGE payload simply contains a usage value, an + associated descriptor provides necessary context such as units and data quality. + required: + - payloadType + properties: + objectType: + type: string + description: Used as discriminator, e.g. program.payloadDescriptors + default: REPORT_PAYLOAD_DESCRIPTOR + payloadType: + type: string + description: Enumerated or private string signifying the nature of values. + minLength: 1 + maxLength: 128 + example: USAGE + readingType: + type: string + description: Enumerated or private string signifying the type of reading. + example: DIRECT_READ + nullable: true + default: null + units: + type: string + description: Units of measure. + example: KWH + nullable: true + default: null + accuracy: + type: number + format: float + description: A quantification of the accuracy of a set of payload values. + example: 0.0 + nullable: true + default: null + confidence: + type: integer + format: int32 + minimum: 0 + maximum: 100 + description: A quantification of the confidence in a set of payload values. + example: 100 + default: 100 + reportDescriptor: + type: object + description: | + An object that may be used to request a report from a VEN. + See OpenADR REST User Guide for detailed description of how configure a report request. + required: + - payloadType + properties: + payloadType: + type: string + description: Enumerated or private string signifying the nature of values. + minLength: 1 + maxLength: 128 + example: USAGE + readingType: + type: string + description: Enumerated or private string signifying the type of reading. + example: DIRECT_READ + nullable: true + default: null + units: + type: string + description: Units of measure. + example: KWH + nullable: true + default: null + targets: + type: array + description: A list of valuesMap objects. + nullable: true + default: null + items: + $ref: '#/components/schemas/valuesMap' + aggregate: + type: boolean + description: | + True if report should aggregate results from all targeted resources. + False if report includes results for each resource. + example: false + default: false + startInterval: + type: integer + format: int32 + description: | + The interval on which to generate a report. + -1 indicates generate report at end of last interval. + example: -1 + default: -1 + numIntervals: + type: integer + format: int32 + description: | + The number of intervals to include in a report. + -1 indicates that all intervals are to be included. + example: -1 + default: -1 + historical: + type: boolean + description: | + True indicates report on intervals preceding startInterval. + False indicates report on intervals following startInterval (e.g. forecast). + example: true + default: true + frequency: + type: integer + format: int32 + description: | + Number of intervals that elapse between reports. + -1 indicates same as numIntervals. + example: -1 + default: -1 + repeat: + type: integer + format: int32 + description: | + Number of times to repeat report. + 1 indicates generate one report. + -1 indicates repeat indefinitely. + example: 1 + default: 1 + objectID: + type: string + pattern: '^[a-zA-Z0-9_-]*$' + minLength: 1 + maxLength: 128 + description: URL safe VTN assigned object ID. + example: object-999 + notification: + type: object + description: | + VTN generated object included in request to subscription callbackUrl. + required: + - objectType + - operation + - object + properties: + objectType: + $ref: '#/components/schemas/objectTypes' + operation: + type: string + description: the operation on on object that triggered the notification. + example: POST + enum: [ GET, POST, PUT, DELETE ] + targets: + type: array + description: A list of valuesMap objects. + nullable: true + default: null + items: + $ref: '#/components/schemas/valuesMap' + object: + type: object + description: the object that is the subject of the notification. + example: { } + oneOf: + - $ref: '#/components/schemas/program' + - $ref: '#/components/schemas/report' + - $ref: '#/components/schemas/event' + - $ref: '#/components/schemas/subscription' + - $ref: '#/components/schemas/ven' + - $ref: '#/components/schemas/resource' + discriminator: + propertyName: objectType + objectTypes: + type: string + description: Types of objects addressable through API. + example: EVENT + enum: [ PROGRAM, EVENT, REPORT, SUBSCRIPTION, VEN, RESOURCE ] + dateTime: + type: string + format: date-time + description: datetime in ISO 8601 format + example: 2023-06-15T09:30:00Z + duration: + type: string + pattern: /^(-?)P(?=\d|T\d)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)([DW]))?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/ + description: duration in ISO 8601 format + example: PT1H + default: PT0S \ No newline at end of file diff --git a/tests/state-of-charge.oadr.yaml b/tests/state-of-charge.oadr.yaml new file mode 100644 index 0000000..716f3a6 --- /dev/null +++ b/tests/state-of-charge.oadr.yaml @@ -0,0 +1,42 @@ +# Based on User Guide 8.6 +programs: + - id: sof101 + programName: State of Charge 101 + +events: + - id: sof101-e1 + programID: sof101 + intervalPeriod: + start: 2024-01-01T00:00Z + reportDescriptors: + - payloadType: STORAGE_USABLE_CAPACITY + units: KWH + - payloadType: STORAGE_CHARGE_LEVEL + units: PERCENT + - payloadType: STORAGE_MAX_DISCHARGE_POWER + units: KW + - payloadType: STORAGE_MAX_CHARGE_POWER + units: KW + intervals: [ ] + +reports: + - reportName: State of Charge + programID: sof101 + eventID: sod101-e1 + clientName: bat0 + resources: + - resourceName: AGGREGATED_REPORT + intervalPeriod: + start: 2024-01-01T00:00Z + duration: PT0S + intervals: + - id: 0 + payloads: + - type: STORAGE_USABLE_CAPACITY + values: [ 100 ] + - type: STORAGE_CHARGE_LEVEL + values: [ 42 ] + - type: STORAGE_MAX_DISCHARGE_POWER + values: [ 25 ] + - type: STORAGE_MAX_CHARGE_POWER + values: [ 15 ] \ No newline at end of file diff --git a/vtn.Dockerfile b/vtn.Dockerfile new file mode 100644 index 0000000..2bba036 --- /dev/null +++ b/vtn.Dockerfile @@ -0,0 +1,15 @@ +FROM rust:1.80 as builder + +ADD . /app +WORKDIR /app +RUN cargo build --release --bin openadr-vtn + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends curl && apt-get clean + +EXPOSE 3000 +COPY --from=builder /app/target/release/openadr-vtn /opt/openadr/ +WORKDIR /opt/openadr + +ENTRYPOINT ["./openadr-vtn"]