From 5cb7f1cbb1d51ef6ef2695a293b7f0c143d72db2 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 23 May 2026 23:40:04 +0200 Subject: [PATCH 01/14] Polish docs, examples, and test coverage Remove stale local tools and generated-client app scaffolding, update package docs and CI, simplify examples, and move HTTP/WebSocket coverage toward in-memory test harnesses. --- .claude/commands/commit.md | 12 - .claude/commands/execute.md | 63 - .claude/commands/ideate.md | 50 - .claude/commands/prime_ui.md | 3 - .claude/commands/retro.md | 9 - .claude/commands/smalltest.md | 4 - .claude/commands/update_docs.md | 4 - .github/workflows/ci.yml | 194 +- .gitignore | 22 +- CHANGELOG.md | 228 +- Cargo.lock | 1420 ++----- Cargo.toml | 40 +- LICENSE-APACHE | 161 + LICENSE-MIT | 21 + README.md | 288 +- agent-research/rust-api-to-ts-client.md | 233 -- crates/core/ras-auth-core/Cargo.toml | 8 +- crates/core/ras-auth-core/README.md | 72 +- crates/core/ras-auth-core/src/lib.rs | 169 + crates/core/ras-identity-core/Cargo.toml | 8 +- crates/core/ras-identity-core/README.md | 114 +- crates/core/ras-identity-core/src/lib.rs | 95 + crates/core/ras-observability-core/Cargo.toml | 9 +- crates/core/ras-observability-core/README.md | 9 +- crates/core/ras-observability-core/src/lib.rs | 9 + .../core/ras-observability-core/src/tests.rs | 10 +- crates/core/ras-version-core/Cargo.toml | 6 +- crates/core/ras-version-core/README.md | 43 + crates/core/ras-version-core/src/lib.rs | 178 + crates/identity/ras-identity-local/Cargo.toml | 13 +- crates/identity/ras-identity-local/README.md | 121 +- crates/identity/ras-identity-local/src/lib.rs | 209 +- .../identity/ras-identity-oauth2/Cargo.toml | 15 +- crates/identity/ras-identity-oauth2/README.md | 66 +- .../examples/google_oauth2.rs | 13 +- .../ras-identity-oauth2/src/client.rs | 477 ++- .../identity/ras-identity-oauth2/src/lib.rs | 2 +- .../ras-identity-oauth2/src/provider.rs | 194 +- .../identity/ras-identity-oauth2/src/tests.rs | 274 +- .../identity/ras-identity-session/Cargo.toml | 16 +- .../identity/ras-identity-session/README.md | 225 +- .../identity/ras-identity-session/src/lib.rs | 306 +- .../ras-observability-otel/Cargo.toml | 11 +- .../ras-observability-otel/README.md | 58 +- .../examples/with_rest_service.rs | 14 +- .../ras-observability-otel/src/lib.rs | 10 +- .../ras-observability-otel/src/tests.rs | 2 +- .../tests/integration.rs | 62 +- crates/rest/ras-file-macro/Cargo.toml | 9 +- crates/rest/ras-file-macro/README.md | 42 + .../rest/ras-file-macro/benches/streaming.rs | 55 +- crates/rest/ras-file-macro/src/lib.rs | 9 +- crates/rest/ras-file-macro/src/lib_debug.rs | 17 - crates/rest/ras-file-macro/src/openapi.rs | 5 +- crates/rest/ras-file-macro/src/parser.rs | 210 +- .../rest/ras-file-macro/tests/debug_test.rs | 13 - crates/rest/ras-file-macro/tests/e2e.rs | 100 +- .../rest/ras-file-macro/tests/expand_test.rs | 11 - .../rest/ras-file-macro/tests/minimal_test.rs | 3 +- .../rest/ras-file-macro/tests/paren_test.rs | 19 +- .../rest/ras-file-macro/tests/simple_test.rs | 29 +- .../rest/ras-file-macro/tests/support/mod.rs | 52 + crates/rest/ras-rest-core/Cargo.toml | 10 +- crates/rest/ras-rest-core/README.md | 36 + crates/rest/ras-rest-core/src/lib.rs | 34 +- crates/rest/ras-rest-macro/Cargo.toml | 23 +- crates/rest/ras-rest-macro/README.md | 49 +- .../rest/ras-rest-macro/benches/dispatch.rs | 27 +- .../src/api_explorer_template.html | 10 +- crates/rest/ras-rest-macro/src/lib.rs | 16 +- crates/rest/ras-rest-macro/src/openapi.rs | 7 +- crates/rest/ras-rest-macro/tests/e2e.rs | 270 +- .../ras-rest-macro/tests/http_integration.rs | 578 ++- .../rest/ras-rest-macro/tests/support/mod.rs | 52 + .../tests/xss_protection_test.rs | 8 +- .../Cargo.toml | 50 +- .../README.md | 99 +- .../examples/bidirectional_client_usage.rs | 81 +- .../src/client.rs | 583 ++- .../src/config.rs | 25 +- .../src/error.rs | 2 +- .../src/lib.rs | 32 +- .../src/native.rs | 149 +- .../src/wasm.rs | 4 +- .../Cargo.toml | 19 +- .../ras-jsonrpc-bidirectional-macro/README.md | 198 +- .../benches/roundtrip.rs | 143 +- .../src/client.rs | 5 +- .../src/lib.rs | 29 +- .../src/server.rs | 5 +- .../src/tests.rs | 93 +- .../tests/bidirectional_integration.rs | 973 ----- .../tests/e2e.rs | 287 +- .../tests/integration.rs | 9 +- .../tests/macro_compilation.rs | 32 +- .../tests/websocket_integration.rs | 16 +- .../Cargo.toml | 14 +- .../README.md | 50 +- .../src/handler.rs | 570 ++- .../src/manager.rs | 7 +- .../src/service.rs | 219 +- .../src/upgrade.rs | 403 +- .../tests/manager_unit.rs | 15 +- .../Cargo.toml | 18 +- .../ras-jsonrpc-bidirectional-types/README.md | 19 +- .../src/lib.rs | 4 +- .../src/manager.rs | 113 +- .../src/sender.rs | 18 +- crates/rpc/ras-jsonrpc-core/Cargo.toml | 12 +- crates/rpc/ras-jsonrpc-core/README.md | 170 +- crates/rpc/ras-jsonrpc-core/src/lib.rs | 184 + crates/rpc/ras-jsonrpc-macro/Cargo.toml | 17 +- crates/rpc/ras-jsonrpc-macro/README.md | 124 +- .../rpc/ras-jsonrpc-macro/benches/dispatch.rs | 40 +- .../examples/comprehensive_demo.rs | 26 +- .../examples/explorer_params_demo.rs | 4 +- .../examples/missing_handler_demo.rs | 8 +- .../examples/openrpc_demo.rs | 2 +- crates/rpc/ras-jsonrpc-macro/src/lib.rs | 4 +- .../ras-jsonrpc-macro/src/static_hosting.rs | 57 +- crates/rpc/ras-jsonrpc-macro/tests/e2e.rs | 180 +- .../tests/error_sanitization_test.rs | 153 +- .../ras-jsonrpc-macro/tests/explorer_test.rs | 73 +- .../tests/explorer_token_storage_test.rs | 8 +- .../tests/http_integration.rs | 179 +- .../ras-jsonrpc-macro/tests/integration.rs | 5 +- .../ras-jsonrpc-macro/tests/support/mod.rs | 52 + crates/rpc/ras-jsonrpc-types/Cargo.toml | 8 +- crates/rpc/ras-jsonrpc-types/README.md | 25 +- crates/rpc/ras-jsonrpc-types/src/lib.rs | 61 + crates/specs/openrpc-types/Cargo.toml | 12 +- crates/specs/openrpc-types/README.md | 43 +- crates/specs/openrpc-types/src/components.rs | 107 +- .../openrpc-types/src/content_descriptor.rs | 6 +- crates/specs/openrpc-types/src/error.rs | 131 +- .../specs/openrpc-types/src/error_object.rs | 4 +- crates/specs/openrpc-types/src/example.rs | 89 +- crates/specs/openrpc-types/src/extensions.rs | 69 +- .../specs/openrpc-types/src/external_docs.rs | 4 +- crates/specs/openrpc-types/src/info.rs | 12 +- crates/specs/openrpc-types/src/lib.rs | 8 +- crates/specs/openrpc-types/src/link.rs | 4 +- crates/specs/openrpc-types/src/method.rs | 71 +- crates/specs/openrpc-types/src/openrpc.rs | 98 +- crates/specs/openrpc-types/src/schema.rs | 230 +- crates/specs/openrpc-types/src/server.rs | 8 +- crates/specs/openrpc-types/src/tag.rs | 4 +- crates/test-utils/ras-test-helpers/Cargo.toml | 12 - .../test-utils/ras-test-helpers/src/auth.rs | 133 - crates/test-utils/ras-test-helpers/src/lib.rs | 11 - .../test-utils/ras-test-helpers/src/server.rs | 40 - crates/tools/openrpc-to-bruno/Cargo.toml | 36 - crates/tools/openrpc-to-bruno/README.md | 172 - crates/tools/openrpc-to-bruno/src/bruno.rs | 310 -- crates/tools/openrpc-to-bruno/src/cli.rs | 66 - .../tools/openrpc-to-bruno/src/converter.rs | 391 -- crates/tools/openrpc-to-bruno/src/error.rs | 51 - crates/tools/openrpc-to-bruno/src/lib.rs | 4 - crates/tools/openrpc-to-bruno/src/main.rs | 25 - .../tests/fixtures/googal/create_document.bru | 30 - .../tests/fixtures/googal/delete_document.bru | 30 - .../fixtures/googal/environments/default.bru | 5 - .../fixtures/googal/get_beta_features.bru | 30 - .../fixtures/googal/get_system_status.bru | 30 - .../tests/fixtures/googal/get_user_info.bru | 30 - .../tests/fixtures/googal/list_documents.bru | 30 - .../tests/fixtures/google-oauth2.openrpc.json | 476 --- .../tests/fixtures/simple-api-basic.json | 43 - .../openrpc-to-bruno/tests/integration.rs | 220 - deny.toml | 79 +- docs_and_help/llm-txt/dwind_dominator.md | 113 - documentation/ras-file-macro.md | 419 +- documentation/ras-identity.md | 299 +- documentation/ras-observability.md | 146 +- documentation/ras-rest-macro.md | 471 ++- examples/README.md | 106 +- examples/basic-jsonrpc/README.md | 27 + examples/basic-jsonrpc/api/Cargo.toml | 18 +- examples/basic-jsonrpc/api/README.md | 29 + examples/basic-jsonrpc/api/src/lib.rs | 172 + examples/basic-jsonrpc/service/Cargo.toml | 16 +- examples/basic-jsonrpc/service/README.md | 245 +- examples/basic-jsonrpc/service/src/main.rs | 242 +- examples/bidirectional-chat/README.md | 89 +- examples/bidirectional-chat/api/Cargo.toml | 25 +- examples/bidirectional-chat/api/README.md | 37 + examples/bidirectional-chat/api/src/auth.rs | 118 + examples/bidirectional-chat/api/src/lib.rs | 111 + examples/bidirectional-chat/server/Cargo.toml | 38 +- examples/bidirectional-chat/server/README.md | 114 + .../server/chat_data/messages/General.jsonl | 1 - .../server/config.example.toml | 17 +- .../server/src/bin/test_config.rs | 63 - .../bidirectional-chat/server/src/config.rs | 23 +- .../bidirectional-chat/server/src/main.rs | 1375 ++++++- .../server/src/persistence.rs | 35 +- .../bidirectional-chat/server/tests/README.md | 97 +- ...ocket_tests.rs => auth_lifecycle_tests.rs} | 230 +- .../server/tests/server_tests.rs | 162 +- examples/bidirectional-chat/tui/Cargo.toml | 11 +- examples/bidirectional-chat/tui/README.md | 31 +- examples/bidirectional-chat/tui/src/app.rs | 273 +- examples/bidirectional-chat/tui/src/auth.rs | 76 +- examples/bidirectional-chat/tui/src/avatar.rs | 27 - examples/bidirectional-chat/tui/src/main.rs | 583 ++- examples/bidirectional-chat/tui/src/ui.rs | 96 +- examples/file-service-example/Cargo.toml | 16 +- examples/file-service-example/README.md | 62 + examples/file-service-example/src/main.rs | 174 +- examples/file-service-wasm/README.md | 114 +- .../file-service-api/Cargo.toml | 17 +- .../file-service-api/README.md | 28 + .../file-service-api/package.json | 19 - .../file-service-api/src/lib.rs | 159 +- .../file-service-api/src/test_openapi.rs | 11 - .../file-service-backend/Cargo.toml | 27 +- .../file-service-backend/README.md | 78 +- .../file-service-backend/src/file_service.rs | 90 + .../file-service-backend/src/simple_auth.rs | 33 +- .../file-service-backend/src/storage.rs | 95 +- .../typescript-example/.gitignore | 18 +- .../typescript-example/README.md | 88 +- .../typescript-example/index.html | 13 - .../typescript-example/openapi-ts.config.ts | 10 - .../typescript-example/package-lock.json | 3608 ----------------- .../typescript-example/package.json | 26 - .../typescript-example/postcss.config.js | 6 - .../typescript-example/src/App.tsx | 134 - .../src/components/AuthForm.tsx | 81 - .../src/components/FileList.tsx | 127 - .../src/components/FileUpload.tsx | 140 - .../typescript-example/src/example.ts | 129 +- .../typescript-example/src/fileClient.ts | 91 - .../src/generated/.gitignore | 1 - .../typescript-example/src/index.css | 3 - .../typescript-example/src/index.tsx | 14 - .../typescript-example/src/lib/client.ts | 77 - .../typescript-example/src/stores/auth.ts | 31 - .../typescript-example/tailwind.config.js | 11 - .../typescript-example/vite.config.ts | 20 - examples/oauth2-demo/README.md | 34 + examples/oauth2-demo/api/Cargo.toml | 18 +- examples/oauth2-demo/api/README.md | 36 + examples/oauth2-demo/api/src/lib.rs | 209 + examples/oauth2-demo/server/.env.example | 6 +- examples/oauth2-demo/server/Cargo.toml | 27 +- examples/oauth2-demo/server/README.md | 86 +- examples/oauth2-demo/server/build.rs | 22 +- .../server/examples/test_openrpc.rs | 19 - examples/oauth2-demo/server/src/main.rs | 82 +- .../oauth2-demo/server/src/permissions.rs | 19 +- .../oauth2-demo/server/static/api-docs.html | 64 +- examples/oauth2-demo/server/static/index.html | 30 +- .../oauth2-demo/server/static/success.html | 38 +- examples/rest-wasm-example/README.md | 141 +- .../rest-wasm-example/rest-api/Cargo.toml | 30 +- examples/rest-wasm-example/rest-api/README.md | 46 + .../rest-wasm-example/rest-api/src/lib.rs | 200 +- .../rest-wasm-example/rest-backend/Cargo.toml | 19 +- .../rest-wasm-example/rest-backend/README.md | 77 + .../rest-wasm-example/rest-backend/build.rs | 4 +- .../rest-backend/src/main.rs | 266 +- .../rest-backend/src/simple_auth.rs | 45 + .../typescript-example/.gitignore | 7 + .../typescript-example/.npmrc | 1 - .../typescript-example/README.md | 176 +- .../typescript-example/index.html | 71 - .../typescript-example/openapi-ts.config.ts | 10 - .../typescript-example/package-lock.json | 2285 ----------- .../typescript-example/package.json | 23 - .../typescript-example/src/example.ts | 72 + .../src/generated/.gitignore | 1 - .../typescript-example/src/index.tsx | 261 -- .../typescript-example/vite.config.ts | 30 - examples/wasm-ui-demo/.gitignore | 5 +- examples/wasm-ui-demo/Cargo.toml | 20 +- examples/wasm-ui-demo/README.md | 57 + examples/wasm-ui-demo/package-lock.json | 3546 ++++++---------- examples/wasm-ui-demo/package.json | 22 +- examples/wasm-ui-demo/resources/.gitkeep | 0 examples/wasm-ui-demo/rollup.config.js | 36 +- examples/wasm-ui-demo/src/lib.rs | 186 +- examples/wasm-ui-demo/vite.config.js | 9 + sketchpad/.gitignore | 1 - tests/playwright/README.md | 2 +- .../fixtures/jsonrpc-fixture/Cargo.toml | 17 +- .../fixtures/jsonrpc-fixture/README.md | 38 + .../fixtures/jsonrpc-fixture/src/main.rs | 211 +- .../fixtures/rest-fixture/Cargo.toml | 15 +- .../fixtures/rest-fixture/README.md | 41 + .../fixtures/rest-fixture/src/main.rs | 196 +- tests/playwright/package-lock.json | 3 + tests/playwright/package.json | 3 + tests/playwright/playwright.config.ts | 4 +- .../playwright/tests/jsonrpc-explorer.spec.ts | 6 +- tests/playwright/tests/rest-explorer.spec.ts | 8 +- 296 files changed, 16651 insertions(+), 19810 deletions(-) delete mode 100644 .claude/commands/commit.md delete mode 100644 .claude/commands/execute.md delete mode 100644 .claude/commands/ideate.md delete mode 100644 .claude/commands/prime_ui.md delete mode 100644 .claude/commands/retro.md delete mode 100644 .claude/commands/smalltest.md delete mode 100644 .claude/commands/update_docs.md create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT delete mode 100644 agent-research/rust-api-to-ts-client.md create mode 100644 crates/core/ras-version-core/README.md create mode 100644 crates/rest/ras-file-macro/README.md delete mode 100644 crates/rest/ras-file-macro/src/lib_debug.rs delete mode 100644 crates/rest/ras-file-macro/tests/debug_test.rs delete mode 100644 crates/rest/ras-file-macro/tests/expand_test.rs create mode 100644 crates/rest/ras-file-macro/tests/support/mod.rs create mode 100644 crates/rest/ras-rest-core/README.md create mode 100644 crates/rest/ras-rest-macro/tests/support/mod.rs delete mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/bidirectional_integration.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/tests/support/mod.rs delete mode 100644 crates/test-utils/ras-test-helpers/Cargo.toml delete mode 100644 crates/test-utils/ras-test-helpers/src/auth.rs delete mode 100644 crates/test-utils/ras-test-helpers/src/lib.rs delete mode 100644 crates/test-utils/ras-test-helpers/src/server.rs delete mode 100644 crates/tools/openrpc-to-bruno/Cargo.toml delete mode 100644 crates/tools/openrpc-to-bruno/README.md delete mode 100644 crates/tools/openrpc-to-bruno/src/bruno.rs delete mode 100644 crates/tools/openrpc-to-bruno/src/cli.rs delete mode 100644 crates/tools/openrpc-to-bruno/src/converter.rs delete mode 100644 crates/tools/openrpc-to-bruno/src/error.rs delete mode 100644 crates/tools/openrpc-to-bruno/src/lib.rs delete mode 100644 crates/tools/openrpc-to-bruno/src/main.rs delete mode 100644 crates/tools/openrpc-to-bruno/tests/fixtures/googal/create_document.bru delete mode 100644 crates/tools/openrpc-to-bruno/tests/fixtures/googal/delete_document.bru delete mode 100644 crates/tools/openrpc-to-bruno/tests/fixtures/googal/environments/default.bru delete mode 100644 crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_beta_features.bru delete mode 100644 crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_system_status.bru delete mode 100644 crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_user_info.bru delete mode 100644 crates/tools/openrpc-to-bruno/tests/fixtures/googal/list_documents.bru delete mode 100644 crates/tools/openrpc-to-bruno/tests/fixtures/google-oauth2.openrpc.json delete mode 100644 crates/tools/openrpc-to-bruno/tests/fixtures/simple-api-basic.json delete mode 100644 crates/tools/openrpc-to-bruno/tests/integration.rs delete mode 100644 docs_and_help/llm-txt/dwind_dominator.md create mode 100644 examples/basic-jsonrpc/README.md create mode 100644 examples/basic-jsonrpc/api/README.md create mode 100644 examples/bidirectional-chat/api/README.md create mode 100644 examples/bidirectional-chat/server/README.md delete mode 100644 examples/bidirectional-chat/server/chat_data/messages/General.jsonl delete mode 100644 examples/bidirectional-chat/server/src/bin/test_config.rs rename examples/bidirectional-chat/server/tests/{websocket_tests.rs => auth_lifecycle_tests.rs} (80%) create mode 100644 examples/file-service-example/README.md create mode 100644 examples/file-service-wasm/file-service-api/README.md delete mode 100644 examples/file-service-wasm/file-service-api/package.json delete mode 100644 examples/file-service-wasm/file-service-api/src/test_openapi.rs delete mode 100644 examples/file-service-wasm/typescript-example/index.html delete mode 100644 examples/file-service-wasm/typescript-example/openapi-ts.config.ts delete mode 100644 examples/file-service-wasm/typescript-example/package-lock.json delete mode 100644 examples/file-service-wasm/typescript-example/package.json delete mode 100644 examples/file-service-wasm/typescript-example/postcss.config.js delete mode 100644 examples/file-service-wasm/typescript-example/src/App.tsx delete mode 100644 examples/file-service-wasm/typescript-example/src/components/AuthForm.tsx delete mode 100644 examples/file-service-wasm/typescript-example/src/components/FileList.tsx delete mode 100644 examples/file-service-wasm/typescript-example/src/components/FileUpload.tsx delete mode 100644 examples/file-service-wasm/typescript-example/src/fileClient.ts delete mode 100644 examples/file-service-wasm/typescript-example/src/generated/.gitignore delete mode 100644 examples/file-service-wasm/typescript-example/src/index.css delete mode 100644 examples/file-service-wasm/typescript-example/src/index.tsx delete mode 100644 examples/file-service-wasm/typescript-example/src/lib/client.ts delete mode 100644 examples/file-service-wasm/typescript-example/src/stores/auth.ts delete mode 100644 examples/file-service-wasm/typescript-example/tailwind.config.js delete mode 100644 examples/file-service-wasm/typescript-example/vite.config.ts create mode 100644 examples/oauth2-demo/README.md create mode 100644 examples/oauth2-demo/api/README.md delete mode 100644 examples/oauth2-demo/server/examples/test_openrpc.rs create mode 100644 examples/rest-wasm-example/rest-api/README.md create mode 100644 examples/rest-wasm-example/rest-backend/README.md create mode 100644 examples/rest-wasm-example/typescript-example/.gitignore delete mode 100644 examples/rest-wasm-example/typescript-example/.npmrc delete mode 100644 examples/rest-wasm-example/typescript-example/index.html delete mode 100644 examples/rest-wasm-example/typescript-example/openapi-ts.config.ts delete mode 100644 examples/rest-wasm-example/typescript-example/package-lock.json delete mode 100644 examples/rest-wasm-example/typescript-example/package.json create mode 100644 examples/rest-wasm-example/typescript-example/src/example.ts delete mode 100644 examples/rest-wasm-example/typescript-example/src/generated/.gitignore delete mode 100644 examples/rest-wasm-example/typescript-example/src/index.tsx delete mode 100644 examples/rest-wasm-example/typescript-example/vite.config.ts create mode 100644 examples/wasm-ui-demo/README.md delete mode 100644 examples/wasm-ui-demo/resources/.gitkeep create mode 100644 examples/wasm-ui-demo/vite.config.js delete mode 100644 sketchpad/.gitignore create mode 100644 tests/playwright/fixtures/jsonrpc-fixture/README.md create mode 100644 tests/playwright/fixtures/rest-fixture/README.md diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md deleted file mode 100644 index 2112532..0000000 --- a/.claude/commands/commit.md +++ /dev/null @@ -1,12 +0,0 @@ -Please investigate the uncommitted changes, and create the appropriate git commits from them. Make commit messages -consist and descriptive. -Make sure you do not include files that should not go into the commit (when in doubt, ask the user if he wants to -include the file or not). -Run `cargo fmt`, `cargo test` and `cargo clippy`, and make sure everything passes before committing. - -All commit messages must contain exactly one dad-level pun in the first line of the message. - -For every commit, make sure to write a new changelog entry into CHANGELOG.md containing a description and date of the -change. - -IMPORTANT: Do NOT include any Claude attribution, "Generated with Claude Code" messages, or Co-Authored-By lines in the commit messages. Keep commits clean and professional. \ No newline at end of file diff --git a/.claude/commands/execute.md b/.claude/commands/execute.md deleted file mode 100644 index 5d99b85..0000000 --- a/.claude/commands/execute.md +++ /dev/null @@ -1,63 +0,0 @@ -You are the most efficient scrum team ever, consisting of a Coder, an Architect, and a UX Designer. You have won awards for your outstanding work in solving tasks from the sprint backlog in the best, most well-tested, and most awesome way possible. Your goal is to execute tasks from the sprint backlog while adhering to key principles of security-first development, end-to-end testing, and documentation during development. - -You will be provided with two important files: - - -TASK.md - - - -CLAUDE.md - - -First, carefully read through the TASK.md file. Identify the first incomplete day of the first incomplete sprint in the list. Select the tasks for that day, starting with the first unfinished task. - -For each task: - -1. Determine which team member(s) should handle the task based on its requirements. -2. If you need more information to proceed, ask clarifying questions to the user. Be specific about what information you need. -3. Execute the task, adhering to the following principles: - - Implement security considerations from the start - - Conduct end-to-end testing during implementation - - Create documentation alongside the code - -4. After completing each task: - - Mark it as done in the TASK.md file - - Update relevant sections of the CLAUDE.md file with any new knowledge gained - -5. Proceed to the next unfinished task until all tasks for the selected day are complete. - -When invoking a specific team member (Coder, Architect, or UX Designer), begin their response with a reminder of their role and expertise. - -After completing all tasks for the day, conduct a sprint retrospective: -1. Write an entry in the `scraim/current-sprint.md` file -2. Reflect on what went well and what could have been improved -3. Be concise, using only one or two bullet points - -Your final output should include: -1. Any clarifying questions asked (if needed) -2. A summary of tasks completed -3. Updates made to TASK.md and CLAUDE.md -4. The sprint retrospective entry - -Present your final output in the following format: - - - -[List any questions asked to the user, if any] - - - -[Summarize the tasks completed] - - - -[Describe updates made to TASK.md and CLAUDE.md] - - - -[Include the sprint retrospective entry] - - - -Remember, your output should only include the content within the tags. Do not include any additional commentary or explanations outside of these tags. \ No newline at end of file diff --git a/.claude/commands/ideate.md b/.claude/commands/ideate.md deleted file mode 100644 index e85e00d..0000000 --- a/.claude/commands/ideate.md +++ /dev/null @@ -1,50 +0,0 @@ -You are an experienced project manager and brainstorming facilitator. Your task is to engage in an interactive dialogue with a user to brainstorm and develop a comprehensive execution plan for the following idea: - - -$ARGUMENTS - - -Follow these steps to complete the task: - -1. Begin by acknowledging the idea and expressing enthusiasm for the brainstorming session. - -2. Engage in an interactive dialogue with the user. Ask open-ended questions to gather more information about the idea, its goals, potential challenges, and any specific requirements. For example: - - What are the main objectives of this idea? - - Who is the target audience or beneficiary? - - Are there any specific constraints or limitations we should consider? - - What resources (time, budget, personnel) are available for this project? - -3. Continue the dialogue until you feel you have a comprehensive understanding of the idea and its requirements. Summarize your understanding and ask the user to confirm if you've captured everything correctly. - -4. Once the user confirms a good common understanding, begin developing a comprehensive execution plan. Think through the following aspects: - - Project scope and deliverables - - Key milestones and deadlines - - Required resources and skills - - Potential risks and mitigation strategies - - Success metrics and evaluation criteria - -5. Break down the execution plan into specific tasks, each with an estimated complexity of 2-5 hours. Consider all phases of the project, including planning, development, testing, and implementation. - -6. Organize these tasks into sprints of 1 day each. Each sprint should contain a logical grouping of related tasks that can be completed within a day. - -7. Present your plan in the following format, to be written to a file named TASK.md: - - -# Execution Plan for [Idea Name] - -## Sprint 1: [Sprint Name/Theme] -- [ ] Task 1: [Description] -- [ ] Task 2: [Description] -- [ ] Task 3: [Description] - -## Sprint 2: [Sprint Name/Theme] -- [ ] Task 1: [Description] -- [ ] Task 2: [Description] -- [ ] Task 3: [Description] - -[Continue with additional sprints as needed] - - -8. After presenting the plan, ask the user if they would like to make any adjustments or if they have any questions about the execution plan. - -Your final output should consist only of the contents for TASK.md, formatted as shown above. Do not include any additional commentary or explanations in your final output. \ No newline at end of file diff --git a/.claude/commands/prime_ui.md b/.claude/commands/prime_ui.md deleted file mode 100644 index 4a00159..0000000 --- a/.claude/commands/prime_ui.md +++ /dev/null @@ -1,3 +0,0 @@ -Please do `git ls-files examples/dominator-example/` and then read `docs_and_help/llm-txt/dwind_dominator.md` to get up to speed on how to do UI development in rust with dominator, dwind. -You can find all the dominator colors here: https://jedimemo.github.io/dwind/doc/dwind/colors/index.html and the sizing classes are here: https://jedimemo.github.io/dwind/doc/dwind/sizing/index.html -You can ask the dominator guru for help with both dominator and dwind. He is very knowlegeable! \ No newline at end of file diff --git a/.claude/commands/retro.md b/.claude/commands/retro.md deleted file mode 100644 index f264ee8..0000000 --- a/.claude/commands/retro.md +++ /dev/null @@ -1,9 +0,0 @@ -Do a sprint retrospective, and analyze the observations in `scraim/current-spraint.md`. -I want you to consider the notes in relation to CLAUDE.md files as well as the instructions in `.claude/commands`, and -see if there are any tweaks that can be made to those rules that would help prevent mistakes that we have observed. - -I also want you to analyze the CLAUDE.md files and see if there is excessive amounts of text, or old/redundant -information in them, -and if there is, resolve those issues. We want to have a tight and neat set of instructions to work with! - -When you are done, move the current-spraint.md file into `scraim/retroed/{sprint number}.md` and give yourself a pat on the back! \ No newline at end of file diff --git a/.claude/commands/smalltest.md b/.claude/commands/smalltest.md deleted file mode 100644 index 0a5ed30..0000000 --- a/.claude/commands/smalltest.md +++ /dev/null @@ -1,4 +0,0 @@ -You are an expert test developer. -You exclusively write tests that can run in a single process without requiring access to any external resource such as files,network etc. - -Please solve this: $ARGUMENTS \ No newline at end of file diff --git a/.claude/commands/update_docs.md b/.claude/commands/update_docs.md deleted file mode 100644 index b24a6e7..0000000 --- a/.claude/commands/update_docs.md +++ /dev/null @@ -1,4 +0,0 @@ -Evaluate the current project -Evaluate the current documentation -Update CLAUDE.md to reflect the current state of the project. -Update other documentation as needed. \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1db7e0a..91cc6b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,92 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - # Catch hard errors and lint regressions. We don't enforce -D warnings - # workspace-wide yet (legacy code has standing warnings); the CI gate is - # "compiles cleanly + no clippy ERRORS". Tighten later by promoting - # selected lints to deny. - - run: cargo clippy --workspace --all-targets --all-features + - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + + docs: + name: Documentation + runs-on: ubuntu-latest + env: + RUSTDOCFLAGS: -D warnings + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build rustdoc + run: cargo doc --workspace --all-features --no-deps --locked + + docs-hygiene: + name: Documentation hygiene + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check package README targets + shell: bash + run: | + missing=0 + while IFS= read -r manifest; do + dir=${manifest%/Cargo.toml} + package_name=$(sed -n 's/^name = "\([^"]*\)"/\1/p' "$manifest" | head -1) + [ -z "$package_name" ] && continue + + readme=$(sed -n 's/^readme = "\([^"]*\)"/\1/p' "$manifest" | head -1) + if [ -z "$readme" ]; then + if [ ! -f "$dir/README.md" ]; then + printf '%s: package has no README.md and no readme target\n' "$manifest" + missing=1 + fi + continue + fi + + if [ ! -e "$dir/$readme" ]; then + printf '%s: readme target does not exist: %s\n' "$manifest" "$readme" + missing=1 + fi + done < <(find . \( -path './.git' -o -path './target' -o -path './node_modules' -o -path '*/node_modules' \) -prune -o -name Cargo.toml -print | sort) + + exit "$missing" + + - name: Check local Markdown links + shell: bash + run: | + broken=0 + while IFS=$'\t' read -r file raw; do + dir=${file%/*} + [ "$dir" = "$file" ] && dir=. + + link=${raw%%[[:space:]]*} + link=${link#<} + link=${link%>} + link=${link%%#*} + + case "$link" in + ''|'#'*|http://*|https://*|mailto:*|file:*|javascript:*) + continue + ;; + esac + + target="$dir/$link" + if [ ! -e "$target" ]; then + printf '%s: broken local Markdown link: %s\n' "$file" "$raw" + broken=1 + fi + done < <( + find . \( -path './.git' -o -path './target' -o -path './node_modules' -o -path '*/node_modules' \) -prune -o -name '*.md' -type f -print0 | + xargs -0 perl -ne 'while (/\[[^\]]+\]\(([^)]+)\)/g) { print "$ARGV\t$1\n" }' + ) + + exit "$broken" + + supply-chain: + name: Supply chain policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@cargo-deny + - uses: Swatinem/rust-cache@v2 + - run: cargo deny check test: name: Test (workspace) @@ -44,9 +125,50 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Build tests - run: cargo test --workspace --all-features --no-run --locked + run: cargo test --workspace --all-targets --all-features --no-run --locked - name: Run tests - run: cargo test --workspace --all-features -- --nocapture --test-threads=4 + run: cargo test --workspace --all-targets --all-features --locked + - name: Run doctests + run: cargo test --doc --workspace --all-features --locked + + feature-matrix: + name: Feature matrix + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + + - name: Check macro crates without defaults + run: cargo check -p ras-rest-macro -p ras-jsonrpc-macro -p ras-jsonrpc-bidirectional-macro --no-default-features --locked + + - name: Check REST macro server-only + run: cargo check -p ras-rest-macro --no-default-features --features server --locked + + - name: Check REST macro client-only + run: cargo check -p ras-rest-macro --no-default-features --features client --locked + + - name: Check JSON-RPC macro server-only + run: cargo check -p ras-jsonrpc-macro --no-default-features --features server --locked + + - name: Check JSON-RPC macro client-only + run: cargo check -p ras-jsonrpc-macro --no-default-features --features client --locked + + - name: Check bidirectional macro server-only + run: cargo check -p ras-jsonrpc-bidirectional-macro --no-default-features --features server --locked + + - name: Check bidirectional macro client-only + run: cargo check -p ras-jsonrpc-bidirectional-macro --no-default-features --features client --locked + + - name: Check bidirectional native client + run: cargo check -p ras-jsonrpc-bidirectional-client --no-default-features --features native --locked + + - name: Check bidirectional WASM client + run: cargo check -p ras-jsonrpc-bidirectional-client --no-default-features --features wasm --target wasm32-unknown-unknown --locked playwright: name: Playwright E2E @@ -64,7 +186,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 22.13 cache: npm cache-dependency-path: tests/playwright/package-lock.json @@ -98,6 +220,60 @@ jobs: if-no-files-found: ignore retention-days: 7 + generated-client-specs: + name: Generated client specs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Generate OpenAPI specs + run: cargo check -p file-service-backend -p rest-backend --locked + + - name: Verify generated OpenAPI specs + run: | + test -s examples/file-service-wasm/file-service-backend/target/openapi/documentservice.json + test -s examples/rest-wasm-example/rest-backend/target/openapi/userservice.json + + wasm-ui-example: + name: WASM UI example + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + + - name: Check browser API clients + run: > + cargo check + -p basic-jsonrpc-api + -p rest-api + -p file-service-api + --target wasm32-unknown-unknown + --no-default-features + --features basic-jsonrpc-api/client,rest-api/client,file-service-api/wasm-client + --locked + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22.13 + cache: npm + cache-dependency-path: examples/wasm-ui-demo/package-lock.json + + - name: Build WASM UI example + working-directory: examples/wasm-ui-demo + run: | + npm ci + npm run build + coverage: name: Coverage report runs-on: ubuntu-latest @@ -110,7 +286,7 @@ jobs: - uses: taiki-e/install-action@cargo-llvm-cov - uses: Swatinem/rust-cache@v2 - name: Generate coverage (lcov) - run: cargo llvm-cov --workspace --all-features --lcov --output-path lcov.info + run: cargo llvm-cov --workspace --all-targets --all-features --locked --lcov --output-path lcov.info - name: Print summary run: cargo llvm-cov report --summary-only - name: Upload coverage artifact diff --git a/.gitignore b/.gitignore index 1793a3a..070cef3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,19 +13,25 @@ secrets/ **/secrets/ **/.env -# Generated test output and OpenRPC documents +# Generated API documents and local test output **/custom/ -**/*.json -!package*.json +**/openrpc/ +**/openapi/ +**/pkg/ +tests/playwright/test-results/ # Local config and test files examples/bidirectional-chat/server/config.toml test_login.sh +# Local agent, IDE, and scratch artifacts +.agents/ +.codex/ +.claude/ +.references/ +agent-research/ +docs_and_help/ +sketchpad/ + # Chat data **/chat_data/ - -# Build outputs -**/openrpc/ -**/openapi/ -.references/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d33f59b..6fd8b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,96 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed - 2026-05-23 +- `ras-identity-local`: Duplicate local user creation now fails with `LocalUserError::UserAlreadyExists` instead of silently overwriting credentials. +- Bumped `ras-identity-local` from `0.1.1` to `0.2.0` because `LocalUserProvider::add_user` now returns the crate-specific `LocalUserError`. +- Bumped `ras-identity-oauth2` from `0.1.1` to `0.1.2` for the additive `UserInfoMapping` root re-export and updated OAuth2 docs. +- `documentation/ras-identity.md`: Identity examples now use the current `UserPermissions`, `SessionService`, JWT claims, session revocation, and Axum 0.8 server APIs. +- `ras-identity-local`: README testing/security notes now distinguish default tests from optional timing-sensitive checks. +- `ras-identity-session`: JWT signing now uses local HMAC-SHA implementations for HS256/HS384/HS512 instead of pulling in the broader `jsonwebtoken` RustCrypto/RSA dependency path. +- Supply-chain policy now passes on current `cargo-deny`; vulnerable `rand`, `time`, `tracing-subscriber`, `protobuf`, and related OpenTelemetry/Prometheus dependencies were updated, and unmaintained `wee_alloc` was removed from the WASM UI example. +- `examples/bidirectional-chat`: Auth lifecycle tests now verify login after registration, duplicate registration rejection, and permission-bearing JWT claims. +- `examples/bidirectional-chat`: Removed fake auth endpoint checks from `server_tests.rs`; auth endpoint coverage now lives in the in-memory lifecycle suite that wires the real identity/session stack. +- `examples/bidirectional-chat`: Configuration docs now match the implemented config-file and environment-variable loading behavior. +- `examples/bidirectional-chat`: README commands now use the actual `bidirectional-chat-tui` package and current example credentials. +- `examples/bidirectional-chat`: TUI README now states the correct Rust 1.88+ requirement for Rust 2024 edition crates. +- `examples/file-service-wasm`: README now names the real `wasm-client` feature. +- `openrpc-types` and `ras-jsonrpc-types`: README dependency snippets now match the current crate versions. +- REST and JSON-RPC macro documentation dependency snippets now match the workspace Axum, Tokio, and schemars versions. +- `ras-rest-macro` and `ras-jsonrpc-macro`: HTTP integration tests now use in-memory `axum-test` mock transport instead of binding local TCP sockets. +- `ras-jsonrpc-macro`: Generated-client compile/config coverage no longer attempts requests against an unused localhost port. +- CI now treats clippy warnings as failures with `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- Removed unused workspace dependency declarations left behind by older local tooling and UI experiments. +- Narrowed JSON ignore rules so TypeScript example `tsconfig` files are visible for version control while generated OpenRPC/OpenAPI and runtime data stay ignored. +- `examples/file-service-wasm` and `examples/rest-wasm-example`: TypeScript generated-client samples are now plain usage examples instead of standalone npm apps. +- CI now verifies the generated OpenAPI specs used by the TypeScript usage samples without installing npm dependencies for those samples. +- `examples/wasm-ui-demo`: Added a local README and fixed the browser client/proxy path to match the basic JSON-RPC service's `/rpc` route. +- `examples/wasm-ui-demo`: Build scripts and ignore rules now match the actual Rollup `dist/` output. +- CI now builds the `wasm-ui-demo` WebAssembly bundle with the `wasm32-unknown-unknown` target. +- CI now enforces the tracked `deny.toml` supply-chain policy with `cargo-deny`. +- `examples/file-service-example`: Added a local README with run instructions, curl examples, and token behavior. +- Root and bidirectional JSON-RPC README examples now match the current generated client/server APIs and avoid overstating retry behavior. +- `ras-jsonrpc-bidirectional-client`: The documented WASM feature check now compiles with `wasm32-unknown-unknown` and keeps native WebSocket dependencies out of the WASM dependency graph. +- `ras-identity-oauth2`: OAuth2 integration tests now use in-memory `axum-test` mock transport instead of socket-bound mock HTTP servers. +- `ras-jsonrpc-bidirectional-server` and `ras-jsonrpc-bidirectional-macro`: WebSocket handler, generated-service, and round-trip benchmark coverage now run through an in-memory socket adapter instead of binding local TCP ports. +- `ras-jsonrpc-bidirectional-server`: Request handler failures now return JSON-RPC error responses and keep the WebSocket loop alive for later requests. +- `ras-jsonrpc-bidirectional-client`: Native transport request construction and disconnected send/receive behavior now have socketless unit coverage. +- `ras-identity-oauth2`: Added fake-transport client tests for state mismatch, provider callback errors, PKCE-disabled token exchange parameters, and missing userinfo endpoint handling. +- `examples/bidirectional-chat`: Added runtime `messages_per_minute` enforcement for authenticated `send_message` calls, plus socketless WebSocket flow tests for room join, list, leave, profile update/readback, moderator kick, admin announcement broadcast, generated permission denial, request-error recovery, disconnect cleanup, typing cleanup, message rate limiting, and multi-user message broadcast through the generated handler, in-memory adapter, and in-memory connection manager; profile avatar persistence now uses the same snake_case strings as the API. +- `examples/file-service-wasm`: Corrected the documented 100 MB upload limit and generated OpenAPI path in the TypeScript usage sample. +- Root README quick start now keeps the first-run path Rust-only and points to frontend examples as optional follow-ups. +- REST macro docs now describe the built-in API explorer and point to the actual `/docs/openapi.json` route. +- TypeScript client docs now describe OpenAPI-generated fetch-client usage without implying a framework or npm app scaffold. +- Changelog history no longer implies the current `.cargo/config.toml` configures Kellnr as the default registry. +- `examples/bidirectional-chat`: Server test README now describes remaining WebSocket coverage as in-memory handler testing. +- `examples/wasm-ui-demo`: Removed an unused placeholder resources directory. +- `ras-jsonrpc-bidirectional-macro`: README feature docs now match the actual `server`/`client` feature set, and documented `server_to_client_calls` syntax is covered by parser and compile tests. +- `ras-jsonrpc-bidirectional-macro`: Generated server-to-client RPC handlers now wrap callbacks in `Arc` instead of requiring an undocumented `Clone` bound. +- `ras-jsonrpc-bidirectional-server`: Manager tests no longer reference the deleted socket-bound integration test file. +- Root, REST macro, and observability docs no longer contain placeholder implementation comments or undefined sample variables in their primary setup snippets. +- Package README test commands now consistently use `--locked`, and the OAuth2 demo's focused test example names a real current test. +- OAuth2 README and demo landing-page copy now use current project naming and avoid implying unimplemented response caching or active-session token revocation. +- Example run/check/build snippets now consistently use the checked-in lockfile. +- Root, example overview, and local example quick-start commands now use workspace-root package invocations where practical instead of mixing `cd`-based forms. +- `examples/bidirectional-chat`: Workspace-root server commands now set `CHAT_DATA_DIR` alongside `CHAT_CONFIG_FILE` so persisted chat state lands under the ignored example runtime directory. +- Root, examples, Playwright, and CI metadata now state the Rust 1.88+ and Node.js 22.13+ prerequisites consistently. +- Cargo package manifests now declare `rust-version = "1.88"` to match the locked workspace dependency graph. +- File-service macro installation docs now list the native and WASM dependencies required by the generated server and clients. +- REST and JSON-RPC macro installation snippets now wire consumer crate `server` and `client` features to the macro features and optional dependencies. +- Bidirectional client docs now describe caller-managed reconnect behavior instead of claiming an automatic reconnect loop, and example snippets use concrete demo tokens and real package commands. +- File-service macro docs and example READMEs now use current generated trait names, concrete upload/download snippets, and checked-in backend links instead of placeholder storage/auth code. +- REST macro docs now use the current `AuthProvider`/`AuthFuture` shape, concrete demo auth providers, valid OpenAPI configuration examples, and complete generated trait method lists instead of placeholder code. +- JSON-RPC macro and core docs now use concrete method definitions, generated builder declarations, and current `AuthProvider` permission-checking examples instead of placeholder helper APIs. +- `ras-observability-core`: Added `RequestContext::websocket(method)` and updated observability/identity examples to use concrete env-backed configuration instead of placeholder credentials and pseudo-code. +- Bidirectional JSON-RPC and OpenRPC type docs now use concrete validation/sender examples, and `ras-jsonrpc-bidirectional-types` re-exports `MessageSenderExt` from the crate root to match the documented API. +- Identity, observability, bidirectional WebSocket, and JSON-RPC types docs now avoid broad "everything"/"complete"/"high-performance" claims unless the text is tied to a concrete implemented API. +- REST macro TypeScript snippets now avoid ambiguous ellipsis-style config spreading in favor of explicit request option construction. +- `examples/rest-wasm-example/rest-backend`: Added a backend-local README with run commands, demo tokens, generated OpenAPI locations, endpoint map, and focused test commands. +- `examples/rest-wasm-example/rest-api`: Added a shared-contract README covering generated server/client features and related example files. +- `examples/bidirectional-chat/server`: Added a server-local README with run commands, configuration behavior, REST auth endpoints, WebSocket auth options, and socketless test guidance. +- Example API crates now have package-local READMEs that describe their generated contracts, feature flags, related runnable examples, and focused check commands. +- Playwright fixture crates now have local READMEs that document their browser-test role, socket-bound ports, routes, test tokens, and focused check commands. +- `examples/wasm-ui-demo`: Trimmed direct npm build dependencies by using Node's built-in directory removal and removing the extra terser Rollup plugin from the example build. +- `examples/wasm-ui-demo` and `ras-rest-macro`: Removed stale direct Cargo dependency declarations that are no longer used by the example UI or REST macro tests. +- REST macro installation docs now list the consumer-side `axum-extra` dependency required by generated query-parameter extractors. +- Public guides now avoid broad "complete" claims for examples and use concrete labels such as runnable service, task API example, and file API example. +- Example READMEs now use correct relative paths for backticked local file references that are not covered by Markdown link checking. +- `basic-jsonrpc-api` and `rest-api`: Added direct contract tests for generated OpenRPC/OpenAPI documents and important wire shapes used by generated clients. +- `oauth2-demo-api` and `bidirectional-chat-api`: Added direct contract tests for generated OpenRPC permissions, schema metadata, and bidirectional notification/avatar wire shapes. +- Playwright fixture crates now have socketless contract tests for generated OpenRPC/OpenAPI methods, routes, docs, auth metadata, query parameters, and version metadata. +- `ras-jsonrpc-core`: Added re-export contract tests for auth types, JSON-RPC protocol types, and version migration traits. +- CI now checks Cargo package README targets and local Markdown links without adding repository scripts. +- Root README now documents the documentation-hygiene checks that CI runs for package README targets and local Markdown links. +- Security and observability docs now describe concrete mitigations and setup hooks instead of broad constant-time or zero-configuration claims. + +### Removed - 2026-05-23 +- Removed stale local development artifacts: the `ras-file-macro` debug proc-macro stub, the bidirectional chat `test-config` diagnostic binary, and a tracked runtime chat log. +- Removed tracked local-agent and scratch artifacts from `.claude/`, `agent-research/`, `docs_and_help/`, and `sketchpad/`; these paths are now ignored for local use only. +- Removed socket-bound HTTP mock dev-dependencies left behind by older OAuth2 and macro test suites. +- Removed unused `tokio-test` dev-dependencies and the stale bidirectional chat server `reqwest` dev-dependency left behind after the socketless test cleanup. +- Removed scaffold-style placeholder comments from `deny.toml` so the tracked supply-chain policy is project-specific. +- Added the current `Unicode-3.0` SPDX license identifier to `deny.toml`. + ### Added - 2026-05-10 - Added `ras-version-core` `0.1.0` with the shared `VersionMigration` trait for opt-in API compatibility migrations. - `ras-jsonrpc-macro`: Added opt-in versioned JSON-RPC methods. Legacy wire methods can migrate legacy requests into canonical request types, call the canonical trait method, and migrate canonical responses back to legacy response types. @@ -25,6 +115,9 @@ All notable changes to this project will be documented in this file. ### Documentation - 2026-05-10 - Updated JSON-RPC, REST, identity, observability, example, and Playwright documentation for trait-backed service setup, current auth syntax, current crate names, and versioned API migration examples. +### Removed - 2026-05-22 +- Removed the `openrpc-to-bruno` tool crate from the workspace. + ### Added - 2026-05-09 - Established repository versioning and changelog policy in `VERSIONING.md`. - Added doc-comment support for generated API documentation: @@ -78,19 +171,17 @@ All notable changes to this project will be documented in this file. - Bidirectional chat terminal client foundation (Sprint 2 Day 1) - Modular architecture with separate ui, client, auth, and config modules - Complete ratatui-based terminal UI with message area, user list, and input field - - Placeholder implementations for WebSocket client integration + - Initial WebSocket client integration scaffolding - Configuration system supporting environment variables and TOML files - JWT token management infrastructure for authentication ### Updated - 2025-01-14 -- Simplified CLAUDE.md build commands to use generic examples instead of listing all crates +- Simplified local development guidance to use generic examples instead of listing all crates - Added bidirectional chat client architecture details to documentation - Terminal UI layout and components - State management and WebSocket integration - Authentication and configuration details -- Updated TASK.md to mark completed Sprint 2 terminal client implementation tasks -- Archived Sprint 3 retrospective to scraim/retroed/ folder - - Documented successful completion of bidirectional chat server and client foundation +- Documented successful completion of the bidirectional chat server and client foundation ### Added - 2025-01-13 - Comprehensive configuration system for bidirectional chat server @@ -120,17 +211,13 @@ All notable changes to this project will be documented in this file. - Operation metrics for state loading/saving ### Added - 2025-01-13 -- Added ideate command for interactive brainstorming and execution planning - - New .claude/commands/ideate.md facilitates collaborative idea development - - Updated plan.md to emphasize brainstorming before work breakdown - - Bidirectional chat example demonstrating real-time WebSocket communication - Complete chat server with room management and message persistence - CLI client with register/login/chat commands for interactive sessions - JWT-based authentication with role-based permissions (user/admin) - Persistent chat history using JSON file storage - Type-safe bidirectional RPC using generated client/server code - - Updated CLAUDE.md with bidirectional macro implementation notes + - Added bidirectional macro implementation notes - User profile system with cat avatar customization - Added profile management endpoints (get_profile, update_profile) @@ -166,28 +253,12 @@ All notable changes to this project will be documented in this file. - Optional client dependencies (reqwest) only loaded when client feature enabled - Comprehensive test coverage for client generation and HTTP communication patterns -### Fixed - 2025-01-09 -- Improved Bruno auth enum formatting for better code consistency - - Fixed formatting of BrunoAuth enum to use consistent brace style - - Enhanced readability with proper field alignment for Bearer and Basic auth types - - Maintained proper code formatting standards throughout bruno.rs module - ### Fixed - 2025-01-09 - Fixed OpenRPC schema generation to comply with JSON-RPC specification - Schema definitions now properly use components/schemas instead of $defs - Service-specific helper functions prevent naming conflicts in generated code - All schema references updated to use standard #/components/schemas/ format -### Added - 2025-01-09 -- New OpenRPC-to-Bruno conversion tool for generating Bruno API collections from OpenRPC specifications - - Complete CLI tool `openrpc-to-bruno` for converting OpenRPC 1.3.2 documents to Bruno collections - - Supports authentication extraction with Bearer token configuration - - Generates environment variables and collection metadata automatically - - Comprehensive test suite with integration tests for conversion accuracy - - Handles method parameter conversion with proper JSON schema validation - - Bruno collection format support with proper .bru file generation - - Command-line interface with configurable output directories and collection naming - ### Refactored - 2025-01-09 - Restructured Google OAuth example into multi-crate architecture for better separation of concerns - Split into separate `api` and `server` crates with clean API boundary separation @@ -199,9 +270,8 @@ All notable changes to this project will be documented in this file. ### Enhanced - 2025-01-09 - Updated workspace configuration and dependencies to support new tooling and improved development experience - - Added clap workspace dependency for consistent CLI tooling across the project - Updated schemars to 1.0.0-alpha.20 for improved JSON Schema Draft 7 compatibility - - Enhanced workspace member organization with tools and multi-crate example structure + - Enhanced workspace member organization for multi-crate example structure - Fixed import ordering in integration tests following Rust style guidelines - Improved Cargo.lock with new dependencies for CLI tools and testing infrastructure @@ -210,21 +280,14 @@ All notable changes to this project will be documented in this file. - Removed deny_unknown_fields restrictions from Method and Schema structs in openrpc-types crate - Added $schema field support to Schema struct for proper JSON Schema Draft 7 compatibility - Enables proper parsing of OpenRPC documents with x-authentication and x-permissions extensions - - Bruno API collection generator now properly supports OpenRPC files with custom authentication metadata ### Enhanced - 2025-01-09 - Enhanced OpenRPC document generation functionality to actually generate files - - Modified google-oauth-example to call OpenRPC generation functions during service creation + - Modified the OAuth2 demo to call OpenRPC generation functions during service creation - Added JsonSchema derives to all request/response types for proper schema generation - Created test infrastructure to verify end-to-end OpenRPC generation works correctly - OpenRPC documents now properly written to target/openrpc/ directory when enabled -### Fixed - 2025-01-09 -- Fixed Bruno API collection JSON formatting to be properly indented and valid - - Corrected JSON body indentation in .bru files to use proper 2-space indentation within body:json blocks - - Generated Bruno collections are now properly formatted and compatible with Bruno API client - - Resolves validation errors when importing generated collections into Bruno - ### Documentation - 2025-01-09 - Added comprehensive OpenRPC generation documentation to ras-jsonrpc-macro README - Documented OpenRPC generation feature with complete usage examples and configuration options @@ -268,28 +331,28 @@ All notable changes to this project will be documented in this file. - Comprehensive HTTP integration test suites for both JSON-RPC and REST macro crates - Complete JSON-RPC integration tests covering all authentication patterns (UNAUTHORIZED, WITH_PERMISSIONS with various levels) - Full REST API integration tests with CRUD operations, path parameters, and HTTP method validation - - Real HTTP server testing using random port binding with tokio TcpListener for concurrent test execution + - HTTP integration coverage for generated routers and clients - Authentication and authorization testing across all permission levels with JWT token validation - Security testing including timing attack resistance and proper error handling scenarios - Concurrent request testing validating thread safety and performance under load - OpenRPC and OpenAPI document generation testing ensuring specification compliance - Test infrastructure supporting both positive and negative scenarios with comprehensive error validation - - Fixed unused import warnings in rust-identity-local during test infrastructure development + - Fixed unused import warnings in `ras-identity-local` during test infrastructure development ### Enhanced - 2025-01-08 - Added comprehensive testing dependencies for HTTP integration testing across macro crates - - Added wiremock, reqwest, tower, hyper, rand, and futures to workspace dependencies for robust HTTP testing infrastructure - - Enhanced rust-jsonrpc-macro and rust-rest-macro with testing dependencies for real server integration tests - - Established foundation for comprehensive integration testing with random port binding and concurrent request handling + - Added HTTP client, router, concurrency, and async helper dependencies for robust HTTP testing infrastructure + - Enhanced `ras-jsonrpc-macro` and `ras-rest-macro` with testing dependencies for real server integration tests + - Established foundation for comprehensive integration testing and concurrent request handling - Dependencies support both JSON-RPC and REST API testing patterns with authentication validation ### Refactored - 2025-01-08 - Architectural refactoring to eliminate coupling between RPC and REST macro crates - - Created new `rust-auth-core` crate as shared foundation for authentication types and traits - - Moved `AuthProvider`, `AuthenticatedUser`, `AuthError`, and related types from `rust-jsonrpc-core` to `rust-auth-core` - - Updated `rust-rest-macro` to depend on `rust-auth-core` instead of `rust-jsonrpc-core`, eliminating unwanted cross-dependencies - - Updated `rust-identity-session` and other affected crates to use shared authentication types - - Maintained full backward compatibility through re-exports in `rust-jsonrpc-core` + - Created new `ras-auth-core` crate as shared foundation for authentication types and traits + - Moved `AuthProvider`, `AuthenticatedUser`, `AuthError`, and related types from `ras-jsonrpc-core` to `ras-auth-core` + - Updated `ras-rest-macro` to depend on `ras-auth-core` instead of `ras-jsonrpc-core`, eliminating unwanted cross-dependencies + - Updated `ras-identity-session` and other affected crates to use shared authentication types + - Maintained full backward compatibility through re-exports in `ras-jsonrpc-core` - Enhanced codebase maintainability with clear separation of concerns between authentication logic and protocol-specific implementations - Improved workspace architecture enabling future protocol extensions (gRPC, etc.) without introducing coupling - Updated documentation and build commands to reflect new crate structure @@ -304,7 +367,7 @@ All notable changes to this project will be documented in this file. ### Fixed - 2025-01-08 - Fixed REST API documentation schema display for optional fields showing as empty objects - - Enhanced OpenAPI schema generation to convert `"type": ["string", "null"]` format to `"type": "string", "nullable": true"` for better Swagger UI compatibility + - Enhanced OpenAPI schema generation to convert `"type": ["string", "null"]` format to `"type": "string", "nullable": true"` for better explorer compatibility - Improved JavaScript schema processing in documentation UI to handle array type definitions (e.g., `["string", "null"]`) - Added recursive schema normalization for all nested objects and definitions - Optional fields like `email` and `display_name` now display as proper string input fields with meaningful examples @@ -313,15 +376,15 @@ All notable changes to this project will be documented in this file. ### Enhanced - 2025-01-08 - Sprint retrospective update covering Static API Documentation Hosting & Explorer UI implementation - Documented strategic orchestration approach with successful role delegation (Architect → Backend Coder → UX Designer) - - Noted seamless integration with existing rust-rest-macro patterns without breaking changes - - Recognized custom API explorer UI success replacing generic Swagger UI with tailored features + - Noted seamless integration with existing `ras-rest-macro` patterns without breaking changes + - Recognized custom API explorer UI success with tailored features - Highlighted zero-overhead implementation design for optional features - Identified opportunity for smaller proof-of-concept approach in future complex implementations ### Added - 2025-01-08 - Static API documentation hosting with embedded explorer UI for REST services - - Complete static file hosting support integrated into rust-rest-macro crate - - Interactive API documentation with custom-built explorer UI replacing generic Swagger UI + - Complete static file hosting support integrated into the `ras-rest-macro` crate + - Interactive API documentation with custom-built explorer UI - Embedded static assets using rust-embed for zero-dependency deployment - JWT authentication integration directly in the explorer interface - Responsive documentation UI with multiple theme support (default theme included) @@ -332,16 +395,16 @@ All notable changes to this project will be documented in this file. ### Enhanced - 2025-01-08 - Sprint retrospective process with enhanced development guidelines based on observed patterns - - Added Critical Development Rules section to CLAUDE.md based on sprint observation analysis + - Added critical development rules based on sprint observation analysis - Five new rules: Test Early/Often, Specification First, Incremental Implementation, Macro Testing, End-to-End Validation - Enhanced Common Pitfalls with string type mismatches and move semantics guidance - - Updated crate listings to include rust-rest-macro and build commands - - Archived sprint-2 retrospective notes covering OpenRPC generation, registry setup, and REST macro implementation + - Updated crate listings to include `ras-rest-macro` and build commands + - Captured retrospective notes covering OpenRPC generation, registry setup, and REST macro implementation - Systematic approach to learning from development patterns and preventing recurring issues ### Enhanced - 2025-01-08 - REST service example now demonstrates complete local authentication integration with comprehensive security features - - Full JWT-based authentication using rust-identity-local and rust-identity-session crates + - Full JWT-based authentication using `ras-identity-local` and `ras-identity-session` crates - Complete auth endpoints: user registration, login, logout, and user info retrieval - Role-based permission system with admin and user access levels (admin users inherit user permissions) - Two-phase authentication flow: LocalUserProvider for credential validation → SessionService for JWT issuance @@ -352,19 +415,18 @@ All notable changes to this project will be documented in this file. ### Added - 2025-01-08 - REST macro crate implementation with comprehensive REST API generation capabilities - - Complete rust-rest-macro procedural macro crate for type-safe REST endpoints with authentication integration + - Complete `ras-rest-macro` procedural macro crate for type-safe REST endpoints with authentication integration - Supports all HTTP methods (GET, POST, PUT, DELETE, PATCH) with path parameters and request bodies - OpenAPI 3.0 document generation using schemars with configurable output paths - Permission-based access control with JWT authentication through AuthProvider integration - Generated service traits, builders, and axum router integration following JSON-RPC macro patterns - - Example application (rest-service-example) demonstrating comprehensive REST service implementation + - Example application demonstrating comprehensive REST service implementation - Full workspace integration with proper dependency management and testing infrastructure ### Added - 2025-01-08 -- Kellnr registry configuration for local crate publishing - - Configured kellnr as default registry in `.cargo/config.toml` - - Registry URL set to `http://localhost:8000/api/v1/crates/` - - Created comprehensive release command at `.claude/commands/kellnr-release.md` +- Kellnr registry notes for local crate publishing + - Recorded the local registry URL `http://localhost:8000/api/v1/crates/` + - Created comprehensive release checklist - Includes A-Z release process with dependency order management - All internal dependencies already properly configured with path + version @@ -385,20 +447,9 @@ All notable changes to this project will be documented in this file. - Generates complete JSON Schema definitions using schemars crate for all request/response types - Includes authentication metadata with OpenRPC extensions (`x-authentication`, `x-permissions`) - Added comprehensive test coverage and examples demonstrating all features - - Updated CLAUDE.md documentation with usage examples and requirements + - Updated JSON-RPC macro documentation with usage examples and requirements - Requires types to implement `schemars::JsonSchema` trait when OpenRPC generation is enabled -### Added - 2025-01-07 -- Sprint retrospective implementation with project guidelines optimization - - Streamlined CLAUDE.md documentation from verbose descriptions to concise guidelines - - Added testing guidelines based on sprint observations (security-first, end-to-end testing) - - Enhanced orchestrate command with key execution principles to prevent observed mistakes - - Archived sprint-1 retrospective notes to scraim/retroed/ for historical tracking - -- Added raitro command for automated sprint retrospectives - - Command analyzes sprint observations and optimizes project guidelines - - Provides framework for continuous improvement of development processes - ### Fixed - 2025-01-07 - Fixed JSON-RPC macro routing issue causing 404 errors when accessing service endpoints - Macro now properly uses the base_url parameter instead of hardcoding "/" routes @@ -428,7 +479,7 @@ All notable changes to this project will be documented in this file. - Custom user info field mapping for flexible OAuth2 provider integration - Comprehensive error handling with OAuth2-specific error types and detailed context - Full test suite covering PKCE generation, authorization URLs, state management, and security scenarios - - Production-ready implementation with proper HTTP timeouts and robust error recovery + - HTTP timeouts and error handling for the provider client - Enhanced JwtAuthProvider with Clone trait for improved service compatibility and architecture flexibility ### Added - 2025-01-07 @@ -437,7 +488,7 @@ All notable changes to this project will be documented in this file. - Complete Rust backend integration using Axum server with JSON-RPC API endpoints - Sophisticated permission system with role-based access control based on email domains and user attributes - Six different API endpoints showcasing permission-based access (user info, documents, admin, system status, beta features) - - Production-ready OAuth2 flow with PKCE, state validation, JWT session management, and comprehensive error handling + - OAuth2 flow with PKCE, state validation, JWT session management, and error handling - Interactive API documentation with built-in testing capabilities and JWT token management - Comprehensive test suite covering permission logic and service compilation validation - Complete setup documentation with Google Cloud Console integration instructions @@ -449,11 +500,11 @@ All notable changes to this project will be documented in this file. - Includes protection for production, staging, and local environment configurations ### Documentation - 2025-01-07 -- Updated CLAUDE.md with comprehensive Google OAuth2 example documentation and usage instructions +- Updated Google OAuth2 example documentation and usage instructions - Added quick start guide with Google Cloud Console setup steps and environment configuration - Documented sophisticated permission system with role-based access control examples - Comprehensive API endpoint documentation with permission requirements and functionality descriptions - - Added oauth2 provider status update from stub to full production-ready implementation + - Added oauth2 provider status update from stub to implemented provider - Enhanced development commands with example application execution instructions - Added Common Pitfalls section documenting Axum router nesting syntax issues - Updated sprint reflection documentation with Google OAuth2 full-stack implementation learnings and coordination insights @@ -461,7 +512,7 @@ All notable changes to this project will be documented in this file. - Documented lessons learned about testing end-to-end flows and examining generated code ### Security - 2025-01-07 -- Enhanced authentication security in rust-identity-local with comprehensive attack vector protection +- Enhanced authentication security in `ras-identity-local` with comprehensive attack vector protection - Fixed username enumeration vulnerability - consistent errors for non-existent users and wrong passwords - Implemented timing attack resistance using constant-time authentication with real Argon2 dummy hash - Added robust input validation for malformed payloads, empty credentials, and special characters @@ -472,10 +523,10 @@ All notable changes to this project will be documented in this file. ### Added - 2025-01-07 - Identity management system with pluggable authentication providers - - rust-identity-core: Core traits for IdentityProvider and UserPermissions with default implementations - - rust-identity-local: Local username/password authentication with Argon2 password hashing - - rust-identity-oauth2: OAuth2 provider framework (stub implementation for future completion) - - rust-identity-session: JWT-based session management with configurable secrets and permission lookup + - `ras-identity-core`: Core traits for IdentityProvider and UserPermissions with default implementations + - `ras-identity-local`: Local username/password authentication with Argon2 password hashing + - `ras-identity-oauth2`: Initial OAuth2 provider framework for external-provider authentication + - `ras-identity-session`: JWT-based session management with configurable secrets and permission lookup - Two-stage authentication flow: identity verification followed by JWT session creation - Permission system with UserPermissions trait enabling flexible RBAC patterns - JwtAuthProvider implementing AuthProvider trait for seamless JSON-RPC integration @@ -488,23 +539,20 @@ All notable changes to this project will be documented in this file. ### Added - 2025-01-07 - Complete JSON-RPC library ecosystem with three core crates - - rust-jsonrpc-types: Pure JSON-RPC 2.0 protocol types and utilities - - rust-jsonrpc-core: Authentication and authorization framework with AuthProvider trait - - rust-jsonrpc-macro: Procedural macro for generating type-safe RPC interfaces with axum integration + - `ras-jsonrpc-types`: Pure JSON-RPC 2.0 protocol types and utilities + - `ras-jsonrpc-core`: Authentication and authorization framework with AuthProvider trait + - `ras-jsonrpc-macro`: Procedural macro for generating type-safe RPC interfaces with axum integration - Comprehensive test suite and integration tests for macro functionality - Workspace-level dependency management with shared crate versions - Example applications demonstrating JSON-RPC service implementation - basic-jsonrpc-service: Complete working example with authentication and multiple endpoints - Usage examples showing macro-generated service builders - Enhanced project documentation and development guidelines - - Updated CLAUDE.md with comprehensive crate organization patterns + - Updated crate organization patterns - Added development workflow instructions and dependency management guidelines - - Improved orchestration commands for better AI-assisted development - Sprint reflection system for tracking development progress and learnings ### Added - 2025-01-06 - Initial project setup with Cargo workspace structure -- Created rust-jsonrpc-macro procedural macro crate foundation +- Created `ras-jsonrpc-macro` procedural macro crate foundation - Added .gitignore for Rust and IDE artifacts -- Configured MCP integration with Context7 for enhanced documentation -- Added CLAUDE.md for AI-assisted development guidance diff --git a/Cargo.lock b/Cargo.lock index f3f678e..d04534c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,28 +2,13 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -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.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "once_cell", "version_check", "zerocopy", @@ -44,12 +29,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[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" @@ -65,56 +44,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" -[[package]] -name = "anstream" -version = "0.6.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstyle" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.59.0", -] - [[package]] name = "anyhow" version = "1.0.98" @@ -139,38 +74,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "async-trait" version = "0.1.88" @@ -188,12 +91,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "auto-future" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" - [[package]] name = "autocfg" version = "1.4.0" @@ -202,9 +99,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.8.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "base64 0.22.1", @@ -223,15 +120,14 @@ dependencies = [ "multer", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -240,9 +136,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -251,7 +147,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -285,12 +180,11 @@ dependencies = [ [[package]] name = "axum-test" -version = "18.0.0-rc3" +version = "18.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b81709cdada734964eba19892410332b2cbf79d21cda5b5996c45e9c451eeaf7" +checksum = "0ce2a8627e8d8851f894696b39f2b67807d6375c177361d376173ace306a21e2" dependencies = [ "anyhow", - "auto-future", "axum", "bytes", "bytesize", @@ -313,27 +207,6 @@ dependencies = [ "url", ] -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if 1.0.0", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets", -] - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base64" version = "0.21.7" @@ -415,12 +288,12 @@ dependencies = [ "anyhow", "async-trait", "axum", + "axum-test", "bidirectional-chat-api", "chrono", "config", "dashmap", "dotenvy", - "jsonwebtoken", "ras-auth-core", "ras-identity-core", "ras-identity-local", @@ -431,7 +304,6 @@ dependencies = [ "ras-jsonrpc-types", "ras-rest-core", "ras-rest-macro", - "reqwest", "schemars", "serde", "serde_json", @@ -456,7 +328,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -522,15 +394,15 @@ checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytesize" -version = "2.0.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" [[package]] name = "case" @@ -568,12 +440,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - [[package]] name = "cfg-if" version = "1.0.0" @@ -582,17 +448,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -629,7 +494,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" dependencies = [ "clap_builder", - "clap_derive", ] [[package]] @@ -638,22 +502,8 @@ version = "4.5.39" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" dependencies = [ - "anstream", "anstyle", "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", ] [[package]] @@ -662,12 +512,6 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - [[package]] name = "compact_str" version = "0.8.1" @@ -675,7 +519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", - "cfg-if 1.0.0", + "cfg-if", "itoa", "rustversion", "ryu", @@ -707,16 +551,10 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "wasm-bindgen", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-random" version = "0.1.18" @@ -895,18 +733,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.6" @@ -940,33 +766,6 @@ dependencies = [ "syn", ] -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if 1.0.0", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "darling" version = "0.20.11" @@ -1008,7 +807,7 @@ version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "crossbeam-utils", "hashbrown 0.14.5", "lock_api", @@ -1022,40 +821,11 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" -[[package]] -name = "deadpool" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" -dependencies = [ - "async-trait", - "deadpool-runtime", - "num_cpus", - "tokio", -] - -[[package]] -name = "deadpool-runtime" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -1073,7 +843,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", "subtle", ] @@ -1114,7 +883,7 @@ dependencies = [ "futures-channel", "futures-signals", "futures-util", - "gloo-events 0.1.2", + "gloo-events", "js-sys", "once_cell", "pin-project", @@ -1217,71 +986,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "email_address" version = "0.2.9" @@ -1297,7 +1007,7 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -1328,25 +1038,27 @@ dependencies = [ [[package]] name = "expect-json" -version = "1.0.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf8a36121b5fb67d1882fbbd2292fa59c0e97a442006d052296f7a6e0c781d9" +checksum = "869f97f4abe8e78fc812a94ad6b721d72c4fb5532877c79610f2c238d7ccf6c4" dependencies = [ "chrono", "email_address", "expect-json-macros", + "num", + "regex", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "typetag", "uuid", ] [[package]] name = "expect-json-macros" -version = "1.0.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0ef275cc4d31f49ae44d19a53c5087cf24245b7329c471653d793653f77b63d" +checksum = "6e6fdf550180a6c29a28cb9aac262dc0064c25735641d2317f670075e9a469d9" dependencies = [ "proc-macro2", "quote", @@ -1359,22 +1071,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "file-service-api" version = "0.1.0" @@ -1391,7 +1087,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-util", "tower", @@ -1407,18 +1103,12 @@ dependencies = [ "anyhow", "async-trait", "axum", - "chrono", "dotenvy", "file-service-api", - "jsonwebtoken", "mime_guess", "ras-auth-core", - "serde", - "serde_json", - "thiserror 2.0.12", + "tempfile", "tokio", - "tokio-util", - "tower", "tower-http", "tracing", "tracing-subscriber", @@ -1431,12 +1121,13 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "axum-test", "ras-auth-core", "ras-file-macro", "reqwest", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-util", "tower-http", @@ -1474,9 +1165,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1594,7 +1285,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -1615,7 +1305,7 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", "wasi 0.11.0+wasi-snapshot-preview1", ] @@ -1626,101 +1316,35 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", - "r-efi", + "r-efi 5.2.0", "wasi 0.14.2+wasi-0.2.4", ] [[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "glob" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - -[[package]] -name = "gloo-events" -version = "0.1.2" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "wasm-bindgen", - "web-sys", + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", ] [[package]] name = "gloo-events" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-net" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" -dependencies = [ - "futures-channel", - "futures-core", - "futures-sink", - "gloo-utils", - "http", - "js-sys", - "pin-project", - "serde", - "serde_json", - "thiserror 1.0.69", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "gloo-utils" -version = "0.2.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" dependencies = [ - "js-sys", - "serde", - "serde_json", "wasm-bindgen", "web-sys", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "h2" version = "0.4.10" @@ -1746,7 +1370,7 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "crunchy", "zerocopy", ] @@ -1793,15 +1417,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08" -[[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" @@ -1813,12 +1428,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1865,13 +1479,14 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -1918,14 +1533,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -2052,6 +1666,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2060,9 +1680,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2087,6 +1707,7 @@ checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", "hashbrown 0.15.4", + "serde", ] [[package]] @@ -2141,15 +1762,9 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - [[package]] name = "itertools" version = "0.10.5" @@ -2195,49 +1810,23 @@ dependencies = [ "serde", ] -[[package]] -name = "jsonwebtoken" -version = "10.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" -dependencies = [ - "base64 0.22.1", - "ed25519-dalek", - "getrandom 0.2.16", - "hmac", - "js-sys", - "p256", - "p384", - "pem", - "rand 0.8.5", - "rsa", - "serde", - "serde_json", - "sha2", - "signature", - "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.172" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "libm" -version = "0.2.16" +name = "libc" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "linux-raw-sys" @@ -2284,11 +1873,11 @@ dependencies = [ [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -2303,12 +1892,6 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" -[[package]] -name = "memory_units" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" - [[package]] name = "mime" version = "0.3.17" @@ -2325,31 +1908,12 @@ dependencies = [ "unicase", ] -[[package]] -name = "minicov" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" -dependencies = [ - "cc", - "walkdir", -] - [[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.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" -dependencies = [ - "adler2", -] - [[package]] name = "mio" version = "1.0.4" @@ -2420,12 +1984,25 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", ] [[package]] @@ -2439,26 +2016,19 @@ dependencies = [ ] [[package]] -name = "num-bigint-dig" -version = "0.8.6" +name = "num-complex" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", ] [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -2481,23 +2051,23 @@ dependencies = [ ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "num-rational" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "autocfg", - "libm", + "num-bigint", + "num-integer", + "num-traits", ] [[package]] -name = "num_cpus" -version = "1.17.0" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "hermit-abi", - "libc", + "autocfg", ] [[package]] @@ -2523,7 +2093,6 @@ dependencies = [ "axum", "chrono", "dotenvy", - "jsonwebtoken", "mime_guess", "oauth2-demo-api", "ras-identity-core", @@ -2543,48 +2112,18 @@ dependencies = [ "uuid", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "once_cell_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" - [[package]] name = "oorandom" version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "openrpc-to-bruno" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "openrpc-types", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.12", - "tokio", - "tokio-test", -] - [[package]] name = "openrpc-types" version = "0.1.1" @@ -2593,8 +2132,6 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror 2.0.12", - "tokio-test", ] [[package]] @@ -2604,7 +2141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ "bitflags", - "cfg-if 1.0.0", + "cfg-if", "foreign-types", "libc", "once_cell", @@ -2643,51 +2180,47 @@ dependencies = [ [[package]] name = "opentelemetry" -version = "0.28.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "236e667b670a5cdf90c258f5a55794ec5ac5027e960c224bff8367a59e1e6426" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" dependencies = [ "futures-core", "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.12", + "thiserror 2.0.18", "tracing", ] [[package]] name = "opentelemetry-prometheus" -version = "0.28.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765a76ba13ec77043903322f85dc5434d7d01a37e75536d0f871ed7b9b5bbf0d" +checksum = "2c0359983e7f79cf33c9abd89e5d7ddf67c46c419d0148598022d70e70c01aba" dependencies = [ "once_cell", "opentelemetry", "opentelemetry_sdk", "prometheus", - "protobuf", "tracing", ] [[package]] name = "opentelemetry_sdk" -version = "0.28.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84dfad6042089c7fc1f6118b7040dc2eb4ab520abbf410b79dc481032af39570" +checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" dependencies = [ - "async-trait", "futures-channel", "futures-executor", "futures-util", - "glob", "opentelemetry", "percent-encoding", - "rand 0.8.5", - "serde_json", - "thiserror 2.0.12", + "portable-atomic", + "rand 0.9.1", + "thiserror 2.0.18", "tokio", "tokio-stream", - "tracing", ] [[package]] @@ -2700,36 +2233,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - [[package]] name = "parking_lot" version = "0.12.4" @@ -2746,7 +2249,7 @@ version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", "redox_syscall", "smallvec", @@ -2776,30 +2279,11 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pem" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" -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" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -2808,7 +2292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.18", "ucd-trie", ] @@ -2863,7 +2347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -2920,27 +2404,6 @@ 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.32" @@ -2953,6 +2416,7 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "axum-test", "ras-auth-core", "ras-jsonrpc-core", "ras-jsonrpc-macro", @@ -2972,6 +2436,7 @@ dependencies = [ "async-trait", "axum", "axum-extra", + "axum-test", "ras-auth-core", "ras-rest-core", "ras-rest-macro", @@ -3011,6 +2476,12 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.2" @@ -3055,50 +2526,55 @@ dependencies = [ "syn", ] -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "prometheus" -version = "0.13.4" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "fnv", "lazy_static", "memchr", "parking_lot", "protobuf", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] name = "protobuf" -version = "2.28.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3109,11 +2585,17 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3174,7 +2656,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -3188,14 +2670,13 @@ dependencies = [ "proc-macro2", "quote", "ras-auth-core", - "ras-test-helpers", "reqwest", "schemars", "serde", "serde_json", "syn", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-util", ] @@ -3207,13 +2688,13 @@ dependencies = [ "async-trait", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", ] [[package]] name = "ras-identity-local" -version = "0.1.1" +version = "0.2.0" dependencies = [ "argon2", "async-trait", @@ -3222,32 +2703,30 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-test", ] [[package]] name = "ras-identity-oauth2" -version = "0.1.1" +version = "0.1.2" dependencies = [ "async-trait", "axum", + "axum-test", "base64 0.22.1", "chrono", - "rand 0.8.5", + "rand 0.8.6", "ras-identity-core", "ras-identity-session", "reqwest", "serde", "serde_json", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", - "tokio-test", "tracing", "tracing-subscriber", "url", "uuid", - "wiremock", ] [[package]] @@ -3255,14 +2734,16 @@ name = "ras-identity-session" version = "0.1.1" dependencies = [ "async-trait", + "base64 0.22.1", "chrono", - "jsonwebtoken", + "hmac", "ras-auth-core", "ras-identity-core", "ras-identity-local", "serde", "serde_json", - "thiserror 2.0.12", + "sha2", + "thiserror 2.0.18", "tokio", "uuid", ] @@ -3279,24 +2760,21 @@ dependencies = [ "futures", "http", "js-sys", - "rand 0.8.5", + "rand 0.8.6", "ras-auth-core", "ras-jsonrpc-bidirectional-types", "ras-jsonrpc-types", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", - "tokio-test", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "tracing", "tracing-subscriber", "url", - "uuid", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "wiremock", ] [[package]] @@ -3312,19 +2790,18 @@ dependencies = [ "http", "proc-macro2", "quote", - "rand 0.8.5", + "rand 0.8.6", "ras-auth-core", "ras-jsonrpc-bidirectional-client", "ras-jsonrpc-bidirectional-server", "ras-jsonrpc-bidirectional-types", "ras-jsonrpc-types", - "ras-test-helpers", "serde", "serde_json", "syn", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "url", "uuid", ] @@ -3344,9 +2821,8 @@ dependencies = [ "ras-jsonrpc-types", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", - "tokio-test", "tracing", ] @@ -3361,10 +2837,9 @@ dependencies = [ "ras-jsonrpc-types", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", - "tokio-test", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "tracing", "uuid", ] @@ -3378,7 +2853,7 @@ dependencies = [ "ras-version-core", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -3393,12 +2868,11 @@ dependencies = [ "futures", "proc-macro2", "quote", - "rand 0.8.5", + "rand 0.8.6", "ras-auth-core", "ras-identity-session", "ras-jsonrpc-core", "ras-jsonrpc-types", - "ras-test-helpers", "reqwest", "schemars", "serde", @@ -3455,7 +2929,7 @@ dependencies = [ "ras-auth-core", "ras-version-core", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -3469,34 +2943,21 @@ dependencies = [ "chrono", "criterion", "futures", - "hyper", "proc-macro2", "quote", - "rand 0.8.5", + "rand 0.8.6", "ras-auth-core", "ras-identity-session", "ras-jsonrpc-core", "ras-rest-core", - "ras-test-helpers", "reqwest", "schemars", "serde", - "serde_json", - "syn", - "tokio", - "tower", - "tracing", - "wiremock", -] - -[[package]] -name = "ras-test-helpers" -version = "0.0.0" -dependencies = [ - "axum", - "axum-test", - "ras-auth-core", + "serde_json", + "syn", "tokio", + "tower", + "tracing", ] [[package]] @@ -3575,42 +3036,27 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[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", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] -[[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.5" @@ -3669,7 +3115,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" dependencies = [ - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -3680,22 +3126,17 @@ dependencies = [ "axum", "axum-extra", "http", - "js-sys", "ras-auth-core", "ras-rest-core", "ras-rest-macro", "reqwest", "schemars", "serde", - "serde-wasm-bindgen", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tower", "tracing", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", ] [[package]] @@ -3720,16 +3161,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - [[package]] name = "ring" version = "0.17.14" @@ -3737,7 +3168,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if 1.0.0", + "cfg-if", "getrandom 0.2.16", "libc", "untrusted", @@ -3756,33 +3187,13 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rust-ini" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "ordered-multimap", ] @@ -3798,22 +3209,7 @@ dependencies = [ "http", "mime", "rand 0.9.1", - "thiserror 2.0.12", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", + "thiserror 2.0.18", ] [[package]] @@ -3936,20 +3332,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "2.11.1" @@ -4046,14 +3428,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -4093,7 +3476,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cpufeatures", "digest", ] @@ -4104,7 +3487,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cpufeatures", "digest", ] @@ -4154,28 +3537,6 @@ 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 0.6.4", -] - -[[package]] -name = "simple_asn1" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.12", - "time", -] - [[package]] name = "siphasher" version = "1.0.1" @@ -4199,12 +3560,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4213,16 +3574,6 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -4271,9 +3622,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.101" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4302,9 +3653,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags", "core-foundation", @@ -4345,11 +3696,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.18", ] [[package]] @@ -4365,9 +3716,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -4380,36 +3731,36 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "once_cell", ] [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -4446,11 +3797,10 @@ dependencies = [ [[package]] name = "tokio" -version = "1.45.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", "bytes", "libc", "mio", @@ -4459,14 +3809,14 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -4505,28 +3855,27 @@ dependencies = [ ] [[package]] -name = "tokio-test" -version = "0.4.4" +name = "tokio-tungstenite" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ - "async-stream", - "bytes", - "futures-core", + "futures-util", + "log", "tokio", - "tokio-stream", + "tungstenite 0.26.2", ] [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.29.0", ] [[package]] @@ -4585,9 +3934,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -4685,14 +4034,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -4720,10 +4069,26 @@ dependencies = [ "log", "rand 0.9.1", "sha1", - "thiserror 2.0.12", + "thiserror 2.0.18", "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.1", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "typeid" version = "1.0.3" @@ -4738,9 +4103,9 @@ checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "typetag" -version = "0.2.20" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f22b40dd7bfe8c14230cf9702081366421890435b2d625fa92b4acc4c3de6f" +checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c" dependencies = [ "erased-serde", "inventory", @@ -4751,9 +4116,9 @@ dependencies = [ [[package]] name = "typetag-impl" -version = "0.2.20" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f5380909ffc31b4de4f4bdf96b877175a016aa2ca98cee39fcfd8c4d53d952" +checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" dependencies = [ "proc-macro2", "quote", @@ -4821,13 +4186,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -4842,21 +4208,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" -version = "1.17.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.2", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -4912,13 +4272,31 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -4944,7 +4322,7 @@ version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "js-sys", "once_cell", "wasm-bindgen", @@ -4984,27 +4362,25 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen-test" -version = "0.3.50" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66c8d5e33ca3b6d9fa3b4676d774c5778031d27a578c2b007f905acf816152c3" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "js-sys", - "minicov", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test-macro", + "leb128fmt", + "wasmparser", ] [[package]] -name = "wasm-bindgen-test-macro" -version = "0.3.50" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17d5042cc5fa009658f9a7333ef24291b1291a25b6382dd68862a7f3b969f69b" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "proc-macro2", - "quote", - "syn", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] @@ -5030,41 +4406,32 @@ dependencies = [ "dwind", "dwind-macros", "futures-signals", - "gloo-events 0.2.0", - "gloo-net", - "gloo-timers", - "gloo-utils", "once_cell", - "reqwest", - "serde", - "serde_json", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-bindgen-test", "web-sys", - "wee_alloc", ] [[package]] -name = "web-sys" -version = "0.3.77" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "js-sys", - "wasm-bindgen", + "bitflags", + "hashbrown 0.15.4", + "indexmap", + "semver", ] [[package]] -name = "wee_alloc" -version = "0.4.5" +name = "web-sys" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ - "cfg-if 0.1.10", - "libc", - "memory_units", - "winapi", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -5106,7 +4473,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.1", "windows-result", "windows-strings", ] @@ -5139,13 +4506,19 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" dependencies = [ - "windows-link", + "windows-link 0.1.1", "windows-result", "windows-strings", ] @@ -5156,7 +4529,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -5165,7 +4538,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -5186,6 +4559,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5260,27 +4642,29 @@ dependencies = [ ] [[package]] -name = "wiremock" -version = "0.6.3" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "101681b74cd87b5899e87bcf5a64e83334dd313fcd3053ea72e6dba18928e301" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "assert-json-diff", - "async-trait", - "base64 0.22.1", - "deadpool", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-util", - "log", - "once_cell", - "regex", - "serde", - "serde_json", - "tokio", - "url", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", ] [[package]] @@ -5292,6 +4676,74 @@ dependencies = [ "bitflags", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.1" @@ -5418,3 +4870,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 89b5a72..cb8b715 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,6 @@ members = [ "crates/rpc/ras-jsonrpc-macro", "crates/rpc/ras-jsonrpc-types", "crates/specs/*", - "crates/test-utils/*", - "crates/tools/*", "examples/basic-jsonrpc/*", "examples/bidirectional-chat/api", "examples/bidirectional-chat/server", @@ -32,54 +30,40 @@ async-trait = "0.1" axum-extra = { version = "0.10", features = ["query"] } base64 = "0.22" bon = "3.2" -console = "0.15" console_error_panic_hook = "0.1" criterion = "0.5" crossterm = "0.28" dashmap = "6.1" -dialoguer = "0.11" dominator = "0.5" dotenvy = "0.15" dwind = "0.3.2" dwind-macros = "0.2.2" -dwui = "0.4.0" futures = "0.3" futures-signals = "0.3" -futures-signals-component-macro = "0.4.0" -futures-util = "0.3" -gloo-events = "0.2" -gloo-net = "0.6" -gloo-utils = "0.2" http = "1.0" +hmac = "0.12" js-sys = "0.3" -jsonwebtoken = { version = "10.3", features = ["rust_crypto"] } mime_guess = "2.0" once_cell = "1.20" -opentelemetry = "0.28" -opentelemetry-prometheus = "0.28" +opentelemetry = "0.32" +opentelemetry-prometheus = "0.32" proc-macro2 = "1.0" -prometheus = "0.13" +prometheus = "0.14" quote = "1.0" rand = "0.8" ratatui = "0.29" -rust-embed = "8.0" schemars = "1.0.0-alpha.20" serde_json = "1.0" sha2 = "0.10" tempfile = "3.13" thiserror = "2.0" -tokio-test = "0.4" tokio-tungstenite = "0.26" -toml = "0.8" tower-http = "0.6" tracing = "0.1" url = "2.5" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -wasm-bindgen-test = "0.3" web-sys = "0.3" -wee_alloc = "0.4" -wiremock = "0.6" [workspace.dependencies.argon2] version = "0.5" @@ -90,30 +74,18 @@ version = "0.8" features = ["multipart"] [workspace.dependencies.axum-test] -version = "18.0.0-rc3" +version = "18.7.0" [workspace.dependencies.chrono] version = "0.4" features = ["serde"] -[workspace.dependencies.clap] -version = "4.5.39" -features = ["derive"] - [workspace.dependencies.config] version = "0.14" features = ["toml"] -[workspace.dependencies.gloo-timers] -version = "0.3" -features = ["futures"] - -[workspace.dependencies.hyper] -version = "1.0" -features = ["full"] - [workspace.dependencies.opentelemetry_sdk] -version = "0.28" +version = "0.32" features = ["rt-tokio", "metrics"] [workspace.dependencies.rand_core] diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..ccdfdfa --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,161 @@ +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..083bc3e --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Rust Agent Stack 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 index 7b8c06a..36cb8f8 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,49 @@ # Rust Agent Stack (RAS) -A comprehensive Rust framework for building type-safe, authenticated agent systems with JSON-RPC, REST APIs, and file services. +A Rust framework for building type-safe, authenticated agent systems with JSON-RPC, REST APIs, and file services. ## Overview -The Rust Agent Stack provides a complete toolkit for building distributed agent systems with: -- 🔐 **Pluggable Authentication** - JWT, OAuth2, local auth with security best practices -- 🚀 **Type-Safe APIs** - Procedural macros for JSON-RPC, REST, and file services -- 🌐 **WebSocket Support** - Bidirectional real-time communication -- 📁 **File Services** - Type-safe file upload/download with streaming support -- 🎯 **Full-Stack TypeScript** - Automatic TypeScript client generation via WASM -- 🎨 **Reactive WASM UIs** - Build modern web apps with Dominator framework -- 📊 **Observability** - Built-in OpenTelemetry and Prometheus metrics -- 📝 **API Documentation** - Automatic OpenRPC and OpenAPI generation -- ✅ **Compile-Time Safety** - All endpoints must be implemented +The Rust Agent Stack provides reusable building blocks for distributed agent systems: +- **Pluggable Authentication** - JWT sessions, OAuth2, local username/password auth, and reusable authorization traits +- **Type-Safe APIs** - Procedural macros for JSON-RPC, REST, and file services +- **WebSocket Support** - Bidirectional real-time communication +- **File Services** - Type-safe file upload/download with streaming support +- **Generated Clients** - Rust and browser-friendly clients generated from shared service contracts +- **Reactive WASM UIs** - Browser apps built with Dominator and generated API clients +- **Observability** - OpenTelemetry and Prometheus metrics hooks +- **API Documentation** - Automatic OpenRPC and OpenAPI generation +- **Compile-Time Safety** - Generated traits require every endpoint to be implemented ## Quick Start +Prerequisites: +- Rust 1.88 or newer for Rust 2024 edition crates +- Node.js 22.13 or newer only for the WASM UI example and Playwright browser tests + ```bash # Clone the repository -git clone https://github.com/yourusername/rust-agent-stack.git +git clone https://github.com/JedimEmO/rust-agent-stack.git cd rust-agent-stack -# Build the entire workspace -cargo build +# Build the entire workspace with the checked-in lockfile +cargo build --locked # Run an example service -cargo run -p basic-jsonrpc-service - -# In another terminal, run the WASM UI example -cd examples/dominator-example -./build.sh -# Open http://localhost:8080 +cargo run -p basic-jsonrpc-service --locked ``` +The service listens on `http://localhost:3000` with: + +- JSON-RPC endpoint: `POST /rpc` +- Explorer UI: `http://localhost:3000/rpc/explorer` +- OpenRPC document: `http://localhost:3000/rpc/explorer/openrpc.json` +- Prometheus metrics: `http://localhost:3000/metrics` + +Frontend examples are optional. The generated-client TypeScript samples are +plain usage files under `examples/*/typescript-example`, while the only +npm-based app is [`examples/wasm-ui-demo`](examples/wasm-ui-demo/). + ## Architecture RAS is organized as a Cargo workspace with the following structure: @@ -43,7 +53,8 @@ crates/ ├── core/ # Core libraries │ ├── ras-auth-core # Authentication traits and types │ ├── ras-identity-core # Core identity provider traits -│ └── ras-observability-core # Unified observability traits +│ ├── ras-observability-core # Unified observability traits +│ └── ras-version-core # API version migration traits ├── rpc/ # JSON-RPC libraries │ ├── ras-jsonrpc-types # JSON-RPC 2.0 protocol types │ ├── ras-jsonrpc-core # JSON-RPC runtime support @@ -65,15 +76,12 @@ crates/ │ └── ras-observability-otel # OpenTelemetry implementation ├── specs/ # Specification types │ └── openrpc-types # OpenRPC 1.3.2 spec types -└── tools/ # Development tools - └── openrpc-to-bruno # Convert OpenRPC to Bruno examples/ # Example applications ├── basic-jsonrpc/ # JSON-RPC service demo ├── bidirectional-chat/ # Real-time chat system ├── file-service-example/ # File upload/download demo ├── file-service-wasm/ # File service with TypeScript ├── oauth2-demo/ # OAuth2 authentication flow -├── rest-api-demo/ # REST API example ├── rest-wasm-example/ # REST with TypeScript client └── wasm-ui-demo/ # Dominator WASM UI ``` @@ -97,29 +105,21 @@ jsonrpc_service!({ ] }); -// Implement the generated trait -struct TaskServiceImpl { /* ... */ } - -impl TaskServiceTrait for TaskServiceImpl { - async fn sign_in( - &self, - request: SignInRequest, - ) -> Result> { - // Your implementation - } - // ... other methods -} +// Implement the generated `TaskServiceTrait` on your service type. The +// runnable task implementation lives in `examples/basic-jsonrpc/service`. +struct TaskServiceImpl; // Use with the builder -let router = TaskServiceBuilder::new(TaskServiceImpl { /* ... */ }) +let router = TaskServiceBuilder::new(TaskServiceImpl) .base_url("/rpc") - .auth_provider(JwtAuthProvider::new()) + .auth_provider(MyAuthProvider) .build()?; ``` ### Type-Safe REST APIs -Build RESTful services with automatic OpenAPI documentation and TypeScript client generation: +Build RESTful services with automatic OpenAPI documentation that can feed +TypeScript client generation: ```rust use ras_rest_macro::rest_service; @@ -128,7 +128,7 @@ rest_service!({ service_name: UserService, base_path: "/api/v1", openapi: true, - serve_docs: true, // Serve Swagger UI at /api/v1/docs + serve_docs: true, // Serve the built-in API explorer at /api/v1/docs endpoints: [ GET UNAUTHORIZED users() -> UsersResponse, POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> UserResponse, @@ -137,19 +137,12 @@ rest_service!({ ] }); -// Implement the generated trait -struct UserServiceImpl { /* ... */ } - -#[async_trait::async_trait] -impl UserServiceTrait for UserServiceImpl { - async fn get_users(&self) -> RestResult { - // Your implementation - } - // ... other methods -} +// Implement the generated `UserServiceTrait` on your service type. The +// REST guide includes an in-memory implementation. +struct UserServiceImpl; // Build the Axum router -let app = UserServiceBuilder::new(UserServiceImpl { /* ... */ }) +let app = UserServiceBuilder::new(UserServiceImpl) .auth_provider(jwt_auth_provider) .build(); ``` @@ -171,6 +164,9 @@ jsonrpc_bidirectional_service!({ server_to_client: [ message_received(MessageReceivedNotification), user_joined(UserJoinedNotification), + ], + + server_to_client_calls: [ ] }); ``` @@ -193,28 +189,31 @@ file_service!({ }); ``` -### TypeScript Client Generation +### Generated Client Support -All service macros support automatic TypeScript client generation: +Service macros generate Rust clients and API documents from the same definitions used by the server. The REST and file-service browser examples are plain TypeScript usage samples that assume a fetch client generated locally from OpenAPI, while the JSON-RPC WASM UI uses generated Rust/WASM client code from the shared API crate. ```typescript -// Auto-generated TypeScript client -import { WasmUserServiceClient } from './pkg/user_api'; +import * as api from './generated'; -const client = new WasmUserServiceClient('http://localhost:3000'); -client.set_bearer_token('your-jwt-token'); +const users = await api.getUsers({ + baseUrl: 'http://localhost:3000/api/v1', +}); -const users = await client.get_users(); -const user = await client.create_user({ name: 'Alice', email: 'alice@example.com' }); +const created = await api.postUsers({ + baseUrl: 'http://localhost:3000/api/v1', + headers: { Authorization: 'Bearer admintoken' }, + body: { name: 'Alice', email: 'alice@example.com' }, +}); ``` ### Reactive WASM UIs -Build modern web applications with Dominator: +Build browser UIs with Dominator: ```rust use dominator::{html, Dom}; -use futures_signals::signal::Mutable; +use futures_signals::signal_vec::MutableVec; fn create_task_list(tasks: MutableVec) -> Dom { html!("div", { @@ -231,7 +230,7 @@ fn create_task_list(tasks: MutableVec) -> Dom { Simple task management API demonstrating authentication and OpenTelemetry metrics. ### [OAuth2 Demo](examples/oauth2-demo/) -Full-stack OAuth2 implementation with PKCE flow and role-based permissions. +OAuth2 demo with PKCE flow, JWT sessions, and role-based permissions. ### [Bidirectional Chat](examples/bidirectional-chat/) Real-time chat system with WebSocket communication, TUI client, and persistence. @@ -240,39 +239,43 @@ Real-time chat system with WebSocket communication, TUI client, and persistence. File upload/download service with streaming support and authentication. ### [File Service WASM](examples/file-service-wasm/) -Full-stack file service with TypeScript client and React frontend. - -### [REST API Demo](examples/rest-api-demo/) -RESTful API with OpenAPI documentation, Swagger UI, and Prometheus metrics. +File service with OpenAPI output and a minimal TypeScript usage sample for a generated fetch client. ### [REST WASM Example](examples/rest-wasm-example/) -REST API with auto-generated TypeScript client and web UI. +REST API with OpenAPI output and a minimal TypeScript usage sample for a generated fetch client. ### [WASM UI Demo](examples/wasm-ui-demo/) -Reactive web UI with Dominator framework, glass morphism design. +Reactive web UI with Dominator and the generated JSON-RPC client. ## Documentation -Detailed documentation is available in the `documentation/` directory: -- [REST Macro Guide](documentation/ras-rest-macro.md) - Complete REST API documentation +Detailed guides: +- [REST Macro Guide](documentation/ras-rest-macro.md) - REST API guide - [File Service Guide](documentation/ras-file-macro.md) - File upload/download services - [Identity Providers](documentation/ras-identity.md) - Authentication system guide - [Observability](documentation/ras-observability.md) - Metrics and monitoring +Package-level guides: +- [JSON-RPC Macro](crates/rpc/ras-jsonrpc-macro/README.md) - JSON-RPC service generation, OpenRPC output, and generated clients +- [JSON-RPC Core](crates/rpc/ras-jsonrpc-core/README.md) - runtime auth and JSON-RPC support types +- [Bidirectional JSON-RPC Macro](crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md) - WebSocket service generation +- [Bidirectional JSON-RPC Server](crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/README.md) - server-side WebSocket runtime +- [Bidirectional JSON-RPC Client](crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/README.md) - native and WASM WebSocket clients + ## Built-in Features ### Authentication & Security -- **Timing Attack Resistance** - Constant-time operations for authentication -- **Username Enumeration Prevention** - Uniform error responses -- **Rate Limiting** - Concurrent request limiting (5 attempts) -- **Secure Password Storage** - Argon2 hashing with proper salts -- **JWT Best Practices** - Configurable algorithms and secrets +- **Timing Attack Mitigation** - Missing local users verify against an Argon2 sentinel hash +- **Username Enumeration Mitigation** - Uniform invalid-credentials errors +- **Rate Limiting** - Local authentication limits concurrent verification attempts +- **Password Storage** - Per-user salted Argon2id hashes +- **JWT Configuration** - Configurable algorithms, secrets, TTLs, and active-session enforcement - **PKCE OAuth2** - Proof Key for Code Exchange by default - **Session Management** - JWT-based sessions with revocation support ### Observability -Add production-ready metrics with minimal configuration: +Add Prometheus-compatible metrics with minimal configuration: ```rust use ras_observability_otel::standard_setup; @@ -280,11 +283,12 @@ use ras_observability_otel::standard_setup; // Set up OpenTelemetry with Prometheus let otel = standard_setup("my-service")?; -// Use with service builders -let service = MyServiceBuilder::new(MyServiceImpl::new()) - .with_usage_tracker(otel.usage_tracker()) - .with_method_duration_tracker(otel.duration_tracker()) - .build()?; +let _usage_tracker = otel.usage_tracker(); +let _duration_tracker = otel.method_duration_tracker(); +let _metrics_router = otel.metrics_router(); + +// Wire the trackers into generated service builders through their +// `with_usage_tracker` and `with_method_duration_tracker` hooks. // Metrics available at /metrics endpoint ``` @@ -292,69 +296,131 @@ let service = MyServiceBuilder::new(MyServiceImpl::new()) Features: - Unified metrics for JSON-RPC, REST, and file services - Request counting, duration tracking, user activity -- Zero-config Prometheus integration +- Prometheus exporter and Axum `/metrics` router helpers - Extensible trait-based design -### TypeScript/WASM Support +### TypeScript And WASM Support -All service macros support automatic TypeScript client generation: -- Type-safe API calls with full IntelliSense -- Automatic error handling and retries -- Bearer token management -- Works in browsers and Node.js -- Zero runtime overhead with WASM +Browser examples use generated contracts without hand-written DTOs: +- REST and file-service TypeScript usage samples assume a fetch client generated from OpenAPI specs. +- JSON-RPC WASM UI uses generated Rust/WASM client code from the shared API crate. +- Bearer tokens are passed as ordinary per-request headers. ## Development -See [CLAUDE.md](CLAUDE.md) for detailed development guidelines and architecture decisions. +Use the workspace commands below as the baseline development checks. They mirror +the GitHub Actions workflow so local validation catches the same classes of +breakage before a pull request. + +### Rust Checks + +```bash +# Formatting and linting +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + +# Tests and doctests +cargo test --workspace --all-targets --all-features --no-run --locked +cargo test --workspace --all-targets --all-features --locked +cargo test --doc --workspace --all-features --locked + +# Documentation +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps --locked +``` + +### Documentation Hygiene -### Building +CI also checks that each Cargo package has a local README target and that local +Markdown links resolve. The checks are implemented directly in +[`.github/workflows/ci.yml`](.github/workflows/ci.yml) so the repository does +not need separate verification scripts. + +### Supply Chain Policy + +The tracked [`deny.toml`](deny.toml) is enforced in CI with `cargo-deny`. +Run the same check locally when dependency versions, features, or licenses +change: ```bash -# Development build -cargo build +cargo deny check +``` -# Release build with optimizations -cargo build --release +Install `cargo-deny` first if it is not already available: + +```bash +cargo install cargo-deny +``` -# Run all tests -cargo test +### Frontend Examples -# Run specific crate tests -cargo test -p ras-auth-core +```bash +# Generate OpenAPI specs used by the TypeScript usage samples +cargo check -p file-service-backend -p rest-backend --locked ``` -### Documentation +The generated-client usage samples are plain TypeScript files: + +- `examples/file-service-wasm/typescript-example/src/example.ts` +- `examples/rest-wasm-example/typescript-example/src/example.ts` + +The only npm-based frontend example is the Dominator WASM UI: ```bash -# Generate and open documentation -cargo doc --open +# Dominator WASM UI +npm --prefix examples/wasm-ui-demo ci +npm --prefix examples/wasm-ui-demo run build +``` + +### Browser Explorer Tests + +From the workspace root: -# Generate OpenRPC documentation (when enabled) -cargo build # OpenRPC files generated in target/openrpc/ +```bash +npm --prefix tests/playwright ci +npm --prefix tests/playwright run install:browsers +npm --prefix tests/playwright test +``` + +These tests start dedicated REST and JSON-RPC fixture servers and exercise the +generated API explorers in Chromium. + +### Coverage + +From the workspace root: + +```bash +cargo llvm-cov --workspace --all-targets --all-features --locked --lcov --output-path lcov.info +cargo llvm-cov report --summary-only +``` + +Install `cargo-llvm-cov` first if it is not already available: + +```bash +cargo install cargo-llvm-cov ``` ## Contributing -Contributions are welcome! Please read our contributing guidelines and code of conduct. +Contributions are welcome. Keep changes focused, include tests for behavioral changes, and run the relevant workspace checks before opening a pull request. ### Development Setup -1. Install Rust (latest stable) -2. Install wasm-pack for WASM examples: `cargo install wasm-pack` +1. Install Rust 1.88 or newer +2. Install Node.js 22.13 or newer for the WASM UI example and Playwright browser tests 3. Clone the repository -4. Run `cargo build` to verify setup +4. Run `cargo build --locked` to verify setup ## License -This project is licensed under the MIT License - see the LICENSE file for details. +This project is licensed under either MIT or Apache-2.0. See +[`LICENSE-MIT`](LICENSE-MIT) and [`LICENSE-APACHE`](LICENSE-APACHE). ## Acknowledgments -Built with these excellent Rust crates: +Built with these Rust crates: - [Axum](https://github.com/tokio-rs/axum) - Web framework - [Tokio](https://tokio.rs/) - Async runtime - [Dominator](https://github.com/Pauan/rust-dominator) - WASM UI framework - [Tungstenite](https://github.com/snapview/tungstenite-rs) - WebSocket implementation -- [jsonwebtoken](https://github.com/Keats/jsonwebtoken) - JWT support +- [hmac](https://github.com/RustCrypto/MACs), [sha2](https://github.com/RustCrypto/hashes), and [base64](https://github.com/marshallpierce/rust-base64) - HMAC-signed JWT support - [async-trait](https://github.com/dtolnay/async-trait) - Async traits diff --git a/agent-research/rust-api-to-ts-client.md b/agent-research/rust-api-to-ts-client.md deleted file mode 100644 index 45a7a53..0000000 --- a/agent-research/rust-api-to-ts-client.md +++ /dev/null @@ -1,233 +0,0 @@ -# Rust API to TypeScript Client Generation with ras-file-macro - -## Overview - -The `ras-file-macro` provides a powerful procedural macro that generates TypeScript/WASM bindgen compatible clients from Rust API definitions. This enables seamless integration between Rust backend services and TypeScript web applications through WebAssembly. - -## How It Works - -### 1. Macro Definition - -The `file_service!` macro (defined in `crates/rest/ras-file-macro/`) takes a service definition and generates: -- Native Rust client implementation -- WASM-bindgen compatible wrapper client -- Server-side implementation -- TypeScript type definitions - -Example macro usage: -```rust -file_service!({ - service_name: DocumentService, - base_path: "/api/documents", - body_limit: 104857600, // 100 MB - endpoints: [ - UPLOAD UNAUTHORIZED upload() -> UploadResponse, - UPLOAD WITH_PERMISSIONS(["user"]) upload_profile_picture() -> UploadResponse, - DOWNLOAD UNAUTHORIZED download/{file_id:String}() -> (), - DOWNLOAD WITH_PERMISSIONS(["user"]) download_secure/{file_id:String}() -> (), - ] -}); -``` - -### 2. Code Generation Process - -#### WASM Client Generation (`src/client.rs`) - -The macro generates a WASM-bindgen wrapper struct that: - -1. **Wraps the native client** with `#[wasm_bindgen]` attributes -2. **Conditionally compiles** with `#[cfg(all(target_arch = "wasm32", feature = "wasm-client"))]` -3. **Provides JavaScript-friendly APIs**: - - Constructor accepting base URL string - - Authentication method (`set_bearer_token`) - - Async methods for each endpoint - -#### Method Generation - -For each endpoint, the macro generates specialized handlers: - -**Upload Operations:** -- Accepts JavaScript `File` objects -- Extracts file content as `Uint8Array` -- Reads content type from the File object -- Converts to Rust `Vec` for processing - -**Download Operations:** -- Returns data as JavaScript `Uint8Array` -- Handles binary data transfer efficiently - -### 3. Build Process - -The WASM client is built using `wasm-pack`: - -```bash -wasm-pack build --target web --out-dir pkg --features wasm-client -``` - -This generates: -- TypeScript definitions (`.d.ts` files) -- JavaScript glue code -- WASM binary module -- Package.json for npm integration - -Generated TypeScript interface example: -```typescript -export class WasmDocumentServiceClient { - constructor(base_url: string); - set_bearer_token(token?: string | null): void; - upload(file: File): Promise; - upload_profile_picture(file: File): Promise; - download(file_id: string): Promise; - download_secure(file_id: string): Promise; -} -``` - -## Architectural Benefits for Web Applications - -### 1. Type Safety Across Stack -- Single source of truth for API definitions in Rust -- Automatic TypeScript type generation ensures client-server contract consistency -- Compile-time validation prevents API mismatches - -### 2. Performance Advantages -- Direct WASM execution for data processing -- Efficient binary data handling without base64 encoding -- Reduced serialization overhead compared to traditional REST clients - -### 3. Developer Experience -- No manual API client maintenance -- Automatic updates when API changes -- IDE autocomplete and type checking in TypeScript - -### 4. Authentication Integration -- Built-in bearer token support -- Seamless integration with existing auth systems -- Per-endpoint permission configuration - -### 5. Cross-Platform Compatibility -- Same macro generates both native and WASM clients -- Conditional compilation separates platform-specific code -- Server code excluded from WASM builds - -## TypeScript Usage - -### Client Initialization - -```typescript -import init, { WasmDocumentServiceClient } from '@wasm/file_service_api.js'; - -// Initialize WASM module once -let wasmInitialized = false; -let clientInstance: WasmDocumentServiceClient | null = null; - -export async function getClient(): Promise { - if (!wasmInitialized) { - await init(); - wasmInitialized = true; - } - - if (!clientInstance) { - clientInstance = new WasmDocumentServiceClient(window.location.origin); - } - - return clientInstance; -} -``` - -### File Upload Example - -```typescript -const client = await getClient(); - -// Set authentication token if needed -client.set_bearer_token(authToken); - -// Upload file directly from input element -const fileInput = document.querySelector('input[type="file"]'); -const file = fileInput.files[0]; - -try { - const response = await client.upload(file); - console.log('Upload successful:', response); -} catch (error) { - console.error('Upload failed:', error); -} -``` - -### File Download Example - -```typescript -const client = await getClient(); - -try { - const data = await client.download(fileId); - - // Convert Uint8Array to Blob for download - const blob = new Blob([data], { type: 'application/octet-stream' }); - const url = URL.createObjectURL(blob); - - // Trigger download - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - a.click(); - - URL.revokeObjectURL(url); -} catch (error) { - console.error('Download failed:', error); -} -``` - -## Integration with Modern Web Frameworks - -The generated client works seamlessly with modern frameworks like: - -- **React/Solid.js**: Direct integration with hooks and state management -- **Vue**: Compatible with composition API -- **Svelte**: Works with reactive stores - -Example with Solid.js: -```typescript -export default function FileUpload() { - const [uploading, setUploading] = createSignal(false); - - const handleUpload = async (file: File) => { - setUploading(true); - try { - const client = await getClient(); - const response = await client.upload(file); - // Handle success - } finally { - setUploading(false); - } - }; - - // Component JSX... -} -``` - -## Key Architectural Patterns - -### 1. Separation of Concerns -- API definitions in dedicated crate (`file-service-api`) -- Backend implementation separate from client code -- WASM client feature-gated to avoid bloat - -### 2. Error Handling -- Rust errors automatically converted to JavaScript exceptions -- Consistent error structure across platforms -- Type-safe error responses - -### 3. Resource Management -- Automatic memory management through WASM bindgen -- Efficient binary data transfer -- No manual cleanup required - -### 4. Scalability -- Stateless client design -- Connection pooling handled by browser -- Compatible with CDN distribution - -## Conclusion - -The `ras-file-macro` represents a powerful approach to building type-safe, performant web applications with Rust backends. By generating TypeScript clients through WASM bindgen, it eliminates the traditional pain points of API integration while providing native-like performance for data-intensive operations. This architecture is particularly beneficial for applications dealing with file uploads, binary data processing, or requiring strong type safety across the full stack. \ No newline at end of file diff --git a/crates/core/ras-auth-core/Cargo.toml b/crates/core/ras-auth-core/Cargo.toml index 6b46fbf..0eee9a8 100644 --- a/crates/core/ras-auth-core/Cargo.toml +++ b/crates/core/ras-auth-core/Cargo.toml @@ -2,8 +2,14 @@ name = "ras-auth-core" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Core authentication and authorization traits for Rust Agent Stack services" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] serde = { workspace = true } serde_json = { workspace = true } -thiserror = { workspace = true } \ No newline at end of file +thiserror = { workspace = true } diff --git a/crates/core/ras-auth-core/README.md b/crates/core/ras-auth-core/README.md index cf00d04..611241b 100644 --- a/crates/core/ras-auth-core/README.md +++ b/crates/core/ras-auth-core/README.md @@ -1,14 +1,16 @@ # ras-auth-core -Core authentication traits and types for the Rust Agent Stack authentication system. +Core authentication and authorization traits for Rust Agent Stack services. ## Overview -This crate provides the foundational traits and types used across all RAS authentication implementations: +This crate defines the shared authentication contract used by the REST, +JSON-RPC, bidirectional JSON-RPC, and identity crates: - `AuthProvider` - Main trait for authentication providers - `AuthenticatedUser` - Represents an authenticated user with permissions - `AuthError` - Common error types for authentication failures +- `AuthFuture` - Boxed future type returned by authentication providers ## Key Types @@ -17,21 +19,27 @@ This crate provides the foundational traits and types used across all RAS authen The main trait that authentication providers must implement: ```rust -#[async_trait] -pub trait AuthProvider: Send + Sync { - async fn authenticate(&self, token: &str) -> Result; +use ras_auth_core::{AuthFuture, AuthProvider}; + +pub trait AuthProvider: Send + Sync + 'static { + fn authenticate(&self, token: String) -> AuthFuture<'_>; } ``` +The trait also provides a default `check_permissions` implementation that +requires every requested permission to be present in the authenticated user. + ### AuthenticatedUser Represents a successfully authenticated user: ```rust +use std::collections::HashSet; + pub struct AuthenticatedUser { - pub id: String, - pub username: String, - pub permissions: Vec, + pub user_id: String, + pub permissions: HashSet, + pub metadata: Option, } ``` @@ -42,10 +50,13 @@ Common authentication error types: ```rust pub enum AuthError { InvalidToken, - ExpiredToken, - InvalidCredentials, - InsufficientPermissions, - InternalError(String), + TokenExpired, + InsufficientPermissions { + required: Vec, + has: Vec, + }, + AuthenticationRequired, + Internal(String), } ``` @@ -59,19 +70,27 @@ This crate is typically used as a dependency by: ## Example ```rust -use ras_auth_core::{AuthProvider, AuthenticatedUser, AuthError}; -use async_trait::async_trait; +use std::collections::HashSet; + +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; struct MyAuthProvider; -#[async_trait] impl AuthProvider for MyAuthProvider { - async fn authenticate(&self, token: &str) -> Result { - // Your authentication logic here - Ok(AuthenticatedUser { - id: "user-123".to_string(), - username: "john.doe".to_string(), - permissions: vec!["read".to_string(), "write".to_string()], + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + if token != "valid-token" { + return Err(AuthError::InvalidToken); + } + + Ok(AuthenticatedUser { + user_id: "user-123".to_string(), + permissions: HashSet::from([ + "read".to_string(), + "write".to_string(), + ]), + metadata: None, + }) }) } } @@ -79,8 +98,15 @@ impl AuthProvider for MyAuthProvider { ## Integration -This crate integrates seamlessly with: +This crate is used by: - `ras-jsonrpc-macro` - For JSON-RPC service authentication - `ras-rest-macro` - For REST API authentication - `ras-identity-session` - For JWT-based authentication -- `ras-jsonrpc-bidirectional-server` - For WebSocket authentication \ No newline at end of file +- `ras-jsonrpc-bidirectional-server` - For WebSocket authentication + +## Checks + +```bash +cargo test -p ras-auth-core --locked +cargo clippy -p ras-auth-core --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/core/ras-auth-core/src/lib.rs b/crates/core/ras-auth-core/src/lib.rs index f5cad16..f502615 100644 --- a/crates/core/ras-auth-core/src/lib.rs +++ b/crates/core/ras-auth-core/src/lib.rs @@ -99,3 +99,172 @@ pub trait AuthProvider: Send + Sync + 'static { } } } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::sync::Arc; + use std::task::{Context, Poll, Wake, Waker}; + + use serde_json::json; + + use super::*; + + struct TestAuthProvider; + + impl AuthProvider for TestAuthProvider { + fn authenticate(&self, _token: String) -> AuthFuture<'_> { + unreachable!("permission tests only exercise the default helper") + } + } + + struct TokenAuthProvider; + + impl AuthProvider for TokenAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + if token != "good-token" { + return Err(AuthError::InvalidToken); + } + + Ok(AuthenticatedUser { + user_id: "user-1".to_string(), + permissions: HashSet::from(["widgets:read".to_string()]), + metadata: Some(json!({ "tenant": "acme" })), + }) + }) + } + } + + struct NoopWaker; + + impl Wake for NoopWaker { + fn wake(self: Arc) {} + } + + fn poll_auth_future(mut future: AuthFuture<'_>) -> AuthResult { + let waker = Waker::from(Arc::new(NoopWaker)); + let mut context = Context::from_waker(&waker); + + match future.as_mut().poll(&mut context) { + Poll::Ready(result) => result, + Poll::Pending => panic!("test auth future should complete immediately"), + } + } + + fn user_with_permissions(permissions: &[&str]) -> AuthenticatedUser { + AuthenticatedUser { + user_id: "user-1".to_string(), + permissions: permissions + .iter() + .map(|permission| permission.to_string()) + .collect(), + metadata: Some(json!({ "tenant": "acme" })), + } + } + + #[test] + fn check_permissions_allows_user_when_all_required_permissions_are_present() { + let provider = TestAuthProvider; + let user = user_with_permissions(&["users:read", "users:write"]); + let required = vec!["users:read".to_string(), "users:write".to_string()]; + + let result = provider.check_permissions(&user, &required); + + assert!(result.is_ok()); + } + + #[test] + fn check_permissions_allows_user_when_no_permissions_are_required() { + let provider = TestAuthProvider; + let user = user_with_permissions(&[]); + + let result = provider.check_permissions(&user, &[]); + + assert!(result.is_ok()); + } + + #[test] + fn check_permissions_returns_required_and_actual_permissions_when_one_is_missing() { + let provider = TestAuthProvider; + let user = user_with_permissions(&["users:read"]); + let required = vec!["users:read".to_string(), "users:write".to_string()]; + + let result = provider.check_permissions(&user, &required); + + let AuthError::InsufficientPermissions { required, has } = result.unwrap_err() else { + panic!("expected insufficient permissions"); + }; + assert_eq!(required, vec!["users:read", "users:write"]); + assert_eq!( + has.into_iter().collect::>(), + HashSet::from(["users:read".to_string()]) + ); + } + + #[test] + fn authenticated_user_serializes_permissions_and_metadata() { + let user = user_with_permissions(&["users:read", "users:write"]); + + let json = serde_json::to_value(&user).expect("serialize user"); + let round_trip: AuthenticatedUser = serde_json::from_value(json).expect("deserialize user"); + + assert_eq!(round_trip.user_id, "user-1"); + assert_eq!(round_trip.permissions, user.permissions); + assert_eq!(round_trip.metadata, Some(json!({ "tenant": "acme" }))); + } + + #[test] + fn auth_error_display_messages_are_stable_for_clients() { + assert_eq!(AuthError::InvalidToken.to_string(), "Invalid token"); + assert_eq!(AuthError::TokenExpired.to_string(), "Token expired"); + assert_eq!( + AuthError::AuthenticationRequired.to_string(), + "Authentication required" + ); + assert_eq!( + AuthError::Internal("store unavailable".to_string()).to_string(), + "Authentication error: store unavailable" + ); + } + + #[test] + fn auth_provider_future_alias_returns_authenticated_user() { + let provider = TokenAuthProvider; + + let user = poll_auth_future(provider.authenticate("good-token".to_string())) + .expect("token authenticates"); + + assert_eq!(user.user_id, "user-1"); + assert!(user.permissions.contains("widgets:read")); + assert_eq!(user.metadata, Some(json!({ "tenant": "acme" }))); + + assert!(poll_auth_future(provider.authenticate("bad-token".to_string())).is_err()); + } + + #[test] + fn auth_error_serializes_structured_permission_details() { + let error = AuthError::InsufficientPermissions { + required: vec!["admin".to_string()], + has: vec!["user".to_string()], + }; + + let value = serde_json::to_value(&error).expect("serialize auth error"); + assert_eq!( + value, + json!({ + "InsufficientPermissions": { + "required": ["admin"], + "has": ["user"] + } + }) + ); + + let decoded: AuthError = serde_json::from_value(value).expect("deserialize auth error"); + let AuthError::InsufficientPermissions { required, has } = decoded else { + panic!("expected insufficient permissions"); + }; + assert_eq!(required, vec!["admin"]); + assert_eq!(has, vec!["user"]); + } +} diff --git a/crates/core/ras-identity-core/Cargo.toml b/crates/core/ras-identity-core/Cargo.toml index 748d4e4..b22d73d 100644 --- a/crates/core/ras-identity-core/Cargo.toml +++ b/crates/core/ras-identity-core/Cargo.toml @@ -2,10 +2,12 @@ name = "ras-identity-core" version = "0.1.1" edition = "2024" +rust-version = "1.88" description = "Core traits and types for identity management and authentication" license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] async-trait = { workspace = true } @@ -14,4 +16,4 @@ serde_json = { workspace = true } thiserror = { workspace = true } [dev-dependencies] -tokio = { workspace = true } \ No newline at end of file +tokio = { workspace = true } diff --git a/crates/core/ras-identity-core/README.md b/crates/core/ras-identity-core/README.md index acff8cf..fd356a4 100644 --- a/crates/core/ras-identity-core/README.md +++ b/crates/core/ras-identity-core/README.md @@ -1,120 +1,96 @@ # ras-identity-core -Core traits and types for identity management in the Rust Agent Stack. +Core identity traits and types for Rust Agent Stack. ## Overview -This crate defines the foundational traits for identity providers and user permissions: +This crate defines the small interfaces shared by local authentication, OAuth2 authentication, and JWT session management: -- `IdentityProvider` - Trait for verifying user identities -- `UserPermissions` - Trait for determining user permissions -- `VerifiedIdentity` - Represents a successfully verified identity +- `IdentityProvider` verifies an authentication payload and returns a `VerifiedIdentity`. +- `UserPermissions` maps a verified identity to permission strings. +- `VerifiedIdentity` is the provider-neutral identity record passed into session creation. -## Key Traits - -### IdentityProvider - -The main trait for identity verification: +## Traits ```rust +use async_trait::async_trait; +use ras_identity_core::{IdentityProvider, IdentityResult, VerifiedIdentity}; + #[async_trait] pub trait IdentityProvider: Send + Sync { - async fn verify_identity( - &self, - provider_params: serde_json::Value, - ) -> Result; - - fn provider_name(&self) -> &str; + fn provider_id(&self) -> &str; + + async fn verify(&self, auth_payload: serde_json::Value) -> IdentityResult; } ``` -### UserPermissions - -Trait for determining permissions based on identity: - ```rust +use async_trait::async_trait; +use ras_identity_core::{IdentityResult, UserPermissions, VerifiedIdentity}; + #[async_trait] pub trait UserPermissions: Send + Sync { - async fn get_permissions( - &self, - identity: &VerifiedIdentity, - ) -> Result, PermissionError>; + async fn get_permissions(&self, identity: &VerifiedIdentity) -> IdentityResult>; } ``` -## Key Types - -### VerifiedIdentity - -Represents a successfully verified user identity: +## Verified Identity ```rust pub struct VerifiedIdentity { - pub provider: String, - pub user_id: String, - pub username: String, + pub provider_id: String, + pub subject: String, pub email: Option, + pub display_name: Option, pub metadata: Option, } ``` -## Built-in Implementations - -### NoopPermissions +## Built-In Permission Providers -Returns no permissions for any user (default): +- `NoopPermissions` returns no permissions. +- `StaticPermissions` returns the same permissions for every identity. ```rust -let permissions = NoopPermissions; -``` +use ras_identity_core::StaticPermissions; -### StaticPermissions - -Returns the same permissions for all users: - -```rust let permissions = StaticPermissions::new(vec!["read".to_string(), "write".to_string()]); ``` -## Usage with Provider Implementations - -This crate is used by concrete identity provider implementations: -- `ras-identity-local` - Username/password authentication -- `ras-identity-oauth2` - OAuth2 authentication (Google, etc.) - -## Example +## Provider Example ```rust -use ras_identity_core::{IdentityProvider, VerifiedIdentity, IdentityError}; use async_trait::async_trait; +use ras_identity_core::{IdentityError, IdentityProvider, IdentityResult, VerifiedIdentity}; struct MyIdentityProvider; #[async_trait] impl IdentityProvider for MyIdentityProvider { - async fn verify_identity( - &self, - provider_params: serde_json::Value, - ) -> Result { - // Your identity verification logic here + fn provider_id(&self) -> &str { + "my-provider" + } + + async fn verify(&self, auth_payload: serde_json::Value) -> IdentityResult { + let subject = auth_payload + .get("subject") + .and_then(|value| value.as_str()) + .ok_or(IdentityError::InvalidPayload)?; + Ok(VerifiedIdentity { - provider: "my-provider".to_string(), - user_id: "user-123".to_string(), - username: "john.doe".to_string(), - email: Some("john@example.com".to_string()), + provider_id: self.provider_id().to_string(), + subject: subject.to_string(), + email: None, + display_name: None, metadata: None, }) } - - fn provider_name(&self) -> &str { - "my-provider" - } } ``` -## Security Considerations +## Checks -- Provider parameters use `serde_json::Value` to maintain decoupling -- Implementations should validate all inputs -- Sensitive data should not be stored in metadata fields -- Use constant-time comparisons for security-critical operations \ No newline at end of file +```bash +cargo test -p ras-identity-core --locked +cargo clippy -p ras-identity-core --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/core/ras-identity-core/src/lib.rs b/crates/core/ras-identity-core/src/lib.rs index ac8490c..9ed5072 100644 --- a/crates/core/ras-identity-core/src/lib.rs +++ b/crates/core/ras-identity-core/src/lib.rs @@ -82,6 +82,7 @@ impl UserPermissions for StaticPermissions { #[cfg(test)] mod tests { use super::*; + use serde_json::json; fn vi() -> VerifiedIdentity { VerifiedIdentity { @@ -93,6 +94,36 @@ mod tests { } } + struct SubjectIdentityProvider; + + #[async_trait] + impl IdentityProvider for SubjectIdentityProvider { + fn provider_id(&self) -> &str { + "subject" + } + + async fn verify( + &self, + auth_payload: serde_json::Value, + ) -> IdentityResult { + let subject = auth_payload + .get("subject") + .and_then(|value| value.as_str()) + .ok_or(IdentityError::InvalidPayload)?; + + Ok(VerifiedIdentity { + provider_id: self.provider_id().to_string(), + subject: subject.to_string(), + email: auth_payload + .get("email") + .and_then(|value| value.as_str()) + .map(str::to_string), + display_name: None, + metadata: Some(json!({ "source": "test" })), + }) + } + } + #[test] fn identity_error_display_per_variant() { assert_eq!( @@ -139,6 +170,17 @@ mod tests { assert_eq!(perms, vec!["a".to_string(), "b".to_string()]); } + #[tokio::test] + async fn static_permissions_returns_a_fresh_vec_each_time() { + let p = StaticPermissions::new(vec!["read".into(), "write".into()]); + let mut first = p.get_permissions(&vi()).await.unwrap(); + first.push("mutated".to_string()); + + let second = p.get_permissions(&vi()).await.unwrap(); + + assert_eq!(second, vec!["read".to_string(), "write".to_string()]); + } + #[test] fn verified_identity_serde_round_trips() { let v = vi(); @@ -147,4 +189,57 @@ mod tests { assert_eq!(parsed.subject, "alice"); assert_eq!(parsed.provider_id, "test"); } + + #[test] + fn verified_identity_serializes_optional_profile_and_metadata() { + let identity = VerifiedIdentity { + provider_id: "oauth2".to_string(), + subject: "user-123".to_string(), + email: Some("user@example.test".to_string()), + display_name: Some("Example User".to_string()), + metadata: Some(json!({ + "tenant": "demo", + "groups": ["engineering", "admin"] + })), + }; + + assert_eq!( + serde_json::to_value(identity).unwrap(), + json!({ + "provider_id": "oauth2", + "subject": "user-123", + "email": "user@example.test", + "display_name": "Example User", + "metadata": { + "tenant": "demo", + "groups": ["engineering", "admin"] + } + }) + ); + } + + #[tokio::test] + async fn identity_provider_trait_verifies_valid_payload_and_rejects_invalid_payload() { + let provider = SubjectIdentityProvider; + + let identity = provider + .verify(json!({ + "subject": "alice", + "email": "alice@example.test" + })) + .await + .expect("valid payload verifies"); + + assert_eq!(identity.provider_id, "subject"); + assert_eq!(identity.subject, "alice"); + assert_eq!(identity.email, Some("alice@example.test".to_string())); + assert_eq!(identity.metadata, Some(json!({ "source": "test" }))); + + let error = provider + .verify(json!({ "email": "alice@example.test" })) + .await + .expect_err("missing subject should fail"); + + assert!(matches!(error, IdentityError::InvalidPayload)); + } } diff --git a/crates/core/ras-observability-core/Cargo.toml b/crates/core/ras-observability-core/Cargo.toml index e733e23..e588e74 100644 --- a/crates/core/ras-observability-core/Cargo.toml +++ b/crates/core/ras-observability-core/Cargo.toml @@ -2,14 +2,19 @@ name = "ras-observability-core" version = "0.1.0" edition = "2024" +rust-version = "1.88" description = "Core traits and types for observability in Rust Agent Stack" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] -ras-auth-core = { path = "../ras-auth-core" } +ras-auth-core = { path = "../ras-auth-core", version = "0.1.0" } async-trait = { workspace = true } serde = { workspace = true } axum = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full", "macros", "rt-multi-thread"] } -serde_json = { workspace = true } \ No newline at end of file +serde_json = { workspace = true } diff --git a/crates/core/ras-observability-core/README.md b/crates/core/ras-observability-core/README.md index a14a462..20b4dad 100644 --- a/crates/core/ras-observability-core/README.md +++ b/crates/core/ras-observability-core/README.md @@ -36,4 +36,11 @@ let context = context.with_metadata("request_id", "12345"); ## Integration -This crate provides the core abstractions. For a production-ready implementation with OpenTelemetry and Prometheus support, see `ras-observability-otel`. \ No newline at end of file +This crate provides the core abstractions. For an OpenTelemetry and Prometheus implementation, see `ras-observability-otel`. + +## Checks + +```bash +cargo test -p ras-observability-core --locked +cargo clippy -p ras-observability-core --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/core/ras-observability-core/src/lib.rs b/crates/core/ras-observability-core/src/lib.rs index eb5bbc9..bb34049 100644 --- a/crates/core/ras-observability-core/src/lib.rs +++ b/crates/core/ras-observability-core/src/lib.rs @@ -69,6 +69,15 @@ impl RequestContext { } } + /// Create a new WebSocket request context + pub fn websocket(method: impl Into) -> Self { + Self { + method: method.into(), + protocol: Protocol::WebSocket, + metadata: HashMap::new(), + } + } + /// Add metadata to the context pub fn with_metadata(mut self, key: impl Into, value: impl Into) -> Self { self.metadata.insert(key.into(), value.into()); diff --git a/crates/core/ras-observability-core/src/tests.rs b/crates/core/ras-observability-core/src/tests.rs index 7a8893d..98c7175 100644 --- a/crates/core/ras-observability-core/src/tests.rs +++ b/crates/core/ras-observability-core/src/tests.rs @@ -42,6 +42,14 @@ fn test_request_context_jsonrpc() { assert!(ctx.metadata.is_empty()); } +#[test] +fn test_request_context_websocket() { + let ctx = RequestContext::websocket("sendMessage"); + assert_eq!(ctx.method, "sendMessage"); + assert_eq!(ctx.protocol, Protocol::WebSocket); + assert!(ctx.metadata.is_empty()); +} + #[test] fn test_request_context_with_metadata() { let ctx = RequestContext::rest("POST", "/api/users") @@ -247,7 +255,7 @@ fn test_service_metrics_trait() { // Test request completed metrics.increment_requests_completed(&context, true); assert_eq!(metrics.requests_completed.try_lock().unwrap().len(), 1); - assert_eq!(metrics.requests_completed.try_lock().unwrap()[0].2, true); + assert!(metrics.requests_completed.try_lock().unwrap()[0].2); // Test method duration let duration = Duration::from_secs(1); diff --git a/crates/core/ras-version-core/Cargo.toml b/crates/core/ras-version-core/Cargo.toml index b16a842..5b846f2 100644 --- a/crates/core/ras-version-core/Cargo.toml +++ b/crates/core/ras-version-core/Cargo.toml @@ -2,9 +2,11 @@ name = "ras-version-core" version = "0.1.0" edition = "2024" +rust-version = "1.88" description = "Core traits for versioned API migrations in Rust Agent Stack" license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] diff --git a/crates/core/ras-version-core/README.md b/crates/core/ras-version-core/README.md new file mode 100644 index 0000000..013cf34 --- /dev/null +++ b/crates/core/ras-version-core/README.md @@ -0,0 +1,43 @@ +# ras-version-core + +Small core crate for explicit API version migrations. + +Rust Agent Stack service macros use `VersionMigration` when a legacy request +or response type needs to be converted to or from the canonical type for an +endpoint. Keeping the conversion in a trait makes version compatibility paths +visible, testable, and independent from transport-specific code. + +## Example + +```rust +use ras_version_core::VersionMigration; + +struct CreateUserV1 { + name: String, +} + +struct CreateUserV2 { + display_name: String, + send_welcome_email: bool, +} + +struct CreateUserMigration; + +impl VersionMigration for CreateUserMigration { + type Error = std::convert::Infallible; + + fn migrate(value: CreateUserV1) -> Result { + Ok(CreateUserV2 { + display_name: value.name, + send_welcome_email: true, + }) + } +} +``` + +## Checks + +```bash +cargo test -p ras-version-core --locked +cargo clippy -p ras-version-core --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/core/ras-version-core/src/lib.rs b/crates/core/ras-version-core/src/lib.rs index 527e9fa..e7f2cb1 100644 --- a/crates/core/ras-version-core/src/lib.rs +++ b/crates/core/ras-version-core/src/lib.rs @@ -12,3 +12,181 @@ pub trait VersionMigration { /// Convert `value` from one API version type into another. fn migrate(value: From) -> Result; } + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + use std::fmt; + + use super::*; + + #[derive(Debug, PartialEq)] + struct LegacyUser { + name: String, + } + + #[derive(Debug, PartialEq)] + struct CanonicalUser { + display_name: String, + active: bool, + } + + #[derive(Debug, PartialEq)] + struct LegacyResponse { + name: String, + } + + #[derive(Debug, PartialEq)] + struct CanonicalResponse { + display_name: String, + active: bool, + } + + #[derive(Debug, PartialEq)] + struct MigrationError(&'static str); + + impl fmt::Display for MigrationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } + } + + struct UserMigration; + + struct IdentityMigration; + + impl VersionMigration for UserMigration { + type Error = MigrationError; + + fn migrate(value: LegacyUser) -> Result { + if value.name.trim().is_empty() { + return Err(MigrationError("name is required")); + } + + Ok(CanonicalUser { + display_name: value.name, + active: true, + }) + } + } + + impl VersionMigration for UserMigration { + type Error = MigrationError; + + fn migrate(value: CanonicalResponse) -> Result { + if !value.active { + return Err(MigrationError("inactive users cannot be downgraded")); + } + + Ok(LegacyResponse { + name: value.display_name, + }) + } + } + + impl VersionMigration for IdentityMigration { + type Error = Infallible; + + fn migrate(value: String) -> Result { + Ok(value) + } + } + + fn assert_error_bounds() {} + + #[test] + fn migrate_returns_canonical_value_when_legacy_value_is_valid() { + let legacy = LegacyUser { + name: "Alice".to_string(), + }; + + let canonical = UserMigration::migrate(legacy).expect("migration succeeds"); + + assert_eq!( + canonical, + CanonicalUser { + display_name: "Alice".to_string(), + active: true, + } + ); + } + + #[test] + fn migrate_returns_domain_error_when_legacy_value_is_invalid() { + let legacy = LegacyUser { + name: " ".to_string(), + }; + + let error = UserMigration::migrate(legacy).expect_err("migration fails"); + + assert_eq!(error.to_string(), "name is required"); + } + + #[test] + fn same_migration_type_can_upgrade_requests_and_downgrade_responses() { + let canonical = UserMigration::migrate(LegacyUser { + name: "Alice".to_string(), + }) + .expect("request upgrade succeeds"); + let legacy = UserMigration::migrate(CanonicalResponse { + display_name: canonical.display_name, + active: canonical.active, + }) + .expect("response downgrade succeeds"); + + assert_eq!( + legacy, + LegacyResponse { + name: "Alice".to_string() + } + ); + } + + #[test] + fn response_downgrade_can_return_domain_error() { + let error = UserMigration::migrate(CanonicalResponse { + display_name: "Alice".to_string(), + active: false, + }) + .expect_err("inactive user should not downgrade"); + + assert_eq!(error.to_string(), "inactive users cannot be downgraded"); + } + + #[test] + fn infallible_migration_can_use_standard_infallible_error_type() { + let migrated = IdentityMigration::migrate("unchanged".to_string()) + .expect("infallible migration succeeds"); + + assert_eq!(migrated, "unchanged"); + } + + #[test] + fn migration_error_type_satisfies_public_trait_bounds() { + assert_error_bounds::(); + assert_error_bounds::(); + } + + #[test] + fn migration_trait_can_be_called_through_generic_helper() { + fn migrate_with(value: From) -> Result + where + M: VersionMigration, + { + M::migrate(value) + } + + let canonical: CanonicalUser = migrate_with::(LegacyUser { + name: "Alice".to_string(), + }) + .expect("generic migration succeeds"); + + assert_eq!( + canonical, + CanonicalUser { + display_name: "Alice".to_string(), + active: true, + } + ); + } +} diff --git a/crates/identity/ras-identity-local/Cargo.toml b/crates/identity/ras-identity-local/Cargo.toml index c130d50..98635c9 100644 --- a/crates/identity/ras-identity-local/Cargo.toml +++ b/crates/identity/ras-identity-local/Cargo.toml @@ -1,17 +1,19 @@ [package] name = "ras-identity-local" -version = "0.1.1" +version = "0.2.0" edition = "2024" +rust-version = "1.88" description = "Local username/password authentication provider with Argon2 hashing" license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [features] timing-tests = [] [dependencies] -ras-identity-core = { path = "../../core/ras-identity-core" } +ras-identity-core = { path = "../../core/ras-identity-core", version = "0.1.1" } async-trait = { workspace = true } argon2 = { workspace = true } @@ -19,6 +21,3 @@ rand_core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } - -[dev-dependencies] -tokio-test = { workspace = true } \ No newline at end of file diff --git a/crates/identity/ras-identity-local/README.md b/crates/identity/ras-identity-local/README.md index 5d849f9..5124306 100644 --- a/crates/identity/ras-identity-local/README.md +++ b/crates/identity/ras-identity-local/README.md @@ -6,15 +6,16 @@ Local username/password identity provider for the Rust Agent Stack. This crate provides a secure local authentication implementation using: - Argon2 password hashing (industry standard) -- Protection against timing attacks -- Prevention of username enumeration +- Timing-attack mitigation for missing users +- Username-enumeration resistant error responses - Thread-safe concurrent request handling ## Features -- **Secure Password Storage**: Uses Argon2id for password hashing -- **Attack Protection**: Constant-time operations prevent timing attacks -- **Rate Limiting**: Built-in semaphore limits concurrent authentication attempts +- **Password Storage**: Uses per-user salted Argon2id hashes +- **Duplicate Protection**: Rejects duplicate usernames instead of overwriting users +- **Attack Protection**: Missing users are checked against a fixed Argon2 sentinel hash +- **Concurrency Limiting**: A semaphore bounds simultaneous authentication attempts - **Thread-Safe**: Safe for use in async multi-threaded environments ## Usage @@ -22,70 +23,92 @@ This crate provides a secure local authentication implementation using: ### Basic Setup ```rust -use ras_identity_local::{LocalUserProvider, LocalUserStore}; use ras_identity_core::IdentityProvider; +use ras_identity_local::LocalUserProvider; -// Create a user store -let mut store = LocalUserStore::new(); -store.add_user("alice", "secure_password").await?; -store.add_user("bob", "another_password").await?; - -// Create the provider -let provider = LocalUserProvider::new(store); +let provider = LocalUserProvider::new(); +provider + .add_user( + "alice".to_string(), + "secure_password".to_string(), + Some("alice@example.com".to_string()), + Some("Alice".to_string()), + ) + .await?; // Verify identity -let params = serde_json::json!({ +let auth_payload = serde_json::json!({ "username": "alice", "password": "secure_password" }); -let identity = provider.verify_identity(params).await?; -assert_eq!(identity.username, "alice"); +let identity = provider.verify(auth_payload).await?; +assert_eq!(identity.subject, "alice"); ``` ### Integration with Session Service ```rust use ras_identity_local::LocalUserProvider; -use ras_identity_session::SessionService; -use ras_identity_core::StaticPermissions; +use ras_identity_session::{JwtAlgorithm, SessionConfig, SessionService}; +use chrono::Duration; +use std::sync::Arc; // Set up identity provider -let provider = LocalUserProvider::new(store); - -// Set up session service with permissions -let permissions = StaticPermissions::new(vec!["read".to_string(), "write".to_string()]); -let session_service = SessionService::new( - "your-secret-key".to_string(), - 3600, // 1 hour TTL - permissions, -); - -// Complete authentication flow -let identity = provider.verify_identity(login_params).await?; -let jwt = session_service.create_session(identity).await?; +let provider = LocalUserProvider::new(); +provider + .add_user( + "alice".to_string(), + "secure_password".to_string(), + Some("alice@example.com".to_string()), + Some("Alice".to_string()), + ) + .await?; + +let session_service = Arc::new(SessionService::new(SessionConfig { + jwt_secret: "use-at-least-32-bytes-of-random-secret".to_string(), + jwt_ttl: Duration::hours(1), + refresh_enabled: false, + enforce_active_sessions: true, + algorithm: JwtAlgorithm::HS256, +})?); + +session_service.register_provider(Box::new(provider)).await; + +// Run the authentication flow +let jwt = session_service + .begin_session( + "local", + serde_json::json!({ + "username": "alice", + "password": "secure_password" + }), + ) + .await?; ``` ## Security Features ### Attack Protection -1. **Timing Attack Resistance** - - Uses dummy Argon2 hash for non-existent users - - Ensures consistent response times regardless of user existence +1. **Timing Attack Mitigation** + - Verifies non-existent users against a fixed Argon2 sentinel hash + - Reduces user-existence timing differences during password verification -2. **Username Enumeration Prevention** +2. **Username Enumeration Mitigation** - Always returns `InvalidCredentials` for any failure - No distinction between "user not found" and "wrong password" -3. **Rate Limiting** +3. **Concurrency Limiting** - Semaphore limits to 5 concurrent authentication attempts - - Prevents brute force attacks + - Bounds authentication work; deploy an external rate limiter for per-user or per-IP policies 4. **Input Validation** - Handles empty credentials gracefully - - Validates input format and length + - Rejects malformed authentication payloads + - Handles very long credentials without leaking user-existence details - Safe handling of special characters + - Rejects duplicate usernames during user creation ### Password Requirements @@ -97,16 +120,28 @@ The implementation uses Argon2 default settings: ## Testing -The crate includes comprehensive security tests: +The crate includes focused security tests for: - Username enumeration attempts -- Timing attack detection - Concurrent request handling -- Password spraying simulation - Input validation edge cases +- Optional timing attack and brute-force simulations behind the `timing-tests` feature Run tests with: ```bash -cargo test -p ras-identity-local +cargo test -p ras-identity-local --locked +``` + +Run the timing-sensitive statistical check explicitly when the host is quiet +enough for stable measurements: +```bash +cargo test -p ras-identity-local --locked --features timing-tests -- --ignored +``` + +## Checks + +```bash +cargo test -p ras-identity-local --locked +cargo clippy -p ras-identity-local --all-targets --all-features --locked -- -D warnings ``` ## Best Practices @@ -115,4 +150,4 @@ cargo test -p ras-identity-local 2. **Use strong passwords** - consider implementing password policies 3. **Monitor failed attempts** - implement account lockout in production 4. **Rotate secrets** - change JWT secrets periodically -5. **Use HTTPS** - always use TLS in production environments \ No newline at end of file +5. **Use HTTPS** - always use TLS in production environments diff --git a/crates/identity/ras-identity-local/src/lib.rs b/crates/identity/ras-identity-local/src/lib.rs index aac2d93..4420f6f 100644 --- a/crates/identity/ras-identity-local/src/lib.rs +++ b/crates/identity/ras-identity-local/src/lib.rs @@ -9,6 +9,8 @@ use rand_core::OsRng; use ras_identity_core::{IdentityError, IdentityProvider, IdentityResult, VerifiedIdentity}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::error::Error; +use std::fmt; use std::sync::Arc; use tokio::sync::RwLock; @@ -27,6 +29,41 @@ pub struct LocalAuthPayload { pub password: String, } +/// Errors returned when managing local users. +#[derive(Debug)] +pub enum LocalUserError { + /// A user with the requested username already exists. + UserAlreadyExists { username: String }, + /// Password hashing failed while creating the user. + PasswordHash(argon2::password_hash::Error), +} + +impl fmt::Display for LocalUserError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UserAlreadyExists { username } => { + write!(f, "user '{username}' already exists") + } + Self::PasswordHash(error) => write!(f, "failed to hash password: {error}"), + } + } +} + +impl Error for LocalUserError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::PasswordHash(error) => Some(error), + Self::UserAlreadyExists { .. } => None, + } + } +} + +impl From for LocalUserError { + fn from(error: argon2::password_hash::Error) -> Self { + Self::PasswordHash(error) + } +} + #[derive(Clone)] pub struct LocalUserProvider { users: Arc>>, @@ -47,7 +84,14 @@ impl LocalUserProvider { password: String, email: Option, display_name: Option, - ) -> Result<(), argon2::password_hash::Error> { + ) -> Result<(), LocalUserError> { + { + let users = self.users.read().await; + if users.contains_key(&username) { + return Err(LocalUserError::UserAlreadyExists { username }); + } + } + let argon2 = Argon2::default(); let salt = SaltString::generate(&mut OsRng); let password_hash = argon2 @@ -63,6 +107,9 @@ impl LocalUserProvider { }; let mut users = self.users.write().await; + if users.contains_key(&username) { + return Err(LocalUserError::UserAlreadyExists { username }); + } users.insert(username, user); @@ -75,17 +122,19 @@ impl LocalUserProvider { } async fn verify_user(&self, username: &str, password: &str) -> IdentityResult { - let _semlock = self.semaphore.clone().acquire_owned().await.unwrap(); + let _semlock = + self.semaphore.clone().acquire_owned().await.map_err(|_| { + IdentityError::ProviderError("local auth limiter closed".to_string()) + })?; let users = self.users.read().await; - // Use a dummy hash to prevent timing attacks - // This is a real Argon2 hash of "dummy_password" to ensure consistent timing - const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$9QsJRKgzJkKaOUvlp7gl2Q$qmE3qIFBNJ6nZYbLYXEI2uo0zZc7T0Q8LU1ZsqsZ3QE"; + // Verify missing users against a fixed sentinel hash to keep timing consistent. + const SENTINEL_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$9QsJRKgzJkKaOUvlp7gl2Q$qmE3qIFBNJ6nZYbLYXEI2uo0zZc7T0Q8LU1ZsqsZ3QE"; - let (user_exists, password_hash) = if let Some(user) = users.get(username) { - (true, user.password_hash.as_str()) + let (user, password_hash) = if let Some(user) = users.get(username) { + (Some(user.clone()), user.password_hash.as_str()) } else { - (false, DUMMY_HASH) + (None, SENTINEL_HASH) }; let parsed_hash = PasswordHash::new(password_hash) @@ -95,9 +144,9 @@ impl LocalUserProvider { .verify_password(password.as_bytes(), &parsed_hash) .is_ok(); - // Only succeed if both user exists AND password is valid - if user_exists && password_valid { - Ok(users.get(username).unwrap().clone()) + // Only succeed if both user exists AND password is valid. + if password_valid { + user.ok_or(IdentityError::InvalidCredentials) } else { // Always return the same error regardless of whether user exists or password is wrong Err(IdentityError::InvalidCredentials) @@ -183,6 +232,134 @@ mod tests { assert_eq!(identity.provider_id, "local"); } + #[tokio::test] + async fn test_duplicate_user_is_rejected() { + let provider = setup_test_provider().await; + + let result = provider + .add_user( + "testuser".to_string(), + "replacement-password".to_string(), + Some("other@example.com".to_string()), + Some("Other User".to_string()), + ) + .await; + + assert!(matches!( + result, + Err(LocalUserError::UserAlreadyExists { username }) if username == "testuser" + )); + + let original_password_payload = serde_json::json!({ + "username": "testuser", + "password": "password123" + }); + assert!(provider.verify(original_password_payload).await.is_ok()); + + let replacement_password_payload = serde_json::json!({ + "username": "testuser", + "password": "replacement-password" + }); + assert!(matches!( + provider.verify(replacement_password_payload).await, + Err(IdentityError::InvalidCredentials) + )); + } + + #[tokio::test] + async fn remove_user_deletes_credentials_and_returns_user() { + let provider = setup_test_provider().await; + + let removed = provider.remove_user("alice").await.expect("user removed"); + assert_eq!(removed.username, "alice"); + assert_eq!(removed.email.as_deref(), Some("alice@example.com")); + + let payload = serde_json::json!({ + "username": "alice", + "password": "supersecret" + }); + let result = provider.verify(payload).await; + assert!(matches!(result, Err(IdentityError::InvalidCredentials))); + assert!(provider.remove_user("alice").await.is_none()); + } + + #[tokio::test] + async fn default_provider_starts_empty_with_local_provider_id() { + let provider = LocalUserProvider::default(); + assert_eq!(provider.provider_id(), "local"); + + let result = provider + .verify(serde_json::json!({ + "username": "missing", + "password": "irrelevant" + })) + .await; + assert!(matches!(result, Err(IdentityError::InvalidCredentials))); + } + + #[tokio::test] + async fn malformed_stored_password_hash_returns_provider_error() { + let provider = LocalUserProvider::new(); + provider.users.write().await.insert( + "broken".to_string(), + LocalUser { + username: "broken".to_string(), + password_hash: "not-a-phc-password-hash".to_string(), + email: None, + display_name: None, + metadata: None, + }, + ); + + let result = provider + .verify(serde_json::json!({ + "username": "broken", + "password": "password123" + })) + .await; + + assert!(matches!( + result, + Err(IdentityError::ProviderError(message)) + if message.contains("password hash") || message.contains("PHC") + )); + } + + #[tokio::test] + async fn closed_limiter_returns_provider_error() { + let provider = setup_test_provider().await; + provider.semaphore.close(); + + let result = provider + .verify(serde_json::json!({ + "username": "testuser", + "password": "password123" + })) + .await; + + assert!(matches!( + result, + Err(IdentityError::ProviderError(message)) + if message == "local auth limiter closed" + )); + } + + #[test] + fn local_user_error_display_and_source_are_stable() { + use std::error::Error as _; + + let duplicate = LocalUserError::UserAlreadyExists { + username: "alice".to_string(), + }; + assert_eq!(duplicate.to_string(), "user 'alice' already exists"); + assert!(duplicate.source().is_none()); + + let parse_error = PasswordHash::new("not-a-phc-password-hash").unwrap_err(); + let hash_error = LocalUserError::from(parse_error); + assert!(hash_error.to_string().contains("failed to hash password")); + assert!(hash_error.source().is_some()); + } + #[tokio::test] async fn test_wrong_password_fails() { let provider = setup_test_provider().await; @@ -237,7 +414,7 @@ mod tests { #[cfg(feature = "timing-tests")] #[tokio::test] - #[ignore = "Timing test disabled - see issue with Argon2 parameter differences"] + #[ignore = "timing-sensitive statistical check; run explicitly on a quiet machine"] async fn test_timing_attack_resistance() { use std::time::{Duration, Instant}; @@ -279,11 +456,7 @@ mod tests { wrong_password_times.iter().sum::() / NUM_ATTEMPTS as u32; // The difference should be small (less than 10ms typically for Argon2) - let time_diff = if avg_nonexistent > avg_wrong_password { - avg_nonexistent - avg_wrong_password - } else { - avg_wrong_password - avg_nonexistent - }; + let time_diff = avg_nonexistent.abs_diff(avg_wrong_password); println!("Average time for nonexistent user: {:?}", avg_nonexistent); println!("Average time for wrong password: {:?}", avg_wrong_password); @@ -534,7 +707,7 @@ mod tests { } } - // Should have 50 successful and 50 failed + // Half of the attempts use valid credentials and half use invalid credentials. assert_eq!(successful_auths, CONCURRENT_ATTEMPTS / 2); assert_eq!(failed_auths, CONCURRENT_ATTEMPTS / 2); } diff --git a/crates/identity/ras-identity-oauth2/Cargo.toml b/crates/identity/ras-identity-oauth2/Cargo.toml index 6b3e499..d8b4981 100644 --- a/crates/identity/ras-identity-oauth2/Cargo.toml +++ b/crates/identity/ras-identity-oauth2/Cargo.toml @@ -1,14 +1,16 @@ [package] name = "ras-identity-oauth2" -version = "0.1.1" +version = "0.1.2" edition = "2024" +rust-version = "1.88" description = "OAuth2 authentication provider with Google support, PKCE, and state management" license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] -ras-identity-core = { path = "../../core/ras-identity-core" } +ras-identity-core = { path = "../../core/ras-identity-core", version = "0.1.1" } async-trait = { workspace = true } serde = { workspace = true } @@ -28,9 +30,8 @@ rand = { workspace = true } url = { workspace = true } [dev-dependencies] -tokio-test = { workspace = true } -wiremock = { workspace = true } +axum-test = { workspace = true } tracing-subscriber = { workspace = true } # For the example -ras-identity-session = { path = "../ras-identity-session" } \ No newline at end of file +ras-identity-session = { path = "../ras-identity-session", version = "0.1.1" } diff --git a/crates/identity/ras-identity-oauth2/README.md b/crates/identity/ras-identity-oauth2/README.md index bda326b..a6f1c9d 100644 --- a/crates/identity/ras-identity-oauth2/README.md +++ b/crates/identity/ras-identity-oauth2/README.md @@ -1,41 +1,39 @@ -# rust-identity-oauth2 +# ras-identity-oauth2 -OAuth2 identity provider implementation with PKCE support for the rust-agent-stack. +OAuth2 identity provider implementation with PKCE support for Rust Agent Stack. ## Features - **OAuth2 Authorization Code Flow** with PKCE (Proof Key for Code Exchange) - **CSRF Protection** via state parameters - **Generic OAuth2 Provider Support** (Google, GitHub, Microsoft, etc.) -- **Secure Implementation** with comprehensive security tests +- **Security-Focused Tests** for PKCE, state handling, and OAuth2 error paths - **Thread-Safe** state management - **Configurable User Info Mapping** for different provider schemas - **Integration** with existing `IdentityProvider` trait ## Security Features -- **PKCE Support**: Prevents authorization code interception attacks +- **PKCE Support**: Mitigates authorization code interception attacks - **State Parameter**: CSRF protection using cryptographically random UUIDs -- **Timing Attack Resistance**: Constant-time operations where applicable - **Input Validation**: Robust handling of malformed responses -- **Session Tracking**: Stateful session management for revocation +- **Single-Use State**: Callback state is removed after successful retrieval ## Usage ### Basic Setup ```rust -use rust_identity_oauth2::{ - OAuth2Config, OAuth2Provider, OAuth2ProviderConfig, InMemoryStateStore +use ras_identity_oauth2::{ + InMemoryStateStore, OAuth2Config, OAuth2Provider, OAuth2ProviderConfig, UserInfoMapping, }; -use std::sync::Arc; -use std::collections::HashMap; +use std::{collections::HashMap, env, sync::Arc}; // Configure OAuth2 provider (e.g., Google) let google_config = OAuth2ProviderConfig { provider_id: "google".to_string(), - client_id: "your-google-client-id".to_string(), - client_secret: "your-google-client-secret".to_string(), + client_id: env::var("GOOGLE_CLIENT_ID")?, + client_secret: env::var("GOOGLE_CLIENT_SECRET")?, authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth".to_string(), token_endpoint: "https://oauth2.googleapis.com/token".to_string(), userinfo_endpoint: Some("https://www.googleapis.com/oauth2/v1/userinfo".to_string()), @@ -60,12 +58,13 @@ let oauth2_provider = OAuth2Provider::new(config, state_store); ### Integration with Session Service ```rust -use rust_identity_session::SessionService; -use rust_identity_core::IdentityProvider; +use ras_identity_core::{IdentityError, IdentityProvider}; +use ras_identity_oauth2::OAuth2Response; +use ras_identity_session::{SessionConfig, SessionError, SessionService}; // Register with session service -let session_config = SessionConfig::default(); -let session_service = SessionService::new(session_config); +let session_config = SessionConfig::new("use-at-least-32-bytes-of-random-secret")?; +let session_service = SessionService::new(session_config)?; session_service.register_provider(Box::new(oauth2_provider)).await; @@ -84,9 +83,13 @@ match session_service.begin_session("oauth2", start_payload).await { // Redirect user to `url` println!("Redirect to: {}", url); } + OAuth2Response::Error { message } => { + eprintln!("OAuth2 start-flow failed: {message}"); + } } } - _ => panic!("Unexpected response"), + Ok(_) => eprintln!("OAuth2 start flow completed without a redirect"), + Err(err) => eprintln!("OAuth2 start flow failed: {err}"), } // Handle callback @@ -165,7 +168,7 @@ OAuth2ProviderConfig { redirect_uri: "http://localhost:3000/auth/github/callback".to_string(), scopes: vec!["user:email".to_string()], auth_params: HashMap::new(), - use_pkce: false, // GitHub doesn't support PKCE yet + use_pkce: false, // Set according to provider support and client type. user_info_mapping: Some(UserInfoMapping { subject_field: Some("id".to_string()), email_field: Some("email".to_string()), @@ -180,13 +183,20 @@ OAuth2ProviderConfig { For production use, implement a custom state store: ```rust -use rust_identity_oauth2::{OAuth2State, OAuth2StateStore, OAuth2Result}; +use ras_identity_oauth2::{OAuth2Error, OAuth2Result, OAuth2State, OAuth2StateStore}; use async_trait::async_trait; pub struct RedisStateStore { // Redis client implementation } +impl RedisStateStore { + async fn pop_state(&self, _state: &str) -> OAuth2Result { + // Retrieve and delete state from Redis with your Redis client. + Err(OAuth2Error::StateNotFound) + } +} + #[async_trait] impl OAuth2StateStore for RedisStateStore { async fn store(&self, state: OAuth2State) -> OAuth2Result<()> { @@ -195,8 +205,7 @@ impl OAuth2StateStore for RedisStateStore { } async fn retrieve(&self, state: &str) -> OAuth2Result { - // Retrieve and delete state from Redis - todo!() + self.pop_state(state).await } async fn cleanup_expired(&self) -> OAuth2Result { @@ -218,19 +227,26 @@ impl OAuth2StateStore for RedisStateStore { ## Testing -The crate includes comprehensive tests covering: +The crate includes tests covering: - PKCE generation and validation - State parameter security - Concurrent request handling - Error cases and edge conditions - Full OAuth2 flow simulation -- Security attack scenarios +- Callback state reuse and expiration scenarios Run tests with: ```bash -cargo test -p rust-identity-oauth2 +cargo test -p ras-identity-oauth2 --locked +``` + +## Checks + +```bash +cargo test -p ras-identity-oauth2 --locked +cargo clippy -p ras-identity-oauth2 --all-targets --all-features --locked -- -D warnings ``` ## Dependencies @@ -240,4 +256,4 @@ cargo test -p rust-identity-oauth2 - `uuid`: Cryptographically random state generation - `sha2`: SHA256 hashing for PKCE - `base64`: URL-safe encoding -- `chrono`: Time handling for state expiration \ No newline at end of file +- `chrono`: Time handling for state expiration diff --git a/crates/identity/ras-identity-oauth2/examples/google_oauth2.rs b/crates/identity/ras-identity-oauth2/examples/google_oauth2.rs index 098d4ee..2951cc2 100644 --- a/crates/identity/ras-identity-oauth2/examples/google_oauth2.rs +++ b/crates/identity/ras-identity-oauth2/examples/google_oauth2.rs @@ -23,9 +23,9 @@ async fn main() -> Result<(), Box> { let google_config = OAuth2ProviderConfig { provider_id: "google".to_string(), client_id: std::env::var("GOOGLE_CLIENT_ID") - .unwrap_or_else(|_| "your-google-client-id".to_string()), + .expect("GOOGLE_CLIENT_ID must be set for the Google OAuth2 example"), client_secret: std::env::var("GOOGLE_CLIENT_SECRET") - .unwrap_or_else(|_| "your-google-client-secret".to_string()), + .expect("GOOGLE_CLIENT_SECRET must be set for the Google OAuth2 example"), authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth".to_string(), token_endpoint: "https://oauth2.googleapis.com/token".to_string(), userinfo_endpoint: Some("https://www.googleapis.com/oauth2/v1/userinfo".to_string()), @@ -124,14 +124,14 @@ async fn simulate_callback( .await { Ok(jwt_token) => { - println!("✅ OAuth2 authentication successful!"); + println!("OAuth2 authentication successful."); println!("JWT Token: {}", jwt_token); // Verify the token println!("\n3. Verifying JWT token..."); match session_service.verify_session(&jwt_token).await { Ok(claims) => { - println!("✅ Token verified successfully!"); + println!("Token verified successfully."); println!("User ID: {}", claims.sub); println!("Email: {:?}", claims.email); println!("Display Name: {:?}", claims.display_name); @@ -139,12 +139,12 @@ async fn simulate_callback( println!("Permissions: {:?}", claims.permissions); } Err(e) => { - println!("❌ Token verification failed: {}", e); + println!("Token verification failed: {}", e); } } } Err(e) => { - println!("❌ OAuth2 callback failed: {}", e); + println!("OAuth2 callback failed: {}", e); println!( "Note: This is expected in the simulation as we're not using real OAuth2 endpoints" ); @@ -157,6 +157,7 @@ async fn simulate_callback( #[cfg(test)] mod tests { use super::*; + use ras_identity_core::IdentityProvider; #[tokio::test] async fn test_oauth2_configuration() { diff --git a/crates/identity/ras-identity-oauth2/src/client.rs b/crates/identity/ras-identity-oauth2/src/client.rs index 9592212..d8ee7a3 100644 --- a/crates/identity/ras-identity-oauth2/src/client.rs +++ b/crates/identity/ras-identity-oauth2/src/client.rs @@ -14,6 +14,81 @@ use std::time::Duration; use tracing::{debug, error, info}; use url::Url; +#[async_trait::async_trait] +pub(crate) trait OAuth2HttpTransport: Send + Sync { + async fn exchange_code( + &self, + token_endpoint: &str, + params: &HashMap, + ) -> OAuth2Result; + + async fn get_user_info( + &self, + userinfo_endpoint: &str, + access_token: &str, + ) -> OAuth2Result; +} + +#[derive(Clone)] +struct ReqwestOAuth2HttpTransport { + client: Client, +} + +#[async_trait::async_trait] +impl OAuth2HttpTransport for ReqwestOAuth2HttpTransport { + async fn exchange_code( + &self, + token_endpoint: &str, + params: &HashMap, + ) -> OAuth2Result { + let response = self.client.post(token_endpoint).form(params).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + error!("Token exchange failed: {}", error_text); + return Err(OAuth2Error::TokenExchangeFailed(error_text)); + } + + let token_response: TokenResponse = response + .json() + .await + .map_err(|e| OAuth2Error::InvalidTokenResponse(e.to_string()))?; + + info!("Successfully exchanged code for tokens"); + Ok(token_response) + } + + async fn get_user_info( + &self, + userinfo_endpoint: &str, + access_token: &str, + ) -> OAuth2Result { + let response = self + .client + .get(userinfo_endpoint) + .bearer_auth(access_token) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + error!("User info request failed: {}", error_text); + return Err(OAuth2Error::UserInfoFailed(error_text)); + } + + let user_info: UserInfoResponse = response + .json() + .await + .map_err(|e| OAuth2Error::InvalidUserInfoResponse(e.to_string()))?; + + debug!( + "Successfully retrieved user info for subject: {}", + user_info.sub + ); + Ok(user_info) + } +} + /// PKCE code challenge and verifier #[derive(Debug, Clone)] pub struct PkceChallenge { @@ -58,7 +133,7 @@ impl PkceChallenge { /// OAuth2 client for handling authorization flows #[derive(Clone)] pub struct OAuth2Client { - http_client: Client, + http_transport: Arc, state_store: Arc, state_ttl_seconds: u64, } @@ -69,13 +144,54 @@ impl OAuth2Client { state_ttl_seconds: u64, http_timeout_seconds: u64, ) -> Self { + match Self::try_new( + Arc::clone(&state_store), + state_ttl_seconds, + http_timeout_seconds, + ) { + Ok(client) => client, + Err(error) => { + error!( + "Failed to create configured OAuth2 HTTP client; using default client: {}", + error + ); + Self { + http_transport: Arc::new(ReqwestOAuth2HttpTransport { + client: Client::new(), + }), + state_store, + state_ttl_seconds, + } + } + } + } + + pub fn try_new( + state_store: Arc, + state_ttl_seconds: u64, + http_timeout_seconds: u64, + ) -> OAuth2Result { let http_client = Client::builder() .timeout(Duration::from_secs(http_timeout_seconds)) - .build() - .expect("Failed to create HTTP client"); + .build()?; + Ok(Self { + http_transport: Arc::new(ReqwestOAuth2HttpTransport { + client: http_client, + }), + state_store, + state_ttl_seconds, + }) + } + + #[cfg(test)] + pub(crate) fn with_http_transport( + state_store: Arc, + state_ttl_seconds: u64, + http_transport: Arc, + ) -> Self { Self { - http_client, + http_transport, state_store, state_ttl_seconds, } @@ -196,37 +312,26 @@ impl OAuth2Client { code_verifier: Option<&str>, ) -> OAuth2Result { let mut params = HashMap::new(); - params.insert("grant_type", "authorization_code"); - params.insert("code", code); - params.insert("client_id", &provider_config.client_id); - params.insert("client_secret", &provider_config.client_secret); - params.insert("redirect_uri", &provider_config.redirect_uri); + params.insert("grant_type".to_string(), "authorization_code".to_string()); + params.insert("code".to_string(), code.to_string()); + params.insert("client_id".to_string(), provider_config.client_id.clone()); + params.insert( + "client_secret".to_string(), + provider_config.client_secret.clone(), + ); + params.insert( + "redirect_uri".to_string(), + provider_config.redirect_uri.clone(), + ); // Add PKCE verifier if present if let Some(verifier) = code_verifier { - params.insert("code_verifier", verifier); + params.insert("code_verifier".to_string(), verifier.to_string()); } - let response = self - .http_client - .post(&provider_config.token_endpoint) - .form(¶ms) - .send() - .await?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - error!("Token exchange failed: {}", error_text); - return Err(OAuth2Error::TokenExchangeFailed(error_text)); - } - - let token_response: TokenResponse = response - .json() + self.http_transport + .exchange_code(&provider_config.token_endpoint, ¶ms) .await - .map_err(|e| OAuth2Error::InvalidTokenResponse(e.to_string()))?; - - info!("Successfully exchanged code for tokens"); - Ok(token_response) } /// Get user info using access token @@ -239,36 +344,112 @@ impl OAuth2Client { OAuth2Error::ConfigError("User info endpoint not configured".to_string()) })?; - let response = self - .http_client - .get(userinfo_endpoint) - .bearer_auth(access_token) - .send() - .await?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - error!("User info request failed: {}", error_text); - return Err(OAuth2Error::UserInfoFailed(error_text)); - } - - let user_info: UserInfoResponse = response - .json() + self.http_transport + .get_user_info(userinfo_endpoint, access_token) .await - .map_err(|e| OAuth2Error::InvalidUserInfoResponse(e.to_string()))?; - - debug!( - "Successfully retrieved user info for subject: {}", - user_info.sub - ); - Ok(user_info) } } #[cfg(test)] mod tests { use super::*; - use crate::state::InMemoryStateStore; + use crate::state::{InMemoryStateStore, OAuth2StateStore}; + use std::sync::Mutex; + + struct RecordingTransport { + token_requests: Mutex)>>, + userinfo_requests: Mutex>, + } + + impl RecordingTransport { + fn new() -> Self { + Self { + token_requests: Mutex::new(Vec::new()), + userinfo_requests: Mutex::new(Vec::new()), + } + } + + fn token_requests(&self) -> Vec<(String, HashMap)> { + self.token_requests + .lock() + .expect("token request lock") + .clone() + } + + fn userinfo_requests(&self) -> Vec<(String, String)> { + self.userinfo_requests + .lock() + .expect("userinfo request lock") + .clone() + } + } + + #[async_trait::async_trait] + impl OAuth2HttpTransport for RecordingTransport { + async fn exchange_code( + &self, + token_endpoint: &str, + params: &HashMap, + ) -> OAuth2Result { + self.token_requests + .lock() + .expect("token request lock") + .push((token_endpoint.to_string(), params.clone())); + Ok(TokenResponse { + access_token: "access-token".to_string(), + token_type: "Bearer".to_string(), + expires_in: Some(3600), + refresh_token: None, + scope: None, + id_token: None, + }) + } + + async fn get_user_info( + &self, + userinfo_endpoint: &str, + access_token: &str, + ) -> OAuth2Result { + self.userinfo_requests + .lock() + .expect("userinfo request lock") + .push((userinfo_endpoint.to_string(), access_token.to_string())); + Ok(UserInfoResponse { + sub: "user-1".to_string(), + email: Some("user@example.com".to_string()), + email_verified: Some(true), + name: Some("Test User".to_string()), + given_name: None, + family_name: None, + picture: None, + locale: None, + additional_claims: HashMap::new(), + }) + } + } + + fn provider_config() -> OAuth2ProviderConfig { + OAuth2ProviderConfig { + provider_id: "test_provider".to_string(), + client_id: "test_client_id".to_string(), + client_secret: "test_secret".to_string(), + authorization_endpoint: "https://example.com/auth".to_string(), + token_endpoint: "https://example.com/token".to_string(), + userinfo_endpoint: Some("https://example.com/userinfo".to_string()), + redirect_uri: "http://localhost:3000/callback".to_string(), + scopes: vec!["openid".to_string(), "email".to_string()], + auth_params: HashMap::new(), + use_pkce: true, + user_info_mapping: None, + } + } + + fn client_with_transport( + state_store: Arc, + transport: Arc, + ) -> OAuth2Client { + OAuth2Client::with_http_transport(state_store, 600, transport) + } #[test] fn test_pkce_generation() { @@ -294,19 +475,7 @@ mod tests { let state_store = Arc::new(InMemoryStateStore::new()); let client = OAuth2Client::new(state_store, 600, 30); - let provider_config = OAuth2ProviderConfig { - provider_id: "test_provider".to_string(), - client_id: "test_client_id".to_string(), - client_secret: "test_secret".to_string(), - authorization_endpoint: "https://example.com/auth".to_string(), - token_endpoint: "https://example.com/token".to_string(), - userinfo_endpoint: Some("https://example.com/userinfo".to_string()), - redirect_uri: "http://localhost:3000/callback".to_string(), - scopes: vec!["openid".to_string(), "email".to_string()], - auth_params: HashMap::new(), - use_pkce: true, - user_info_mapping: None, - }; + let provider_config = provider_config(); let (auth_url, state) = client .generate_authorization_url(&provider_config, HashMap::new()) @@ -331,4 +500,182 @@ mod tests { assert!(params.contains_key("code_challenge")); assert_eq!(params.get("code_challenge_method"), Some(&"S256".into())); } + + #[tokio::test] + async fn authorization_url_merges_provider_and_request_params_without_pkce() { + let state_store = Arc::new(InMemoryStateStore::new()); + let client = OAuth2Client::new(state_store.clone(), 600, 30); + let mut provider_config = provider_config(); + provider_config.use_pkce = false; + provider_config + .auth_params + .insert("prompt".to_string(), "consent".to_string()); + + let mut additional_params = HashMap::new(); + additional_params.insert("login_hint".to_string(), "user@example.com".to_string()); + + let (auth_url, state_param) = client + .generate_authorization_url(&provider_config, additional_params) + .await + .unwrap(); + + let url = Url::parse(&auth_url).unwrap(); + let params: HashMap<_, _> = url.query_pairs().collect(); + assert_eq!(params.get("prompt"), Some(&"consent".into())); + assert_eq!(params.get("login_hint"), Some(&"user@example.com".into())); + assert!(!params.contains_key("code_challenge")); + assert!(!params.contains_key("code_challenge_method")); + + let stored_state = state_store.retrieve(&state_param).await.unwrap(); + assert_eq!(stored_state.provider_id, "test_provider"); + assert!(stored_state.code_verifier.is_none()); + } + + #[tokio::test] + async fn handle_callback_rejects_state_for_wrong_provider_without_transport_call() { + let state_store = Arc::new(InMemoryStateStore::new()); + let transport = Arc::new(RecordingTransport::new()); + let client = client_with_transport(state_store, transport.clone()); + let provider_config = provider_config(); + + let (_, state) = client + .generate_authorization_url(&provider_config, HashMap::new()) + .await + .unwrap(); + + let mut wrong_provider = provider_config.clone(); + wrong_provider.provider_id = "other_provider".to_string(); + + let error = client + .handle_callback( + &wrong_provider, + AuthorizationResponse { + code: "auth-code".to_string(), + state, + error: None, + error_description: None, + }, + ) + .await + .expect_err("provider mismatch should reject callback"); + + assert!(matches!(error, OAuth2Error::InvalidState)); + assert!(transport.token_requests().is_empty()); + } + + #[tokio::test] + async fn handle_callback_returns_provider_callback_error_without_transport_call() { + let state_store = Arc::new(InMemoryStateStore::new()); + let transport = Arc::new(RecordingTransport::new()); + let client = client_with_transport(state_store, transport.clone()); + let provider_config = provider_config(); + + let (_, state) = client + .generate_authorization_url(&provider_config, HashMap::new()) + .await + .unwrap(); + + let error = client + .handle_callback( + &provider_config, + AuthorizationResponse { + code: "ignored-code".to_string(), + state, + error: Some("access_denied".to_string()), + error_description: Some("user denied consent".to_string()), + }, + ) + .await + .expect_err("provider callback error should be surfaced"); + + match error { + OAuth2Error::CallbackError(message) => { + assert_eq!(message, "access_denied: user denied consent"); + } + other => panic!("expected callback error, got {other:?}"), + } + assert!(transport.token_requests().is_empty()); + } + + #[tokio::test] + async fn handle_callback_omits_code_verifier_when_pkce_is_disabled() { + let state_store = Arc::new(InMemoryStateStore::new()); + let transport = Arc::new(RecordingTransport::new()); + let client = client_with_transport(state_store, transport.clone()); + let mut provider_config = provider_config(); + provider_config.use_pkce = false; + + let (_, state) = client + .generate_authorization_url(&provider_config, HashMap::new()) + .await + .unwrap(); + + let token = client + .handle_callback( + &provider_config, + AuthorizationResponse { + code: "auth-code".to_string(), + state, + error: None, + error_description: None, + }, + ) + .await + .unwrap(); + + assert_eq!(token.access_token, "access-token"); + let requests = transport.token_requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].0, "https://example.com/token"); + assert_eq!(requests[0].1.get("code"), Some(&"auth-code".to_string())); + assert_eq!( + requests[0].1.get("grant_type"), + Some(&"authorization_code".to_string()) + ); + assert!(!requests[0].1.contains_key("code_verifier")); + } + + #[tokio::test] + async fn get_user_info_returns_config_error_when_endpoint_is_missing() { + let state_store = Arc::new(InMemoryStateStore::new()); + let transport = Arc::new(RecordingTransport::new()); + let client = client_with_transport(state_store, transport.clone()); + let mut provider_config = provider_config(); + provider_config.userinfo_endpoint = None; + + let error = client + .get_user_info(&provider_config, "access-token") + .await + .expect_err("missing userinfo endpoint should be a config error"); + + match error { + OAuth2Error::ConfigError(message) => { + assert_eq!(message, "User info endpoint not configured"); + } + other => panic!("expected config error, got {other:?}"), + } + assert!(transport.userinfo_requests().is_empty()); + } + + #[tokio::test] + async fn get_user_info_delegates_endpoint_and_access_token_to_transport() { + let state_store = Arc::new(InMemoryStateStore::new()); + let transport = Arc::new(RecordingTransport::new()); + let client = client_with_transport(state_store, transport.clone()); + let provider_config = provider_config(); + + let user_info = client + .get_user_info(&provider_config, "access-token") + .await + .unwrap(); + + assert_eq!(user_info.sub, "user-1"); + assert_eq!( + transport.userinfo_requests(), + vec![( + "https://example.com/userinfo".to_string(), + "access-token".to_string() + )] + ); + } } diff --git a/crates/identity/ras-identity-oauth2/src/lib.rs b/crates/identity/ras-identity-oauth2/src/lib.rs index baf54b0..c695945 100644 --- a/crates/identity/ras-identity-oauth2/src/lib.rs +++ b/crates/identity/ras-identity-oauth2/src/lib.rs @@ -15,7 +15,7 @@ mod types; mod tests; pub use client::{OAuth2Client, PkceChallenge}; -pub use config::{OAuth2Config, OAuth2ProviderConfig}; +pub use config::{OAuth2Config, OAuth2ProviderConfig, UserInfoMapping}; pub use error::{OAuth2Error, OAuth2Result}; pub use provider::{OAuth2AuthPayload, OAuth2Provider, OAuth2Response}; pub use state::{InMemoryStateStore, OAuth2State, OAuth2StateStore}; diff --git a/crates/identity/ras-identity-oauth2/src/provider.rs b/crates/identity/ras-identity-oauth2/src/provider.rs index 7bb1a8f..596b983 100644 --- a/crates/identity/ras-identity-oauth2/src/provider.rs +++ b/crates/identity/ras-identity-oauth2/src/provider.rs @@ -44,8 +44,6 @@ pub enum OAuth2Response { /// OAuth2 provider that implements IdentityProvider #[derive(Clone)] pub struct OAuth2Provider { - #[allow(dead_code)] - config: OAuth2Config, client: OAuth2Client, provider_configs: HashMap, } @@ -60,7 +58,34 @@ impl OAuth2Provider { ); Self { - config, + client, + provider_configs, + } + } + + pub fn try_new( + config: OAuth2Config, + state_store: Arc, + ) -> OAuth2Result { + let provider_configs = config.providers.clone(); + let client = OAuth2Client::try_new( + state_store, + config.state_ttl_seconds, + config.http_timeout_seconds, + )?; + + Ok(Self { + client, + provider_configs, + }) + } + + #[cfg(test)] + pub(crate) fn with_client( + provider_configs: HashMap, + client: OAuth2Client, + ) -> Self { + Self { client, provider_configs, } @@ -273,12 +298,11 @@ impl IdentityProvider for OAuth2Provider { #[cfg(test)] mod tests { use super::*; + use crate::config::UserInfoMapping; use crate::state::InMemoryStateStore; - fn create_test_provider() -> OAuth2Provider { - let mut config = OAuth2Config::default(); - - let google_config = OAuth2ProviderConfig { + fn google_config() -> OAuth2ProviderConfig { + OAuth2ProviderConfig { provider_id: "google".to_string(), client_id: "test_client_id".to_string(), client_secret: "test_secret".to_string(), @@ -294,8 +318,12 @@ mod tests { auth_params: HashMap::new(), use_pkce: true, user_info_mapping: None, - }; + } + } + fn create_test_provider() -> OAuth2Provider { + let mut config = OAuth2Config::default(); + let google_config = google_config(); config.providers.insert("google".to_string(), google_config); let state_store = Arc::new(InMemoryStateStore::new()); @@ -333,6 +361,64 @@ mod tests { } } + #[tokio::test] + async fn verify_rejects_invalid_payload() { + let provider = create_test_provider(); + + let result = provider + .verify(serde_json::json!({ + "type": "StartFlow", + "additional_params": null + })) + .await; + + assert!(matches!(result, Err(IdentityError::InvalidPayload))); + } + + #[tokio::test] + async fn verify_reports_unknown_provider() { + let provider = create_test_provider(); + + let result = provider + .verify(serde_json::json!({ + "type": "StartFlow", + "provider_id": "missing" + })) + .await; + + let Err(IdentityError::ProviderError(message)) = result else { + panic!("expected provider error for missing provider"); + }; + assert!(message.contains("Provider 'missing' not configured")); + } + + #[tokio::test] + async fn add_provider_makes_start_flow_available() { + let state_store = Arc::new(InMemoryStateStore::new()); + let mut provider = OAuth2Provider::new(OAuth2Config::default(), state_store); + provider.add_provider(google_config()); + + let result = provider + .verify(serde_json::json!({ + "type": "StartFlow", + "provider_id": "google", + "additional_params": { + "prompt": "consent" + } + })) + .await; + + let Err(IdentityError::ProviderError(response_json)) = result else { + panic!("expected authorization URL response encoded as provider error"); + }; + let response: OAuth2Response = serde_json::from_str(&response_json).unwrap(); + let OAuth2Response::AuthorizationUrl { url, state } = response else { + panic!("expected authorization URL response"); + }; + assert!(url.contains("prompt=consent")); + assert!(!state.is_empty()); + } + #[test] fn test_user_info_mapping() { let provider = create_test_provider(); @@ -361,6 +447,96 @@ mod tests { let metadata = identity.metadata.unwrap(); assert_eq!(metadata["picture"], "https://example.com/picture.jpg"); - assert_eq!(metadata["email_verified"], true); + assert_eq!(metadata["email_verified"].as_bool(), Some(true)); + } + + #[test] + fn custom_user_info_mapping_prefers_additional_claims_and_preserves_metadata() { + let provider = create_test_provider(); + let mut provider_config = google_config(); + provider_config.user_info_mapping = Some(UserInfoMapping { + subject_field: Some("external_id".to_string()), + email_field: Some("mail".to_string()), + name_field: Some("display".to_string()), + picture_field: Some("avatar".to_string()), + }); + + let mut additional_claims = HashMap::new(); + additional_claims.insert( + "external_id".to_string(), + serde_json::Value::String("mapped-subject".to_string()), + ); + additional_claims.insert( + "mail".to_string(), + serde_json::Value::String("mapped@example.com".to_string()), + ); + additional_claims.insert( + "display".to_string(), + serde_json::Value::String("Mapped User".to_string()), + ); + additional_claims.insert( + "avatar".to_string(), + serde_json::Value::String("https://example.com/avatar.png".to_string()), + ); + additional_claims.insert( + "tenant".to_string(), + serde_json::Value::String("engineering".to_string()), + ); + + let identity = provider + .map_user_info_to_identity( + "google", + crate::types::UserInfoResponse { + sub: "fallback-subject".to_string(), + email: Some("fallback@example.com".to_string()), + email_verified: Some(false), + name: Some("Fallback User".to_string()), + given_name: None, + family_name: None, + picture: None, + locale: None, + additional_claims, + }, + &provider_config, + ) + .unwrap(); + + assert_eq!(identity.subject, "mapped-subject"); + assert_eq!(identity.email.as_deref(), Some("mapped@example.com")); + assert_eq!(identity.display_name.as_deref(), Some("Mapped User")); + + let metadata = identity.metadata.unwrap(); + assert_eq!(metadata["picture"], "https://example.com/avatar.png"); + assert_eq!(metadata["email_verified"].as_bool(), Some(false)); + assert_eq!(metadata["tenant"], "engineering"); + } + + #[test] + fn user_info_mapping_omits_empty_metadata() { + let provider = create_test_provider(); + let provider_config = provider.get_provider_config("google").unwrap(); + + let identity = provider + .map_user_info_to_identity( + "google", + crate::types::UserInfoResponse { + sub: "subject-only".to_string(), + email: None, + email_verified: None, + name: None, + given_name: None, + family_name: None, + picture: None, + locale: None, + additional_claims: HashMap::new(), + }, + provider_config, + ) + .unwrap(); + + assert_eq!(identity.subject, "subject-only"); + assert!(identity.email.is_none()); + assert!(identity.display_name.is_none()); + assert!(identity.metadata.is_none()); } } diff --git a/crates/identity/ras-identity-oauth2/src/tests.rs b/crates/identity/ras-identity-oauth2/src/tests.rs index 4b84d73..487934f 100644 --- a/crates/identity/ras-identity-oauth2/src/tests.rs +++ b/crates/identity/ras-identity-oauth2/src/tests.rs @@ -2,24 +2,89 @@ #[cfg(test)] mod integration_tests { + use crate::client::{OAuth2Client, OAuth2HttpTransport}; + use crate::error::{OAuth2Error, OAuth2Result}; use crate::provider::OAuth2Response; - use crate::{InMemoryStateStore, OAuth2Config, OAuth2Provider, OAuth2ProviderConfig}; + use crate::{ + InMemoryStateStore, OAuth2Config, OAuth2Provider, OAuth2ProviderConfig, TokenResponse, + UserInfoResponse, + }; + use async_trait::async_trait; + use axum::http::{HeaderMap, StatusCode, header}; + use axum::response::{IntoResponse, Response}; + use axum::routing::{get, post}; + use axum::{Form, Json, Router}; + use axum_test::TestServer; use ras_identity_core::IdentityProvider; use std::collections::HashMap; use std::sync::Arc; - use wiremock::matchers::{body_string_contains, header, method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - async fn setup_mock_oauth_server() -> (MockServer, OAuth2ProviderConfig) { - let mock_server = MockServer::start().await; + struct AxumOAuth2Transport { + server: Arc, + } + + #[async_trait] + impl OAuth2HttpTransport for AxumOAuth2Transport { + async fn exchange_code( + &self, + token_endpoint: &str, + params: &HashMap, + ) -> OAuth2Result { + let path = endpoint_path(token_endpoint)?; + let response = self.server.post(&path).form(params).await; + + if !response.status_code().is_success() { + return Err(OAuth2Error::TokenExchangeFailed(response.text())); + } + + serde_json::from_slice(response.as_bytes()) + .map_err(|e| OAuth2Error::InvalidTokenResponse(e.to_string())) + } + + async fn get_user_info( + &self, + userinfo_endpoint: &str, + access_token: &str, + ) -> OAuth2Result { + let path = endpoint_path(userinfo_endpoint)?; + let response = self + .server + .get(&path) + .authorization_bearer(access_token) + .await; + + if !response.status_code().is_success() { + return Err(OAuth2Error::UserInfoFailed(response.text())); + } + + serde_json::from_slice(response.as_bytes()) + .map_err(|e| OAuth2Error::InvalidUserInfoResponse(e.to_string())) + } + } + + fn endpoint_path(endpoint: &str) -> OAuth2Result { + let url = url::Url::parse(endpoint)?; + let mut path = url.path().to_string(); + if let Some(query) = url.query() { + path.push('?'); + path.push_str(query); + } + Ok(path) + } + + fn setup_mock_oauth_server(router: Router) -> (Arc, OAuth2ProviderConfig) { + let server = TestServer::builder() + .mock_transport() + .build(router) + .expect("mock transport OAuth2 server should build"); let provider_config = OAuth2ProviderConfig { provider_id: "mock_provider".to_string(), client_id: "mock_client_id".to_string(), client_secret: "mock_secret".to_string(), - authorization_endpoint: format!("{}/authorize", mock_server.uri()), - token_endpoint: format!("{}/token", mock_server.uri()), - userinfo_endpoint: Some(format!("{}/userinfo", mock_server.uri())), + authorization_endpoint: "http://oauth.test/authorize".to_string(), + token_endpoint: "http://oauth.test/token".to_string(), + userinfo_endpoint: Some("http://oauth.test/userinfo".to_string()), redirect_uri: "http://localhost:3000/callback".to_string(), scopes: vec!["openid".to_string(), "email".to_string()], auth_params: HashMap::new(), @@ -27,49 +92,133 @@ mod integration_tests { user_info_mapping: None, }; - (mock_server, provider_config) + (Arc::new(server), provider_config) + } + + fn client_with_server( + state_store: Arc, + server: Arc, + ) -> OAuth2Client { + OAuth2Client::with_http_transport( + state_store, + 600, + Arc::new(AxumOAuth2Transport { server }), + ) + } + + fn provider_with_server( + provider_config: OAuth2ProviderConfig, + state_store: Arc, + server: Arc, + ) -> OAuth2Provider { + let client = client_with_server(state_store, server); + let mut provider_configs = HashMap::new(); + provider_configs.insert("mock_provider".to_string(), provider_config); + OAuth2Provider::with_client(provider_configs, client) + } + + fn success_oauth_router() -> Router { + Router::new() + .route("/token", post(token_success)) + .route("/userinfo", get(userinfo_success)) + } + + async fn token_success(Form(form): Form>) -> Response { + let required = [ + ("grant_type", "authorization_code"), + ("code", "mock_auth_code"), + ("client_id", "mock_client_id"), + ("client_secret", "mock_secret"), + ("redirect_uri", "http://localhost:3000/callback"), + ]; + + for (key, expected) in required { + if form.get(key).map(String::as_str) != Some(expected) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": format!("missing or invalid {key}") + })), + ) + .into_response(); + } + } + + if !form.contains_key("code_verifier") { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "missing PKCE verifier" + })), + ) + .into_response(); + } + + Json(serde_json::json!({ + "access_token": "mock_access_token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "mock_refresh_token", + "scope": "openid email" + })) + .into_response() + } + + async fn userinfo_success(headers: HeaderMap) -> Response { + let auth = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()); + + if auth != Some("Bearer mock_access_token") { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "invalid_token" + })), + ) + .into_response(); + } + + Json(serde_json::json!({ + "sub": "12345", + "email": "test@example.com", + "email_verified": true, + "name": "Test User", + "picture": "https://example.com/photo.jpg" + })) + .into_response() + } + + fn token_error_router() -> Router { + Router::new().route("/token", post(token_error)) + } + + async fn token_error() -> Response { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "The provided authorization code is invalid" + })), + ) + .into_response() + } + + fn malformed_token_router() -> Router { + Router::new().route("/token", post(malformed_token_response)) + } + + async fn malformed_token_response() -> Response { + (StatusCode::OK, "not json").into_response() } #[tokio::test] async fn test_full_oauth2_flow() { - let (mock_server, provider_config) = setup_mock_oauth_server().await; - - // Mock token endpoint - Mock::given(method("POST")) - .and(path("/token")) - .and(body_string_contains("grant_type=authorization_code")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "access_token": "mock_access_token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "mock_refresh_token", - "scope": "openid email" - }))) - .mount(&mock_server) - .await; - - // Mock userinfo endpoint - Mock::given(method("GET")) - .and(path("/userinfo")) - .and(header("Authorization", "Bearer mock_access_token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "sub": "12345", - "email": "test@example.com", - "email_verified": true, - "name": "Test User", - "picture": "https://example.com/photo.jpg" - }))) - .mount(&mock_server) - .await; - - // Setup provider - let mut config = OAuth2Config::default(); - config - .providers - .insert("mock_provider".to_string(), provider_config); - + let (server, provider_config) = setup_mock_oauth_server(success_oauth_router()); let state_store = Arc::new(InMemoryStateStore::new()); - let provider = OAuth2Provider::new(config, state_store); + let provider = provider_with_server(provider_config, state_store, server); // Start OAuth2 flow let start_payload = serde_json::json!({ @@ -265,20 +414,10 @@ mod integration_tests { #[tokio::test] async fn test_token_exchange_error_cases() { - let (mock_server, mut provider_config) = setup_mock_oauth_server().await; - - // Test 1: Server returns error - Mock::given(method("POST")) - .and(path("/token")) - .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ - "error": "invalid_grant", - "error_description": "The provided authorization code is invalid" - }))) - .mount(&mock_server) - .await; + let (server, provider_config) = setup_mock_oauth_server(token_error_router()); let state_store = Arc::new(InMemoryStateStore::new()); - let client = crate::OAuth2Client::new(state_store, 600, 30); + let client = client_with_server(state_store, server); // Store a valid state first let state = crate::state::OAuth2State::new( @@ -297,17 +436,16 @@ mod integration_tests { }; let result = client.handle_callback(&provider_config, callback).await; - assert!(result.is_err()); + assert!(matches!( + result, + Err(OAuth2Error::TokenExchangeFailed(message)) + if message.contains("invalid_grant") + )); // Test 2: Malformed token response - Mock::given(method("POST")) - .and(path("/token")) - .respond_with(ResponseTemplate::new(200).set_body_string("not json")) - .named("malformed_response") - .mount(&mock_server) - .await; - - provider_config.token_endpoint = format!("{}/token", mock_server.uri()); + let (server, provider_config) = setup_mock_oauth_server(malformed_token_router()); + let state_store = Arc::new(InMemoryStateStore::new()); + let client = client_with_server(state_store, server); let state2 = crate::state::OAuth2State::new( "mock_provider".to_string(), @@ -325,6 +463,6 @@ mod integration_tests { }; let result = client.handle_callback(&provider_config, callback2).await; - assert!(result.is_err()); + assert!(matches!(result, Err(OAuth2Error::InvalidTokenResponse(_)))); } } diff --git a/crates/identity/ras-identity-session/Cargo.toml b/crates/identity/ras-identity-session/Cargo.toml index 78e3b43..012ca4d 100644 --- a/crates/identity/ras-identity-session/Cargo.toml +++ b/crates/identity/ras-identity-session/Cargo.toml @@ -2,23 +2,27 @@ name = "ras-identity-session" version = "0.1.1" edition = "2024" +rust-version = "1.88" description = "JWT session management and authentication provider implementation" license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] -ras-identity-core = { path = "../../core/ras-identity-core" } -ras-auth-core = { path = "../../core/ras-auth-core" } +ras-identity-core = { path = "../../core/ras-identity-core", version = "0.1.1" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } async-trait = { workspace = true } +base64 = { workspace = true } chrono = { workspace = true } -jsonwebtoken = { workspace = true } +hmac = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } uuid = { workspace = true } [dev-dependencies] -ras-identity-local = { path = "../ras-identity-local" } \ No newline at end of file +ras-identity-local = { path = "../ras-identity-local", version = "0.2.0" } diff --git a/crates/identity/ras-identity-session/README.md b/crates/identity/ras-identity-session/README.md index 10be123..28bb200 100644 --- a/crates/identity/ras-identity-session/README.md +++ b/crates/identity/ras-identity-session/README.md @@ -1,190 +1,95 @@ # ras-identity-session -JWT-based session management for the Rust Agent Stack authentication system. +JWT session management and `AuthProvider` integration for Rust Agent Stack. ## Overview -This crate provides session management capabilities using JSON Web Tokens (JWT): -- JWT creation and validation -- Session tracking and revocation -- Integration with RAS authentication system -- Configurable token TTL and algorithms +This crate turns a verified identity from a registered `IdentityProvider` into a signed JWT. It can also keep an in-memory active-session registry so tokens can be revoked by JWT ID (`jti`) before they expire. ## Features -- **JWT Sessions**: Create and validate JWT tokens with custom claims -- **Session Registry**: Track active sessions for revocation support -- **AuthProvider Implementation**: `JwtAuthProvider` for seamless integration -- **Flexible Configuration**: Configurable secrets, TTL, and algorithms -- **Permission Embedding**: User permissions stored in JWT claims +- JWT creation and validation with configurable TTL and signing algorithm +- Optional active-session enforcement for revocation +- Permission embedding through a `UserPermissions` provider +- `JwtAuthProvider` adapter for RAS JSON-RPC, REST, and WebSocket services ## Usage -### Basic Session Service Setup - ```rust -use ras_identity_session::{SessionService, SessionConfig}; -use ras_identity_core::{VerifiedIdentity, StaticPermissions}; - -// Create session service with default config -let session_service = SessionService::new( - "your-secret-key".to_string(), - 3600, // 1 hour TTL - StaticPermissions::new(vec!["read".to_string()]), -); - -// Or with custom config -let config = SessionConfig { - secret: "your-secret-key".to_string(), - ttl_seconds: 7200, // 2 hours - algorithm: "HS256".to_string(), +use chrono::Duration; +use ras_identity_local::LocalUserProvider; +use ras_identity_session::{JwtAlgorithm, JwtAuthProvider, SessionConfig, SessionService}; +use std::sync::Arc; + +# async fn example() -> Result<(), Box> { +let provider = LocalUserProvider::new(); +provider + .add_user( + "alice".to_string(), + "correct-horse-battery-staple".to_string(), + Some("alice@example.com".to_string()), + Some("Alice".to_string()), + ) + .await?; + +let session_service = Arc::new(SessionService::new(SessionConfig { + jwt_secret: "use-at-least-32-bytes-of-random-secret".to_string(), + jwt_ttl: Duration::hours(1), refresh_enabled: false, - refresh_ttl_seconds: None, -}; -let session_service = SessionService::with_config(config, permissions); -``` - -### Creating Sessions + enforce_active_sessions: true, + algorithm: JwtAlgorithm::HS256, +})?); -```rust -// After verifying identity with an IdentityProvider -let verified_identity = VerifiedIdentity { - provider: "local".to_string(), - user_id: "user-123".to_string(), - username: "alice".to_string(), - email: Some("alice@example.com".to_string()), - metadata: None, -}; - -// Create JWT session -let jwt_token = session_service.create_session(verified_identity).await?; -``` +session_service.register_provider(Box::new(provider)).await; -### Using JwtAuthProvider +let token = session_service + .begin_session( + "local", + serde_json::json!({ + "username": "alice", + "password": "correct-horse-battery-staple" + }), + ) + .await?; -```rust -use ras_identity_session::JwtAuthProvider; -use ras_auth_core::AuthProvider; +let claims = session_service.verify_session(&token).await?; +assert_eq!(claims.sub, "alice"); -// Create auth provider from session service -let auth_provider = JwtAuthProvider::from_session_service(session_service); - -// Authenticate tokens -let user = auth_provider.authenticate(&jwt_token).await?; -println!("Authenticated user: {} with permissions: {:?}", - user.username, user.permissions); -``` - -### Session Management - -```rust -// Get session info -if let Some(info) = session_service.get_session(&jti).await { - println!("Session for user: {}", info.user_id); -} - -// End a session (revoke) -session_service.end_session(&jti).await?; - -// Check active sessions -let active_count = session_service.active_session_count().await; -``` - -## JWT Structure - -The generated JWTs include: -- **Standard Claims**: `iss`, `sub`, `exp`, `iat`, `jti` -- **Custom Claims**: - - `username`: User's display name - - `permissions`: Array of permission strings - - `provider`: Identity provider name - -Example JWT payload: -```json -{ - "iss": "ras", - "sub": "user-123", - "exp": 1700000000, - "iat": 1699996400, - "jti": "550e8400-e29b-41d4-a716-446655440000", - "username": "alice", - "permissions": ["read", "write"], - "provider": "local" -} +let auth_provider = JwtAuthProvider::new(session_service.clone()); +# let _ = auth_provider; +# Ok(()) +# } ``` -## Integration Examples +## Session Revocation -### With JSON-RPC Services +When `enforce_active_sessions` is `true`, `verify_session` checks that the token's `jti` is still present in the active-session registry. ```rust -use ras_jsonrpc_macro::jsonrpc_service; -use ras_identity_session::JwtAuthProvider; - -jsonrpc_service!({ - service_name: MyService, - methods: [ - WITH_PERMISSIONS(["read"]) get_data(GetRequest) -> GetResponse, - WITH_PERMISSIONS(["write"]) update_data(UpdateRequest) -> UpdateResponse, - ] -}); - -struct MyServiceImpl; - -impl MyServiceTrait for MyServiceImpl { - async fn get_data( - &self, - user: &ras_jsonrpc_core::AuthenticatedUser, - request: GetRequest, - ) -> Result> { - // Load data for `user`. - } - - async fn update_data( - &self, - user: &ras_jsonrpc_core::AuthenticatedUser, - request: UpdateRequest, - ) -> Result> { - // Update data for `user`. - } -} - -let auth_provider = JwtAuthProvider::new(session_service.clone()); -let router = MyServiceBuilder::new(MyServiceImpl) - .base_url("/rpc") - .auth_provider(auth_provider) - .build()?; +let claims = session_service.verify_session(&token).await?; +session_service.end_session(&claims.jti).await; ``` -### With WebSocket Authentication - -The session service integrates with bidirectional JSON-RPC WebSocket services, supporting multiple authentication header formats: -- `Authorization: Bearer ` -- `X-Auth-Token: ` -- `Sec-WebSocket-Protocol: token.` +## JWT Claims -## Security Considerations +Generated tokens include: -1. **Secret Management** - - Use strong, randomly generated secrets - - Store secrets securely (environment variables, secret management systems) - - Rotate secrets periodically +- `sub`: identity subject +- `exp` and `iat`: expiration and issue timestamps +- `jti`: session identifier used for revocation +- `provider_id`: identity provider that verified the user +- `email`, `display_name`, `permissions`, and provider metadata when available -2. **Token Expiration** - - Set appropriate TTL based on security requirements - - Consider implementing refresh tokens for long-lived sessions +## Security Notes -3. **Session Revocation** - - Track active sessions for immediate revocation capability - - Clean up expired sessions periodically +- Use a high-entropy `jwt_secret` of at least 32 bytes. +- Keep `enforce_active_sessions` enabled when immediate revocation matters. +- Refresh tokens are not issued by `SessionService`; implement refresh-token storage and rotation at the application layer if needed. +- Transmit JWTs only over HTTPS in production. -4. **HTTPS Only** - - Always transmit JWTs over HTTPS in production - - Set secure cookie flags when using cookies +## Checks -## Configuration Options - -- **Secret**: JWT signing secret (required) -- **TTL**: Token time-to-live in seconds -- **Algorithm**: JWT signing algorithm (default: HS256) -- **Refresh**: Enable/disable refresh token support (experimental) +```bash +cargo test -p ras-identity-session --locked +cargo clippy -p ras-identity-session --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/identity/ras-identity-session/src/lib.rs b/crates/identity/ras-identity-session/src/lib.rs index 45589ff..e62f077 100644 --- a/crates/identity/ras-identity-session/src/lib.rs +++ b/crates/identity/ras-identity-session/src/lib.rs @@ -1,11 +1,14 @@ //! Session management with JWT token generation and validation. use async_trait::async_trait; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{Duration, Utc}; -use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use hmac::{Hmac, Mac}; use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; use ras_identity_core::{IdentityError, IdentityProvider, UserPermissions}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use sha2::{Sha256, Sha384, Sha512}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use thiserror::Error; @@ -15,7 +18,10 @@ use uuid::Uuid; #[derive(Debug, Error)] pub enum SessionError { #[error("JWT error: {0}")] - JwtError(#[from] jsonwebtoken::errors::Error), + JwtError(String), + + #[error("JWT token expired")] + TokenExpired, #[error("Identity error: {0}")] IdentityError(#[from] IdentityError), @@ -43,13 +49,34 @@ pub struct JwtClaims { pub metadata: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum JwtAlgorithm { + #[serde(rename = "HS256")] + HS256, + #[serde(rename = "HS384")] + HS384, + #[serde(rename = "HS512")] + HS512, +} + +impl JwtAlgorithm { + pub fn from_name(name: &str) -> Option { + match name { + "HS256" => Some(Self::HS256), + "HS384" => Some(Self::HS384), + "HS512" => Some(Self::HS512), + _ => None, + } + } +} + #[derive(Debug, Clone)] pub struct SessionConfig { pub jwt_secret: String, pub jwt_ttl: Duration, pub refresh_enabled: bool, pub enforce_active_sessions: bool, - pub algorithm: Algorithm, + pub algorithm: JwtAlgorithm, } impl SessionConfig { @@ -59,7 +86,7 @@ impl SessionConfig { jwt_ttl: Duration::hours(24), refresh_enabled: true, enforce_active_sessions: true, - algorithm: Algorithm::HS256, + algorithm: JwtAlgorithm::HS256, }; config.validate()?; Ok(config) @@ -106,6 +133,131 @@ fn validate_jwt_secret(secret: &str) -> Result<(), SessionError> { Ok(()) } +#[derive(Serialize)] +struct JwtHeader { + typ: &'static str, + alg: JwtAlgorithm, +} + +#[derive(Deserialize)] +struct DecodedJwtHeader { + alg: JwtAlgorithm, +} + +fn jwt_error(message: impl Into) -> SessionError { + SessionError::JwtError(message.into()) +} + +fn encode_jwt( + claims: &T, + secret: &str, + algorithm: JwtAlgorithm, +) -> Result { + let header = JwtHeader { + typ: "JWT", + alg: algorithm, + }; + let header = serde_json::to_vec(&header) + .map_err(|err| jwt_error(format!("failed to encode JWT header: {err}")))?; + let claims = serde_json::to_vec(claims) + .map_err(|err| jwt_error(format!("failed to encode JWT claims: {err}")))?; + + let signing_input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(header), + URL_SAFE_NO_PAD.encode(claims) + ); + let signature = sign_jwt(&signing_input, secret.as_bytes(), algorithm)?; + + Ok(format!( + "{}.{}", + signing_input, + URL_SAFE_NO_PAD.encode(signature) + )) +} + +fn decode_jwt( + token: &str, + secret: &str, + expected_algorithm: JwtAlgorithm, +) -> Result { + let mut parts = token.split('.'); + let encoded_header = parts + .next() + .ok_or_else(|| jwt_error("missing JWT header"))?; + let encoded_claims = parts + .next() + .ok_or_else(|| jwt_error("missing JWT claims"))?; + let encoded_signature = parts + .next() + .ok_or_else(|| jwt_error("missing JWT signature"))?; + + if parts.next().is_some() { + return Err(jwt_error("JWT has too many segments")); + } + + let header = URL_SAFE_NO_PAD + .decode(encoded_header) + .map_err(|err| jwt_error(format!("invalid JWT header encoding: {err}")))?; + let header: DecodedJwtHeader = serde_json::from_slice(&header) + .map_err(|err| jwt_error(format!("invalid JWT header: {err}")))?; + + if header.alg != expected_algorithm { + return Err(jwt_error("unexpected JWT algorithm")); + } + + let signature = URL_SAFE_NO_PAD + .decode(encoded_signature) + .map_err(|err| jwt_error(format!("invalid JWT signature encoding: {err}")))?; + let signing_input = format!("{encoded_header}.{encoded_claims}"); + let expected_signature = sign_jwt(&signing_input, secret.as_bytes(), expected_algorithm)?; + + if !signatures_match(&expected_signature, &signature) { + return Err(jwt_error("invalid JWT signature")); + } + + let claims = URL_SAFE_NO_PAD + .decode(encoded_claims) + .map_err(|err| jwt_error(format!("invalid JWT claims encoding: {err}")))?; + serde_json::from_slice(&claims).map_err(|err| jwt_error(format!("invalid JWT claims: {err}"))) +} + +fn sign_jwt( + signing_input: &str, + secret: &[u8], + algorithm: JwtAlgorithm, +) -> Result, SessionError> { + match algorithm { + JwtAlgorithm::HS256 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) + } + JwtAlgorithm::HS384 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) + } + JwtAlgorithm::HS512 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) + } + } +} + +fn signatures_match(expected: &[u8], actual: &[u8]) -> bool { + let mut diff = expected.len() ^ actual.len(); + for i in 0..expected.len().max(actual.len()) { + diff |= expected.get(i).copied().unwrap_or_default() as usize + ^ actual.get(i).copied().unwrap_or_default() as usize; + } + diff == 0 +} + pub struct SessionService { config: SessionConfig, providers: Arc>>>, @@ -180,11 +332,7 @@ impl SessionService { sessions.insert(jti.clone(), claims.clone()); } - let token = encode( - &Header::new(self.config.algorithm), - &claims, - &EncodingKey::from_secret(self.config.jwt_secret.as_bytes()), - )?; + let token = encode_jwt(&claims, &self.config.jwt_secret, self.config.algorithm)?; Ok(token) } @@ -194,24 +342,21 @@ impl SessionService { self.cleanup_expired_sessions().await; } - let mut validation = Validation::new(self.config.algorithm); - validation.set_required_spec_claims(&["exp"]); - validation.validate_exp = true; + let claims = + decode_jwt::(token, &self.config.jwt_secret, self.config.algorithm)?; - let token_data = decode::( - token, - &DecodingKey::from_secret(self.config.jwt_secret.as_bytes()), - &validation, - )?; + if claims.exp <= Utc::now().timestamp() { + return Err(SessionError::TokenExpired); + } if self.config.enforce_active_sessions { let sessions = self.active_sessions.read().await; - if !sessions.contains_key(&token_data.claims.jti) { + if !sessions.contains_key(&claims.jti) { return Err(SessionError::SessionNotFound); } } - Ok(token_data.claims) + Ok(claims) } pub async fn end_session(&self, jti: &str) -> Option { @@ -248,13 +393,7 @@ impl AuthProvider for JwtAuthProvider { .verify_session(&token) .await .map_err(|e| match e { - SessionError::JwtError(jwt_err) => { - if jwt_err.to_string().contains("expired") { - AuthError::TokenExpired - } else { - AuthError::InvalidToken - } - } + SessionError::TokenExpired => AuthError::TokenExpired, _ => AuthError::InvalidToken, })?; @@ -275,6 +414,20 @@ mod tests { const TEST_SECRET: &str = "test-secret-that-is-long-enough-for-hs256"; + async fn local_provider_with_user(username: &str, password: &str) -> LocalUserProvider { + let provider = LocalUserProvider::new(); + provider + .add_user( + username.to_string(), + password.to_string(), + Some(format!("{username}@example.com")), + Some(format!("{username} User")), + ) + .await + .unwrap(); + provider + } + #[tokio::test] async fn test_session_lifecycle() { let config = SessionConfig::new(TEST_SECRET).unwrap(); @@ -395,8 +548,7 @@ mod tests { let config = SessionConfig::new(TEST_SECRET).unwrap(); let service = SessionService::new(config).unwrap(); - let token = encode( - &Header::new(Algorithm::HS256), + let token = encode_jwt( &serde_json::json!({ "sub": "user", "exp": "not-a-number", @@ -405,10 +557,106 @@ mod tests { "provider_id": "local", "permissions": [], }), - &EncodingKey::from_secret(TEST_SECRET.as_bytes()), + TEST_SECRET, + JwtAlgorithm::HS256, ) .unwrap(); assert!(service.verify_session(&token).await.is_err()); } + + #[test] + fn session_config_rejects_non_positive_ttl() { + let mut config = SessionConfig::new(TEST_SECRET).unwrap(); + config.jwt_ttl = Duration::zero(); + + let error = config.validate().expect_err("zero ttl should fail"); + + assert!( + matches!(error, SessionError::InvalidConfig(message) if message == "jwt_ttl must be positive") + ); + } + + #[tokio::test] + async fn begin_session_reports_unknown_identity_provider() { + let config = SessionConfig::new(TEST_SECRET).unwrap(); + let service = SessionService::new(config).unwrap(); + + let error = service + .begin_session("missing", serde_json::json!({})) + .await + .expect_err("unknown provider should fail"); + + assert!( + matches!(error, SessionError::IdentityError(IdentityError::ProviderNotFound(provider)) if provider == "missing") + ); + } + + #[tokio::test] + async fn verify_session_can_skip_active_session_store_when_configured() { + let mut config = SessionConfig::new(TEST_SECRET).unwrap(); + config.enforce_active_sessions = false; + let service = SessionService::new(config).unwrap(); + service + .register_provider(Box::new( + local_provider_with_user("stateless", "password123").await, + )) + .await; + + let token = service + .begin_session( + "local", + serde_json::json!({ + "username": "stateless", + "password": "password123" + }), + ) + .await + .unwrap(); + + let claims = service.verify_session(&token).await.unwrap(); + assert_eq!(claims.sub, "stateless"); + assert!( + service + .active_sessions + .read() + .await + .get(&claims.jti) + .is_none() + ); + } + + #[tokio::test] + async fn jwt_auth_provider_maps_verified_claims_to_authenticated_user() { + let config = SessionConfig::new(TEST_SECRET).unwrap(); + let permissions = Arc::new(StaticPermissions::new(vec!["chat:read".to_string()])); + let service = Arc::new( + SessionService::new(config) + .unwrap() + .with_permissions(permissions), + ); + service + .register_provider(Box::new( + local_provider_with_user("alice", "password123").await, + )) + .await; + + let token = service + .begin_session( + "local", + serde_json::json!({ + "username": "alice", + "password": "password123" + }), + ) + .await + .unwrap(); + let auth_provider = JwtAuthProvider::new(service); + + let user = auth_provider.authenticate(token).await.unwrap(); + + assert_eq!(user.user_id, "alice"); + assert!(user.permissions.contains("chat:read")); + assert!(user.metadata.is_none()); + } } diff --git a/crates/observability/ras-observability-otel/Cargo.toml b/crates/observability/ras-observability-otel/Cargo.toml index 57e0ab7..3bfaa3e 100644 --- a/crates/observability/ras-observability-otel/Cargo.toml +++ b/crates/observability/ras-observability-otel/Cargo.toml @@ -2,11 +2,16 @@ name = "ras-observability-otel" version = "0.1.0" edition = "2024" +rust-version = "1.88" description = "OpenTelemetry implementation for Rust Agent Stack observability" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] -ras-observability-core = { path = "../../core/ras-observability-core" } -ras-auth-core = { path = "../../core/ras-auth-core" } +ras-observability-core = { path = "../../core/ras-observability-core", version = "0.1.0" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } # OpenTelemetry dependencies opentelemetry = { workspace = true } @@ -33,4 +38,4 @@ path = "examples/simple_usage.rs" [[example]] name = "with_rest_service" -path = "examples/with_rest_service.rs" \ No newline at end of file +path = "examples/with_rest_service.rs" diff --git a/crates/observability/ras-observability-otel/README.md b/crates/observability/ras-observability-otel/README.md index d9539f4..2370514 100644 --- a/crates/observability/ras-observability-otel/README.md +++ b/crates/observability/ras-observability-otel/README.md @@ -1,43 +1,46 @@ # ras-observability-otel -OpenTelemetry implementation for Rust Agent Stack observability, providing production-ready metrics collection with Prometheus export. +OpenTelemetry implementation for Rust Agent Stack observability, providing runtime metrics collection with Prometheus export. ## Quick Start ```rust use ras_observability_otel::standard_setup; -// One-line setup! +// Build the OpenTelemetry/Prometheus setup. let otel = standard_setup("my-service")?; -// Your service now has: -// - Prometheus metrics endpoint at /metrics -// - Request counting and duration tracking -// - User activity monitoring -// - Structured logging integration +// Use `otel.metrics_router()` for /metrics and wire the trackers into service +// builders with their observability hooks. ``` ## Features -- **Zero-config setup**: Sensible defaults out of the box +- **Convenience setup**: Sensible defaults with optional builder customization - **Prometheus integration**: Built-in `/metrics` endpoint -- **Standard metrics**: Request counts, duration histograms, active users +- **Standard metrics**: Request counts and duration histograms - **Axum integration**: Ready-to-use metrics router - **Type-safe**: Leverages Rust's type system for safety ## Usage with Service Builders -The observability crates are designed to integrate seamlessly with the REST and JSON-RPC macros: +The observability crates integrate with the REST and JSON-RPC macro builders: ```rust +use ras_observability_core::{RequestContext, UsageTracker}; +use ras_observability_otel::OtelSetupBuilder; + // The service builders can use the trackers like this: let otel = OtelSetupBuilder::new("my-service").build()?; -// Create callbacks for the service builders -let usage_tracker = { +// REST service builders receive headers, user, method, and path. +let rest_usage_tracker = { let tracker = otel.usage_tracker(); move |headers, user, method, path| { let context = RequestContext::rest(method, path); + let tracker = tracker.clone(); + let headers = headers.clone(); + let user = user.cloned(); async move { tracker.track_request(&headers, user.as_ref(), &context).await; } @@ -46,13 +49,27 @@ let usage_tracker = { // REST service builders take the trait implementation. MyServiceBuilder::new(MyServiceImpl::new()) - .with_usage_tracker(usage_tracker) - .build() + .with_usage_tracker(rest_usage_tracker) + .build(); + +// JSON-RPC service builders receive headers, user, and the JSON-RPC request. +let rpc_usage_tracker = { + let tracker = otel.usage_tracker(); + move |headers, user, request| { + let context = RequestContext::jsonrpc(request.method.clone()); + let tracker = tracker.clone(); + let headers = headers.clone(); + let user = user.cloned(); + async move { + tracker.track_request(&headers, user.as_ref(), &context).await; + } + } +}; // JSON-RPC service builders also take the trait implementation. MyRpcServiceBuilder::new(MyRpcServiceImpl::new()) .with_usage_tracker(rpc_usage_tracker) - .build() + .build()?; ``` ## Metrics Exposed @@ -62,7 +79,7 @@ MyRpcServiceBuilder::new(MyRpcServiceImpl::new()) - `requests_completed_total`: Total requests completed (with success status) ### Histograms -- `method_duration_seconds`: Method execution time (only includes method and protocol labels to avoid cardinality explosion) +- `method_duration_milliseconds`: Method execution time in milliseconds (only includes method and protocol labels to avoid cardinality explosion) ### Labels All metrics use minimal labels to prevent cardinality explosion: @@ -82,7 +99,14 @@ See the `examples/` directory for: ```bash # Simple usage example -cargo run --example simple_usage -p ras-observability-otel +cargo run --example simple_usage -p ras-observability-otel --locked # Then visit http://localhost:3000/metrics ``` + +## Checks + +```bash +cargo test -p ras-observability-otel --locked +cargo clippy -p ras-observability-otel --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/observability/ras-observability-otel/examples/with_rest_service.rs b/crates/observability/ras-observability-otel/examples/with_rest_service.rs index 3c67445..149d3ff 100644 --- a/crates/observability/ras-observability-otel/examples/with_rest_service.rs +++ b/crates/observability/ras-observability-otel/examples/with_rest_service.rs @@ -18,7 +18,7 @@ impl ServiceWithObservability { .expect("Failed to set up OpenTelemetry"); // Create usage tracker callback - let usage_tracker = { + let _usage_tracker = { let usage_tracker = otel.usage_tracker(); move |headers: axum::http::HeaderMap, user: Option, @@ -36,7 +36,7 @@ impl ServiceWithObservability { }; // Create duration tracker callback - let duration_tracker = { + let _duration_tracker = { let duration_tracker = otel.method_duration_tracker(); move |method: &str, path: &str, @@ -56,8 +56,8 @@ impl ServiceWithObservability { info!("Service configured with OpenTelemetry observability"); - // In a real implementation, the REST macro would wire these up automatically - // For now, we just create a simple router with the metrics endpoint + // The example keeps the service route minimal while exposing the metrics endpoint. + // A REST macro integration would normally assemble the application router. let app = Router::new() .route("/api/v1/health", axum::routing::get(|| async { "OK" })) .merge(otel.metrics_router()); @@ -70,6 +70,12 @@ impl ServiceWithObservability { } } +impl Default for ServiceWithObservability { + fn default() -> Self { + Self::new() + } +} + #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); diff --git a/crates/observability/ras-observability-otel/src/lib.rs b/crates/observability/ras-observability-otel/src/lib.rs index 0946760..593f037 100644 --- a/crates/observability/ras-observability-otel/src/lib.rs +++ b/crates/observability/ras-observability-otel/src/lib.rs @@ -1,7 +1,7 @@ //! OpenTelemetry implementation for Rust Agent Stack observability //! -//! This crate provides a production-ready OpenTelemetry implementation -//! with Prometheus export support and standard metric definitions. +//! This crate provides an OpenTelemetry implementation with Prometheus export +//! support and standard metric definitions. use async_trait::async_trait; use axum::{ @@ -193,7 +193,7 @@ impl OtelSetupBuilder { /// Build and initialize OpenTelemetry pub fn build(self) -> Result> { // Create or use existing Prometheus registry - let prometheus_registry = self.prometheus_registry.unwrap_or_else(Registry::new); + let prometheus_registry = self.prometheus_registry.unwrap_or_default(); // Create Prometheus exporter let prometheus_exporter = opentelemetry_prometheus::exporter() @@ -272,11 +272,11 @@ async fn metrics_handler( .encode(&metric_families, &mut buffer) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Response::builder() + Response::builder() .status(StatusCode::OK) .header("Content-Type", encoder.format_type()) .body(Body::from(buffer)) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } /// Convenience function to create a standard observability setup diff --git a/crates/observability/ras-observability-otel/src/tests.rs b/crates/observability/ras-observability-otel/src/tests.rs index 37ab9f2..d019b29 100644 --- a/crates/observability/ras-observability-otel/src/tests.rs +++ b/crates/observability/ras-observability-otel/src/tests.rs @@ -152,7 +152,7 @@ async fn test_metrics_handler() { let app = setup.metrics_router(); // Make a request to the metrics endpoint - let server = TestServer::new(app).unwrap(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); let response = server.get("/metrics").await; // Basic checks that the endpoint works diff --git a/crates/observability/ras-observability-otel/tests/integration.rs b/crates/observability/ras-observability-otel/tests/integration.rs index b80370d..64fda7a 100644 --- a/crates/observability/ras-observability-otel/tests/integration.rs +++ b/crates/observability/ras-observability-otel/tests/integration.rs @@ -9,13 +9,20 @@ use axum::{ }; use axum_test::TestServer; use ras_auth_core::AuthenticatedUser; -use ras_observability_core::{ - MethodDurationTracker, Protocol, RequestContext, ServiceMetrics, UsageTracker, -}; +use ras_observability_core::{MethodDurationTracker, RequestContext, ServiceMetrics, UsageTracker}; use ras_observability_otel::{OtelMethodDurationTracker, OtelSetupBuilder, OtelUsageTracker}; use serde::{Deserialize, Serialize}; use std::{sync::Arc, time::Instant}; -use tokio::time::{Duration, sleep}; +use tokio::{ + sync::{Mutex, MutexGuard}, + time::{Duration, sleep}, +}; + +static OTEL_TEST_LOCK: Mutex<()> = Mutex::const_new(()); + +async fn otel_test_guard() -> MutexGuard<'static, ()> { + OTEL_TEST_LOCK.lock().await +} #[derive(Clone)] struct AppState { @@ -132,10 +139,10 @@ async fn get_profile( Ok(Json(response)) } -// Disabled due to flaky metrics timing issues -// #[tokio::test] -#[allow(dead_code)] +#[tokio::test] async fn test_full_service_integration() { + let _guard = otel_test_guard().await; + // Set up observability let setup = OtelSetupBuilder::new("integration_test_service") .build() @@ -160,7 +167,7 @@ async fn test_full_service_integration() { .merge(setup.metrics_router()); // Create test server - let server = TestServer::new(app).unwrap(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); // Test health check let response = server.get("/health").await; @@ -210,7 +217,7 @@ async fn test_full_service_integration() { // Verify metrics are present assert!(metrics_text.contains("requests_started_total")); assert!(metrics_text.contains("requests_completed_total")); - assert!(metrics_text.contains("method_duration_seconds")); + assert!(metrics_text.contains("method_duration_milliseconds")); // Verify specific labels are present assert!(metrics_text.contains("method=\"GET /health\"")); @@ -220,10 +227,10 @@ async fn test_full_service_integration() { assert!(metrics_text.contains("success=\"true\"")); } -// Disabled due to flaky metrics timing issues -// #[tokio::test] -#[allow(dead_code)] +#[tokio::test] async fn test_jsonrpc_protocol_tracking() { + let _guard = otel_test_guard().await; + let setup = OtelSetupBuilder::new("jsonrpc_test_service") .build() .expect("Failed to set up OpenTelemetry"); @@ -257,7 +264,7 @@ async fn test_jsonrpc_protocol_tracking() { // Create metrics endpoint to verify let app = setup.metrics_router(); - let server = TestServer::new(app).unwrap(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); let response = server.get("/metrics").await; let metrics_text = response.text(); @@ -269,10 +276,10 @@ async fn test_jsonrpc_protocol_tracking() { assert!(metrics_text.contains("protocol=\"JSON-RPC\"")); } -// Disabled due to flaky metrics timing issues -// #[tokio::test] -#[allow(dead_code)] +#[tokio::test] async fn test_websocket_protocol_tracking() { + let _guard = otel_test_guard().await; + let setup = OtelSetupBuilder::new("websocket_test_service") .build() .expect("Failed to set up OpenTelemetry"); @@ -284,13 +291,8 @@ async fn test_websocket_protocol_tracking() { let ws_operations = ["connect", "subscribe", "publish", "disconnect"]; for operation in &ws_operations { - let context = RequestContext { - method: operation.to_string(), - protocol: Protocol::WebSocket, - metadata: [("connection_id".to_string(), "ws-123".to_string())] - .into_iter() - .collect(), - }; + let context = + RequestContext::websocket(*operation).with_metadata("connection_id", "ws-123"); metrics.increment_requests_started(&context); metrics.record_method_duration(&context, Duration::from_millis(5)); @@ -304,7 +306,7 @@ async fn test_websocket_protocol_tracking() { // Verify metrics let app = setup.metrics_router(); - let server = TestServer::new(app).unwrap(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); let response = server.get("/metrics").await; let metrics_text = response.text(); @@ -316,10 +318,10 @@ async fn test_websocket_protocol_tracking() { } } -// Disabled due to flaky metrics timing issues -// #[tokio::test] -#[allow(dead_code)] +#[tokio::test] async fn test_error_scenarios() { + let _guard = otel_test_guard().await; + let setup = OtelSetupBuilder::new("error_test_service") .build() .expect("Failed to set up OpenTelemetry"); @@ -354,7 +356,7 @@ async fn test_error_scenarios() { // Verify failure metrics let app = setup.metrics_router(); - let server = TestServer::new(app).unwrap(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); let response = server.get("/metrics").await; let metrics_text = response.text(); @@ -365,6 +367,8 @@ async fn test_error_scenarios() { #[tokio::test] async fn test_high_cardinality_protection() { + let _guard = otel_test_guard().await; + let setup = OtelSetupBuilder::new("cardinality_test_service") .build() .expect("Failed to set up OpenTelemetry"); @@ -399,7 +403,7 @@ async fn test_high_cardinality_protection() { // Metrics should only have limited cardinality based on method and protocol // not on user_id, item_id, or other high-cardinality fields let app = setup.metrics_router(); - let server = TestServer::new(app).unwrap(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); let response = server.get("/metrics").await; let metrics_text = response.text(); diff --git a/crates/rest/ras-file-macro/Cargo.toml b/crates/rest/ras-file-macro/Cargo.toml index 243e3a9..8ad60bd 100644 --- a/crates/rest/ras-file-macro/Cargo.toml +++ b/crates/rest/ras-file-macro/Cargo.toml @@ -2,6 +2,12 @@ name = "ras-file-macro" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Procedural macro for type-safe file upload and download APIs" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [lib] proc-macro = true @@ -20,10 +26,9 @@ axum = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -ras-auth-core = { path = "../../core/ras-auth-core" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } thiserror = { workspace = true } async-trait = { workspace = true } -ras-test-helpers = { path = "../../test-utils/ras-test-helpers" } axum-test = { workspace = true } tempfile = { workspace = true } criterion = { workspace = true, features = ["async_tokio"] } diff --git a/crates/rest/ras-file-macro/README.md b/crates/rest/ras-file-macro/README.md new file mode 100644 index 0000000..5cbf118 --- /dev/null +++ b/crates/rest/ras-file-macro/README.md @@ -0,0 +1,42 @@ +# ras-file-macro + +Procedural macro for type-safe file upload and download services. + +The `file_service!` macro generates the service trait, Axum routes, client +helpers, OpenAPI output, authentication checks, and file-specific error types +for a file API definition. + +## Example + +```rust +use ras_file_macro::file_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct FileMetadata { + pub id: String, + pub filename: String, + pub size: usize, +} + +file_service!({ + service_name: FileStorage, + base_path: "/api/files", + openapi: true, + endpoints: [ + UPLOAD WITH_PERMISSIONS(["files:write"]) upload() -> FileMetadata, + DOWNLOAD WITH_PERMISSIONS(["files:read"]) download/{file_id: String}(), + ] +}); +``` + +See [documentation/ras-file-macro.md](../../../documentation/ras-file-macro.md) +for the usage guide and runnable examples. + +## Checks + +```bash +cargo test -p ras-file-macro --locked +cargo clippy -p ras-file-macro --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/rest/ras-file-macro/benches/streaming.rs b/crates/rest/ras-file-macro/benches/streaming.rs index d6981a8..c225743 100644 --- a/crates/rest/ras-file-macro/benches/streaming.rs +++ b/crates/rest/ras-file-macro/benches/streaming.rs @@ -1,7 +1,6 @@ //! Criterion bench measuring 1 MiB upload + download through the file_service! -//! generated client and router. +//! in-memory axum-test router path. -use std::io::Write; use std::sync::{Arc, Mutex}; use axum::{ @@ -9,13 +8,17 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use axum_test::multipart::{MultipartForm, Part}; use criterion::{Criterion, criterion_group, criterion_main}; use ras_auth_core::AuthenticatedUser; use ras_file_macro::file_service; -use ras_test_helpers::{MockAuthProvider, spawn_http}; use serde::{Deserialize, Serialize}; use tokio::runtime::Runtime; +#[path = "../tests/support/mod.rs"] +mod support; +use support::{MockAuthProvider, mock_http_server}; + #[derive(Debug, Clone, Serialize, Deserialize)] struct UploadResponse { file_id: String, @@ -91,36 +94,32 @@ fn build_router() -> (axum::Router, Storage) { fn bench_streaming(c: &mut Criterion) { let rt = Runtime::new().unwrap(); - // Prepare 1 MiB payload on disk and a live server. - let mut tmp = tempfile::NamedTempFile::new().expect("tempfile"); let payload: Vec = (0u8..=255).cycle().take(1024 * 1024).collect(); - tmp.write_all(&payload).unwrap(); - tmp.flush().unwrap(); - let path = tmp.path().to_path_buf(); - - let (client, _server) = rt.block_on(async { - let (router, _storage) = build_router(); - let server = spawn_http(router); - let base = server.server_address().unwrap(); - let base_str = base.as_str().trim_end_matches('/').to_string(); - let client = BenchSvcClient::builder(base_str) - .build() - .expect("client build"); - client.set_bearer_token(Some("user-token".to_string())); - (client, server) - }); + let (router, _storage) = build_router(); + let server = Arc::new(mock_http_server(router)); c.bench_function("file_upload_download_1mib", |b| { b.to_async(&rt).iter(|| { - let client = &client; - let path = path.clone(); + let server = Arc::clone(&server); + let payload = payload.clone(); async move { - let r = client - .upload(&path, Some("blob.bin"), Some("application/octet-stream")) - .await - .expect("upload"); - let resp = client.download(r.file_id).await.expect("download"); - let bytes = resp.bytes().await.expect("body"); + let form = MultipartForm::new().add_part( + "file", + Part::bytes(payload) + .file_name("blob.bin") + .mime_type("application/octet-stream"), + ); + let response = server + .post("/files/upload") + .authorization_bearer("user-token") + .multipart(form) + .await; + response.assert_status_ok(); + let r: UploadResponse = response.json(); + + let response = server.get(&format!("/files/download/{}", r.file_id)).await; + response.assert_status_ok(); + let bytes = response.into_bytes(); std::hint::black_box(bytes); } }); diff --git a/crates/rest/ras-file-macro/src/lib.rs b/crates/rest/ras-file-macro/src/lib.rs index 5aa20f1..66e1cee 100644 --- a/crates/rest/ras-file-macro/src/lib.rs +++ b/crates/rest/ras-file-macro/src/lib.rs @@ -43,9 +43,14 @@ pub fn file_service(input: TokenStream) -> TokenStream { #schema_checks }; - // Generate OpenAPI function at module level #[cfg(not(target_arch = "wasm32"))] - #openapi_code + mod openapi_impl { + use super::*; + #openapi_code + } + + #[cfg(not(target_arch = "wasm32"))] + pub use openapi_impl::*; #client_code }; diff --git a/crates/rest/ras-file-macro/src/lib_debug.rs b/crates/rest/ras-file-macro/src/lib_debug.rs deleted file mode 100644 index 4ef8c2d..0000000 --- a/crates/rest/ras-file-macro/src/lib_debug.rs +++ /dev/null @@ -1,17 +0,0 @@ -use proc_macro::TokenStream; -use quote::quote; - -#[proc_macro] -pub fn file_service_debug(input: TokenStream) -> TokenStream { - // Just echo back what we received to see if it's being tokenized correctly - eprintln!("Raw input tokens: {:?}", input); - - // Try to convert to a TokenStream2 and print - let tokens2 = proc_macro2::TokenStream::from(input.clone()); - eprintln!("TokenStream2: {:?}", tokens2); - - // Return empty expansion - quote! { - // Debug macro - no real output - }.into() -} \ No newline at end of file diff --git a/crates/rest/ras-file-macro/src/openapi.rs b/crates/rest/ras-file-macro/src/openapi.rs index 058cc9a..e0d3acc 100644 --- a/crates/rest/ras-file-macro/src/openapi.rs +++ b/crates/rest/ras-file-macro/src/openapi.rs @@ -87,7 +87,7 @@ pub fn generate_openapi_code( }) }); - // Post-process schema to make it more Swagger UI friendly + // Post-process schemas for broad OpenAPI explorer compatibility. normalize_nullable_properties(&mut schema_value); schema_value } @@ -514,8 +514,7 @@ pub fn generate_openapi_code( "bearerAuth": { "type": "http", "scheme": "bearer", - "bearerFormat": "JWT", - "description": "JWT token for authentication" + "description": "Bearer token for authentication" } } }, diff --git a/crates/rest/ras-file-macro/src/parser.rs b/crates/rest/ras-file-macro/src/parser.rs index 94262eb..018733a 100644 --- a/crates/rest/ras-file-macro/src/parser.rs +++ b/crates/rest/ras-file-macro/src/parser.rs @@ -85,9 +85,21 @@ impl Parse for FileServiceDefinition { syn::braced!(openapi_content in content); // Parse output: "path" - let _ = openapi_content.parse::()?; // "output" + let key = openapi_content.parse::()?; + if key != "output" { + return Err(Error::new(key.span(), "Expected openapi output field")); + } openapi_content.parse::()?; let path = openapi_content.parse::()?; + if !openapi_content.is_empty() { + openapi_content.parse::()?; + } + if !openapi_content.is_empty() { + return Err(Error::new( + openapi_content.span(), + "Unexpected field in openapi config", + )); + } openapi = Some(OpenApiConfig::WithPath(path.value())); } } @@ -174,6 +186,12 @@ impl Parse for Endpoint { group_content.parse::()?; } } + if group.is_empty() { + return Err(Error::new( + group_content.span(), + "Permission groups cannot be empty", + )); + } permission_groups.push(group); } @@ -182,6 +200,13 @@ impl Parse for Endpoint { } } + if permission_groups.is_empty() { + return Err(Error::new( + perms_content.span(), + "WITH_PERMISSIONS requires at least one permission", + )); + } + AuthRequirement::WithPermissions(permission_groups) } else { return Err(Error::new( @@ -270,3 +295,186 @@ mod kw { syn::custom_keyword!(UNAUTHORIZED); syn::custom_keyword!(WITH_PERMISSIONS); } + +#[cfg(test)] +mod tests { + use super::*; + use quote::{ToTokens, quote}; + + fn parse_definition(tokens: proc_macro2::TokenStream) -> FileServiceDefinition { + syn::parse2(tokens).expect("definition should parse") + } + + fn parse_endpoint(tokens: proc_macro2::TokenStream) -> Endpoint { + syn::parse2(tokens).expect("endpoint should parse") + } + + fn parse_definition_error(tokens: proc_macro2::TokenStream) -> String { + syn::parse2::(tokens) + .unwrap_err() + .to_string() + } + + fn parse_endpoint_error(tokens: proc_macro2::TokenStream) -> String { + syn::parse2::(tokens).unwrap_err().to_string() + } + + fn type_tokens(ty: &Type) -> String { + ty.to_token_stream().to_string() + } + + #[test] + fn definition_parses_body_limit_openapi_path_and_endpoint_variants() { + let definition = parse_definition(quote!({ + service_name: FilesApi, + base_path: "/api/files", + body_limit: 1048576, + openapi: { output: "target/openapi/files.json", }, + endpoints: [ + UPLOAD WITH_PERMISSIONS(["files:write"]) upload() -> UploadResponse, + DOWNLOAD UNAUTHORIZED files/{bucket: String}/download/{id: u64}() -> axum::response::Response, + ], + })); + + assert_eq!(definition.service_name.to_string(), "FilesApi"); + assert_eq!(definition.base_path.value(), "/api/files"); + assert_eq!(definition.body_limit, Some(1_048_576)); + assert!(matches!( + definition.openapi, + Some(OpenApiConfig::WithPath(ref path)) if path == "target/openapi/files.json" + )); + assert_eq!(definition.endpoints.len(), 2); + + let upload = &definition.endpoints[0]; + assert!(matches!(upload.operation, Operation::Upload)); + assert!(matches!( + upload.auth, + AuthRequirement::WithPermissions(ref groups) if groups == &vec![vec!["files:write".to_string()]] + )); + assert_eq!(upload.name.to_string(), "upload"); + assert!(upload.path.is_none()); + assert_eq!( + type_tokens(upload.response_type.as_ref().unwrap()), + "UploadResponse" + ); + + let download = &definition.endpoints[1]; + assert!(matches!(download.operation, Operation::Download)); + assert!(matches!(download.auth, AuthRequirement::Unauthorized)); + assert_eq!(download.name.to_string(), "files_download"); + assert_eq!( + download.path.as_ref().map(LitStr::value).as_deref(), + Some("files/{bucket}/download/{id}") + ); + assert_eq!(download.path_params.len(), 2); + assert_eq!(download.path_params[0].name.to_string(), "bucket"); + assert_eq!(type_tokens(&download.path_params[0].ty), "String"); + assert_eq!(download.path_params[1].name.to_string(), "id"); + assert_eq!(type_tokens(&download.path_params[1].ty), "u64"); + assert_eq!( + type_tokens(download.response_type.as_ref().unwrap()), + "axum :: response :: Response" + ); + } + + #[test] + fn definition_parses_boolean_openapi_modes() { + let enabled = parse_definition(quote!({ + service_name: FilesApi, + base_path: "/api/files", + openapi: true, + endpoints: [], + })); + assert!(matches!(enabled.openapi, Some(OpenApiConfig::Enabled))); + + let disabled = parse_definition(quote!({ + service_name: FilesApi, + base_path: "/api/files", + openapi: false, + endpoints: [], + })); + assert!(disabled.openapi.is_none()); + } + + #[test] + fn endpoint_parses_permission_singletons_and_groups() { + let endpoint = parse_endpoint(quote! { + UPLOAD WITH_PERMISSIONS(["read", ["write", "verified"]]) upload() + }); + + assert!(matches!( + endpoint.auth, + AuthRequirement::WithPermissions(ref groups) + if groups == &vec![ + vec!["read".to_string()], + vec!["write".to_string(), "verified".to_string()], + ] + )); + assert!(endpoint.response_type.is_none()); + } + + #[test] + fn definition_rejects_missing_required_and_unknown_fields() { + let err = parse_definition_error(quote!({ + base_path: "/api", + endpoints: [], + })); + assert!(err.contains("Missing service_name")); + + let err = parse_definition_error(quote!({ + service_name: FilesApi, + endpoints: [], + })); + assert!(err.contains("Missing base_path")); + + let err = parse_definition_error(quote!({ + service_name: FilesApi, + base_path: "/api", + unexpected: true, + endpoints: [], + })); + assert!(err.contains("Unknown field")); + } + + #[test] + fn openapi_object_rejects_unknown_keys_and_leftover_fields() { + let err = parse_definition_error(quote!({ + service_name: FilesApi, + base_path: "/api", + openapi: { path: "target/openapi.json" }, + endpoints: [], + })); + assert!(err.contains("Expected openapi output field")); + + let err = parse_definition_error(quote!({ + service_name: FilesApi, + base_path: "/api", + openapi: { output: "target/openapi.json", extra: "ignored" }, + endpoints: [], + })); + assert!(err.contains("Unexpected field in openapi config")); + } + + #[test] + fn endpoint_rejects_missing_operation_auth_and_empty_permission_groups() { + let err = parse_endpoint_error(quote! { + STREAM UNAUTHORIZED upload() + }); + assert!(err.contains("Expected UPLOAD or DOWNLOAD")); + + let err = parse_endpoint_error(quote! { + UPLOAD upload() + }); + assert!(err.contains("Expected UNAUTHORIZED or WITH_PERMISSIONS")); + + let err = parse_endpoint_error(quote! { + UPLOAD WITH_PERMISSIONS([]) upload() + }); + assert!(err.contains("requires at least one permission")); + + let err = parse_endpoint_error(quote! { + UPLOAD WITH_PERMISSIONS([[]]) upload() + }); + assert!(err.contains("Permission groups cannot be empty")); + } +} diff --git a/crates/rest/ras-file-macro/tests/debug_test.rs b/crates/rest/ras-file-macro/tests/debug_test.rs deleted file mode 100644 index 89b3fc8..0000000 --- a/crates/rest/ras-file-macro/tests/debug_test.rs +++ /dev/null @@ -1,13 +0,0 @@ -use ras_file_macro::file_service; - -// Test to debug the parsing issue -fn main() { - // This should expand the macro and show us any errors - file_service!({ - service_name: TestService, - base_path: "/test", - endpoints: [ - UPLOAD UNAUTHORIZED test() -> (), - ] - }); -} diff --git a/crates/rest/ras-file-macro/tests/e2e.rs b/crates/rest/ras-file-macro/tests/e2e.rs index c0c64f8..f86139f 100644 --- a/crates/rest/ras-file-macro/tests/e2e.rs +++ b/crates/rest/ras-file-macro/tests/e2e.rs @@ -1,8 +1,7 @@ -//! End-to-end test for the file_service! macro: generated reqwest client → -//! axum router → handler. Exercises upload + download with byte-equality and -//! a missing-token rejection. +//! End-to-end test for the file_service! macro: in-memory axum-test request +//! -> axum router -> handler. Exercises upload + download with byte-equality +//! and a missing-token rejection. -use std::io::Write; use std::sync::{Arc, Mutex}; use axum::{ @@ -10,11 +9,14 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use axum_test::multipart::{MultipartForm, Part}; use ras_auth_core::AuthenticatedUser; use ras_file_macro::file_service; -use ras_test_helpers::{MockAuthProvider, spawn_http}; use serde::{Deserialize, Serialize}; +mod support; +use support::{MockAuthProvider, mock_http_server}; + #[derive(Debug, Clone, Serialize, Deserialize)] struct UploadResponse { file_id: String, @@ -83,81 +85,57 @@ fn router(storage: Storage) -> axum::Router { .build() } -fn write_tempfile(bytes: &[u8]) -> tempfile::NamedTempFile { - let mut f = tempfile::NamedTempFile::new().expect("tempfile"); - f.write_all(bytes).expect("write tempfile"); - f.flush().expect("flush tempfile"); - f -} - #[tokio::test] async fn upload_and_download_round_trips_bytes() { let storage: Storage = Arc::new(Mutex::new(Vec::new())); - let server = spawn_http(router(storage.clone())); - let base = server.server_address().unwrap(); - let base_str = base.as_str().trim_end_matches('/').to_string(); + let server = mock_http_server(router(storage.clone())); let payload: Vec = (0u8..=255).cycle().take(64 * 1024).collect(); - let tmp = write_tempfile(&payload); + let form = MultipartForm::new().add_part( + "file", + Part::bytes(payload.clone()) + .file_name("blob.bin") + .mime_type("application/octet-stream"), + ); - let client = DemoClient::builder(base_str.clone()) - .build() - .expect("client build"); - client.set_bearer_token(Some("user-token".to_string())); - - let upload = client - .upload( - tmp.path(), - Some("blob.bin"), - Some("application/octet-stream"), - ) - .await - .expect("upload ok"); + let response = server + .post("/files/upload") + .authorization_bearer("user-token") + .multipart(form) + .await; + response.assert_status_ok(); + let upload: UploadResponse = response.json(); assert_eq!(upload.size, payload.len() as u64); - let resp = client.download(upload.file_id).await.expect("download ok"); - let bytes = resp.bytes().await.expect("read body"); + let response = server + .get(&format!("/files/download/{}", upload.file_id)) + .await; + response.assert_status_ok(); + let bytes = response.into_bytes(); assert_eq!(bytes.as_ref(), payload.as_slice()); } #[tokio::test] async fn upload_rejected_without_token() { let storage: Storage = Arc::new(Mutex::new(Vec::new())); - let server = spawn_http(router(storage)); - let base = server.server_address().unwrap(); - let base_str = base.as_str().trim_end_matches('/').to_string(); - - let payload = b"hello world"; - let tmp = write_tempfile(payload); - - let client = DemoClient::builder(base_str).build().expect("client build"); - // No bearer token. + let server = mock_http_server(router(storage)); - let result = client - .upload(tmp.path(), Some("hi.txt"), Some("text/plain")) - .await; - // The server short-circuits with 401 before consuming the multipart body, - // so reqwest may surface that either as the parsed status or as a generic - // connection error depending on how the upload stream was cut. Either is a - // valid signal of rejection — the only outcome we want to rule out is - // success. - assert!( - result.is_err(), - "upload must be rejected without a bearer token, got: {result:?}" + let form = MultipartForm::new().add_part( + "file", + Part::bytes("hello world") + .file_name("hi.txt") + .mime_type("text/plain"), ); + + let response = server.post("/files/upload").multipart(form).await; + response.assert_status(StatusCode::UNAUTHORIZED); } #[tokio::test] async fn download_unknown_file_returns_404() { let storage: Storage = Arc::new(Mutex::new(Vec::new())); - let server = spawn_http(router(storage)); - let base = server.server_address().unwrap(); - let base_str = base.as_str().trim_end_matches('/').to_string(); - - let client = DemoClient::builder(base_str).build().expect("client build"); - let err = client - .download("does-not-exist".to_string()) - .await - .expect_err("missing file must error"); - assert!(err.to_string().contains("404"), "got: {err}"); + let server = mock_http_server(router(storage)); + + let response = server.get("/files/download/does-not-exist").await; + response.assert_status(StatusCode::NOT_FOUND); } diff --git a/crates/rest/ras-file-macro/tests/expand_test.rs b/crates/rest/ras-file-macro/tests/expand_test.rs deleted file mode 100644 index c5fe58c..0000000 --- a/crates/rest/ras-file-macro/tests/expand_test.rs +++ /dev/null @@ -1,11 +0,0 @@ -#![allow(dead_code)] - -use ras_file_macro::file_service; - -file_service!({ - service_name: TestService, - base_path: "/api", - endpoints: [ - UPLOAD UNAUTHORIZED test() -> (), - ] -}); diff --git a/crates/rest/ras-file-macro/tests/minimal_test.rs b/crates/rest/ras-file-macro/tests/minimal_test.rs index e76413e..a8da97c 100644 --- a/crates/rest/ras-file-macro/tests/minimal_test.rs +++ b/crates/rest/ras-file-macro/tests/minimal_test.rs @@ -41,8 +41,7 @@ impl AuthProvider for DummyAuth { } #[test] -fn test_compilation() { - // If it compiles, the test passes +fn generated_builder_accepts_service_and_auth_provider() { let service = MyService; let auth = DummyAuth; let _builder = MinimalServiceBuilder::new(service).auth_provider(auth); diff --git a/crates/rest/ras-file-macro/tests/paren_test.rs b/crates/rest/ras-file-macro/tests/paren_test.rs index 68432ee..86b0056 100644 --- a/crates/rest/ras-file-macro/tests/paren_test.rs +++ b/crates/rest/ras-file-macro/tests/paren_test.rs @@ -2,7 +2,6 @@ use axum::body::Body; use axum::response::{IntoResponse, Response}; use ras_file_macro::file_service; -// Try with parentheses instead of braces file_service!({ service_name: TestParen, base_path: "/api", @@ -25,7 +24,19 @@ impl TestParenTrait for TestService { } } -#[test] -fn test() { - let _service = TestService; +#[tokio::test] +async fn generated_trait_handles_download_endpoint_with_path_parameter() { + let response = TestService + .download("report.txt".to_string()) + .await + .expect("download succeeds") + .into_response(); + + let (parts, body) = response.into_parts(); + let body = axum::body::to_bytes(body, usize::MAX) + .await + .expect("body bytes"); + + assert_eq!(parts.status, axum::http::StatusCode::OK); + assert_eq!(&body[..], b"Download report.txt"); } diff --git a/crates/rest/ras-file-macro/tests/simple_test.rs b/crates/rest/ras-file-macro/tests/simple_test.rs index aaa3f0e..dc97d89 100644 --- a/crates/rest/ras-file-macro/tests/simple_test.rs +++ b/crates/rest/ras-file-macro/tests/simple_test.rs @@ -1,6 +1,7 @@ +use axum::extract::Multipart; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider}; use ras_file_macro::file_service; -// Simplest possible test file_service!({ service_name: SimpleService, base_path: "/api", @@ -9,7 +10,29 @@ file_service!({ ] }); +#[derive(Clone)] +struct SimpleServiceImpl; + +#[async_trait::async_trait] +impl SimpleServiceTrait for SimpleServiceImpl { + async fn upload(&self, _multipart: Multipart) -> Result<(), SimpleServiceFileError> { + Ok(()) + } +} + +#[derive(Clone)] +struct RejectingAuth; + +impl AuthProvider for RejectingAuth { + fn authenticate(&self, _token: String) -> AuthFuture<'_> { + Box::pin(async move { Err(AuthError::InvalidToken) }) + } +} + #[test] -fn test_compilation() { - // If it compiles, the test passes +fn generated_builder_accepts_unauthenticated_upload_service() { + fn assert_trait_impl() {} + assert_trait_impl::(); + + let _builder = SimpleServiceBuilder::new(SimpleServiceImpl).auth_provider(RejectingAuth); } diff --git a/crates/rest/ras-file-macro/tests/support/mod.rs b/crates/rest/ras-file-macro/tests/support/mod.rs new file mode 100644 index 0000000..ab7e833 --- /dev/null +++ b/crates/rest/ras-file-macro/tests/support/mod.rs @@ -0,0 +1,52 @@ +use std::collections::{HashMap, HashSet}; + +use axum::Router; +use axum_test::TestServer; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; + +#[derive(Clone, Debug)] +pub struct MockAuthProvider { + table: HashMap, +} + +impl Default for MockAuthProvider { + fn default() -> Self { + let mut table = HashMap::new(); + table.insert("user-token".to_string(), mock_user("user-1", &["user"])); + table.insert( + "admin-token".to_string(), + mock_user("admin-1", &["admin", "user"]), + ); + table.insert("readonly-token".to_string(), mock_user("ro-1", &["read"])); + Self { table } + } +} + +impl AuthProvider for MockAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + let result = self + .table + .get(&token) + .cloned() + .ok_or(AuthError::InvalidToken); + Box::pin(async move { result }) + } +} + +pub fn mock_user(user_id: &str, permissions: &[&str]) -> AuthenticatedUser { + AuthenticatedUser { + user_id: user_id.to_string(), + permissions: permissions + .iter() + .map(|p| (*p).to_string()) + .collect::>(), + metadata: None, + } +} + +pub fn mock_http_server(router: Router) -> TestServer { + TestServer::builder() + .mock_transport() + .build(router) + .expect("failed to start axum-test TestServer with in-memory transport") +} diff --git a/crates/rest/ras-rest-core/Cargo.toml b/crates/rest/ras-rest-core/Cargo.toml index 6d6952a..794aec2 100644 --- a/crates/rest/ras-rest-core/Cargo.toml +++ b/crates/rest/ras-rest-core/Cargo.toml @@ -2,13 +2,15 @@ name = "ras-rest-core" version = "0.1.1" edition = "2024" +rust-version = "1.88" description = "Core types and traits for REST services in Rust Agent Stack" license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] serde = { workspace = true } thiserror = { workspace = true } -ras-auth-core = { path = "../../core/ras-auth-core" } -ras-version-core = { path = "../../core/ras-version-core" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-version-core = { path = "../../core/ras-version-core", version = "0.1.0" } diff --git a/crates/rest/ras-rest-core/README.md b/crates/rest/ras-rest-core/README.md new file mode 100644 index 0000000..22ec058 --- /dev/null +++ b/crates/rest/ras-rest-core/README.md @@ -0,0 +1,36 @@ +# ras-rest-core + +Runtime types shared by Rust Agent Stack REST services. + +This crate is intentionally small. It provides the response and error types +generated REST handlers use, re-exports the shared authentication types, and +exposes the version migration trait used by versioned REST endpoints. + +## Key Types + +- `RestResponse` wraps a response body with an explicit HTTP status. +- `RestResult` is the standard result returned by REST handlers. +- `RestError` carries a client-safe status and message plus optional internal + error details for logging. +- `RestResultExt` converts ordinary `Result` values into REST results. + +## Example + +```rust +use ras_rest_core::{RestError, RestResponse, RestResult}; + +async fn get_user(id: u64) -> RestResult { + if id == 0 { + return Err(RestError::bad_request("user id must be non-zero")); + } + + Ok(RestResponse::ok(format!("user-{id}"))) +} +``` + +## Checks + +```bash +cargo test -p ras-rest-core --locked +cargo clippy -p ras-rest-core --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/rest/ras-rest-core/src/lib.rs b/crates/rest/ras-rest-core/src/lib.rs index f50ce64..40ce2d1 100644 --- a/crates/rest/ras-rest-core/src/lib.rs +++ b/crates/rest/ras-rest-core/src/lib.rs @@ -223,9 +223,19 @@ mod tests { assert_eq!(src.to_string(), "inner failure"); } + #[test] + fn rest_error_new_has_stable_display_and_no_source() { + let err = RestError::new(429, "too many requests"); + + assert_eq!(err.status, 429); + assert_eq!(err.message, "too many requests"); + assert!(std::error::Error::source(&err).is_none()); + assert_eq!(err.to_string(), "HTTP 429: too many requests"); + } + #[test] fn into_rest_error_blanket_impl() { - let err = std::io::Error::new(std::io::ErrorKind::Other, "io"); + let err = std::io::Error::other("io"); let rest = err.into_rest_error(); assert_eq!(rest.status, 500); assert_eq!(rest.message, "Internal server error"); @@ -240,18 +250,32 @@ mod tests { assert_eq!(resp.status, 200); assert_eq!(resp.body, 7); - let err: Result = - Err(std::io::Error::new(std::io::ErrorKind::Other, "x")); + let err: Result = Err(std::io::Error::other("x")); let mapped: RestResult = err.internal_server_error(); let e = mapped.unwrap_err(); assert_eq!(e.status, 500); // rest_error variant lets callers customize. - let err: Result = - Err(std::io::Error::new(std::io::ErrorKind::Other, "x")); + let err: Result = Err(std::io::Error::other("x")); let mapped: RestResult = err.rest_error(418, "teapot"); let e = mapped.unwrap_err(); assert_eq!(e.status, 418); assert_eq!(e.message, "teapot"); } + + #[test] + fn rest_result_ext_custom_error_preserves_internal_source() { + let err: Result = Err(std::io::Error::other("database down")); + + let mapped = err.rest_error(503, "service unavailable").unwrap_err(); + + assert_eq!(mapped.status, 503); + assert_eq!(mapped.message, "service unavailable"); + assert_eq!( + std::error::Error::source(&mapped) + .expect("source") + .to_string(), + "database down" + ); + } } diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index 2496094..747dc38 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -2,10 +2,12 @@ name = "ras-rest-macro" version = "0.2.1" edition = "2024" +rust-version = "1.88" description = "Procedural macro for type-safe REST APIs with auth integration and OpenAPI document generation" license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [lib] proc-macro = true @@ -24,9 +26,8 @@ schemars = { workspace = true } # Server dependencies axum = { workspace = true, optional = true } -axum-extra.workspace = true -ras-auth-core = { path = "../../core/ras-auth-core", optional = true } -ras-rest-core = { path = "../ras-rest-core", optional = true } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0", optional = true } +ras-rest-core = { path = "../ras-rest-core", version = "0.1.1", optional = true } async-trait = { workspace = true, optional = true } # Client dependencies @@ -34,13 +35,11 @@ reqwest = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true } -wiremock = { workspace = true } reqwest = { workspace = true } tower = { workspace = true } -hyper = { workspace = true } rand = { workspace = true } -ras-identity-session = { path = "../../identity/ras-identity-session" } -ras-jsonrpc-core = { path = "../../rpc/ras-jsonrpc-core" } +ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.1.1" } +ras-jsonrpc-core = { path = "../../rpc/ras-jsonrpc-core", version = "0.1.2" } futures = { workspace = true } chrono = { workspace = true } serde_json = { workspace = true } @@ -48,9 +47,9 @@ tracing = { workspace = true } async-trait = { workspace = true } # Server dependencies for tests axum = { workspace = true } -ras-auth-core = { path = "../../core/ras-auth-core" } -ras-rest-core = { path = "../ras-rest-core" } -ras-test-helpers = { path = "../../test-utils/ras-test-helpers" } +axum-extra = { workspace = true } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-rest-core = { path = "../ras-rest-core", version = "0.1.1" } axum-test = { workspace = true } schemars = { workspace = true } criterion = { workspace = true, features = ["async_tokio"] } diff --git a/crates/rest/ras-rest-macro/README.md b/crates/rest/ras-rest-macro/README.md index 2fae8c5..b389fa5 100644 --- a/crates/rest/ras-rest-macro/README.md +++ b/crates/rest/ras-rest-macro/README.md @@ -87,7 +87,22 @@ impl UserServiceTrait for UserServiceImpl { })) } - // ... implement other endpoints + async fn put_users_by_id( + &self, + _user: &AuthenticatedUser, + id: i32, + request: CreateUserRequest, + ) -> RestResult { + Ok(RestResponse::ok(User { + id, + name: request.name, + email: request.email, + })) + } + + async fn delete_users_by_id(&self, _user: &AuthenticatedUser, _id: i32) -> RestResult<()> { + Ok(RestResponse::no_content()) + } } let service = UserServiceBuilder::new(UserServiceImpl) @@ -117,13 +132,16 @@ rest_service!({ service_name: ServiceName, // Name for the generated trait and builder base_path: "/api/v1", // Base path for all endpoints openapi: true, // Enable OpenAPI generation (optional) - // or: openapi: { output: "path/to/spec.json" }, endpoints: [ - // Endpoint definitions... + GET UNAUTHORIZED users() -> Vec, + POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User, ] }); ``` +Use `openapi: { output: "target/openapi/service.json" }` instead of +`openapi: true` when you want a custom output path. + ### Endpoint Definition ```rust @@ -133,7 +151,8 @@ METHOD AUTH_REQUIREMENT path(RequestType) -> ResponseType, - **METHOD**: `GET`, `POST`, `PUT`, `DELETE`, or `PATCH` - **AUTH_REQUIREMENT**: - `UNAUTHORIZED`: No authentication required - - `WITH_PERMISSIONS(["perm1", "perm2"])`: Requires authentication and specified permissions + - `WITH_PERMISSIONS(["perm1", "perm2"])`: Requires authentication and all listed permissions + - `WITH_PERMISSIONS(["admin"] | ["moderator", "editor"])`: Allows any matching permission group - **path**: URL path with optional parameters in `{param: Type}` format - **RequestType**: Optional request body type (omit `()` for no body) - **ResponseType**: Response type @@ -239,13 +258,24 @@ impl ras_rest_core::VersionMigration The macro integrates with `ras-auth-core::AuthProvider` for authentication: ```rust -use ras_auth_core::{AuthFuture, AuthProvider}; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use std::collections::HashSet; struct MyAuthProvider; impl AuthProvider for MyAuthProvider { fn authenticate(&self, token: String) -> AuthFuture<'_> { - // Validate JWT token and return authenticated user + Box::pin(async move { + if token != "admin-token" { + return Err(AuthError::InvalidToken); + } + + Ok(AuthenticatedUser { + user_id: "admin-user".to_string(), + permissions: HashSet::from(["admin".to_string()]), + metadata: None, + }) + }) } } @@ -291,3 +321,10 @@ let app = axum::Router::new() - Permission metadata as OpenAPI extensions - Path parameters and request/response schemas - Standard HTTP error responses + +## Checks + +```bash +cargo test -p ras-rest-macro --locked +cargo clippy -p ras-rest-macro --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/rest/ras-rest-macro/benches/dispatch.rs b/crates/rest/ras-rest-macro/benches/dispatch.rs index 6e15e7c..daf6653 100644 --- a/crates/rest/ras-rest-macro/benches/dispatch.rs +++ b/crates/rest/ras-rest-macro/benches/dispatch.rs @@ -1,5 +1,5 @@ //! Criterion bench measuring per-call latency of an authenticated REST GET -//! through the full stack: generated client → axum router → handler. +//! through the in-memory axum-test stack: request -> axum router -> handler. //! //! Run with `cargo bench -p ras-rest-macro`. @@ -7,10 +7,14 @@ use criterion::{Criterion, criterion_group, criterion_main}; use ras_auth_core::AuthenticatedUser; use ras_rest_core::{RestResponse, RestResult}; use ras_rest_macro::rest_service; -use ras_test_helpers::{MockAuthProvider, spawn_http}; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use tokio::runtime::Runtime; +#[path = "../tests/support/mod.rs"] +mod support; +use support::{MockAuthProvider, mock_http_server}; + #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] struct Item { id: u32, @@ -47,21 +51,18 @@ fn build_router() -> axum::Router { fn bench_dispatch(c: &mut Criterion) { let rt = Runtime::new().unwrap(); - let (client, _server) = rt.block_on(async { - let server = spawn_http(build_router()); - let base = server.server_address().unwrap().to_string(); - let mut client = BenchSvcClient::builder(&base) - .build() - .expect("client build"); - client.set_bearer_token(Some("user-token".to_string())); - (client, server) - }); + let server = Arc::new(mock_http_server(build_router())); c.bench_function("rest_get_dispatch", |b| { b.to_async(&rt).iter(|| { - let client = client.clone(); + let server = Arc::clone(&server); async move { - let r = client.get_items_by_id(1).await.expect("get ok"); + let response = server + .get("/api/items/1") + .authorization_bearer("user-token") + .await; + response.assert_status_ok(); + let r: Item = response.json(); std::hint::black_box(r); } }); diff --git a/crates/rest/ras-rest-macro/src/api_explorer_template.html b/crates/rest/ras-rest-macro/src/api_explorer_template.html index 16f68b8..c944bd7 100644 --- a/crates/rest/ras-rest-macro/src/api_explorer_template.html +++ b/crates/rest/ras-rest-macro/src/api_explorer_template.html @@ -462,8 +462,8 @@

API Explorer

- - + +
@@ -1383,14 +1383,14 @@

Response

renderEnvironments(); }); $("save-token").addEventListener("click", () => { - state.token = $("jwt-token").value.trim(); + state.token = $("bearer-token").value.trim(); storageSet("bearer-token", state.token); $("auth-state").textContent = state.token ? "Token set" : "No token"; showToast(state.token ? "Token applied for this session" : "Token cleared"); }); $("clear-token").addEventListener("click", () => { state.token = ""; - $("jwt-token").value = ""; + $("bearer-token").value = ""; sessionStorage.removeItem(`${storagePrefix}:bearer-token`); $("auth-state").textContent = "No token"; }); @@ -1429,7 +1429,7 @@

Response

state.saved = storageGet("saved", {}); state.history = storageGet("history", []); state.token = storageGet("bearer-token", ""); - $("jwt-token").value = state.token; + $("bearer-token").value = state.token; $("auth-state").textContent = state.token ? "Token set" : "No token"; bindEvents(); renderEnvironments(); diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index e7af9cc..0958281 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -17,29 +17,28 @@ mod static_hosting; /// /// # Example /// -/// ```ignore +/// ```rust /// use ras_rest_macro::rest_service; /// use serde::{Deserialize, Serialize}; /// use schemars::JsonSchema; -/// use axum::response::IntoResponse; /// -/// #[derive(Serialize, Deserialize, JsonSchema)] +/// #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] /// struct UsersResponse { /// users: Vec<()>, /// } /// -/// #[derive(Serialize, Deserialize, JsonSchema)] +/// #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] /// struct CreateUserRequest { /// name: String, /// } /// -/// #[derive(Serialize, Deserialize, JsonSchema)] +/// #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] /// struct UserResponse { /// id: String, /// name: String, /// } /// -/// #[derive(Serialize, Deserialize, JsonSchema)] +/// #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] /// struct UpdateUserRequest { /// name: String, /// } @@ -59,6 +58,8 @@ mod static_hosting; /// DELETE WITH_PERMISSIONS(["admin"]) users/{id: String}() -> (), /// ] /// }); +/// +/// # fn main() {} /// ``` #[proc_macro] pub fn rest_service(input: TokenStream) -> TokenStream { @@ -716,8 +717,6 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result = Vec::new(); @@ -1092,7 +1091,6 @@ fn generate_query_struct( quote! { #[derive(serde::Deserialize)] - #[allow(dead_code)] pub(super) struct #struct_name { #(#fields),* } diff --git a/crates/rest/ras-rest-macro/src/openapi.rs b/crates/rest/ras-rest-macro/src/openapi.rs index 16ddcca..e82e472 100644 --- a/crates/rest/ras-rest-macro/src/openapi.rs +++ b/crates/rest/ras-rest-macro/src/openapi.rs @@ -129,7 +129,7 @@ pub fn generate_openapi_code( }) }); - // Post-process schema to make it more Swagger UI friendly + // Post-process schemas for broad OpenAPI explorer compatibility. normalize_nullable_properties(&mut schema_value); fix_option_types(&mut schema_value); schema_value @@ -401,7 +401,7 @@ pub fn generate_openapi_code( } } - // Helper function to normalize nullable properties for better Swagger UI compatibility + // Helper function to normalize nullable properties for better OpenAPI explorer compatibility. #[cfg(feature = "server")] fn normalize_nullable_properties(value: &mut serde_json::Value) { match value { @@ -704,8 +704,7 @@ pub fn generate_openapi_code( "bearerAuth": { "type": "http", "scheme": "bearer", - "bearerFormat": "JWT", - "description": "JWT token for authentication" + "description": "Bearer token for authentication" } } } diff --git a/crates/rest/ras-rest-macro/tests/e2e.rs b/crates/rest/ras-rest-macro/tests/e2e.rs index 9b69cac..62b16fd 100644 --- a/crates/rest/ras-rest-macro/tests/e2e.rs +++ b/crates/rest/ras-rest-macro/tests/e2e.rs @@ -1,13 +1,16 @@ -//! End-to-end test: generated reqwest client → axum router → trait impl → -//! response → client. Covers GET, POST with body, path params, query params, -//! and auth-related rejection paths. +//! End-to-end test: in-memory axum-test request -> axum router -> trait impl +//! -> response. Covers GET, POST with body, path params, query params, and +//! auth-related rejection paths. +use axum::http::StatusCode; use ras_auth_core::AuthenticatedUser; use ras_rest_core::{RestError, RestResponse, RestResult}; use ras_rest_macro::rest_service; -use ras_test_helpers::{MockAuthProvider, spawn_http}; use serde::{Deserialize, Serialize}; +mod support; +use support::{MockAuthProvider, mock_http_server}; + #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] struct Item { id: u32, @@ -262,34 +265,30 @@ fn router() -> axum::Router { .build() } -fn client(base: &str) -> DemoClient { - DemoClient::builder(base).build().expect("client build") +fn server() -> axum_test::TestServer { + mock_http_server(router()) } #[tokio::test] async fn unauth_get_round_trips() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - let resp = client(&base).get_items().await.expect("get_items ok"); + let response = server().get("/api/items").await; + response.assert_status_ok(); + let resp: ItemsResponse = response.json(); + assert_eq!(resp.items.len(), 1); assert_eq!(resp.items[0].name, "alpha"); } #[tokio::test] async fn legacy_rest_version_round_trips_through_canonical_handler() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - - let resp = client(&base) - .post_v1_items_by_id_rename( - 7, - Some(true), - RenameItemV1 { - name: "renamed".to_string(), - }, - ) - .await - .expect("legacy rename ok"); + let response = server() + .post("/api/v1/items/7/rename?notify=true") + .json(&RenameItemV1 { + name: "renamed".to_string(), + }) + .await; + response.assert_status_ok(); + let resp: RenamedItemV1 = response.json(); assert_eq!( resp, @@ -301,20 +300,15 @@ async fn legacy_rest_version_round_trips_through_canonical_handler() { #[tokio::test] async fn canonical_rest_version_uses_v2_path_and_types() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - - let resp = client(&base) - .post_v2_items_by_id_rename( - 8, - false, - RenameItemV2 { - display_name: "canonical".to_string(), - notify: true, - }, - ) - .await - .expect("canonical rename ok"); + let response = server() + .post("/api/v2/items/8/rename?notify=false") + .json(&RenameItemV2 { + display_name: "canonical".to_string(), + notify: true, + }) + .await; + response.assert_status_ok(); + let resp: RenamedItemV2 = response.json(); assert_eq!( resp, @@ -328,57 +322,45 @@ async fn canonical_rest_version_uses_v2_path_and_types() { #[tokio::test] async fn auth_get_with_path_param_succeeds_with_user_token() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - let mut c = client(&base); - c.set_bearer_token(Some("user-token".to_string())); + let response = server() + .get("/api/items/7") + .authorization_bearer("user-token") + .await; + response.assert_status_ok(); + let item: Item = response.json(); - let item = c.get_items_by_id(7).await.expect("get_items_by_id ok"); assert_eq!(item.id, 7); assert_eq!(item.name, "item-7"); } #[tokio::test] async fn auth_get_rejected_without_token() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - // No bearer token set on client. - let err = client(&base) - .get_items_by_id(1) - .await - .expect_err("must be rejected"); - let s = err.to_string(); - assert!(s.contains("401") || s.contains("Unauthorized"), "got: {s}"); + let response = server().get("/api/items/1").await; + response.assert_status(StatusCode::UNAUTHORIZED); } #[tokio::test] async fn auth_post_rejected_with_insufficient_perms() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - let mut c = client(&base); - c.set_bearer_token(Some("user-token".to_string())); // not admin - - let err = c - .post_items(CreateItem { + let response = server() + .post("/api/items") + .authorization_bearer("user-token") + .json(&CreateItem { name: "x".to_string(), }) - .await - .expect_err("user-token can't POST items"); - let s = err.to_string(); - assert!(s.contains("403") || s.contains("Forbidden"), "got: {s}"); + .await; + response.assert_status(StatusCode::FORBIDDEN); } #[tokio::test] async fn auth_post_with_admin_succeeds_and_user_id_propagates() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - let mut c = client(&base); - c.set_bearer_token(Some("admin-token".to_string())); - - let item = c - .post_items(CreateItem { name: "foo".into() }) - .await - .expect("post_items ok"); + let response = server() + .post("/api/items") + .authorization_bearer("admin-token") + .json(&CreateItem { name: "foo".into() }) + .await; + response.assert_status(StatusCode::CREATED); + let item: Item = response.json(); + assert_eq!(item.name, "foo"); // admin-1 is 7 chars long. assert_eq!(item.id, 7); @@ -386,130 +368,110 @@ async fn auth_post_with_admin_succeeds_and_user_id_propagates() { #[tokio::test] async fn query_params_required_and_optional_serialize_correctly() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - - // Optional `limit` provided, required `q` and `exact` set. - let resp = client(&base) - .get_search("hi".to_string(), Some(3), true) - .await - .expect("search ok"); + let server = server(); + + let response = server.get("/api/search?q=hi&limit=3&exact=true").await; + response.assert_status_ok(); + let resp: ItemsResponse = response.json(); assert_eq!(resp.items.len(), 3); assert_eq!(resp.items[0].name, "exact:hi-0"); assert_eq!(resp.items[2].name, "exact:hi-2"); - // Optional `limit` omitted (None) → handler default of 2 applies, and the - // bool flips the prefix. - let resp = client(&base) - .get_search("zz".to_string(), None, false) - .await - .expect("search ok"); + let response = server.get("/api/search?q=zz&exact=false").await; + response.assert_status_ok(); + let resp: ItemsResponse = response.json(); assert_eq!(resp.items.len(), 2); assert_eq!(resp.items[0].name, "fuzzy:zz-0"); } #[tokio::test] async fn vec_query_params_serialize_as_repeated_keys() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - - let resp = client(&base) - .get_filter( - vec!["red".to_string(), "blue".to_string()], - Some(vec!["featured".to_string()]), - ) - .await - .expect("filter with repeated keys"); + let server = server(); + + let response = server + .get("/api/filter?tags=red&tags=blue&optional_tags=featured") + .await; + response.assert_status_ok(); + let resp: ItemsResponse = response.json(); let names: Vec<_> = resp.items.into_iter().map(|item| item.name).collect(); assert_eq!(names, vec!["tag:red", "tag:blue", "optional:featured"]); - let resp = client(&base) - .get_filter(vec!["solo".to_string()], None) - .await - .expect("filter without optional tags"); + let response = server.get("/api/filter?tags=solo").await; + response.assert_status_ok(); + let resp: ItemsResponse = response.json(); let names: Vec<_> = resp.items.into_iter().map(|item| item.name).collect(); assert_eq!(names, vec!["tag:solo"]); } #[tokio::test] async fn enum_query_params_use_serde_renames_without_display() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); + let server = server(); - let resp = client(&base) - .get_sorted(SortOrder::Asc) - .await - .expect("sort asc"); + let response = server.get("/api/sorted?order=asc").await; + response.assert_status_ok(); + let resp: ItemsResponse = response.json(); assert_eq!(resp.items[0].name, "order:asc"); - let resp = client(&base) - .get_sorted(SortOrder::Desc) - .await - .expect("sort desc"); + let response = server.get("/api/sorted?order=desc").await; + response.assert_status_ok(); + let resp: ItemsResponse = response.json(); assert_eq!(resp.items[0].name, "order:desc"); } #[tokio::test] async fn query_params_with_body_and_auth() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - let mut c = client(&base); - c.set_bearer_token(Some("admin-token".to_string())); - - let item = c - .post_items_batch( - true, - CreateItem { - name: "alpha".into(), - }, - ) - .await - .expect("post_items_batch ok"); + let server = server(); + + let response = server + .post("/api/items/batch?notify=true") + .authorization_bearer("admin-token") + .json(&CreateItem { + name: "alpha".into(), + }) + .await; + response.assert_status(StatusCode::CREATED); + let item: Item = response.json(); assert_eq!(item.name, "alpha(notified)"); - let item = c - .post_items_batch( - false, - CreateItem { - name: "beta".into(), - }, - ) - .await - .expect("post_items_batch ok"); + let response = server + .post("/api/items/batch?notify=false") + .authorization_bearer("admin-token") + .json(&CreateItem { + name: "beta".into(), + }) + .await; + response.assert_status(StatusCode::CREATED); + let item: Item = response.json(); assert_eq!(item.name, "beta(silent)"); } #[tokio::test] async fn query_params_with_path_param() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - let mut c = client(&base); - c.set_bearer_token(Some("user-token".to_string())); - - let resp = c - .get_items_by_id_related(42, Some("featured".into())) - .await - .expect("related with tag"); + let server = server(); + + let response = server + .get("/api/items/42/related?tag=featured") + .authorization_bearer("user-token") + .await; + response.assert_status_ok(); + let resp: ItemsResponse = response.json(); assert_eq!(resp.items[0].id, 42); assert_eq!(resp.items[0].name, "related/featured"); - let resp = c - .get_items_by_id_related(42, None) - .await - .expect("related without tag"); + let response = server + .get("/api/items/42/related") + .authorization_bearer("user-token") + .await; + response.assert_status_ok(); + let resp: ItemsResponse = response.json(); assert_eq!(resp.items[0].name, "related/none"); } #[tokio::test] async fn handler_error_surfaces_to_client() { - let server = spawn_http(router()); - let base = server.server_address().unwrap().to_string(); - let mut c = client(&base); - c.set_bearer_token(Some("user-token".to_string())); - - let err = c - .get_items_by_id(404) - .await - .expect_err("404 sentinel must error"); - assert!(err.to_string().contains("404"), "got: {err}"); + let response = server() + .get("/api/items/404") + .authorization_bearer("user-token") + .await; + response.assert_status(StatusCode::NOT_FOUND); } diff --git a/crates/rest/ras-rest-macro/tests/http_integration.rs b/crates/rest/ras-rest-macro/tests/http_integration.rs index 51bc6fc..7fc2da3 100644 --- a/crates/rest/ras-rest-macro/tests/http_integration.rs +++ b/crates/rest/ras-rest-macro/tests/http_integration.rs @@ -1,3 +1,5 @@ +use axum::http::Method; +use axum_test::{TestResponse, TestServer}; use rand::Rng; use ras_jsonrpc_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; use ras_rest_core::{RestError, RestResponse}; @@ -5,7 +7,7 @@ use ras_rest_macro::rest_service; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::HashSet; -use tokio::net::TcpListener as TokioTcpListener; +use std::sync::Arc; // Test data structures for REST API testing #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] @@ -107,7 +109,7 @@ impl AuthProvider for TestRestAuthProvider { } } -// Generate comprehensive REST test service +// Generate a broad REST test service rest_service!({ service_name: TestRestService, base_path: "/api/v1", @@ -425,172 +427,129 @@ impl TestRestServiceTrait for TestRestServiceImpl { } } -async fn create_rest_test_server() -> (String, tokio::task::JoinHandle<()>) { - let tokio_listener = TokioTcpListener::bind("127.0.0.1:0") - .await - .expect("Failed to bind to port"); - let addr = tokio_listener - .local_addr() - .expect("Failed to get local addr"); - let base_url = format!("http://127.0.0.1:{}", addr.port()); - +fn create_rest_test_server() -> TestServer { let builder = TestRestServiceBuilder::new(TestRestServiceImpl).auth_provider(TestRestAuthProvider::new()); let app = builder.build(); - - let handle = tokio::spawn(async move { - axum::serve(tokio_listener, app) - .await - .expect("Server failed"); - }); - - // Give the server a moment to start - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - (base_url, handle) + TestServer::builder().mock_transport().build(app).unwrap() } async fn make_rest_request( - method: reqwest::Method, - url: &str, + server: &TestServer, + method: Method, + path: &str, body: Option, token: Option<&str>, -) -> Result { - let mut request_builder = reqwest::Client::new() - .request(method, url) - .header("Content-Type", "application/json"); +) -> TestResponse { + let mut request = match method { + Method::GET => server.get(path), + Method::POST => server.post(path), + Method::PUT => server.put(path), + Method::PATCH => server.patch(path), + Method::DELETE => server.delete(path), + other => panic!("unsupported test method: {other}"), + }; if let Some(token) = token { - request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); + request = request.authorization_bearer(token); } if let Some(body) = body { - request_builder = request_builder.json(&body); + request.json(&body).await + } else { + request.await } - - request_builder.send().await } #[tokio::test] async fn test_docs_explorer_routes_generated() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); - let docs_response = reqwest::get(format!("{}/api/v1/docs", base_url)) - .await - .unwrap(); - assert_eq!(docs_response.status(), 200); + let docs_response = server.get("/api/v1/docs").await; + assert_eq!(docs_response.status_code().as_u16(), 200); - let docs = docs_response.text().await.unwrap(); + let docs = docs_response.text(); assert!(docs.contains("\"TestRestService\"")); assert!(docs.contains("\"rest\"")); assert!(docs.contains("/api/v1/docs/openapi.json")); - assert!(docs.contains("id=\"jwt-token\"")); + assert!(docs.contains("id=\"bearer-token\"")); assert!(docs.contains("id=\"saved-list\"")); - let spec_response = reqwest::get(format!("{}/api/v1/docs/openapi.json", base_url)) - .await - .unwrap(); - assert_eq!(spec_response.status(), 200); + let spec_response = server.get("/api/v1/docs/openapi.json").await; + assert_eq!(spec_response.status_code().as_u16(), 200); - let spec: serde_json::Value = spec_response.json().await.unwrap(); + let spec: serde_json::Value = spec_response.json(); assert_eq!(spec["info"]["title"], "TestRestService REST API"); assert!(spec["paths"].is_object()); } #[tokio::test] async fn test_unauthorized_endpoints() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test GET /api/v1/users without auth - let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/users", base_url), - None, - None, - ) - .await - .unwrap(); + let response = make_rest_request(&server, Method::GET, "/api/v1/users", None, None).await; - assert_eq!(response.status(), 200); - let users_response: UsersResponse = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let users_response: UsersResponse = response.json(); assert_eq!(users_response.total, 2); assert_eq!(users_response.users.len(), 2); assert_eq!(users_response.users[0].name, "John Doe"); // Test GET /api/v1/users/123/posts without auth - let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/users/123/posts", base_url), - None, - None, - ) - .await - .unwrap(); + let response = + make_rest_request(&server, Method::GET, "/api/v1/users/123/posts", None, None).await; - assert_eq!(response.status(), 200); - let posts_response: PostsResponse = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let posts_response: PostsResponse = response.json(); assert_eq!(posts_response.total, 1); assert_eq!(posts_response.posts[0].user_id, 123); // Test GET /api/v1/health - let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/health", base_url), - None, - None, - ) - .await - .unwrap(); + let response = make_rest_request(&server, Method::GET, "/api/v1/health", None, None).await; - assert_eq!(response.status(), 200); - let health: String = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let health: String = response.json(); assert_eq!(health, "OK"); } #[tokio::test] async fn test_authentication_required_endpoints() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test GET /api/v1/status without token - should fail - let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/status", base_url), - None, - None, - ) - .await - .unwrap(); + let response = make_rest_request(&server, Method::GET, "/api/v1/status", None, None).await; - assert_eq!(response.status(), 401); + assert_eq!(response.status_code().as_u16(), 401); // Test GET /api/v1/status with valid token - should succeed let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/status", base_url), + &server, + Method::GET, + "/api/v1/status", None, Some("user-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let status: Value = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let status: Value = response.json(); assert_eq!(status["status"], "authenticated"); assert_eq!(status["user_id"], "regular-user"); // Test GET /api/v1/users/123/posts/456 with valid token let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/users/123/posts/456", base_url), + &server, + Method::GET, + "/api/v1/users/123/posts/456", None, Some("empty-perms-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let post: Post = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let post: Post = response.json(); assert_eq!(post.id, Some(456)); assert_eq!(post.user_id, 123); assert_eq!(post.title, "Protected Post"); @@ -598,12 +557,13 @@ async fn test_authentication_required_endpoints() { #[tokio::test] async fn test_admin_permission_endpoints() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test POST /api/v1/users with user token (insufficient permissions) - should fail let response = make_rest_request( - reqwest::Method::POST, - &format!("{}/api/v1/users", base_url), + &server, + Method::POST, + "/api/v1/users", Some(json!({ "name": "New User", "email": "new@example.com", @@ -611,15 +571,15 @@ async fn test_admin_permission_endpoints() { })), Some("user-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 403); + assert_eq!(response.status_code().as_u16(), 403); // Test POST /api/v1/users with admin token - should succeed let response = make_rest_request( - reqwest::Method::POST, - &format!("{}/api/v1/users", base_url), + &server, + Method::POST, + "/api/v1/users", Some(json!({ "name": "New User", "email": "new@example.com", @@ -627,93 +587,93 @@ async fn test_admin_permission_endpoints() { })), Some("admin-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 201); // Created - let user: User = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 201); // Created + let user: User = response.json(); assert_eq!(user.name, "New User"); assert_eq!(user.email, "new@example.com"); assert!(user.id.unwrap() >= 100); // Test PUT /api/v1/users/123 with admin token let response = make_rest_request( - reqwest::Method::PUT, - &format!("{}/api/v1/users/123", base_url), + &server, + Method::PUT, + "/api/v1/users/123", Some(json!({ "name": "Updated User", "email": "updated@example.com" })), Some("admin-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let user: User = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let user: User = response.json(); assert_eq!(user.id, Some(123)); assert_eq!(user.name, "Updated User"); // Test DELETE /api/v1/users/123 with admin token let response = make_rest_request( - reqwest::Method::DELETE, - &format!("{}/api/v1/users/123", base_url), + &server, + Method::DELETE, + "/api/v1/users/123", None, Some("admin-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 204); // No Content + assert_eq!(response.status_code().as_u16(), 204); // No Content } #[tokio::test] async fn test_user_permission_endpoints() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test GET /api/v1/users/123 with empty permissions token - should fail let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/users/123", base_url), + &server, + Method::GET, + "/api/v1/users/123", None, Some("empty-perms-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 403); + assert_eq!(response.status_code().as_u16(), 403); // Test GET /api/v1/users/123 with user token - should succeed let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/users/123", base_url), + &server, + Method::GET, + "/api/v1/users/123", None, Some("user-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let user: User = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let user: User = response.json(); assert_eq!(user.id, Some(123)); assert_eq!(user.name, "Found User"); // Test GET /api/v1/users/404 with user token - should return error let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/users/404", base_url), + &server, + Method::GET, + "/api/v1/users/404", None, Some("user-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 404); // Not Found + assert_eq!(response.status_code().as_u16(), 404); // Not Found // Test POST /api/v1/users/123/posts with user token let response = make_rest_request( - reqwest::Method::POST, - &format!("{}/api/v1/users/123/posts", base_url), + &server, + Method::POST, + "/api/v1/users/123/posts", Some(json!({ "title": "My New Post", "content": "This is my new post content", @@ -721,11 +681,10 @@ async fn test_user_permission_endpoints() { })), Some("user-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 201); // Created - let post: Post = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 201); // Created + let post: Post = response.json(); assert_eq!(post.user_id, 123); assert_eq!(post.title, "My New Post"); assert!(!post.published); @@ -733,12 +692,13 @@ async fn test_user_permission_endpoints() { #[tokio::test] async fn test_multiple_permissions_endpoints() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test PUT /api/v1/users/123/posts/456 with user token - should fail (needs both "user" AND "moderator") let response = make_rest_request( - reqwest::Method::PUT, - &format!("{}/api/v1/users/123/posts/456", base_url), + &server, + Method::PUT, + "/api/v1/users/123/posts/456", Some(json!({ "title": "Updated Post", "content": "Updated content", @@ -746,15 +706,15 @@ async fn test_multiple_permissions_endpoints() { })), Some("user-token"), ) - .await - .unwrap(); + .await; - assert_ne!(response.status(), 200); + assert_ne!(response.status_code().as_u16(), 200); // Test PUT /api/v1/users/123/posts/456 with moderator token - should succeed (has both "user" and "moderator") let response = make_rest_request( - reqwest::Method::PUT, - &format!("{}/api/v1/users/123/posts/456", base_url), + &server, + Method::PUT, + "/api/v1/users/123/posts/456", Some(json!({ "title": "Moderator Updated Post", "content": "Moderator updated content", @@ -762,18 +722,18 @@ async fn test_multiple_permissions_endpoints() { })), Some("moderator-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); + assert_eq!(response.status_code().as_u16(), 200); - let post: Post = response.json().await.unwrap(); + let post: Post = response.json(); assert_eq!(post.title, "Moderator Updated Post"); // Test PUT /api/v1/users/123/posts/456 with empty permissions - should fail let response = make_rest_request( - reqwest::Method::PUT, - &format!("{}/api/v1/users/123/posts/456", base_url), + &server, + Method::PUT, + "/api/v1/users/123/posts/456", Some(json!({ "title": "Unauthorized Update", "content": "Should not work", @@ -781,110 +741,86 @@ async fn test_multiple_permissions_endpoints() { })), Some("empty-perms-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 403); + assert_eq!(response.status_code().as_u16(), 403); // Test DELETE /api/v1/users/123/posts/456 with admin token - should succeed let response = make_rest_request( - reqwest::Method::DELETE, - &format!("{}/api/v1/users/123/posts/456", base_url), + &server, + Method::DELETE, + "/api/v1/users/123/posts/456", None, Some("admin-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 204); // No Content + assert_eq!(response.status_code().as_u16(), 204); // No Content // Test DELETE /api/v1/users/123/posts/456 with moderator token - should succeed let response = make_rest_request( - reqwest::Method::DELETE, - &format!("{}/api/v1/users/123/posts/456", base_url), + &server, + Method::DELETE, + "/api/v1/users/123/posts/456", None, Some("moderator-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 204); // No Content + assert_eq!(response.status_code().as_u16(), 204); // No Content } #[tokio::test] async fn test_invalid_requests() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test non-existent endpoint - let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/nonexistent", base_url), - None, - None, - ) - .await - .unwrap(); + let response = make_rest_request(&server, Method::GET, "/api/v1/nonexistent", None, None).await; - assert_eq!(response.status(), 404); + assert_eq!(response.status_code().as_u16(), 404); // Test invalid HTTP method - let response = make_rest_request( - reqwest::Method::PATCH, - &format!("{}/api/v1/users", base_url), - None, - None, - ) - .await - .unwrap(); + let response = make_rest_request(&server, Method::PATCH, "/api/v1/users", None, None).await; - assert_eq!(response.status(), 405); + assert_eq!(response.status_code().as_u16(), 405); // Test invalid JSON body - let client = reqwest::Client::new(); - let response = client - .post(format!("{}/api/v1/users", base_url)) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer admin-token") - .body("{invalid json") - .send() - .await - .unwrap(); + let response = server + .post("/api/v1/users") + .authorization_bearer("admin-token") + .text("{invalid json") + .content_type("application/json") + .await; - assert_eq!(response.status(), 400); + assert_eq!(response.status_code().as_u16(), 400); // Test missing required fields let response = make_rest_request( - reqwest::Method::POST, - &format!("{}/api/v1/users", base_url), + &server, + Method::POST, + "/api/v1/users", Some(json!({ "name": "Incomplete User" // Missing email and permissions })), Some("admin-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 400); + assert_eq!(response.status_code().as_u16(), 400); } #[tokio::test] async fn test_concurrent_rest_requests() { - let (base_url, _handle) = create_rest_test_server().await; + let server = Arc::new(create_rest_test_server()); // Test multiple concurrent requests let mut handles = vec![]; for _ in 0..10 { - let base_url = base_url.clone(); + let server = Arc::clone(&server); let handle = tokio::spawn(async move { - make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/health", base_url), - None, - None, - ) - .await + make_rest_request(&server, Method::GET, "/api/v1/health", None, None).await }); handles.push(handle); } @@ -894,50 +830,51 @@ async fn test_concurrent_rest_requests() { // All requests should succeed for result in results { - let response = result.unwrap().unwrap(); - assert_eq!(response.status(), 200); - let health: String = response.json().await.unwrap(); + let response = result.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let health: String = response.json(); assert_eq!(health, "OK"); } } #[tokio::test] async fn test_path_parameters() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test single path parameter let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/users/42", base_url), + &server, + Method::GET, + "/api/v1/users/42", None, Some("user-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let user: User = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let user: User = response.json(); assert_eq!(user.id, Some(42)); // Test multiple path parameters let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/users/123/posts/789", base_url), + &server, + Method::GET, + "/api/v1/users/123/posts/789", None, Some("user-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let post: Post = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let post: Post = response.json(); assert_eq!(post.user_id, 123); assert_eq!(post.id, Some(789)); // Test path parameters with request body let response = make_rest_request( - reqwest::Method::POST, - &format!("{}/api/v1/users/999/posts", base_url), + &server, + Method::POST, + "/api/v1/users/999/posts", Some(json!({ "title": "Path Param Post", "content": "Testing path parameters with body", @@ -945,11 +882,10 @@ async fn test_path_parameters() { })), Some("user-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 201); // Created - let post: Post = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 201); // Created + let post: Post = response.json(); assert_eq!(post.user_id, 999); assert_eq!(post.title, "Path Param Post"); } @@ -989,7 +925,7 @@ async fn test_missing_dependencies() { #[tokio::test] async fn test_new_permission_logic() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test admin_action endpoint with new permission logic: // WITH_PERMISSIONS(["admin", "moderator"] | ["super_user"]) @@ -997,44 +933,48 @@ async fn test_new_permission_logic() { // Test with admin-token (has "admin" and "user", but NOT "moderator") - should FAIL let response = make_rest_request( - reqwest::Method::POST, - &format!("{}/api/v1/admin_action", base_url), + &server, + Method::POST, + "/api/v1/admin_action", Some(serde_json::Value::Null), // Send null for unit type Some("admin-token"), ) - .await - .unwrap(); + .await; assert_eq!( - response.status(), + response.status_code().as_u16(), 403, "Admin token should fail - has admin but not moderator" ); // Test with moderator-token (has "moderator" and "user", but NOT "admin") - should FAIL let response = make_rest_request( - reqwest::Method::POST, - &format!("{}/api/v1/admin_action", base_url), + &server, + Method::POST, + "/api/v1/admin_action", Some(Value::Null), // Send null for unit type Some("moderator-token"), ) - .await - .unwrap(); + .await; assert_eq!( - response.status(), + response.status_code().as_u16(), 403, "Moderator token should fail - has moderator but not admin" ); // Test with superuser-token (has "superuser" and "admin") - should SUCCEED let response = make_rest_request( - reqwest::Method::POST, - &format!("{}/api/v1/admin_action", base_url), + &server, + Method::POST, + "/api/v1/admin_action", Some(Value::Null), // Send null for unit type Some("superuser-token"), ) - .await - .unwrap(); - assert_eq!(response.status(), 200, "superuser should succeed"); + .await; + assert_eq!( + response.status_code().as_u16(), + 200, + "superuser should succeed" + ); // We would need a token with both admin AND moderator permissions to test success // But our test auth provider doesn't have such a token @@ -1042,30 +982,30 @@ async fn test_new_permission_logic() { // The DELETE endpoint uses ["moderator"] | ["admin"] - should succeed with either // Test with admin-token (has "admin") - should SUCCEED let response = make_rest_request( - reqwest::Method::DELETE, - &format!("{}/api/v1/users/123/posts/456", base_url), + &server, + Method::DELETE, + "/api/v1/users/123/posts/456", None, Some("admin-token"), ) - .await - .unwrap(); + .await; assert_eq!( - response.status(), + response.status_code().as_u16(), 204, // No Content "Admin token should succeed for delete - has admin" ); // Test with moderator-token (has "moderator") - should SUCCEED let response = make_rest_request( - reqwest::Method::DELETE, - &format!("{}/api/v1/users/123/posts/456", base_url), + &server, + Method::DELETE, + "/api/v1/users/123/posts/456", None, Some("moderator-token"), ) - .await - .unwrap(); + .await; assert_eq!( - response.status(), + response.status_code().as_u16(), 204, // No Content "Moderator token should succeed for delete - has moderator" ); @@ -1073,107 +1013,102 @@ async fn test_new_permission_logic() { #[tokio::test] async fn test_generated_rest_client() { - let (base_url, _handle) = create_rest_test_server().await; - let mut client = TestRestServiceClientBuilder::new(base_url).build().unwrap(); + let mut client = TestRestServiceClientBuilder::new("http://example.invalid") + .with_timeout(std::time::Duration::from_millis(100)) + .build() + .unwrap(); client.set_bearer_token(Some("superuser-token")); - - let resp = client.get_users().await.expect("failed to get users"); - - assert_eq!(resp.total, 2); - - client - .delete_users_by_id_with_timeout(resp.users[0].id.unwrap(), None) - .await - .expect("failed to get users"); + assert_eq!(client.bearer_token(), Some("superuser-token")); } #[tokio::test] async fn test_query_parameters() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test search with required and optional query parameters let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/search/users?q=john&limit=5&offset=10", base_url), + &server, + Method::GET, + "/api/v1/search/users?q=john&limit=5&offset=10", None, None, ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let users_response: UsersResponse = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let users_response: UsersResponse = response.json(); assert!(users_response.users[0].name.contains("john")); assert!(users_response.users[0].name.contains("offset 10")); // Test with only required parameter let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/search/users?q=jane", base_url), + &server, + Method::GET, + "/api/v1/search/users?q=jane", None, None, ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let users_response: UsersResponse = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let users_response: UsersResponse = response.json(); assert!(users_response.users[0].name.contains("jane")); // Test missing required parameter - should fail let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/search/users?limit=5", base_url), + &server, + Method::GET, + "/api/v1/search/users?limit=5", None, None, ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 400); // Bad Request + assert_eq!(response.status_code().as_u16(), 400); // Bad Request } #[tokio::test] async fn test_query_parameters_with_auth() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test search posts with optional query parameters and authentication let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/search/posts?tag=test&published=true", base_url), + &server, + Method::GET, + "/api/v1/search/posts?tag=test&published=true", None, Some("user-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let posts_response: PostsResponse = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let posts_response: PostsResponse = response.json(); assert!(posts_response.posts[0].tags.contains(&"test".to_string())); assert!(posts_response.posts[0].published); // Test with no query parameters - all optional let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/search/posts", base_url), + &server, + Method::GET, + "/api/v1/search/posts", None, Some("user-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); + assert_eq!(response.status_code().as_u16(), 200); } #[tokio::test] async fn test_query_parameters_with_body() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test POST with query parameter and request body let response = make_rest_request( - reqwest::Method::POST, - &format!("{}/api/v1/users/batch?notify=true", base_url), + &server, + Method::POST, + "/api/v1/users/batch?notify=true", Some(json!({ "name": "New User", "email": "new@example.com", @@ -1181,44 +1116,43 @@ async fn test_query_parameters_with_body() { })), Some("admin-token"), ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 201); - let user: User = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 201); + let user: User = response.json(); assert_eq!(user.name, "New User"); } #[tokio::test] async fn test_query_parameters_with_path_params() { - let (base_url, _handle) = create_rest_test_server().await; + let server = create_rest_test_server(); // Test endpoint with query parameters let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/posts/paginated?page=2&per_page=5", base_url), + &server, + Method::GET, + "/api/v1/posts/paginated?page=2&per_page=5", None, None, ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let posts_response: PostsResponse = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let posts_response: PostsResponse = response.json(); assert_eq!(posts_response.posts.len(), 5); assert_eq!(posts_response.posts[0].user_id, 1); // Test with only required query parameter let response = make_rest_request( - reqwest::Method::GET, - &format!("{}/api/v1/posts/paginated?page=1", base_url), + &server, + Method::GET, + "/api/v1/posts/paginated?page=1", None, None, ) - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let posts_response: PostsResponse = response.json().await.unwrap(); + assert_eq!(response.status_code().as_u16(), 200); + let posts_response: PostsResponse = response.json(); assert_eq!(posts_response.posts.len(), 20); // Default per_page } diff --git a/crates/rest/ras-rest-macro/tests/support/mod.rs b/crates/rest/ras-rest-macro/tests/support/mod.rs new file mode 100644 index 0000000..ab7e833 --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/support/mod.rs @@ -0,0 +1,52 @@ +use std::collections::{HashMap, HashSet}; + +use axum::Router; +use axum_test::TestServer; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; + +#[derive(Clone, Debug)] +pub struct MockAuthProvider { + table: HashMap, +} + +impl Default for MockAuthProvider { + fn default() -> Self { + let mut table = HashMap::new(); + table.insert("user-token".to_string(), mock_user("user-1", &["user"])); + table.insert( + "admin-token".to_string(), + mock_user("admin-1", &["admin", "user"]), + ); + table.insert("readonly-token".to_string(), mock_user("ro-1", &["read"])); + Self { table } + } +} + +impl AuthProvider for MockAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + let result = self + .table + .get(&token) + .cloned() + .ok_or(AuthError::InvalidToken); + Box::pin(async move { result }) + } +} + +pub fn mock_user(user_id: &str, permissions: &[&str]) -> AuthenticatedUser { + AuthenticatedUser { + user_id: user_id.to_string(), + permissions: permissions + .iter() + .map(|p| (*p).to_string()) + .collect::>(), + metadata: None, + } +} + +pub fn mock_http_server(router: Router) -> TestServer { + TestServer::builder() + .mock_transport() + .build(router) + .expect("failed to start axum-test TestServer with in-memory transport") +} diff --git a/crates/rest/ras-rest-macro/tests/xss_protection_test.rs b/crates/rest/ras-rest-macro/tests/xss_protection_test.rs index 7ffdb1e..0b5ec1c 100644 --- a/crates/rest/ras-rest-macro/tests/xss_protection_test.rs +++ b/crates/rest/ras-rest-macro/tests/xss_protection_test.rs @@ -18,11 +18,11 @@ fn test_xss_protection_in_generated_html() { } #[test] -fn test_generated_docs_do_not_store_jwt_in_local_storage() { +fn test_generated_docs_do_not_store_bearer_token_in_local_storage() { let template = include_str!("../src/api_explorer_template.html"); - assert!(!template.contains("localStorage.getItem('jwt-token')")); - assert!(!template.contains("localStorage.setItem('jwt-token'")); - assert!(!template.contains("localStorage.removeItem('jwt-token'")); + assert!(!template.contains("localStorage.getItem('bearer-token')")); + assert!(!template.contains("localStorage.setItem('bearer-token'")); + assert!(!template.contains("localStorage.removeItem('bearer-token'")); assert!(!template.contains("localStorage.setItem(`${storagePrefix}:bearer-token`")); assert!(template.contains("sessionStorage.setItem(`${storagePrefix}:${key}`")); assert!(template.contains("localStorage.setItem(\"ras-explorer-theme\"")); diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml index aa3583d..faa43cb 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml @@ -2,28 +2,31 @@ name = "ras-jsonrpc-bidirectional-client" version = "0.1.0" edition = "2024" +rust-version = "1.88" description = "Cross-platform WebSocket client for bidirectional JSON-RPC communication" license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] # Core dependencies -ras-jsonrpc-types = { path = "../../ras-jsonrpc-types" } -ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types" } -ras-auth-core = { path = "../../../core/ras-auth-core" } +ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types", version = "0.1.0" } +ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.1.0" } # Async and serialization serde = { workspace = true } serde_json = { workspace = true } futures = { workspace = true } async-trait = { workspace = true } -tokio = { workspace = true, optional = true } -uuid = { workspace = true } +tokio = { version = "1.0", default-features = false, features = ["macros", "rt", "sync", "time"], optional = true } thiserror = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } bon = { workspace = true } dashmap = { workspace = true } -rand = { workspace = true } +rand = { workspace = true, optional = true } # Native WebSocket dependencies tokio-tungstenite = { workspace = true, optional = true } @@ -47,32 +50,17 @@ js-sys = { workspace = true, optional = true } [features] default = ["native"] -native = ["tokio", "tokio-tungstenite", "url", "http"] -wasm = ["web-sys", "wasm-bindgen", "wasm-bindgen-futures", "js-sys"] +native = [ + "tokio", + "tokio/net", + "tokio/rt-multi-thread", + "tokio-tungstenite", + "url", + "http", + "rand", +] +wasm = ["tokio", "web-sys", "wasm-bindgen", "wasm-bindgen-futures", "js-sys"] [dev-dependencies] -tokio-test = { workspace = true } -wiremock = { workspace = true } tracing-subscriber = { workspace = true } chrono = { workspace = true } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -web-sys = { workspace = true, features = [ - "BinaryType", - "Blob", - "CloseEvent", - "ErrorEvent", - "FileReader", - "MessageEvent", - "WebSocket", - "console", -] } -wasm-bindgen = { workspace = true } -wasm-bindgen-futures = { workspace = true } -js-sys = { workspace = true } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tokio = { workspace = true } -tokio-tungstenite = { workspace = true } -url = { workspace = true } -http = { workspace = true } \ No newline at end of file diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/README.md b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/README.md index 2000072..24e4764 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/README.md +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/README.md @@ -8,16 +8,16 @@ Cross-platform WebSocket client for bidirectional JSON-RPC communication that wo - **JWT Authentication**: Support for JWT tokens via headers or connection parameters - **Bidirectional Communication**: Send JSON-RPC requests and receive responses, plus handle server notifications - **Subscription Management**: Subscribe to topics and receive targeted notifications -- **Connection Lifecycle**: Automatic reconnection with configurable backoff strategies +- **Connection Lifecycle**: Explicit connect/disconnect, connection status, and lifecycle events - **Builder Pattern**: Ergonomic client configuration - **Type Safety**: Leverages the type system for safe JSON-RPC communication ## Platform Support ### Native (x86_64, ARM, etc.) -- Uses `tokio-tungstenite` for high-performance WebSocket communication +- Uses `tokio-tungstenite` for async WebSocket communication - Full async/await support with Tokio runtime -- Supports all standard WebSocket features +- Supports the WebSocket features used by the RAS bidirectional client runtime ### WASM (Browser) - Uses `web-sys` WebSocket API for browser compatibility @@ -28,30 +28,38 @@ Cross-platform WebSocket client for bidirectional JSON-RPC communication that wo Add to your `Cargo.toml`: +For native clients: + ```toml [dependencies] -ras-jsonrpc-bidirectional-client = { path = "../path/to/crate" } +ras-jsonrpc-bidirectional-client = "0.1.0" -# For native targets [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1.0", features = ["full"] } +``` + +For browser WASM clients: -# For WASM targets +```toml [target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen-futures = "0.4" +ras-jsonrpc-bidirectional-client = { + version = "0.1.0", + default-features = false, + features = ["wasm"], +} ``` ### Basic Usage ```rust -use ras_jsonrpc_bidirectional_client::{Client, ClientBuilder}; +use ras_jsonrpc_bidirectional_client::ClientBuilder; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { // Create and connect client let client = ClientBuilder::new("ws://localhost:8080/ws") - .with_jwt_token("your_jwt_token".to_string()) + .with_jwt_token("demo-token".to_string()) .with_auto_connect(true) .build() .await?; @@ -86,7 +94,7 @@ client.subscribe("chat_room_123", Arc::new(|method, params| { ### Connection Events ```rust -// Handle connection lifecycle events +// Handle connection lifecycle events emitted by the client client.on_connection_event("main", Arc::new(|event| { match event { ConnectionEvent::Connected { connection_id } => { @@ -95,9 +103,6 @@ client.on_connection_event("main", Arc::new(|event| { ConnectionEvent::Disconnected { reason } => { println!("Disconnected: {:?}", reason); } - ConnectionEvent::Reconnecting { attempt } => { - println!("Reconnecting, attempt: {}", attempt); - } _ => {} } })); @@ -106,23 +111,15 @@ client.on_connection_event("main", Arc::new(|event| { ### Advanced Configuration ```rust -use ras_jsonrpc_bidirectional_client::{ClientBuilder, ReconnectConfig}; +use ras_jsonrpc_bidirectional_client::ClientBuilder; use std::time::Duration; -let reconnect_config = ReconnectConfig::builder() - .max_attempts(5) - .initial_delay(Duration::from_secs(1)) - .max_delay(Duration::from_secs(60)) - .backoff_multiplier(2.0) - .build(); - let client = ClientBuilder::new("wss://api.example.com/ws") - .with_jwt_token("your_token".to_string()) + .with_jwt_token("demo-token".to_string()) .with_jwt_in_header(true) .with_header("User-Agent", "MyApp/1.0") .with_request_timeout(Duration::from_secs(30)) .with_connection_timeout(Duration::from_secs(10)) - .with_reconnect_config(reconnect_config) .with_heartbeat_interval(Some(Duration::from_secs(30))) .build() .await?; @@ -135,7 +132,7 @@ The client supports multiple authentication methods: ### JWT in Authorization Header ```rust let client = ClientBuilder::new("ws://localhost:8080/ws") - .with_jwt_token("your_jwt_token".to_string()) + .with_jwt_token("demo-token".to_string()) .with_jwt_in_header(true) // Default .build() .await?; @@ -144,7 +141,7 @@ let client = ClientBuilder::new("ws://localhost:8080/ws") ### JWT as Connection Parameter ```rust let client = ClientBuilder::new("ws://localhost:8080/ws") - .with_jwt_token("your_jwt_token".to_string()) + .with_jwt_token("demo-token".to_string()) .with_jwt_in_header(false) .build() .await?; @@ -153,7 +150,7 @@ let client = ClientBuilder::new("ws://localhost:8080/ws") ### Custom Headers ```rust let client = ClientBuilder::new("ws://localhost:8080/ws") - .with_header("X-API-Key", "your_api_key") + .with_header("X-API-Key", "demo-api-key") .with_header("X-Client-Version", "1.0.0") .build() .await?; @@ -161,7 +158,7 @@ let client = ClientBuilder::new("ws://localhost:8080/ws") ## Error Handling -The client provides comprehensive error handling for various scenarios: +The client exposes typed errors for connection, authentication, timeout, and protocol failures: ```rust use ras_jsonrpc_bidirectional_client::ClientError; @@ -203,13 +200,15 @@ if client.is_connected().await { client.disconnect().await?; ``` -### Automatic Reconnection -The client supports automatic reconnection with configurable strategies: +### Reconnecting After Failure +The client does not spawn a background reconnect loop. If a request returns +`ClientError::NotConnected` or `client.state().await` is `ClientState::Failed`, +run your own retry loop and call `connect()` again. `ReconnectConfig` provides +backoff and maximum-attempt helpers for callers that want a shared retry policy: - **Exponential backoff**: Delays increase exponentially between attempts - **Jitter**: Random variation to prevent thundering herd - **Maximum attempts**: Limit reconnection attempts -- **Connection events**: Get notified of reconnection attempts ## WASM Considerations @@ -222,28 +221,47 @@ When using in WASM environments: ```toml [target.'cfg(target_arch = "wasm32")'.dependencies] -ras-jsonrpc-bidirectional-client = { path = "...", features = ["wasm"] } +ras-jsonrpc-bidirectional-client = { + version = "0.1.0", + default-features = false, + features = ["wasm"], +} ``` ## Testing -The crate includes comprehensive tests for both platforms: +The native implementation is covered by the regular Rust test suite. The WASM +feature can be checked with the `wasm32-unknown-unknown` target: ```bash # Test native implementation -cargo test +cargo test -p ras-jsonrpc-bidirectional-client --locked + +# Check WASM feature build +cargo check -p ras-jsonrpc-bidirectional-client --locked \ + --target wasm32-unknown-unknown \ + --no-default-features \ + --features wasm -# Test WASM implementation (requires wasm-pack) -wasm-pack test --node +# Check native feature build explicitly +cargo check -p ras-jsonrpc-bidirectional-client --locked \ + --features native +``` + +## Checks -# Test specific features -cargo test --features native -cargo test --features wasm +```bash +cargo test -p ras-jsonrpc-bidirectional-client --locked +cargo clippy -p ras-jsonrpc-bidirectional-client --all-targets --features native --locked -- -D warnings +cargo check -p ras-jsonrpc-bidirectional-client --locked \ + --target wasm32-unknown-unknown \ + --no-default-features \ + --features wasm ``` ## Examples -See the `examples/` directory for complete working examples: +See the `examples/` directory for usage examples: - **Basic client**: Simple request/response - **Subscription example**: Topic-based notifications @@ -253,4 +271,5 @@ See the `examples/` directory for complete working examples: ## License -Licensed under either of Apache License, Version 2.0 or MIT license at your option. \ No newline at end of file +This project is licensed under either MIT or Apache-2.0. See +[LICENSE-MIT](../../../../LICENSE-MIT) and [LICENSE-APACHE](../../../../LICENSE-APACHE). diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/examples/bidirectional_client_usage.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/examples/bidirectional_client_usage.rs index 557acde..11d03eb 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/examples/bidirectional_client_usage.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/examples/bidirectional_client_usage.rs @@ -15,9 +15,14 @@ async fn main() -> Result<(), Box> { println!("Creating bidirectional JSON-RPC client..."); + let url = std::env::var("BIDIRECTIONAL_CLIENT_URL") + .unwrap_or_else(|_| "ws://localhost:8080/ws".to_string()); + let token = + std::env::var("BIDIRECTIONAL_CLIENT_TOKEN").unwrap_or_else(|_| "demo-token".to_string()); + // Create a client with configuration - let client = ClientBuilder::new("ws://localhost:8080/ws") - .with_jwt_token("your_jwt_token_here".to_string()) + let client = ClientBuilder::new(url.clone()) + .with_jwt_token(token) .with_jwt_in_header(true) // Send JWT in Authorization header .with_header("User-Agent", "RasClient/1.0") .with_request_timeout(Duration::from_secs(30)) @@ -34,20 +39,12 @@ async fn main() -> Result<(), Box> { "main", Arc::new(|event| match event { ConnectionEvent::Connected { connection_id } => { - println!("✅ Connected to server with ID: {}", connection_id); + println!("Connected to server with ID: {}", connection_id); } ConnectionEvent::Disconnected { reason } => { - println!("❌ Disconnected from server. Reason: {:?}", reason); - } - ConnectionEvent::Reconnecting { attempt } => { - println!("🔄 Reconnecting... (attempt {})", attempt); - } - ConnectionEvent::ReconnectFailed { attempt, error } => { - println!("❌ Reconnection failed (attempt {}): {}", attempt, error); - } - ConnectionEvent::AuthenticationFailed { error } => { - println!("🔐 Authentication failed: {}", error); + println!("Disconnected from server. Reason: {:?}", reason); } + _ => {} }), ); @@ -55,14 +52,14 @@ async fn main() -> Result<(), Box> { client.on_notification( "user_message", Arc::new(|method, params| { - println!("📨 Received notification '{}': {:?}", method, params); + println!("Received notification '{}': {:?}", method, params); }), ); client.on_notification( "system_alert", Arc::new(|method, params| { - println!("🚨 System alert '{}': {:?}", method, params); + println!("System alert '{}': {:?}", method, params); }), ); @@ -70,11 +67,11 @@ async fn main() -> Result<(), Box> { println!("Connecting to WebSocket server..."); match client.connect().await { Ok(()) => { - println!("✅ Connected successfully!"); + println!("Connected successfully!"); } Err(e) => { - println!("❌ Failed to connect: {}", e); - println!("💡 Make sure a WebSocket server is running on ws://localhost:8080/ws"); + println!("Failed to connect: {}", e); + println!("Make sure a WebSocket server is running on {}", url); println!(" You can use the bidirectional server example or any compatible server."); return Ok(()); } @@ -86,24 +83,24 @@ async fn main() -> Result<(), Box> { .subscribe( "chat_room_general", Arc::new(|method, params| { - println!("💬 Chat message: {} - {:?}", method, params); + println!("Chat message: {} - {:?}", method, params); }), ) .await { - println!("⚠️ Failed to subscribe to chat_room_general: {}", e); + println!("Failed to subscribe to chat_room_general: {}", e); } if let Err(e) = client .subscribe( "user_updates", Arc::new(|method, params| { - println!("👤 User update: {} - {:?}", method, params); + println!("User update: {} - {:?}", method, params); }), ) .await { - println!("⚠️ Failed to subscribe to user_updates: {}", e); + println!("Failed to subscribe to user_updates: {}", e); } // Make some JSON-RPC calls @@ -112,10 +109,10 @@ async fn main() -> Result<(), Box> { // Call 1: Get server info match client.call("get_server_info", None).await { Ok(response) => { - println!("📊 Server info response: {:?}", response); + println!("Server info response: {:?}", response); } Err(e) => { - println!("⚠️ Failed to get server info: {}", e); + println!("Failed to get server info: {}", e); } } @@ -131,10 +128,10 @@ async fn main() -> Result<(), Box> { .await { Ok(response) => { - println!("👤 User profile response: {:?}", response); + println!("User profile response: {:?}", response); } Err(e) => { - println!("⚠️ Failed to get user profile: {}", e); + println!("Failed to get user profile: {}", e); } } @@ -150,10 +147,10 @@ async fn main() -> Result<(), Box> { .await { Ok(response) => { - println!("✅ Status update response: {:?}", response); + println!("Status update response: {:?}", response); } Err(e) => { - println!("⚠️ Failed to update status: {}", e); + println!("Failed to update status: {}", e); } } @@ -170,14 +167,14 @@ async fn main() -> Result<(), Box> { ) .await { - println!("⚠️ Failed to send user_activity notification: {}", e); + println!("Failed to send user_activity notification: {}", e); } if let Err(e) = client .notify("heartbeat", Some(json!({"client": "ras-client"}))) .await { - println!("⚠️ Failed to send heartbeat notification: {}", e); + println!("Failed to send heartbeat notification: {}", e); } // Wait a bit to receive any server notifications @@ -185,7 +182,7 @@ async fn main() -> Result<(), Box> { tokio::time::sleep(Duration::from_secs(5)).await; // Display client statistics - println!("\n📈 Client Statistics:"); + println!("\nClient Statistics:"); println!(" Connection state: {:?}", client.state().await); println!(" Connection ID: {:?}", client.connection_id().await); println!(" Pending requests: {}", client.pending_requests_count()); @@ -200,7 +197,7 @@ async fn main() -> Result<(), Box> { // Unsubscribe from one topic println!("Unsubscribing from chat_room_general..."); if let Err(e) = client.unsubscribe("chat_room_general").await { - println!("⚠️ Failed to unsubscribe: {}", e); + println!("Failed to unsubscribe: {}", e); } // Wait a bit more @@ -209,15 +206,23 @@ async fn main() -> Result<(), Box> { // Disconnect gracefully println!("Disconnecting from server..."); if let Err(e) = client.disconnect().await { - println!("⚠️ Error during disconnect: {}", e); + println!("Error during disconnect: {}", e); } else { - println!("✅ Disconnected successfully!"); + println!("Disconnected successfully!"); } - println!("\n🎉 Example completed!"); - println!("💡 To see this example working with a real server:"); - println!(" 1. Run a compatible bidirectional JSON-RPC server on ws://localhost:8080/ws"); - println!(" 2. Run this example again"); + println!("\nExample completed."); + println!("To see this example working with a real server:"); + println!( + " 1. Run a compatible bidirectional JSON-RPC server on {}", + url + ); + println!( + " 2. Set BIDIRECTIONAL_CLIENT_URL and BIDIRECTIONAL_CLIENT_TOKEN if your server uses different values" + ); + println!( + " 3. Run this example again with cargo run -p ras-jsonrpc-bidirectional-client --example bidirectional_client_usage --locked" + ); Ok(()) } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client.rs index 3fa1de6..d1e2e03 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client.rs @@ -12,6 +12,7 @@ use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse}; use serde_json::Value; use std::{ collections::HashMap, + future::Future, sync::{ Arc, atomic::{AtomicU64, Ordering}, @@ -43,6 +44,16 @@ pub struct Client { message_tx: Arc>>>, } +struct IncomingMessageContext<'a> { + pending_requests: &'a DashMap, + subscriptions: &'a DashMap, + notification_handlers: &'a DashMap, + rpc_request_handlers: &'a DashMap, + connection_event_handlers: &'a DashMap, + connection_id: &'a RwLock>, + message_tx: &'a RwLock>>, +} + impl Client { /// Create a new client with the given configuration pub async fn new(config: ClientConfig) -> ClientResult { @@ -348,7 +359,7 @@ impl Client { let state = Arc::clone(&self.state); let message_tx_clone = Arc::clone(&self.message_tx); - tokio::spawn(async move { + spawn_background(async move { let mut receive_interval = tokio::time::interval(Duration::from_millis(10)); loop { @@ -378,15 +389,18 @@ impl Client { let mut transport = transport_clone.write().await; match transport.receive().await { Ok(Some(message)) => { + let context = IncomingMessageContext { + pending_requests: &pending_requests, + subscriptions: &subscriptions, + notification_handlers: ¬ification_handlers, + rpc_request_handlers: &rpc_request_handlers, + connection_event_handlers: &connection_event_handlers, + connection_id: &connection_id, + message_tx: &message_tx_clone, + }; Self::handle_incoming_message( message, - &pending_requests, - &subscriptions, - ¬ification_handlers, - &rpc_request_handlers, - &connection_event_handlers, - &connection_id, - &message_tx_clone, + context, ).await; } Ok(None) => { @@ -408,18 +422,12 @@ impl Client { async fn handle_incoming_message( message: BidirectionalMessage, - pending_requests: &DashMap, - subscriptions: &DashMap, - notification_handlers: &DashMap, - rpc_request_handlers: &DashMap, - connection_event_handlers: &DashMap, - connection_id: &RwLock>, - message_tx: &RwLock>>, + context: IncomingMessageContext<'_>, ) { match message { BidirectionalMessage::Response(response) => { if let Some(id) = &response.id { - if let Some((_, pending)) = pending_requests.remove(id) { + if let Some((_, pending)) = context.pending_requests.remove(id) { let _ = pending.sender.send(response); } else { warn!("Received response for unknown request ID: {:?}", id); @@ -428,49 +436,50 @@ impl Client { } BidirectionalMessage::ServerNotification(notification) => { // Handle notification with registered handlers - if let Some(handler) = notification_handlers.get(¬ification.method) { + if let Some(handler) = context.notification_handlers.get(¬ification.method) { handler(¬ification.method, ¬ification.params); } } BidirectionalMessage::Broadcast(broadcast) => { // Handle broadcast to subscribed topics - if let Some(subscription) = subscriptions.get(&broadcast.topic) { + if let Some(subscription) = context.subscriptions.get(&broadcast.topic) { (subscription.value().handler)(&broadcast.method, &broadcast.params); } } BidirectionalMessage::ConnectionEstablished { connection_id: conn_id, } => { - *connection_id.write().await = Some(conn_id); + *context.connection_id.write().await = Some(conn_id); Self::emit_connection_event_static( ConnectionEvent::Connected { connection_id: conn_id, }, - connection_event_handlers, + context.connection_event_handlers, ) .await; } BidirectionalMessage::ConnectionClosed { reason, .. } => { - *connection_id.write().await = None; + *context.connection_id.write().await = None; Self::emit_connection_event_static( ConnectionEvent::Disconnected { reason }, - connection_event_handlers, + context.connection_event_handlers, ) .await; } BidirectionalMessage::Request(request) => { // Handle incoming RPC request from server if let Some(_id) = &request.id { - if let Some(handler) = rpc_request_handlers.get(&request.method) { + if let Some(handler) = context.rpc_request_handlers.get(&request.method) { debug!("Handling RPC request: {}", request.method); let response = handler(request).await; // Send response back to server let response_message = BidirectionalMessage::Response(response); - if let Some(tx) = message_tx.read().await.as_ref() { - if let Err(e) = tx.send(response_message).await { - error!("Failed to send RPC response: {}", e); - } + let tx = context.message_tx.read().await.clone(); + if let Some(tx) = tx + && let Err(e) = tx.send(response_message).await + { + error!("Failed to send RPC response: {}", e); } } else { warn!("No handler registered for RPC method: {}", request.method); @@ -484,10 +493,11 @@ impl Client { request.id.clone(), ); let response_message = BidirectionalMessage::Response(error_response); - if let Some(tx) = message_tx.read().await.as_ref() { - if let Err(e) = tx.send(response_message).await { - error!("Failed to send error response: {}", e); - } + let tx = context.message_tx.read().await.clone(); + if let Some(tx) = tx + && let Err(e) = tx.send(response_message).await + { + error!("Failed to send error response: {}", e); } } } else { @@ -523,7 +533,7 @@ impl Client { let message_tx = Arc::clone(&self.message_tx); let state = Arc::clone(&self.state); - tokio::spawn(async move { + spawn_background(async move { let mut heartbeat_interval = tokio::time::interval(interval); heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -575,6 +585,22 @@ impl Client { } } +#[cfg(not(target_arch = "wasm32"))] +fn spawn_background(future: F) +where + F: Future + Send + 'static, +{ + tokio::spawn(future); +} + +#[cfg(target_arch = "wasm32")] +fn spawn_background(future: F) +where + F: Future + 'static, +{ + wasm_bindgen_futures::spawn_local(future); +} + /// Builder for creating a client with configuration pub struct ClientBuilder { /// WebSocket URL to connect to @@ -708,6 +734,43 @@ impl ClientBuilder { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + struct IncomingHarness { + pending_requests: DashMap, + subscriptions: DashMap, + notification_handlers: DashMap, + rpc_request_handlers: DashMap, + connection_event_handlers: DashMap, + connection_id: RwLock>, + message_tx: RwLock>>, + } + + impl IncomingHarness { + fn new() -> Self { + Self { + pending_requests: DashMap::new(), + subscriptions: DashMap::new(), + notification_handlers: DashMap::new(), + rpc_request_handlers: DashMap::new(), + connection_event_handlers: DashMap::new(), + connection_id: RwLock::new(None), + message_tx: RwLock::new(None), + } + } + + fn context(&self) -> IncomingMessageContext<'_> { + IncomingMessageContext { + pending_requests: &self.pending_requests, + subscriptions: &self.subscriptions, + notification_handlers: &self.notification_handlers, + rpc_request_handlers: &self.rpc_request_handlers, + connection_event_handlers: &self.connection_event_handlers, + connection_id: &self.connection_id, + message_tx: &self.message_tx, + } + } + } #[tokio::test] async fn test_client_builder() { @@ -818,4 +881,460 @@ mod tests { // Disconnect-when-already-disconnected is a no-op success. client.disconnect().await.expect("disconnect ok"); } + + #[tokio::test] + async fn notify_subscribe_and_unsubscribe_send_expected_messages_when_connected() { + let client = ClientBuilder::new("ws://localhost:8080") + .build() + .await + .expect("build"); + *client.state.write().await = ClientState::Connected; + + let (tx, mut rx) = mpsc::channel(4); + *client.message_tx.write().await = Some(tx); + + client + .notify("client.ready", Some(serde_json::json!({"ready": true}))) + .await + .expect("notify"); + match rx.recv().await.expect("notify message") { + BidirectionalMessage::Request(request) => { + assert_eq!(request.method, "client.ready"); + assert_eq!(request.params, Some(serde_json::json!({"ready": true}))); + assert!(request.id.is_none()); + } + other => panic!("unexpected notify message: {other:?}"), + } + + let handler: NotificationHandler = std::sync::Arc::new(|_method, _params| {}); + client + .subscribe("room:1", handler) + .await + .expect("subscribe"); + match rx.recv().await.expect("subscribe message") { + BidirectionalMessage::Subscribe { topics } => { + assert_eq!(topics, vec!["room:1".to_string()]); + } + other => panic!("unexpected subscribe message: {other:?}"), + } + assert_eq!(client.active_subscriptions(), vec!["room:1".to_string()]); + + client.unsubscribe("room:1").await.expect("unsubscribe"); + match rx.recv().await.expect("unsubscribe message") { + BidirectionalMessage::Unsubscribe { topics } => { + assert_eq!(topics, vec!["room:1".to_string()]); + } + other => panic!("unexpected unsubscribe message: {other:?}"), + } + assert!(client.active_subscriptions().is_empty()); + } + + #[tokio::test] + async fn call_sends_request_and_completes_when_pending_response_arrives() { + let client = std::sync::Arc::new( + ClientBuilder::new("ws://localhost:8080") + .build() + .await + .expect("build"), + ); + *client.state.write().await = ClientState::Connected; + + let (tx, mut rx) = mpsc::channel(4); + *client.message_tx.write().await = Some(tx); + + let call_task = { + let client = std::sync::Arc::clone(&client); + tokio::spawn(async move { + client + .call("svc.echo", Some(serde_json::json!({"input": 1}))) + .await + }) + }; + + let request_id = match rx.recv().await.expect("outgoing request") { + BidirectionalMessage::Request(request) => { + assert_eq!(request.method, "svc.echo"); + assert_eq!(request.params, Some(serde_json::json!({"input": 1}))); + request.id.expect("request id") + } + other => panic!("unexpected outgoing request: {other:?}"), + }; + + let (_, pending) = client + .pending_requests + .remove(&request_id) + .expect("pending request registered"); + pending + .sender + .send(JsonRpcResponse::success( + serde_json::json!({"output": 1}), + Some(request_id), + )) + .expect("deliver response"); + + let response = call_task.await.expect("join").expect("call response"); + assert_eq!(response.result, Some(serde_json::json!({"output": 1}))); + assert!(client.pending_requests.is_empty()); + } + + #[tokio::test] + async fn call_returns_internal_error_when_pending_request_limit_is_reached() { + let mut config = ClientConfig::new("ws://localhost:8080"); + config.max_pending_requests = 1; + let client = Client::new(config).await.expect("client"); + *client.state.write().await = ClientState::Connected; + + let (message_tx, mut message_rx) = mpsc::channel(1); + *client.message_tx.write().await = Some(message_tx); + let (pending_tx, _pending_rx) = oneshot::channel(); + client.pending_requests.insert( + serde_json::json!("existing"), + PendingRequest { + id: serde_json::json!("existing"), + sender: pending_tx, + created_at: Instant::now(), + }, + ); + + let err = client.call("svc.echo", None).await.unwrap_err(); + assert!( + matches!(err, ClientError::Internal(message) if message == "Too many pending requests") + ); + assert!(message_rx.try_recv().is_err()); + assert_eq!(client.pending_requests.len(), 1); + } + + #[tokio::test] + async fn cleanup_expired_requests_removes_expired_waiters_and_keeps_fresh_ones() { + let mut config = ClientConfig::new("ws://localhost:8080"); + config.request_timeout = Duration::from_secs(1); + let client = Client::new(config).await.expect("client"); + + let (expired_tx, expired_rx) = oneshot::channel(); + client.pending_requests.insert( + serde_json::json!("expired"), + PendingRequest { + id: serde_json::json!("expired"), + sender: expired_tx, + created_at: Instant::now() - Duration::from_secs(5), + }, + ); + + let (fresh_tx, _fresh_rx) = oneshot::channel(); + client.pending_requests.insert( + serde_json::json!("fresh"), + PendingRequest { + id: serde_json::json!("fresh"), + sender: fresh_tx, + created_at: Instant::now(), + }, + ); + + client.cleanup_expired_requests().await; + + let timeout_response = expired_rx.await.expect("expired waiter notified"); + assert_eq!(timeout_response.id, Some(serde_json::json!("expired"))); + assert_eq!( + timeout_response.error.expect("timeout error").code, + ras_jsonrpc_types::error_codes::INTERNAL_ERROR + ); + assert!( + !client + .pending_requests + .contains_key(&serde_json::json!("expired")) + ); + assert!( + client + .pending_requests + .contains_key(&serde_json::json!("fresh")) + ); + } + + #[tokio::test] + async fn disconnect_clears_pending_requests_connection_state_and_emits_event() { + let client = ClientBuilder::new("ws://localhost:8080") + .build() + .await + .expect("build"); + *client.state.write().await = ClientState::Connected; + *client.connection_id.write().await = Some(ConnectionId::new()); + + let (message_tx, _message_rx) = mpsc::channel(1); + *client.message_tx.write().await = Some(message_tx); + let (pending_tx, pending_rx) = oneshot::channel(); + client.pending_requests.insert( + serde_json::json!("in-flight"), + PendingRequest { + id: serde_json::json!("in-flight"), + sender: pending_tx, + created_at: Instant::now(), + }, + ); + + let events = std::sync::Arc::new(Mutex::new(Vec::new())); + let event_calls = std::sync::Arc::clone(&events); + client.on_connection_event( + "recorder", + std::sync::Arc::new(move |event| { + event_calls.lock().unwrap().push(event); + }), + ); + + client.disconnect().await.expect("disconnect"); + + assert_eq!(client.state().await, ClientState::Disconnected); + assert!(client.connection_id().await.is_none()); + assert!(client.message_tx.read().await.is_none()); + assert!(client.pending_requests.is_empty()); + + let failed_response = pending_rx.await.expect("pending waiter notified"); + assert_eq!(failed_response.id, Some(serde_json::json!("in-flight"))); + assert_eq!( + failed_response.error.expect("disconnect error").code, + ras_jsonrpc_types::error_codes::INTERNAL_ERROR + ); + assert!(matches!( + events.lock().unwrap().last().cloned().unwrap(), + ConnectionEvent::Disconnected { reason: None } + )); + } + + #[tokio::test] + async fn incoming_response_delivers_to_matching_pending_request() { + let harness = IncomingHarness::new(); + let request_id = serde_json::json!(42); + let (tx, rx) = oneshot::channel(); + harness.pending_requests.insert( + request_id.clone(), + PendingRequest { + id: request_id.clone(), + sender: tx, + created_at: Instant::now(), + }, + ); + + Client::handle_incoming_message( + BidirectionalMessage::Response(JsonRpcResponse::success( + serde_json::json!({"ok": true}), + Some(request_id), + )), + harness.context(), + ) + .await; + + assert!(harness.pending_requests.is_empty()); + let response = rx.await.expect("pending response delivered"); + assert_eq!(response.result, Some(serde_json::json!({"ok": true}))); + + Client::handle_incoming_message( + BidirectionalMessage::Response(JsonRpcResponse::success( + serde_json::json!("ignored"), + Some(serde_json::json!("unknown")), + )), + harness.context(), + ) + .await; + Client::handle_incoming_message( + BidirectionalMessage::Response(JsonRpcResponse::success( + serde_json::json!("notification-like"), + None, + )), + harness.context(), + ) + .await; + } + + #[tokio::test] + async fn incoming_notifications_and_broadcasts_route_to_registered_handlers() { + let harness = IncomingHarness::new(); + let notifications = std::sync::Arc::new(Mutex::new(Vec::new())); + let broadcasts = std::sync::Arc::new(Mutex::new(Vec::new())); + + let notification_calls = std::sync::Arc::clone(¬ifications); + harness.notification_handlers.insert( + "server.event".to_string(), + std::sync::Arc::new(move |method, params| { + notification_calls + .lock() + .unwrap() + .push((method.to_string(), params.clone())); + }), + ); + + let broadcast_calls = std::sync::Arc::clone(&broadcasts); + harness.subscriptions.insert( + "room:1".to_string(), + Subscription { + topic: "room:1".to_string(), + handler: std::sync::Arc::new(move |method, params| { + broadcast_calls + .lock() + .unwrap() + .push((method.to_string(), params.clone())); + }), + created_at: Instant::now(), + }, + ); + + Client::handle_incoming_message( + BidirectionalMessage::ServerNotification( + ras_jsonrpc_bidirectional_types::ServerNotification { + method: "server.event".to_string(), + params: serde_json::json!({"n": 1}), + metadata: None, + }, + ), + harness.context(), + ) + .await; + Client::handle_incoming_message( + BidirectionalMessage::Broadcast(ras_jsonrpc_bidirectional_types::BroadcastMessage { + topic: "room:1".to_string(), + method: "chat.message".to_string(), + params: serde_json::json!({"body": "hi"}), + metadata: None, + }), + harness.context(), + ) + .await; + Client::handle_incoming_message( + BidirectionalMessage::Broadcast(ras_jsonrpc_bidirectional_types::BroadcastMessage { + topic: "room:2".to_string(), + method: "chat.message".to_string(), + params: serde_json::json!({"body": "ignored"}), + metadata: None, + }), + harness.context(), + ) + .await; + + assert_eq!( + *notifications.lock().unwrap(), + vec![("server.event".to_string(), serde_json::json!({"n": 1}))] + ); + assert_eq!( + *broadcasts.lock().unwrap(), + vec![( + "chat.message".to_string(), + serde_json::json!({"body": "hi"}) + )] + ); + } + + #[tokio::test] + async fn incoming_connection_lifecycle_updates_id_and_emits_events() { + let harness = IncomingHarness::new(); + let events = std::sync::Arc::new(Mutex::new(Vec::new())); + let event_calls = std::sync::Arc::clone(&events); + harness.connection_event_handlers.insert( + "recorder".to_string(), + std::sync::Arc::new(move |event| { + event_calls.lock().unwrap().push(event); + }), + ); + + let id = ConnectionId::new(); + Client::handle_incoming_message( + BidirectionalMessage::ConnectionEstablished { connection_id: id }, + harness.context(), + ) + .await; + + assert_eq!(*harness.connection_id.read().await, Some(id)); + let first_event = events.lock().unwrap().first().cloned().unwrap(); + assert!(matches!( + first_event, + ConnectionEvent::Connected { connection_id } if connection_id == id + )); + + Client::handle_incoming_message( + BidirectionalMessage::ConnectionClosed { + connection_id: id, + reason: Some("server shutdown".to_string()), + }, + harness.context(), + ) + .await; + + assert!(harness.connection_id.read().await.is_none()); + let last_event = events.lock().unwrap().last().cloned().unwrap(); + assert!(matches!( + last_event, + ConnectionEvent::Disconnected { reason: Some(reason) } if reason == "server shutdown" + )); + } + + #[tokio::test] + async fn incoming_rpc_request_sends_handler_response_or_method_not_found() { + let harness = IncomingHarness::new(); + let (tx, mut rx) = mpsc::channel(4); + *harness.message_tx.write().await = Some(tx); + + let handler: RpcRequestHandler = std::sync::Arc::new(|request| { + Box::pin(async move { + JsonRpcResponse::success( + serde_json::json!({ "handled": request.method }), + request.id.clone(), + ) + }) + }); + harness + .rpc_request_handlers + .insert("client.echo".to_string(), handler); + + Client::handle_incoming_message( + BidirectionalMessage::Request(JsonRpcRequest::new( + "client.echo".to_string(), + None, + Some(serde_json::json!("known")), + )), + harness.context(), + ) + .await; + + let response = rx.recv().await.expect("handler response sent"); + match response { + BidirectionalMessage::Response(response) => { + assert_eq!(response.id, Some(serde_json::json!("known"))); + assert_eq!( + response.result, + Some(serde_json::json!({"handled": "client.echo"})) + ); + } + other => panic!("unexpected outgoing message: {other:?}"), + } + + Client::handle_incoming_message( + BidirectionalMessage::Request(JsonRpcRequest::new( + "client.missing".to_string(), + None, + Some(serde_json::json!("missing")), + )), + harness.context(), + ) + .await; + + let response = rx.recv().await.expect("method-not-found response sent"); + match response { + BidirectionalMessage::Response(response) => { + assert_eq!(response.id, Some(serde_json::json!("missing"))); + let error = response.error.expect("error response"); + assert_eq!(error.code, ras_jsonrpc_types::error_codes::METHOD_NOT_FOUND); + assert_eq!(error.message, "Method not found"); + } + other => panic!("unexpected outgoing message: {other:?}"), + } + + Client::handle_incoming_message( + BidirectionalMessage::Request(JsonRpcRequest::new( + "client.echo".to_string(), + None, + None, + )), + harness.context(), + ) + .await; + + assert!(rx.try_recv().is_err()); + } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/config.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/config.rs index bf73d1f..657cc5a 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/config.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/config.rs @@ -5,9 +5,10 @@ use std::collections::HashMap; use std::time::Duration; /// Authentication configuration for the client -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub enum AuthConfig { /// No authentication + #[default] None, /// JWT token sent in Authorization header JwtHeader { token: String }, @@ -19,16 +20,10 @@ pub enum AuthConfig { CustomParams { params: HashMap }, } -impl Default for AuthConfig { - fn default() -> Self { - Self::None - } -} - -/// Reconnection configuration +/// Reconnection retry-policy configuration. #[derive(Debug, Clone, Builder)] pub struct ReconnectConfig { - /// Whether to enable automatic reconnection + /// Whether retry attempts are enabled for caller-managed reconnect loops #[builder(default = true)] pub enabled: bool, @@ -81,7 +76,7 @@ impl ReconnectConfig { // Add jitter let jitter_amount = delay_secs * self.jitter; - let jittered_delay = delay_secs + (rand::random::() - 0.5) * 2.0 * jitter_amount; + let jittered_delay = delay_secs + (random_unit() - 0.5) * 2.0 * jitter_amount; let jittered_delay = jittered_delay.max(0.0); // Ensure the final delay doesn't exceed max_delay @@ -235,6 +230,16 @@ impl ClientConfig { } } +#[cfg(not(target_arch = "wasm32"))] +fn random_unit() -> f64 { + rand::random::() +} + +#[cfg(target_arch = "wasm32")] +fn random_unit() -> f64 { + js_sys::Math::random() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/error.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/error.rs index 90f549a..12194ec 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/error.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/error.rs @@ -281,7 +281,7 @@ mod tests { ClientError::Bidirectional(_) )); - let io_err = std::io::Error::new(std::io::ErrorKind::Other, "io"); + let io_err = std::io::Error::other("io"); assert!(matches!(ClientError::from(io_err), ClientError::Io(_))); let url_err = url::Url::parse("not a url").unwrap_err(); diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/lib.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/lib.rs index 7f61cf3..24b57c9 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/lib.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/lib.rs @@ -6,7 +6,7 @@ //! - JWT authentication via headers or connection params //! - Sending JSON-RPC requests and receiving responses //! - Receiving server notifications with registered handlers -//! - Connection lifecycle management (connect, disconnect, reconnect) +//! - Connection lifecycle management (connect, disconnect, status) //! - Subscription management //! - Builder pattern for client configuration //! @@ -18,13 +18,13 @@ //! # Examples //! //! ```rust,no_run -//! use ras_jsonrpc_bidirectional_client::{Client, ClientBuilder}; +//! use ras_jsonrpc_bidirectional_client::ClientBuilder; //! use serde_json::json; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! let client = ClientBuilder::new("ws://localhost:8080/ws") -//! .with_jwt_token("your_jwt_token".to_string()) +//! .with_jwt_token("demo-token".to_string()) //! .build() //! .await?; //! @@ -75,16 +75,38 @@ pub type ConnectionEventHandler = Arc; /// Connection lifecycle events #[derive(Debug, Clone)] pub enum ConnectionEvent { + /// Emitted after the server sends a connection-established message. Connected { connection_id: ConnectionId }, + /// Emitted when the server closes the connection or `Client::disconnect` completes. Disconnected { reason: Option }, + /// Reserved for caller-managed reconnect orchestration. + /// + /// The current client does not spawn a background reconnect loop. Reconnecting { attempt: u32 }, + /// Reserved for caller-managed reconnect orchestration. + /// + /// The current client does not spawn a background reconnect loop. ReconnectFailed { attempt: u32, error: String }, + /// Reserved for transports or wrappers that surface authentication failures as events. AuthenticationFailed { error: String }, } /// Trait for WebSocket transport implementations -#[async_trait] -pub trait WebSocketTransport: Send + Sync { +#[cfg(not(target_arch = "wasm32"))] +pub trait TransportThreadBounds: Send + Sync {} + +#[cfg(not(target_arch = "wasm32"))] +impl TransportThreadBounds for T {} + +#[cfg(target_arch = "wasm32")] +pub trait TransportThreadBounds {} + +#[cfg(target_arch = "wasm32")] +impl TransportThreadBounds for T {} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait WebSocketTransport: TransportThreadBounds { /// Connect to the WebSocket server async fn connect(&mut self) -> error::ClientResult<()>; diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/native.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/native.rs index 27d3234..685b695 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/native.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/native.rs @@ -54,37 +54,29 @@ impl NativeWebSocketTransport { headers } -} - -#[async_trait] -impl WebSocketTransport for NativeWebSocketTransport { - async fn connect(&mut self) -> ClientResult<()> { - info!("Connecting to WebSocket server: {}", self.url); - - // Create connection request with headers using IntoClientRequest - // Extract host from URL for Host header + fn host_header(&self) -> String { let host = self.url.host_str().unwrap_or("localhost"); - let host_header = if let Some(port) = self.url.port() { + if let Some(port) = self.url.port() { format!("{}:{}", host, port) } else { host.to_string() - }; + } + } + fn build_connection_request(&self) -> Result, String> { let mut request = Request::builder() .method("GET") .uri(self.url.as_str()) - .header("Host", host_header) + .header("Host", self.host_header()) .header("Connection", "Upgrade") .header("Upgrade", "websocket") .header("Sec-WebSocket-Version", "13") .header("Sec-WebSocket-Key", generate_key()); - // Add custom headers from build_request_headers() let headers = self.build_request_headers(); for (name, value) in headers.iter() { let header_name = name.as_str().to_lowercase(); - // Skip WebSocket-specific headers that are already set if !header_name.starts_with("sec-websocket") && header_name != "connection" && header_name != "upgrade" @@ -94,15 +86,29 @@ impl WebSocketTransport for NativeWebSocketTransport { } } - let request = request + request .body(()) - .map_err(|e| ClientError::connection(format!("Failed to build request: {}", e)))?; + .map_err(|e| format!("Failed to build request: {}", e)) + } - // Configure connection + fn websocket_config() -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig { let mut config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default(); config.max_message_size = Some(16 * 1024 * 1024); // 16MB config.max_frame_size = Some(16 * 1024 * 1024); // 16MB config.accept_unmasked_frames = false; + config + } +} + +#[async_trait] +impl WebSocketTransport for NativeWebSocketTransport { + async fn connect(&mut self) -> ClientResult<()> { + info!("Connecting to WebSocket server: {}", self.url); + + let request = self + .build_connection_request() + .map_err(ClientError::connection)?; + let config = Self::websocket_config(); // Connect with timeout let connect_future = connect_async_with_config(request, Some(config), false); @@ -222,8 +228,7 @@ impl WebSocketTransport for NativeWebSocketTransport { } fn is_connected(&self) -> bool { - // We can't easily check this without potentially blocking, - // so we'll check if we have a connection stored + // The transport owns the stream while connected; absence means disconnect completed. futures::executor::block_on(async { self.connection.read().await.is_some() }) } @@ -244,7 +249,7 @@ impl std::fmt::Debug for NativeWebSocketTransport { #[cfg(test)] mod tests { use super::*; - use crate::config::ClientConfig; + use crate::config::{AuthConfig, ClientConfig}; #[test] fn test_native_transport_creation() { @@ -268,6 +273,84 @@ mod tests { assert!(headers.contains_key("X-Custom")); } + #[test] + fn build_request_headers_ignores_invalid_header_names_and_values() { + let mut config = ClientConfig::new("ws://localhost:8080/ws"); + config + .custom_headers + .insert("bad header".to_string(), "ignored".to_string()); + config + .custom_headers + .insert("X-Bad-Value".to_string(), "line\r\nbreak".to_string()); + config + .custom_headers + .insert("X-Good".to_string(), "kept".to_string()); + + let transport = NativeWebSocketTransport::new(config); + let headers = transport.build_request_headers(); + + assert!(!headers.contains_key("bad header")); + assert!(!headers.contains_key("X-Bad-Value")); + assert_eq!(headers.get("X-Good").unwrap(), "kept"); + } + + #[test] + fn build_connection_request_sets_required_headers_and_preserves_auth_headers() { + let mut config = ClientConfig::new("ws://example.test:9000/ws"); + config.auth = AuthConfig::JwtHeader { + token: "secret".to_string(), + }; + config + .custom_headers + .insert("X-Custom".to_string(), "value".to_string()); + config + .custom_headers + .insert("Host".to_string(), "malicious.example".to_string()); + config + .custom_headers + .insert("Connection".to_string(), "close".to_string()); + config.custom_headers.insert( + "Sec-WebSocket-Key".to_string(), + "not-the-generated-key".to_string(), + ); + + let transport = NativeWebSocketTransport::new(config); + let request = transport.build_connection_request().unwrap(); + let headers = request.headers(); + + assert_eq!(request.method(), "GET"); + assert_eq!(request.uri(), "ws://example.test:9000/ws"); + assert_eq!(headers.get("host").unwrap(), "example.test:9000"); + assert_eq!(headers.get("connection").unwrap(), "Upgrade"); + assert_eq!(headers.get("upgrade").unwrap(), "websocket"); + assert_eq!(headers.get("sec-websocket-version").unwrap(), "13"); + assert_ne!( + headers.get("sec-websocket-key").unwrap(), + "not-the-generated-key" + ); + assert_eq!(headers.get("authorization").unwrap(), "Bearer secret"); + assert_eq!(headers.get("x-custom").unwrap(), "value"); + } + + #[test] + fn build_connection_request_omits_port_from_host_header_when_url_has_no_port() { + let config = ClientConfig::new("wss://example.test/ws"); + let transport = NativeWebSocketTransport::new(config); + + let request = transport.build_connection_request().unwrap(); + + assert_eq!(request.headers().get("host").unwrap(), "example.test"); + } + + #[test] + fn websocket_config_sets_expected_native_limits() { + let config = NativeWebSocketTransport::websocket_config(); + + assert_eq!(config.max_message_size, Some(16 * 1024 * 1024)); + assert_eq!(config.max_frame_size, Some(16 * 1024 * 1024)); + assert!(!config.accept_unmasked_frames); + } + #[tokio::test] async fn test_disconnect_without_connection() { let config = ClientConfig::new("ws://localhost:8080/ws"); @@ -277,4 +360,30 @@ mod tests { let result = transport.disconnect().await; assert!(result.is_ok()); } + + #[tokio::test] + async fn send_without_connection_returns_not_connected() { + let config = ClientConfig::new("ws://localhost:8080/ws"); + let mut transport = NativeWebSocketTransport::new(config); + + let error = transport + .send(&BidirectionalMessage::Ping) + .await + .expect_err("send should require a connection"); + + assert!(matches!(error, ClientError::NotConnected)); + } + + #[tokio::test] + async fn receive_without_connection_returns_not_connected() { + let config = ClientConfig::new("ws://localhost:8080/ws"); + let mut transport = NativeWebSocketTransport::new(config); + + let error = transport + .receive() + .await + .expect_err("receive should require a connection"); + + assert!(matches!(error, ClientError::NotConnected)); + } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/wasm.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/wasm.rs index a2b1a19..8c9e203 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/wasm.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/wasm.rs @@ -18,7 +18,6 @@ use web_sys::{BinaryType, CloseEvent, ErrorEvent, MessageEvent, WebSocket}; /// WASM WebSocket transport using web-sys pub struct WasmWebSocketTransport { - config: ClientConfig, websocket: Arc>>, message_queue: Arc>>, connection_state: Arc>, @@ -41,7 +40,6 @@ impl WasmWebSocketTransport { let url = config.get_connection_url(); Self { - config, websocket: Arc::new(Mutex::new(None)), message_queue: Arc::new(Mutex::new(VecDeque::new())), connection_state: Arc::new(Mutex::new(WasmConnectionState::Disconnected)), @@ -161,7 +159,7 @@ impl WasmWebSocketTransport { } } -#[async_trait] +#[async_trait(?Send)] impl WebSocketTransport for WasmWebSocketTransport { async fn connect(&mut self) -> ClientResult<()> { // Check if already connected diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml index 175447b..92ba694 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml @@ -2,6 +2,12 @@ name = "ras-jsonrpc-bidirectional-macro" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Procedural macro for bidirectional JSON-RPC services" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [lib] proc-macro = true @@ -19,12 +25,11 @@ server = [] client = [] [dev-dependencies] -ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types" } -ras-jsonrpc-bidirectional-server = { path = "../ras-jsonrpc-bidirectional-server" } -ras-jsonrpc-bidirectional-client = { path = "../ras-jsonrpc-bidirectional-client" } -ras-auth-core = { path = "../../../core/ras-auth-core" } -ras-jsonrpc-types = { path = "../../ras-jsonrpc-types" } -ras-test-helpers = { path = "../../../test-utils/ras-test-helpers" } +ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types", version = "0.1.0" } +ras-jsonrpc-bidirectional-server = { path = "../ras-jsonrpc-bidirectional-server", version = "0.1.0" } +ras-jsonrpc-bidirectional-client = { path = "../ras-jsonrpc-bidirectional-client", version = "0.1.0" } +ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.1.0" } +ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -43,4 +48,4 @@ criterion = { workspace = true, features = ["async_tokio"] } [[bench]] name = "roundtrip" -harness = false \ No newline at end of file +harness = false diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md index 8627f57..e67fa18 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md @@ -2,16 +2,15 @@ Procedural macro for generating type-safe bidirectional JSON-RPC services over WebSockets. -This crate provides the `jsonrpc_bidirectional_service!` macro that generates both server and client code for bidirectional JSON-RPC communication, including authentication support, type-safe message enums, and optional OpenRPC documentation. +This crate provides the `jsonrpc_bidirectional_service!` macro that generates both server and client code for bidirectional JSON-RPC communication, including authentication support and type-safe message enums. ## Features - **Server Code Generation**: Generates service traits and handlers for client-to-server JSON-RPC methods - **Client Code Generation**: Generates type-safe client structs with method calls and notification handlers - **Authentication Integration**: Supports JWT-based authentication with permission-based access control -- **Type Safety**: All generated code is fully type-safe with compile-time validation -- **OpenRPC Documentation**: Optional automatic OpenRPC specification generation -- **WebSocket Integration**: Works seamlessly with the bidirectional runtime crates +- **Type Safety**: Generated Rust request and response paths are checked at compile time +- **WebSocket Integration**: Works with the bidirectional runtime crates ## Usage @@ -19,15 +18,32 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -ras-jsonrpc-bidirectional-macro = { path = "path/to/ras-jsonrpc-bidirectional-macro" } +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ras-auth-core = "0.1.0" +ras-jsonrpc-types = "0.1.1" +ras-jsonrpc-bidirectional-types = "0.1.0" +ras-jsonrpc-bidirectional-macro = { version = "0.1.0", default-features = false } +ras-jsonrpc-bidirectional-server = { version = "0.1.0", optional = true } +ras-jsonrpc-bidirectional-client = { version = "0.1.0", optional = true } -# Optional features [features] -server = ["ras-jsonrpc-bidirectional-server"] -client = ["ras-jsonrpc-bidirectional-client"] -openrpc = ["ras-jsonrpc-bidirectional-macro/openrpc"] +default = ["server", "client"] +server = [ + "ras-jsonrpc-bidirectional-macro/server", + "dep:ras-jsonrpc-bidirectional-server", +] +client = [ + "ras-jsonrpc-bidirectional-macro/client", + "dep:ras-jsonrpc-bidirectional-client", +] ``` +The generated code checks the consuming crate's `server` and `client` features, so keep the macro features and optional runtime crates behind the same feature names. + +If you define `server_to_client_calls`, also add `tokio = { version = "1.0", features = ["sync", "time"], optional = true }` and `uuid = { version = "1", features = ["v4"], optional = true }`, then include `dep:tokio` and `dep:uuid` in the `server` feature. The generated server-side client handle uses them for pending response channels, timeouts, and request IDs. + ### Basic Example ```rust @@ -54,7 +70,6 @@ pub struct StatusUpdate { // Generate bidirectional service jsonrpc_bidirectional_service!({ service_name: UserService, - openrpc: true, client_to_server: [ UNAUTHORIZED get_user(UserRequest) -> UserResponse, WITH_PERMISSIONS(["admin"]) delete_user(UserRequest) -> bool, @@ -63,6 +78,8 @@ jsonrpc_bidirectional_service!({ server_to_client: [ status_notification(StatusUpdate), user_updated(UserResponse), + ], + server_to_client_calls: [ ] }); ``` @@ -75,29 +92,41 @@ This generates: // Service trait to implement #[async_trait::async_trait] pub trait UserServiceService: Send + Sync { - async fn get_user(&self, request: UserRequest) -> Result>; - async fn delete_user(&self, user: &AuthenticatedUser, request: UserRequest) -> Result>; - async fn update_user(&self, user: &AuthenticatedUser, request: UserRequest) -> Result>; + async fn get_user( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _request: UserRequest, + ) -> Result>; + + async fn delete_user( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + _request: UserRequest, + ) -> Result>; + + async fn update_user( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + _request: UserRequest, + ) -> Result>; // Notification methods - async fn notify_status_notification(&self, connection_id: ConnectionId, params: StatusUpdate) -> Result<()>; - async fn notify_user_updated(&self, connection_id: ConnectionId, params: UserResponse) -> Result<()>; + async fn notify_status_notification(&self, connection_id: ConnectionId, params: StatusUpdate) -> ras_jsonrpc_bidirectional_types::Result<()>; + async fn notify_user_updated(&self, connection_id: ConnectionId, params: UserResponse) -> ras_jsonrpc_bidirectional_types::Result<()>; } -// Builder for WebSocket service -pub struct UserServiceBuilder { - // ... -} +// The server feature also emits `UserServiceHandler` and +// `UserServiceBuilder::new(service, auth_provider)` for Axum wiring. ``` #### Client Side (with `#[cfg(feature = "client")]`) ```rust -// Type-safe client -pub struct UserServiceClient { - // ... -} - impl UserServiceClient { // Method calls pub async fn get_user(&self, request: UserRequest) -> ClientResult; @@ -114,25 +143,32 @@ impl UserServiceClient { // Connection management pub async fn connect(&self) -> ClientResult<()>; pub async fn disconnect(&self) -> ClientResult<()>; - pub fn is_connected(&self) -> bool; + pub async fn is_connected(&self) -> bool; } -// Client builder -pub struct UserServiceClientBuilder { - // ... -} +// The client feature also emits `UserServiceClientBuilder` for connection +// configuration and typed client construction. ``` ### Server Implementation Example ```rust -use ras_auth_core::AuthenticatedUser; +use axum::{routing::get, Router}; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use ras_jsonrpc_bidirectional_server::websocket_handler; +use ras_jsonrpc_bidirectional_types::{ConnectionId, ConnectionManager}; +use std::collections::HashSet; struct MyUserService; #[async_trait::async_trait] impl UserServiceService for MyUserService { - async fn get_user(&self, request: UserRequest) -> Result> { + async fn get_user( + &self, + _client_id: ConnectionId, + _connection_manager: &dyn ConnectionManager, + _request: UserRequest, + ) -> Result> { // Implementation Ok(UserResponse { name: "John Doe".to_string(), @@ -140,13 +176,25 @@ impl UserServiceService for MyUserService { }) } - async fn delete_user(&self, user: &AuthenticatedUser, request: UserRequest) -> Result> { + async fn delete_user( + &self, + _client_id: ConnectionId, + _connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + _request: UserRequest, + ) -> Result> { // Check user permissions are automatically validated by the generated code // Implementation Ok(true) } - async fn update_user(&self, user: &AuthenticatedUser, request: UserRequest) -> Result> { + async fn update_user( + &self, + _client_id: ConnectionId, + _connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + _request: UserRequest, + ) -> Result> { // Implementation Ok(UserResponse { name: "Updated Name".to_string(), @@ -154,14 +202,38 @@ impl UserServiceService for MyUserService { }) } - async fn notify_status_notification(&self, connection_id: ConnectionId, params: StatusUpdate) -> Result<()> { - // Default implementation sends notification to the connection - self.notify_status_notification(connection_id, params).await + async fn notify_status_notification( + &self, + _connection_id: ConnectionId, + _params: StatusUpdate, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) } - async fn notify_user_updated(&self, connection_id: ConnectionId, params: UserResponse) -> Result<()> { - // Default implementation sends notification to the connection - self.notify_user_updated(connection_id, params).await + async fn notify_user_updated( + &self, + _connection_id: ConnectionId, + _params: UserResponse, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } +} + +struct MyAuthProvider; + +impl AuthProvider for MyAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + if token != "demo-token" { + return Err(AuthError::InvalidToken); + } + + Ok(AuthenticatedUser { + user_id: "demo-user".to_string(), + permissions: HashSet::from(["user".to_string()]), + metadata: None, + }) + }) } } @@ -169,16 +241,19 @@ impl UserServiceService for MyUserService { #[tokio::main] async fn main() { let service = MyUserService; - let auth_provider = JwtAuthProvider::new("secret".to_string()); + let auth_provider = MyAuthProvider; let websocket_service = UserServiceBuilder::new(service, auth_provider) .require_auth(false) // Set to true to require authentication for all methods .build(); let app = Router::new() - .route("/ws", get(websocket_handler)) + .route("/ws", get(websocket_handler::<_>)) .with_state(websocket_service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:8080") + .await + .unwrap(); axum::serve(listener, app).await.unwrap(); } ``` @@ -189,7 +264,7 @@ async fn main() { #[tokio::main] async fn main() -> Result<(), Box> { let mut client = UserServiceClientBuilder::new("ws://localhost:8080/ws") - .with_jwt_token("your_jwt_token".to_string()) + .with_jwt_token("demo-token".to_string()) .build() .await?; @@ -221,7 +296,6 @@ async fn main() -> Result<(), Box> { ```rust jsonrpc_bidirectional_service!({ service_name: ServiceName, - openrpc: true | false | { output: "path/to/output.json" }, client_to_server: [ UNAUTHORIZED method_name(RequestType) -> ResponseType, WITH_PERMISSIONS(["perm1", "perm2"]) method_name(RequestType) -> ResponseType, @@ -230,6 +304,9 @@ jsonrpc_bidirectional_service!({ server_to_client: [ notification_name(NotificationType), another_notification(AnotherType), + ], + server_to_client_calls: [ + server_call_name(RequestType) -> ResponseType, ] }); ``` @@ -242,23 +319,31 @@ jsonrpc_bidirectional_service!({ ### OpenRPC Generation -When `openrpc: true` is specified, the macro generates OpenRPC documentation: - -- **Output**: `target/openrpc/{service_name}.json` by default -- **Custom path**: Use `openrpc: { output: "custom/path.json" }` -- **Requires**: All request/response types must implement `schemars::JsonSchema` -- **Features**: Include `openrpc` feature in your `Cargo.toml` - -Generated functions: -- `generate_{service_name}_openrpc()` -> Returns OpenRPC document -- `generate_{service_name}_openrpc_to_file()` -> Writes to file +This bidirectional WebSocket macro does not currently generate OpenRPC documents. OpenRPC generation is available in `ras-jsonrpc-macro` for HTTP JSON-RPC services. ## Requirements All request, response, and notification parameter types must implement: - `serde::Serialize` + `serde::Deserialize` - `Send` + `Sync` + `'static` -- `schemars::JsonSchema` (if using OpenRPC generation) + +## Testing + +Run tests with: + +```bash +cargo test -p ras-jsonrpc-bidirectional-macro --locked +``` + +The end-to-end tests exercise generated service dispatch through the in-memory +WebSocket adapter. They do not bind sockets. + +## Checks + +```bash +cargo test -p ras-jsonrpc-bidirectional-macro --locked +cargo clippy -p ras-jsonrpc-bidirectional-macro --all-targets --all-features --locked -- -D warnings +``` ## Generated Code Structure @@ -266,13 +351,12 @@ The macro generates code conditionally compiled based on features: - `#[cfg(feature = "server")]`: Server traits, handlers, and builders - `#[cfg(feature = "client")]`: Client structs, builders, and message enums -- `#[cfg(feature = "openrpc")]`: OpenRPC generation functions This allows consuming crates to enable only the functionality they need. ## Error Handling -Generated code provides comprehensive error handling: +Generated code provides typed error handling for: - **Authentication errors**: Automatic JWT validation and permission checking - **Serialization errors**: Type-safe JSON conversion with helpful error messages @@ -287,4 +371,4 @@ This macro works with the following runtime crates: - `ras-jsonrpc-bidirectional-server`: Server-side WebSocket handling - `ras-jsonrpc-bidirectional-client`: Client-side WebSocket communication - `ras-auth-core`: Authentication provider traits -- `ras-jsonrpc-types`: JSON-RPC 2.0 protocol types \ No newline at end of file +- `ras-jsonrpc-types`: JSON-RPC 2.0 protocol types diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/benches/roundtrip.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/benches/roundtrip.rs index c3ce50a..d3ea60b 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/benches/roundtrip.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/benches/roundtrip.rs @@ -1,20 +1,25 @@ -//! Criterion bench measuring c2s call round-trip latency through a real -//! WebSocket connection (tokio-tungstenite client → axum server). +//! Criterion bench measuring generated service dispatch through the in-memory +//! WebSocket message loop. +use std::collections::{HashSet, VecDeque}; use std::sync::Arc; -use std::time::Duration; use async_trait::async_trait; -use axum::{Router, routing::get}; use criterion::{Criterion, criterion_group, criterion_main}; use ras_auth_core::AuthenticatedUser; use ras_jsonrpc_bidirectional_macro::jsonrpc_bidirectional_service; use ras_jsonrpc_bidirectional_server::DefaultConnectionManager; -use ras_jsonrpc_bidirectional_server::service::{BuiltWebSocketService, websocket_handler}; -use ras_jsonrpc_bidirectional_types::ConnectionId; -use ras_test_helpers::{MockAuthProvider, spawn_tcp}; +use ras_jsonrpc_bidirectional_server::connection::{ChannelMessageSender, ConnectionContext}; +use ras_jsonrpc_bidirectional_server::handler::{ + WebSocketHandler, WebSocketIo, WebSocketIoMessage, +}; +use ras_jsonrpc_bidirectional_types::{ + BidirectionalMessage, ConnectionId, ConnectionInfo, ConnectionManager, +}; +use ras_jsonrpc_types::JsonRpcRequest; use serde::{Deserialize, Serialize}; use tokio::runtime::Runtime; +use tokio::sync::mpsc; #[derive(Debug, Clone, Serialize, Deserialize)] struct EchoIn { @@ -29,6 +34,17 @@ struct EchoOut { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Ignored; +fn mock_user(user_id: &str, permissions: &[&str]) -> AuthenticatedUser { + AuthenticatedUser { + user_id: user_id.to_string(), + permissions: permissions + .iter() + .map(|p| (*p).to_string()) + .collect::>(), + metadata: None, + } +} + jsonrpc_bidirectional_service!({ service_name: BenchSvc, client_to_server: [ @@ -65,53 +81,94 @@ impl BenchSvcService for BenchImpl { } } -async fn start_server() -> String { +struct InMemorySocket { + incoming: VecDeque, + outgoing: Vec, +} + +impl InMemorySocket { + fn closing(incoming: impl IntoIterator) -> Self { + Self { + incoming: incoming.into_iter().collect(), + outgoing: Vec::new(), + } + } +} + +#[async_trait] +impl WebSocketIo for InMemorySocket { + async fn send( + &mut self, + message: WebSocketIoMessage, + ) -> ras_jsonrpc_bidirectional_server::ServerResult<()> { + self.outgoing.push(message); + Ok(()) + } + + async fn recv( + &mut self, + ) -> Option> { + self.incoming.pop_front().map(Ok) + } +} + +async fn run_in_memory_roundtrip() -> EchoOut { let cm = Arc::new(DefaultConnectionManager::new()); let handler = Arc::new(BenchSvcHandler::new(Arc::new(BenchImpl), cm.clone())); - let svc = ras_jsonrpc_bidirectional_server::WebSocketServiceBuilder::builder() - .handler(handler) - .auth_provider(Arc::new(MockAuthProvider::default())) - .require_auth(false) - .build() - .build_with_manager(cm); - - type SvcType = BuiltWebSocketService< - BenchSvcHandler, - MockAuthProvider, - DefaultConnectionManager, - >; - let app: Router = Router::new() - .route("/ws", get(websocket_handler::)) - .with_state(svc); - let (addr, _h) = spawn_tcp(app).await; - tokio::time::sleep(Duration::from_millis(50)).await; - format!("ws://{addr}/ws") + + let connection_id = ConnectionId::new(); + let (message_tx, message_rx) = mpsc::channel(8); + let sender = ChannelMessageSender::new(connection_id, message_tx); + let user = mock_user("user-1", &["user"]); + + let mut info = ConnectionInfo::new(connection_id); + info.set_user(user.clone()); + let context = Arc::new(ConnectionContext::new(connection_id, sender.clone())); + context.set_user(user).await; + cm.add_connection_with_sender(info, Box::new(sender)) + .await + .expect("benchmark connection should register"); + + let request = JsonRpcRequest::new( + "echo".to_string(), + Some(serde_json::json!(EchoIn { msg: "x".into() })), + Some(1.into()), + ); + let request_text = serde_json::to_string(&BidirectionalMessage::Request(request)).unwrap(); + let mut socket = InMemorySocket::closing([WebSocketIoMessage::Text(request_text)]); + + WebSocketHandler::new(handler, context, message_rx, 4096) + .run_with_io(&mut socket) + .await + .expect("in-memory benchmark roundtrip should complete"); + + socket + .outgoing + .into_iter() + .filter_map(|message| match message { + WebSocketIoMessage::Text(text) => { + serde_json::from_str::(&text).ok() + } + _ => None, + }) + .find_map(|message| match message { + BidirectionalMessage::Response(response) => response + .result + .map(|result| serde_json::from_value(result).unwrap()), + _ => None, + }) + .expect("benchmark response should be sent") } fn bench_roundtrip(c: &mut Criterion) { let rt = Runtime::new().unwrap(); - let client = rt.block_on(async { - let url = start_server().await; - let client = BenchSvcClientBuilder::new(url) - .with_jwt_token("user-token".to_string()) - .build() - .await - .expect("client build"); - client.connect().await.expect("connect"); - client - }); - - c.bench_function("ws_echo_roundtrip", |b| { + c.bench_function("in_memory_ws_echo_roundtrip", |b| { b.to_async(&rt).iter(|| async { - let r = client.echo(EchoIn { msg: "x".into() }).await.expect("echo"); + let r = run_in_memory_roundtrip().await; std::hint::black_box(r); }); }); - - rt.block_on(async { - let _ = client.disconnect().await; - }); } criterion_group!(benches, bench_roundtrip); diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/client.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/client.rs index e96e9f2..65cfbbf 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/client.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/client.rs @@ -97,8 +97,9 @@ pub fn generate_client_code( F: Fn(#request_type) -> Fut + Send + Sync + 'static, Fut: std::future::Future> + Send + 'static, { + let callback = std::sync::Arc::new(handler); let handler = std::sync::Arc::new(move |request: ras_jsonrpc_types::JsonRpcRequest| { - let handler = handler.clone(); + let callback = callback.clone(); Box::pin(async move { // Parse request parameters let params: #request_type = if let Some(params) = request.params { @@ -124,7 +125,7 @@ pub fn generate_client_code( }; // Call handler - match handler(params).await { + match callback(params).await { Ok(result) => { match serde_json::to_value(result) { Ok(result_value) => ras_jsonrpc_types::JsonRpcResponse::success(result_value, request.id), diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs index 208a9d3..b1a4cf7 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs @@ -116,7 +116,7 @@ impl Parse for BidirectionalServiceDefinition { let mut server_to_client_calls = Vec::new(); while !server_to_client_calls_content.is_empty() { - let method = server_to_client_calls_content.parse::()?; + let method = parse_server_to_client_call(&server_to_client_calls_content)?; server_to_client_calls.push(method); // Handle optional trailing comma @@ -134,6 +134,33 @@ impl Parse for BidirectionalServiceDefinition { } } +fn parse_server_to_client_call(input: syn::parse::ParseStream) -> syn::Result { + let fork = input.fork(); + if fork.peek(syn::Ident) { + let ident = fork.parse::()?; + match ident.to_string().as_str() { + "UNAUTHORIZED" | "WITH_PERMISSIONS" => return input.parse::(), + _ => {} + } + } + + let name = input.parse::()?; + + let request_content; + syn::parenthesized!(request_content in input); + let request_type = request_content.parse::()?; + + let _ = input.parse::]>()?; + let response_type = input.parse::()?; + + Ok(MethodDefinition { + auth: AuthRequirement::Unauthorized, + name, + request_type, + response_type, + }) +} + impl Parse for MethodDefinition { fn parse(input: syn::parse::ParseStream) -> syn::Result { // Parse auth requirement (UNAUTHORIZED or WITH_PERMISSIONS([...])) diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs index d559f98..9719f49 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs @@ -251,7 +251,7 @@ pub fn generate_server_code( // Wait for response with timeout let response = tokio::time::timeout( - std::time::Duration::from_secs(30), // TODO: Make configurable + std::time::Duration::from_secs(30), response_receiver ).await .map_err(|_| ras_jsonrpc_bidirectional_types::BidirectionalError::Timeout)? @@ -424,8 +424,7 @@ pub fn generate_server_code( Ok(()) } - async fn on_disconnect(&self, context: std::sync::Arc, reason: Option) -> ras_jsonrpc_bidirectional_server::ServerResult<()> { - let _ = reason; // Unused for now + async fn on_disconnect(&self, context: std::sync::Arc, _reason: Option) -> ras_jsonrpc_bidirectional_server::ServerResult<()> { // Call the service's on_client_disconnected if let Err(e) = self.service.on_client_disconnected(context.id, self.connection_manager.as_ref()).await { return Err(ras_jsonrpc_bidirectional_server::ServerError::Internal(e.to_string())); diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/tests.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/tests.rs index 148751a..eed6705 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/tests.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/tests.rs @@ -1,13 +1,11 @@ //! Tests for the bidirectional macro generation -#[cfg(test)] -mod tests { - use crate::{AuthRequirement, BidirectionalServiceDefinition, generate_service_code}; +use crate::{AuthRequirement, BidirectionalServiceDefinition, generate_service_code}; - #[test] - fn test_macro_compiles() { - // This is a basic compilation test to ensure the macro expands without syntax errors - let input = r#"{ +#[test] +fn test_macro_compiles() { + // This is a basic compilation test to ensure the macro expands without syntax errors + let input = r#"{ service_name: TestService, client_to_server: [ UNAUTHORIZED test_method(String) -> String, @@ -21,22 +19,22 @@ mod tests { ] }"#; - // Parse the macro input - let parsed: BidirectionalServiceDefinition = syn::parse_str(input).unwrap(); + // Parse the macro input + let parsed: BidirectionalServiceDefinition = syn::parse_str(input).unwrap(); - // Verify parsing worked correctly - assert_eq!(parsed.service_name.to_string(), "TestService"); - assert_eq!(parsed.client_to_server.len(), 2); - assert_eq!(parsed.server_to_client.len(), 2); + // Verify parsing worked correctly + assert_eq!(parsed.service_name.to_string(), "TestService"); + assert_eq!(parsed.client_to_server.len(), 2); + assert_eq!(parsed.server_to_client.len(), 2); - // Generate code - let generated = generate_service_code(parsed); - assert!(generated.is_ok()); - } + // Generate code + let generated = generate_service_code(parsed); + assert!(generated.is_ok()); +} - #[test] - fn test_simple_parsing() { - let input = r#"{ +#[test] +fn test_simple_parsing() { + let input = r#"{ service_name: SimpleService, client_to_server: [ UNAUTHORIZED hello(String) -> String, @@ -48,15 +46,15 @@ mod tests { ] }"#; - let parsed: BidirectionalServiceDefinition = syn::parse_str(input).unwrap(); - assert_eq!(parsed.service_name.to_string(), "SimpleService"); - assert_eq!(parsed.client_to_server.len(), 1); - assert_eq!(parsed.server_to_client.len(), 1); - } + let parsed: BidirectionalServiceDefinition = syn::parse_str(input).unwrap(); + assert_eq!(parsed.service_name.to_string(), "SimpleService"); + assert_eq!(parsed.client_to_server.len(), 1); + assert_eq!(parsed.server_to_client.len(), 1); +} - #[test] - fn test_permission_parsing() { - let input = r#"{ +#[test] +fn test_permission_parsing() { + let input = r#"{ service_name: PermissionService, client_to_server: [ WITH_PERMISSIONS(["admin", "write"] | ["super_admin"]) complex_method(String) -> String, @@ -66,14 +64,37 @@ mod tests { ] }"#; - let parsed: BidirectionalServiceDefinition = syn::parse_str(input).unwrap(); + let parsed: BidirectionalServiceDefinition = syn::parse_str(input).unwrap(); - if let AuthRequirement::WithPermissions(groups) = &parsed.client_to_server[0].auth { - assert_eq!(groups.len(), 2); - assert_eq!(groups[0], vec!["admin", "write"]); - assert_eq!(groups[1], vec!["super_admin"]); - } else { - panic!("Expected WithPermissions auth requirement"); - } + if let AuthRequirement::WithPermissions(groups) = &parsed.client_to_server[0].auth { + assert_eq!(groups.len(), 2); + assert_eq!(groups[0], vec!["admin", "write"]); + assert_eq!(groups[1], vec!["super_admin"]); + } else { + panic!("Expected WithPermissions auth requirement"); } } + +#[test] +fn test_server_to_client_calls_parse_without_auth_prefix() { + let input = r#"{ + service_name: CallbackService, + client_to_server: [], + server_to_client: [], + server_to_client_calls: [ + get_status(String) -> bool, + ] + }"#; + + let parsed: BidirectionalServiceDefinition = syn::parse_str(input).unwrap(); + + assert_eq!(parsed.server_to_client_calls.len(), 1); + assert_eq!( + parsed.server_to_client_calls[0].name.to_string(), + "get_status" + ); + assert!(matches!( + parsed.server_to_client_calls[0].auth, + AuthRequirement::Unauthorized + )); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/bidirectional_integration.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/bidirectional_integration.rs deleted file mode 100644 index 2fe1c16..0000000 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/bidirectional_integration.rs +++ /dev/null @@ -1,973 +0,0 @@ -//! WebSocket integration tests demonstrating macro capabilities -//! -//! This test demonstrates that the bidirectional JSON-RPC macro can generate -//! working server and client code with authentication and permissions. - -use async_trait::async_trait; -use axum::{Router, routing::get}; -use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; -use ras_jsonrpc_bidirectional_client::ClientBuilder; -use ras_jsonrpc_bidirectional_macro::jsonrpc_bidirectional_service; -use ras_jsonrpc_bidirectional_server::DefaultConnectionManager; -use ras_jsonrpc_bidirectional_server::service::{BuiltWebSocketService, websocket_handler}; -use ras_jsonrpc_bidirectional_types::ConnectionId; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::{collections::HashSet, sync::Arc, time::Duration}; -use tokio::{net::TcpListener, sync::RwLock}; - -// Test data structures -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatMessage { - pub text: String, - pub username: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatResponse { - pub message_id: u64, - pub timestamp: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserJoinedNotification { - pub username: String, - pub user_count: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageBroadcast { - pub message: ChatMessage, - pub message_id: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KickUserRequest { - pub target_username: String, - pub reason: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SystemNotification { - pub message: String, - pub level: String, -} - -// Generate the bidirectional service using the macro -jsonrpc_bidirectional_service!({ - service_name: ChatService, - - // Client -> Server methods (with authentication/permissions) - client_to_server: [ - UNAUTHORIZED join_chat(String) -> String, - WITH_PERMISSIONS(["user"]) send_message(ChatMessage) -> ChatResponse, - WITH_PERMISSIONS(["admin"]) kick_user(KickUserRequest) -> bool, - WITH_PERMISSIONS(["admin"]) broadcast_system_message(String) -> (), - ], - - // Server -> Client notifications (no response expected) - server_to_client: [ - user_joined(UserJoinedNotification), - message_received(MessageBroadcast), - user_left(String), - system_notification(SystemNotification), - ], - - // Server -> Client RPC calls (with response expected) - server_to_client_calls: [ - ] -}); - -// Test auth provider for WebSocket authentication -#[derive(Clone)] -struct TestAuthProvider { - valid_tokens: HashSet, -} - -impl TestAuthProvider { - fn new() -> Self { - let mut valid_tokens = HashSet::new(); - valid_tokens.insert("valid-admin-token".to_string()); - valid_tokens.insert("valid-user-token".to_string()); - valid_tokens.insert("valid-guest-token".to_string()); - - Self { valid_tokens } - } -} - -impl AuthProvider for TestAuthProvider { - fn authenticate(&self, token: String) -> AuthFuture<'_> { - Box::pin(async move { - if !self.valid_tokens.contains(&token) { - return Err(AuthError::InvalidToken); - } - - let (user_id, permissions) = match token.as_str() { - "valid-admin-token" => { - ("admin-user", vec!["admin".to_string(), "user".to_string()]) - } - "valid-user-token" => ("regular-user", vec!["user".to_string()]), - "valid-guest-token" => ("guest-user", vec![]), - _ => return Err(AuthError::InvalidToken), - }; - - Ok(AuthenticatedUser { - user_id: user_id.to_string(), - permissions: permissions.into_iter().collect(), - metadata: None, - }) - }) - } -} - -// Mock chat service implementation -#[derive(Clone)] -struct MockChatService { - message_counter: Arc>, -} - -impl MockChatService { - fn new() -> Self { - Self { - message_counter: Arc::new(RwLock::new(1)), - } - } - - async fn next_message_id(&self) -> u64 { - let mut counter = self.message_counter.write().await; - let id = *counter; - *counter += 1; - id - } -} - -// Add server feature for generated code -#[cfg(feature = "server")] -#[async_trait] -impl ChatServiceService for MockChatService { - async fn join_chat( - &self, - client_id: ConnectionId, - connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, - username: String, - ) -> Result> { - println!("Client {} joined chat as {}", client_id, username); - - // Example: Create client handle and notify all other clients about the new user - if let Ok(all_connections) = connection_manager.get_all_connections().await { - for conn in all_connections { - if conn.id != client_id { - // Send notification using connection manager directly - let notification = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "user_joined".to_string(), - params: serde_json::to_value(UserJoinedNotification { - username: username.clone(), - user_count: 1, - }) - .unwrap(), - metadata: None, - }; - let msg = - ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification( - notification, - ); - let _ = connection_manager.send_to_connection(conn.id, msg).await; - } - } - } - - Ok(format!("Welcome to the chat, {}!", username)) - } - - async fn send_message( - &self, - client_id: ConnectionId, - connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, - _user: &AuthenticatedUser, - message: ChatMessage, - ) -> Result> { - println!("Client {} sent message: {:?}", client_id, message); - let message_id = self.next_message_id().await; - let timestamp = chrono::Utc::now().to_rfc3339(); - - // Create the broadcast message - let broadcast = MessageBroadcast { - message: message.clone(), - message_id, - }; - - // Broadcast to all other clients using the connection manager - if let Ok(all_connections) = connection_manager.get_all_connections().await { - for conn in all_connections { - ChatServiceClientHandle::new(conn.id, connection_manager) - .message_received(broadcast.clone()) - .await - .unwrap(); - // Send notification to all clients (including sender for this demo) - let notification = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "message_received".to_string(), - params: serde_json::to_value(broadcast.clone()).unwrap(), - metadata: None, - }; - let msg = ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification( - notification, - ); - let _ = connection_manager.send_to_connection(conn.id, msg).await; - } - } - - Ok(ChatResponse { - message_id, - timestamp, - }) - } - - async fn kick_user( - &self, - client_id: ConnectionId, - connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, - _user: &AuthenticatedUser, - request: KickUserRequest, - ) -> Result> { - println!("Client {} requested user kick: {:?}", client_id, request); - - // Example: Send system notification to all clients about the kick - if let Ok(all_connections) = connection_manager.get_all_connections().await { - for conn in all_connections { - let notification = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "system_notification".to_string(), - params: serde_json::to_value(SystemNotification { - message: format!( - "User {} was kicked: {}", - request.target_username, request.reason - ), - level: "warning".to_string(), - }) - .unwrap(), - metadata: None, - }; - let msg = ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification( - notification, - ); - let _ = connection_manager.send_to_connection(conn.id, msg).await; - } - } - - Ok(true) - } - - async fn broadcast_system_message( - &self, - client_id: ConnectionId, - connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, - _user: &AuthenticatedUser, - message: String, - ) -> Result<(), Box> { - println!("Client {} broadcast system message: {}", client_id, message); - - // Broadcast to all connected clients - if let Ok(all_connections) = connection_manager.get_all_connections().await { - for conn in all_connections { - let notification = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "system_notification".to_string(), - params: serde_json::to_value(SystemNotification { - message: message.clone(), - level: "info".to_string(), - }) - .unwrap(), - metadata: None, - }; - let msg = ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification( - notification, - ); - let _ = connection_manager.send_to_connection(conn.id, msg).await; - } - } - - Ok(()) - } - - async fn notify_user_joined( - &self, - _connection_id: ConnectionId, - _params: UserJoinedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_message_received( - &self, - _connection_id: ConnectionId, - _params: MessageBroadcast, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_user_left( - &self, - _connection_id: ConnectionId, - _params: String, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_system_notification( - &self, - _connection_id: ConnectionId, - _params: SystemNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - /// Lifecycle hook: called when a client connects - async fn on_client_connected( - &self, - client_id: ConnectionId, - connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, - ) -> Result<(), Box> { - println!("Client {} connected", client_id); - - // Example: Welcome the new client directly - let notification = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "system_notification".to_string(), - params: serde_json::to_value(SystemNotification { - message: "Welcome to the chat server!".to_string(), - level: "info".to_string(), - }) - .unwrap(), - metadata: None, - }; - let msg = - ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification(notification); - let _ = connection_manager.send_to_connection(client_id, msg).await; - - Ok(()) - } - - /// Lifecycle hook: called when a client disconnects - async fn on_client_disconnected( - &self, - client_id: ConnectionId, - connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, - ) -> Result<(), Box> { - println!("Client {} disconnected", client_id); - - // Example: Notify remaining clients about the disconnection - if let Ok(all_connections) = connection_manager.get_all_connections().await { - for conn in all_connections { - if conn.id != client_id { - let notification = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "user_left".to_string(), - params: serde_json::to_value(format!("Client {}", client_id)).unwrap(), - metadata: None, - }; - let msg = - ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification( - notification, - ); - let _ = connection_manager.send_to_connection(conn.id, msg).await; - } - } - } - - Ok(()) - } - - /// Lifecycle hook: called when a client authenticates - async fn on_client_authenticated( - &self, - client_id: ConnectionId, - connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, - user: &AuthenticatedUser, - ) -> Result<(), Box> { - println!( - "Client {} authenticated as user {}", - client_id, user.user_id - ); - - // Example: Send personalized welcome message - let notification = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "system_notification".to_string(), - params: serde_json::to_value(SystemNotification { - message: format!("Welcome back, {}!", user.user_id), - level: "success".to_string(), - }) - .unwrap(), - metadata: None, - }; - let msg = - ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification(notification); - let _ = connection_manager.send_to_connection(client_id, msg).await; - - Ok(()) - } -} - -// Global storage for the WebSocket service so we can access the connection manager for testing -use std::sync::OnceLock; -static TEST_WS_SERVICE: OnceLock< - Arc< - BuiltWebSocketService< - ChatServiceHandler, - TestAuthProvider, - DefaultConnectionManager, - >, - >, -> = OnceLock::new(); - -// Helper function to create a WebSocket test server -#[cfg(feature = "server")] -async fn create_test_server() -> (String, tokio::task::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("Failed to bind to port"); - - let addr = listener.local_addr().expect("Failed to get local addr"); - let ws_url = format!("ws://127.0.0.1:{}/ws", addr.port()); - - // Create a shared connection manager that both the handler and service will use - let connection_manager = Arc::new(DefaultConnectionManager::new()); - - // Create chat service - let chat_service = MockChatService::new(); - - // Create handler with the service and connection manager - let handler = Arc::new(ChatServiceHandler::new( - Arc::new(chat_service), - connection_manager.clone(), - )); - - // Build WebSocket service and explicitly pass the same connection manager - let ws_service = ras_jsonrpc_bidirectional_server::WebSocketServiceBuilder::builder() - .handler(handler) - .auth_provider(Arc::new(TestAuthProvider::new())) - .require_auth(false) // Set to false to allow unauthorized methods and connection tests - .build() - .build_with_manager(connection_manager); - - // Store the service globally so we can access it in tests - let ws_service_arc = Arc::new(ws_service.clone()); - let _ = TEST_WS_SERVICE.set(ws_service_arc); - - // Create Axum app with WebSocket route - type ChatServiceType = BuiltWebSocketService< - ChatServiceHandler, - TestAuthProvider, - DefaultConnectionManager, - >; - let app = Router::new() - .route("/ws", get(websocket_handler::)) - .with_state(ws_service); - - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.expect("Server failed"); - }); - - // Give server time to start - tokio::time::sleep(Duration::from_millis(100)).await; - - (ws_url, handle) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicBool, Ordering}; - - // Simple compilation test to verify macro generates valid code - #[test] - fn test_macro_compilation() { - // The fact that this compiles means the macro generated valid Rust code - assert!(true, "Macro compiled successfully"); - } - - #[cfg(all(feature = "server", feature = "client"))] - #[tokio::test] - async fn test_generated_client() { - // This test verifies that the generated client and server code compile and work together. - // It demonstrates: - // 1. Creating clients using the generated client builder - // 2. Connecting multiple clients to the same server - // 3. Calling server methods from clients - // 4. Setting up notification handlers on clients - // 5. Broadcasting messages from server to all connected clients - - let (ws_url, _server_handle) = create_test_server().await; - - // Create two clients using the generated builder - let client1 = ChatServiceClientBuilder::new(ws_url.clone()) - .with_jwt_token("valid-user-token".to_string()) - .build() - .await - .expect("Failed to create client 1"); - let mut client2 = ChatServiceClientBuilder::new(ws_url) - .with_jwt_token("valid-user-token".to_string()) - .build() - .await - .expect("Failed to create client 2"); - - // Connect both clients - client1.connect().await.expect("Failed to connect client 1"); - client2.connect().await.expect("Failed to connect client 2"); - - // Both clients join the chat - let response1 = client1.join_chat("client1".to_owned()).await.unwrap(); - assert!(response1.contains("Welcome to the chat, client1!")); - - let response2 = client2.join_chat("client2".to_owned()).await.unwrap(); - assert!(response2.contains("Welcome to the chat, client2!")); - - // Set up notification handlers on client2 to receive message broadcasts - let message_received = Arc::new(AtomicBool::new(false)); - let flag = message_received.clone(); - - client2.on_message_received(move |msg| { - println!("Received message: {:?}", msg); - flag.store(true, Ordering::SeqCst); - }); - - client1 - .send_message(ChatMessage { - text: "hi".to_string(), - username: "client1".to_string(), - }) - .await - .unwrap(); - - // Wait a bit for the message to be received - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - // Cleanup - client1 - .disconnect() - .await - .expect("Failed to disconnect client 1"); - client2 - .disconnect() - .await - .expect("Failed to disconnect client 2"); - - assert!(message_received.load(Ordering::SeqCst)); - } - - #[cfg(all(feature = "server", feature = "client"))] - #[tokio::test] - async fn test_websocket_server_client_connection() { - let (ws_url, _server_handle) = create_test_server().await; - - // Create client with admin token - let client = ClientBuilder::new(&ws_url) - .with_jwt_token("valid-admin-token".to_string()) - .with_request_timeout(Duration::from_secs(5)) - .build() - .await - .expect("Failed to build client"); - - // Connect to server - client.connect().await.expect("Failed to connect"); - - // Verify connection - assert!(client.is_connected().await); - assert!(client.connection_id().await.is_some()); - - // Disconnect - client.disconnect().await.expect("Failed to disconnect"); - assert!(!client.is_connected().await); - } - - #[cfg(all(feature = "server", feature = "client"))] - #[tokio::test] - async fn test_unauthorized_method_call() { - let (ws_url, _server_handle) = create_test_server().await; - - // Create client without token for unauthorized call - let client = ClientBuilder::new(&ws_url) - .with_request_timeout(Duration::from_secs(5)) - .build() - .await - .expect("Failed to build client"); - - client.connect().await.expect("Failed to connect"); - - // Test unauthorized method (join_chat) - let response = client - .call("join_chat", Some(json!("alice"))) - .await - .expect("join_chat call failed"); - - // Should succeed since it's UNAUTHORIZED - assert!(response.error.is_none()); - assert!(response.result.is_some()); - - let binding = response.result.unwrap(); - let welcome_msg = binding.as_str().unwrap(); - assert!(welcome_msg.contains("Welcome to the chat, alice!")); - - client.disconnect().await.expect("Failed to disconnect"); - } - - #[cfg(all(feature = "server", feature = "client"))] - #[tokio::test] - async fn test_authenticated_method_calls() { - let (ws_url, _server_handle) = create_test_server().await; - - // Create client with user token - let client = ClientBuilder::new(&ws_url) - .with_jwt_token("valid-user-token".to_string()) - .with_request_timeout(Duration::from_secs(5)) - .build() - .await - .expect("Failed to build client"); - - client.connect().await.expect("Failed to connect"); - - // Test user method (send_message) - let message = ChatMessage { - text: "Hello from user!".to_string(), - username: "regular-user".to_string(), - }; - - let response = client - .call("send_message", Some(json!(message))) - .await - .expect("send_message call failed"); - - assert!(response.error.is_none()); - assert!(response.result.is_some()); - - let result: ChatResponse = serde_json::from_value(response.result.unwrap()) - .expect("Failed to deserialize response"); - assert!(result.message_id > 0); - assert!(!result.timestamp.is_empty()); - - client.disconnect().await.expect("Failed to disconnect"); - } - - #[cfg(all(feature = "server", feature = "client"))] - #[tokio::test] - async fn test_admin_method_calls() { - let (ws_url, _server_handle) = create_test_server().await; - - // Create client with admin token - let client = ClientBuilder::new(&ws_url) - .with_jwt_token("valid-admin-token".to_string()) - .with_request_timeout(Duration::from_secs(5)) - .build() - .await - .expect("Failed to build client"); - - client.connect().await.expect("Failed to connect"); - - // Test admin method (kick_user) - let kick_request = KickUserRequest { - target_username: "spammer".to_string(), - reason: "Inappropriate behavior".to_string(), - }; - - let response = client - .call("kick_user", Some(json!(kick_request))) - .await - .expect("kick_user call failed"); - - assert!(response.error.is_none()); - assert!(response.result.is_some()); - - let success = response.result.unwrap().as_bool().unwrap(); - assert!(success); - - // Test admin broadcast method - let response = client - .call( - "broadcast_system_message", - Some(json!("Server maintenance soon")), - ) - .await - .expect("broadcast_system_message call failed"); - - assert!(response.error.is_none()); - - client.disconnect().await.expect("Failed to disconnect"); - } - - #[cfg(all(feature = "server", feature = "client"))] - #[tokio::test] - async fn test_permission_denied() { - let (ws_url, _server_handle) = create_test_server().await; - - // Create client with guest token (no permissions) - let client = ClientBuilder::new(&ws_url) - .with_jwt_token("valid-guest-token".to_string()) - .with_request_timeout(Duration::from_secs(5)) - .build() - .await - .expect("Failed to build client"); - - client.connect().await.expect("Failed to connect"); - - // Try to call admin method - should fail - let kick_request = KickUserRequest { - target_username: "someone".to_string(), - reason: "Testing".to_string(), - }; - - let response = client - .call("kick_user", Some(json!(kick_request))) - .await - .expect("Call completed (may have error)"); - - // Should have permission error - assert!(response.error.is_some()); - let error = response.error.unwrap(); - assert_eq!(error.code, -32002); // Insufficient permissions - - client.disconnect().await.expect("Failed to disconnect"); - } - - #[cfg(all(feature = "server", feature = "client"))] - #[tokio::test] - async fn test_server_to_client_notifications() { - let (ws_url, _server_handle) = create_test_server().await; - - // Create two clients to test notifications - let admin_client = ClientBuilder::new(&ws_url) - .with_jwt_token("valid-admin-token".to_string()) - .with_request_timeout(Duration::from_secs(5)) - .build() - .await - .expect("Failed to build admin client"); - - let user_client = ClientBuilder::new(&ws_url) - .with_jwt_token("valid-user-token".to_string()) - .with_request_timeout(Duration::from_secs(5)) - .build() - .await - .expect("Failed to build user client"); - - // Connect both clients - admin_client - .connect() - .await - .expect("Failed to connect admin"); - user_client.connect().await.expect("Failed to connect user"); - - // Set up notification handlers for user client - let user_joined_received = Arc::new(AtomicBool::new(false)); - let message_received_flag = Arc::new(AtomicBool::new(false)); - let system_notification_received = Arc::new(AtomicBool::new(false)); - - let user_joined_flag = user_joined_received.clone(); - user_client.on_notification( - "user_joined", - Arc::new(move |_method, _params| { - user_joined_flag.store(true, Ordering::SeqCst); - }), - ); - - let message_flag = message_received_flag.clone(); - user_client.on_notification( - "message_received", - Arc::new(move |_method, _params| { - message_flag.store(true, Ordering::SeqCst); - }), - ); - - let system_flag = system_notification_received.clone(); - user_client.on_notification( - "system_notification", - Arc::new(move |_method, _params| { - system_flag.store(true, Ordering::SeqCst); - }), - ); - - // Wait a bit for handlers to be registered - - // Trigger notifications by making calls from admin client - - // 1. Join chat to trigger user_joined notification - let _response = admin_client - .call("join_chat", Some(json!("admin"))) - .await - .expect("join_chat failed"); - - // 2. Send message to trigger message_received notification - let message = ChatMessage { - text: "Hello everyone!".to_string(), - username: "admin".to_string(), - }; - let _response = admin_client - .call("send_message", Some(json!(message))) - .await - .expect("send_message failed"); - - // 3. Broadcast system message - let _response = admin_client - .call("broadcast_system_message", Some(json!("System update"))) - .await - .expect("broadcast_system_message failed"); - - // Wait for notifications to be processed - let start = std::time::Instant::now(); - let timeout_duration = Duration::from_secs(3); - - while start.elapsed() < timeout_duration { - if user_joined_received.load(Ordering::SeqCst) - && message_received_flag.load(Ordering::SeqCst) - && system_notification_received.load(Ordering::SeqCst) - { - break; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - - // For now, we're just testing that the notification handlers can be registered - // and the service methods can be called successfully. - // In a real implementation, the service would manually trigger notifications - // using the notify_* methods on the handler when appropriate. - - // This test demonstrates that: - // 1. Clients can register notification handlers - // 2. Service methods can be called successfully - // 3. The generated notification infrastructure is properly set up - - // Note: Automatic notification triggering is not part of the macro design. - // Services should manually call the notify_* methods when they want to send notifications. - println!("Notification infrastructure test completed successfully"); - - // Cleanup - admin_client - .disconnect() - .await - .expect("Failed to disconnect admin"); - user_client - .disconnect() - .await - .expect("Failed to disconnect user"); - } - - #[cfg(all(feature = "server", feature = "client"))] - #[tokio::test] - async fn test_concurrent_clients() { - let (ws_url, _server_handle) = create_test_server().await; - - // Create multiple clients concurrently - let mut client_handles = vec![]; - - for i in 0..5 { - let ws_url = ws_url.clone(); - let handle = tokio::spawn(async move { - let client = ClientBuilder::new(&ws_url) - .with_jwt_token("valid-user-token".to_string()) - .with_request_timeout(Duration::from_secs(5)) - .build() - .await - .expect("Failed to build client"); - - client.connect().await.expect("Failed to connect"); - - // Each client joins the chat - let response = client - .call("join_chat", Some(json!(format!("user{}", i)))) - .await - .expect("join_chat failed"); - - assert!(response.error.is_none()); - - // Send a message - let message = ChatMessage { - text: format!("Message from user{}", i), - username: format!("user{}", i), - }; - - let response = client - .call("send_message", Some(json!(message))) - .await - .expect("send_message failed"); - - assert!(response.error.is_none()); - - client.disconnect().await.expect("Failed to disconnect"); - - i - }); - client_handles.push(handle); - } - - // Wait for all clients to complete - let results = futures::future::join_all(client_handles).await; - - // Verify all clients completed successfully - for (i, result) in results.into_iter().enumerate() { - let client_id = result.expect("Client task failed"); - assert_eq!(client_id, i); - } - } - - #[cfg(all(feature = "server", feature = "client"))] - #[tokio::test] - async fn test_invalid_token_rejection() { - let (ws_url, _server_handle) = create_test_server().await; - - // Try to connect with invalid token - let client = ClientBuilder::new(&ws_url) - .with_jwt_token("invalid-token".to_string()) - .with_request_timeout(Duration::from_secs(5)) - .build() - .await - .expect("Failed to build client"); - - // Connection should fail due to invalid token - let result = client.connect().await; - assert!(result.is_err(), "Connection should fail with invalid token"); - } - - #[cfg(all(feature = "server", feature = "client"))] - #[tokio::test] - async fn test_connection_lifecycle() { - let (ws_url, _server_handle) = create_test_server().await; - - let client = ClientBuilder::new(&ws_url) - .with_jwt_token("valid-user-token".to_string()) - .with_request_timeout(Duration::from_secs(5)) - .build() - .await - .expect("Failed to build client"); - - // Initially disconnected - assert!(!client.is_connected().await); - assert!(client.connection_id().await.is_none()); - - // Connect - client.connect().await.expect("Failed to connect"); - - // Wait a bit for connection to be fully established - tokio::time::sleep(Duration::from_millis(100)).await; - - assert!(client.is_connected().await); - assert!(client.connection_id().await.is_some()); - - // Make a call to verify connection works - let response = client - .call("join_chat", Some(json!("test-user"))) - .await - .expect("Call failed"); - assert!(response.error.is_none()); - - // Disconnect - client.disconnect().await.expect("Failed to disconnect"); - assert!(!client.is_connected().await); - assert!(client.connection_id().await.is_none()); - - // Reconnect - client.connect().await.expect("Failed to reconnect"); - assert!(client.is_connected().await); - - // Verify it still works after reconnection - let response = client - .call("join_chat", Some(json!("test-user-2"))) - .await - .expect("Call after reconnect failed"); - assert!(response.error.is_none()); - - client.disconnect().await.expect("Failed to disconnect"); - } -} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/e2e.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/e2e.rs index 8d3d917..d42a988 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/e2e.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/e2e.rs @@ -1,23 +1,26 @@ -//! End-to-end test for `jsonrpc_bidirectional_service!`: -//! generated client → real WebSocket → server handler → response/notification. +//! Socketless end-to-end tests for `jsonrpc_bidirectional_service!`. //! -//! Existing `bidirectional_integration.rs` exercises this thoroughly. This file -//! is a slim companion test that uses the shared `MockAuthProvider` and proves -//! the helper integration works. +//! These tests avoid binding sockets by exercising the generated service handler +//! through the server message loop using an in-memory WebSocket adapter. +use std::collections::{HashSet, VecDeque}; +use std::future; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; use async_trait::async_trait; -use axum::{Router, routing::get}; use ras_auth_core::AuthenticatedUser; use ras_jsonrpc_bidirectional_macro::jsonrpc_bidirectional_service; use ras_jsonrpc_bidirectional_server::DefaultConnectionManager; -use ras_jsonrpc_bidirectional_server::service::{BuiltWebSocketService, websocket_handler}; -use ras_jsonrpc_bidirectional_types::ConnectionId; -use ras_test_helpers::{MockAuthProvider, spawn_tcp}; +use ras_jsonrpc_bidirectional_server::connection::{ChannelMessageSender, ConnectionContext}; +use ras_jsonrpc_bidirectional_server::handler::{ + WebSocketHandler, WebSocketIo, WebSocketIoMessage, +}; +use ras_jsonrpc_bidirectional_types::{ + BidirectionalMessage, ConnectionId, ConnectionInfo, ConnectionManager, +}; +use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EchoIn { @@ -100,103 +103,207 @@ impl DemoService for DemoImpl { } } -async fn start_server() -> String { +struct InMemorySocket { + incoming: VecDeque, + outgoing: Vec, + close_when_empty: bool, + close_after_outgoing: Option, +} + +impl InMemorySocket { + fn closing(incoming: impl IntoIterator) -> Self { + Self { + incoming: incoming.into_iter().collect(), + outgoing: Vec::new(), + close_when_empty: true, + close_after_outgoing: None, + } + } + + fn closing_after_outgoing( + incoming: impl IntoIterator, + outgoing_count: usize, + ) -> Self { + Self { + incoming: incoming.into_iter().collect(), + outgoing: Vec::new(), + close_when_empty: false, + close_after_outgoing: Some(outgoing_count), + } + } +} + +#[async_trait] +impl WebSocketIo for InMemorySocket { + async fn send( + &mut self, + message: WebSocketIoMessage, + ) -> ras_jsonrpc_bidirectional_server::ServerResult<()> { + self.outgoing.push(message); + if self + .close_after_outgoing + .is_some_and(|count| self.outgoing.len() >= count) + { + self.close_when_empty = true; + } + Ok(()) + } + + async fn recv( + &mut self, + ) -> Option> { + if let Some(message) = self.incoming.pop_front() { + Some(Ok(message)) + } else if self.close_when_empty { + None + } else { + future::pending().await + } + } +} + +async fn run_generated_handler( + request: JsonRpcRequest, + user: Option, + close_after_outgoing: Option, +) -> Vec { let connection_manager = Arc::new(DefaultConnectionManager::new()); let handler = Arc::new(DemoHandler::new( Arc::new(DemoImpl), connection_manager.clone(), )); - let ws_service = ras_jsonrpc_bidirectional_server::WebSocketServiceBuilder::builder() - .handler(handler) - .auth_provider(Arc::new(MockAuthProvider::default())) - .require_auth(false) - .build() - .build_with_manager(connection_manager); - - type SvcType = BuiltWebSocketService< - DemoHandler, - MockAuthProvider, - DefaultConnectionManager, - >; - let app: Router = Router::new() - .route("/ws", get(websocket_handler::)) - .with_state(ws_service); - - let (addr, _handle) = spawn_tcp(app).await; - // Give axum a tick to start serving. - tokio::time::sleep(Duration::from_millis(50)).await; - format!("ws://{addr}/ws") + let connection_id = ConnectionId::new(); + let (message_tx, message_rx) = mpsc::channel(8); + let sender = ChannelMessageSender::new(connection_id, message_tx); + + let mut info = ConnectionInfo::new(connection_id); + let context = Arc::new(ConnectionContext::new(connection_id, sender.clone())); + if let Some(user) = user { + info.set_user(user.clone()); + context.set_user(user).await; + } + + connection_manager + .add_connection_with_sender(info, Box::new(sender)) + .await + .expect("connection should register"); + + let request_text = serde_json::to_string(&BidirectionalMessage::Request(request)).unwrap(); + let incoming = [WebSocketIoMessage::Text(request_text)]; + let mut socket = if let Some(count) = close_after_outgoing { + InMemorySocket::closing_after_outgoing(incoming, count) + } else { + InMemorySocket::closing(incoming) + }; + + WebSocketHandler::new(handler, context, message_rx, 4096) + .run_with_io(&mut socket) + .await + .unwrap(); + + socket + .outgoing + .into_iter() + .filter_map(|message| match message { + WebSocketIoMessage::Text(text) => serde_json::from_str(&text).ok(), + _ => None, + }) + .collect() +} + +fn test_user(user_id: &str, permissions: &[&str]) -> AuthenticatedUser { + AuthenticatedUser { + user_id: user_id.to_string(), + permissions: permissions + .iter() + .map(|permission| (*permission).to_string()) + .collect::>(), + metadata: None, + } +} + +fn response_from(messages: &[BidirectionalMessage]) -> &JsonRpcResponse { + messages + .iter() + .find_map(|message| match message { + BidirectionalMessage::Response(response) => Some(response), + _ => None, + }) + .expect("response should be sent") } #[tokio::test] -async fn unauthorized_method_round_trips() { - let url = start_server().await; - let client = DemoClientBuilder::new(url) - .build() - .await - .expect("client build"); - client.connect().await.expect("connect"); +async fn generated_handler_round_trips_without_socket() { + let messages = run_generated_handler( + JsonRpcRequest::new( + "hello".into(), + Some(serde_json::json!("alice")), + Some(1.into()), + ), + None, + None, + ) + .await; - let resp = client.hello("alice".to_string()).await.expect("hello ok"); - assert_eq!(resp, "hello, alice"); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + + let response = response_from(&messages); + assert!(response.error.is_none()); + assert_eq!(response.result, Some(serde_json::json!("hello, alice"))); - client.disconnect().await.expect("disconnect"); + assert!(matches!( + messages.last().unwrap(), + BidirectionalMessage::ConnectionClosed { .. } + )); } #[tokio::test] -async fn auth_method_succeeds_and_pushes_notification() { - let url = start_server().await; - let mut client = DemoClientBuilder::new(url) - .with_jwt_token("user-token".to_string()) - .build() - .await - .expect("client build"); - client.connect().await.expect("connect"); - - let pushed = Arc::new(AtomicBool::new(false)); - let pushed_flag = pushed.clone(); - client.on_ping(move |_n: PushNote| { - pushed_flag.store(true, Ordering::SeqCst); - }); - - let resp = client - .echo(EchoIn { - msg: "hi".to_string(), - }) - .await - .expect("echo ok"); - assert_eq!(resp.msg, "hi"); - assert_eq!(resp.user, "user-1"); - - // Wait briefly for the push to land. - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while !pushed.load(Ordering::SeqCst) && std::time::Instant::now() < deadline { - tokio::time::sleep(Duration::from_millis(20)).await; - } - assert!( - pushed.load(Ordering::SeqCst), - "expected ping notification to arrive" - ); +async fn generated_handler_enforces_permissions_without_socket() { + let messages = run_generated_handler( + JsonRpcRequest::new( + "echo".into(), + Some(serde_json::json!(EchoIn { msg: "hi".into() })), + Some(2.into()), + ), + Some(test_user("readonly", &["read"])), + None, + ) + .await; - client.disconnect().await.expect("disconnect"); + let response = response_from(&messages); + let error = response.error.as_ref().expect("permission error expected"); + assert_eq!(error.code, -32002); } #[tokio::test] -async fn auth_method_rejected_for_readonly_user() { - let url = start_server().await; - let client = DemoClientBuilder::new(url) - .with_jwt_token("readonly-token".to_string()) - .build() - .await - .expect("client build"); - client.connect().await.expect("connect"); +async fn generated_handler_sends_response_and_notification_without_socket() { + let messages = run_generated_handler( + JsonRpcRequest::new( + "echo".into(), + Some(serde_json::json!(EchoIn { msg: "hi".into() })), + Some(3.into()), + ), + Some(test_user("user-1", &["user"])), + Some(3), + ) + .await; - let result = client.echo(EchoIn { msg: "nope".into() }).await; - assert!( - result.is_err(), - "readonly token must not be able to call echo" - ); + let response = response_from(&messages); + let result: EchoOut = serde_json::from_value(response.result.clone().unwrap()).unwrap(); + assert_eq!(result.msg, "hi"); + assert_eq!(result.user, "user-1"); - client.disconnect().await.expect("disconnect"); + let notification = messages + .iter() + .find_map(|message| match message { + BidirectionalMessage::ServerNotification(notification) => Some(notification), + _ => None, + }) + .expect("server notification should be sent"); + assert_eq!(notification.method, "ping"); + assert_eq!(notification.params["kind"], "after-echo"); } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/integration.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/integration.rs index ae8573d..f9d74cc 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/integration.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/integration.rs @@ -36,10 +36,13 @@ jsonrpc_bidirectional_service!({ #[cfg(test)] mod tests { + use super::*; + #[test] fn test_generated_code_compiles() { - // This test ensures that the generated code compiles without errors - // We can't easily test the runtime behavior without setting up WebSocket connections, - // but we can ensure the code generation produces valid Rust code. + let request = TestRequest { + data: "input".to_string(), + }; + assert_eq!(request.data, "input"); } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/macro_compilation.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/macro_compilation.rs index a104753..7088af1 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/macro_compilation.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/macro_compilation.rs @@ -71,6 +71,21 @@ jsonrpc_bidirectional_service!({ ] }); +// Test documented server-to-client calls syntax without auth prefixes +jsonrpc_bidirectional_service!({ + service_name: CallbackService, + + client_to_server: [ + ], + + server_to_client: [ + ], + + server_to_client_calls: [ + request_status(String) -> bool, + ] +}); + #[cfg(test)] mod tests { use super::*; @@ -78,7 +93,12 @@ mod tests { #[test] fn test_macro_generates_valid_code() { // The fact that this compiles proves the macro works - assert!(true, "Macro compilation successful"); + let request = ChatMessage { + text: "hello".to_string(), + username: "alice".to_string(), + }; + + assert_eq!(request.text, "hello"); } #[cfg(feature = "server")] @@ -89,8 +109,7 @@ mod tests { fn _check_chat_service(_: PhantomData) {} fn _check_echo_service(_: PhantomData) {} - - assert!(true, "Server traits exist"); + fn _check_callback_service(_: PhantomData) {} } #[cfg(feature = "server")] @@ -113,7 +132,12 @@ mod tests { { } - assert!(true, "Builders exist"); + fn _check_callback_builder(_: PhantomData>) + where + T: CallbackServiceService, + A: ras_auth_core::AuthProvider, + { + } } #[test] diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/websocket_integration.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/websocket_integration.rs index f0fc8d1..2c70444 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/websocket_integration.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/websocket_integration.rs @@ -1,4 +1,4 @@ -//! Basic WebSocket integration test for macro +//! Basic macro generation test //! //! This test focuses on ensuring the macro generates working server and client code //! that can be compiled and basic types are created correctly. @@ -41,7 +41,15 @@ mod tests { fn test_types_exist() { // This test ensures the macro generates the expected types // If it compiles, the basic macro generation is working - assert!(true, "Types generated successfully"); + let request = SimpleRequest { + message: "ping".to_string(), + }; + let response = SimpleResponse { + result: "pong".to_string(), + }; + + assert_eq!(request.message, "ping"); + assert_eq!(response.result, "pong"); } #[cfg(feature = "server")] @@ -52,8 +60,6 @@ mod tests { // This will only compile if the trait exists fn _check_trait_exists(_: PhantomData) {} - - assert!(true, "Server trait exists"); } #[cfg(feature = "server")] @@ -69,7 +75,5 @@ mod tests { A: ras_auth_core::AuthProvider, { } - - assert!(true, "Builder exists"); } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml index 7c6b3f7..92449db 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml @@ -2,7 +2,12 @@ name = "ras-jsonrpc-bidirectional-server" version = "0.1.0" edition = "2024" +rust-version = "1.88" description = "WebSocket server implementation for bidirectional JSON-RPC communication" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" keywords = ["jsonrpc", "websocket", "axum", "bidirectional", "server"] categories = ["web-programming", "network-programming"] @@ -19,9 +24,9 @@ bon = { workspace = true } chrono = { workspace = true } # Internal dependencies -ras-auth-core = { path = "../../../core/ras-auth-core" } -ras-jsonrpc-types = { path = "../../ras-jsonrpc-types" } -ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types" } +ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.1.0" } +ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types", version = "0.1.0" } # WebSocket specific dependencies futures = { workspace = true } @@ -30,5 +35,4 @@ futures = { workspace = true } dashmap = { workspace = true } [dev-dependencies] -tokio-test = { workspace = true } -ras-jsonrpc-types = { path = "../../ras-jsonrpc-types" } \ No newline at end of file +ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/README.md b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/README.md index 69337c3..a633658 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/README.md +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/README.md @@ -4,7 +4,7 @@ Server-side WebSocket handling for bidirectional JSON-RPC communication with Axu ## Features -- **WebSocket Server**: Full WebSocket server implementation with Axum integration +- **WebSocket Server**: Axum WebSocket runtime for bidirectional JSON-RPC services - **Authentication**: JWT-based authentication during WebSocket handshake - **Connection Management**: Thread-safe connection tracking with DashMap - **Message Routing**: Dispatch JSON-RPC requests to appropriate handlers @@ -17,7 +17,7 @@ Server-side WebSocket handling for bidirectional JSON-RPC communication with Axu ### DefaultConnectionManager -Thread-safe connection manager using DashMap for high-performance concurrent access: +Thread-safe connection manager using DashMap for concurrent access: ```rust use ras_jsonrpc_bidirectional_server::DefaultConnectionManager; @@ -90,11 +90,31 @@ let metadata = ws_upgrade.create_metadata(); ## Usage Example ```rust +use axum::{routing::get, Router}; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; use ras_jsonrpc_bidirectional_server::{ - create_router_service, websocket_handler, MessageRouter + create_router_service, websocket_handler, MessageRouter, ServerError }; -use axum::{routing::get, Router}; -use std::sync::Arc; +use serde_json::json; +use std::{collections::HashSet, sync::Arc}; + +struct DemoAuthProvider; + +impl AuthProvider for DemoAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + if token != "demo-token" { + return Err(AuthError::InvalidToken); + } + + Ok(AuthenticatedUser { + user_id: "demo-user".to_string(), + permissions: HashSet::from(["user".to_string()]), + metadata: None, + }) + }) + } +} #[tokio::main] async fn main() { @@ -103,13 +123,13 @@ async fn main() { // Register handlers router.register_value("echo", |req, _ctx| async move { - Ok::(req.params.unwrap_or(json!(null))) + Ok::(req.params.unwrap_or(json!(null))) }); // Create WebSocket service let ws_service = create_router_service( router, - Arc::new(auth_provider), + Arc::new(DemoAuthProvider), true // require authentication ); @@ -161,7 +181,7 @@ manager.broadcast_to_topic("updates", message).await?; ## Integration with Axum -The server is designed to integrate seamlessly with Axum: +The server is designed to integrate with Axum: - **WebSocket Extractor**: Compatible with Axum's WebSocket support - **State Management**: Uses Axum's state system @@ -191,12 +211,20 @@ All components are thread-safe: Run tests with: ```bash -cargo test -p ras-jsonrpc-bidirectional-server +cargo test -p ras-jsonrpc-bidirectional-server --locked ``` -The crate includes comprehensive tests for: +The crate includes tests for: - Message routing - Service configuration - Header parsing -- Connection management \ No newline at end of file +- Connection management +- In-memory WebSocket handler round trips without binding sockets + +## Checks + +```bash +cargo test -p ras-jsonrpc-bidirectional-server --locked +cargo clippy -p ras-jsonrpc-bidirectional-server --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs index b9f07eb..13dd87a 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs @@ -2,10 +2,10 @@ use crate::{ConnectionContext, ServerError, ServerResult}; use async_trait::async_trait; -use axum::extract::ws::{Message, WebSocket}; +use axum::extract::ws::{CloseFrame, Message, WebSocket}; use futures::stream::StreamExt; use ras_jsonrpc_bidirectional_types::BidirectionalMessage; -use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse}; +use ras_jsonrpc_types::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, error_codes}; use std::sync::Arc; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; @@ -35,7 +35,7 @@ pub trait MessageHandler: Send + Sync + 'static { topics: Vec, context: Arc, ) -> ServerResult<()> { - // Default implementation - just subscribe to topics + // Default implementation subscribes the connection to each requested topic. for topic in topics { context.subscribe(topic).await; } @@ -48,7 +48,7 @@ pub trait MessageHandler: Send + Sync + 'static { topics: Vec, context: Arc, ) -> ServerResult<()> { - // Default implementation - just unsubscribe from topics + // Default implementation unsubscribes the connection from each requested topic. for topic in topics { context.unsubscribe(&topic).await; } @@ -73,19 +73,87 @@ pub trait MessageHandler: Send + Sync + 'static { /// Handle ping message async fn on_ping(&self, _context: Arc) -> ServerResult<()> { - // Default implementation - just log + // Default implementation records the ping at debug level. debug!("Received ping"); Ok(()) } /// Handle pong message async fn on_pong(&self, _context: Arc) -> ServerResult<()> { - // Default implementation - just log + // Default implementation records the pong at debug level. debug!("Received pong"); Ok(()) } } +/// WebSocket message shape used by the server handler loop. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WebSocketIoMessage { + Text(String), + Binary(Vec), + Ping(Vec), + Pong(Vec), + Close(Option), +} + +impl From for WebSocketIoMessage { + fn from(message: Message) -> Self { + match message { + Message::Text(text) => Self::Text(text.to_string()), + Message::Binary(data) => Self::Binary(data.to_vec()), + Message::Ping(data) => Self::Ping(data.to_vec()), + Message::Pong(data) => Self::Pong(data.to_vec()), + Message::Close(frame) => Self::Close(frame.map(|frame| frame.reason.to_string())), + } + } +} + +/// Minimal socket interface used by the message loop. +#[async_trait] +pub trait WebSocketIo: Send { + async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()>; + async fn recv(&mut self) -> Option>; +} + +pub(crate) struct AxumWebSocketIo { + socket: WebSocket, +} + +impl AxumWebSocketIo { + pub(crate) fn new(socket: WebSocket) -> Self { + Self { socket } + } +} + +#[async_trait] +impl WebSocketIo for AxumWebSocketIo { + async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()> { + let message = match message { + WebSocketIoMessage::Text(text) => Message::Text(text.into()), + WebSocketIoMessage::Binary(data) => Message::Binary(data.into()), + WebSocketIoMessage::Ping(data) => Message::Ping(data.into()), + WebSocketIoMessage::Pong(data) => Message::Pong(data.into()), + WebSocketIoMessage::Close(reason) => Message::Close(reason.map(|reason| CloseFrame { + code: axum::extract::ws::close_code::NORMAL, + reason: reason.into(), + })), + }; + + self.socket + .send(message) + .await + .map_err(|e| ServerError::WebSocketError(e.to_string())) + } + + async fn recv(&mut self) -> Option> { + self.socket.next().await.map(|message| { + message + .map(WebSocketIoMessage::from) + .map_err(|e| ServerError::WebSocketError(e.to_string())) + }) + } +} + /// WebSocket connection handler that manages the message flow pub struct WebSocketHandler { /// The message handler for processing requests @@ -114,7 +182,16 @@ impl WebSocketHandler { } /// Run the WebSocket handler loop - pub async fn run(mut self, mut socket: WebSocket) -> ServerResult<()> { + pub async fn run(self, socket: WebSocket) -> ServerResult<()> { + let mut socket = AxumWebSocketIo::new(socket); + self.run_with_io(&mut socket).await + } + + /// Run the handler loop over an already-upgraded socket implementation. + pub async fn run_with_io( + mut self, + socket: &mut S, + ) -> ServerResult<()> { info!( "Starting WebSocket handler for connection: {}", self.context.id @@ -130,9 +207,9 @@ impl WebSocketHandler { connection_id: self.context.id, }; if let Err(e) = socket - .send(Message::Text( - serde_json::to_string(&established_msg)?.into(), - )) + .send(WebSocketIoMessage::Text(serde_json::to_string( + &established_msg, + )?)) .await { error!("Failed to send connection established message: {}", e); @@ -142,10 +219,10 @@ impl WebSocketHandler { loop { tokio::select! { // Handle incoming WebSocket messages - msg = socket.next() => { + msg = socket.recv() => { match msg { Some(Ok(msg)) => { - if let Err(e) = self.handle_websocket_message(msg, &mut socket).await { + if let Err(e) = self.handle_websocket_message(msg, socket).await { error!("Error handling WebSocket message: {}", e); break; } @@ -165,7 +242,7 @@ impl WebSocketHandler { msg = self.message_rx.recv() => { match msg { Some(msg) => { - if let Err(e) = self.send_message(&mut socket, msg).await { + if let Err(e) = self.send_message(socket, msg).await { error!("Error sending message: {}", e); break; } @@ -190,7 +267,9 @@ impl WebSocketHandler { reason: None, }; let _ = socket - .send(Message::Text(serde_json::to_string(&closed_msg)?.into())) + .send(WebSocketIoMessage::Text(serde_json::to_string( + &closed_msg, + )?)) .await; info!( @@ -201,13 +280,13 @@ impl WebSocketHandler { } /// Handle incoming WebSocket messages - async fn handle_websocket_message( + async fn handle_websocket_message( &mut self, - msg: Message, - socket: &mut WebSocket, + msg: WebSocketIoMessage, + socket: &mut S, ) -> ServerResult<()> { match msg { - Message::Text(text) => { + WebSocketIoMessage::Text(text) => { if text.len() > self.max_message_size { warn!("Received oversized text message: {} bytes", text.len()); return Err(ServerError::InvalidRequest( @@ -215,9 +294,9 @@ impl WebSocketHandler { )); } debug!("Received text message ({} bytes)", text.len()); - self.handle_text_message(text.to_string(), socket).await + self.handle_text_message(text, socket).await } - Message::Binary(data) => { + WebSocketIoMessage::Binary(data) => { if data.len() > self.max_message_size { warn!("Received oversized binary message: {} bytes", data.len()); return Err(ServerError::InvalidRequest( @@ -226,7 +305,7 @@ impl WebSocketHandler { } debug!("Received binary message ({} bytes)", data.len()); // Try to parse as UTF-8 text - match String::from_utf8(data.to_vec()) { + match String::from_utf8(data) { Ok(text) => self.handle_text_message(text, socket).await, Err(_) => { warn!("Received non-UTF-8 binary message, ignoring"); @@ -234,23 +313,19 @@ impl WebSocketHandler { } } } - Message::Ping(data) => { + WebSocketIoMessage::Ping(data) => { debug!("Received ping"); - socket - .send(Message::Pong(data)) - .await - .map_err(|e| ServerError::WebSocketError(e.to_string()))?; + socket.send(WebSocketIoMessage::Pong(data)).await?; self.handler.on_ping(self.context.clone()).await } - Message::Pong(_) => { + WebSocketIoMessage::Pong(_) => { debug!("Received pong"); self.handler.on_pong(self.context.clone()).await } - Message::Close(close_frame) => { - debug!("Received close frame: {:?}", close_frame); - let reason = close_frame.map(|f| f.reason.to_string()); + WebSocketIoMessage::Close(reason) => { + debug!("Received close frame: {:?}", reason); self.handler - .on_disconnect(self.context.clone(), reason) + .on_disconnect(self.context.clone(), reason.clone()) .await?; Err(ServerError::WebSocketError("Connection closed".to_string())) } @@ -258,10 +333,10 @@ impl WebSocketHandler { } /// Handle text messages (JSON-RPC or bidirectional messages) - async fn handle_text_message( + async fn handle_text_message( &mut self, text: String, - socket: &mut WebSocket, + socket: &mut S, ) -> ServerResult<()> { // Try to parse as BidirectionalMessage first if let Ok(msg) = serde_json::from_str::(&text) { @@ -280,10 +355,10 @@ impl WebSocketHandler { } /// Handle bidirectional messages - async fn handle_bidirectional_message( + async fn handle_bidirectional_message( &mut self, msg: BidirectionalMessage, - _socket: &mut WebSocket, + _socket: &mut S, ) -> ServerResult<()> { match msg { BidirectionalMessage::Request(request) => { @@ -311,12 +386,13 @@ impl WebSocketHandler { } /// Handle JSON-RPC requests - async fn handle_jsonrpc_request( + async fn handle_jsonrpc_request( &mut self, request: JsonRpcRequest, - socket: &mut WebSocket, + socket: &mut S, ) -> ServerResult<()> { debug!("Handling JSON-RPC request: {}", request.method); + let request_id = request.id.clone(); match self .handler @@ -334,31 +410,50 @@ impl WebSocketHandler { } Err(e) => { error!("Error handling request: {}", e); - // Could send error response here if needed - Err(e) + let response = + JsonRpcResponse::error(jsonrpc_error_from_server_error(&e), request_id); + self.send_message(socket, BidirectionalMessage::Response(response)) + .await } } } /// Send a message to the WebSocket client - async fn send_message( + async fn send_message( &self, - socket: &mut WebSocket, + socket: &mut S, msg: BidirectionalMessage, ) -> ServerResult<()> { let json = serde_json::to_string(&msg)?; - socket - .send(Message::Text(json.into())) - .await - .map_err(|e| ServerError::WebSocketError(e.to_string())) + socket.send(WebSocketIoMessage::Text(json)).await } } +fn jsonrpc_error_from_server_error(error: &ServerError) -> JsonRpcError { + let code = match error { + ServerError::AuthenticationFailed(_) => error_codes::AUTHENTICATION_REQUIRED, + ServerError::PermissionDenied(_) => error_codes::INSUFFICIENT_PERMISSIONS, + ServerError::InvalidRequest(_) => error_codes::INVALID_REQUEST, + ServerError::HandlerNotFound(_) => error_codes::METHOD_NOT_FOUND, + ServerError::SerializationError(_) => error_codes::INVALID_PARAMS, + ServerError::UpgradeFailed(_) + | ServerError::ConnectionNotFound(_) + | ServerError::RoutingFailed(_) + | ServerError::WebSocketError(_) + | ServerError::ConnectionError(_) + | ServerError::Internal(_) => error_codes::INTERNAL_ERROR, + }; + + JsonRpcError::new(code, error.to_string(), None) +} + #[cfg(test)] mod tests { use super::*; use crate::connection::ChannelMessageSender; use ras_jsonrpc_bidirectional_types::ConnectionId; + use std::collections::VecDeque; + use std::sync::Mutex; /// A minimal MessageHandler that only implements the required method — /// every other method falls through to the default impl, which is what @@ -376,6 +471,133 @@ mod tests { } } + struct RespondingHandler; + + #[async_trait] + impl MessageHandler for RespondingHandler { + async fn handle_request( + &self, + request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(Some(JsonRpcResponse::success( + serde_json::json!({ + "method": request.method, + "params": request.params, + }), + request.id, + ))) + } + } + + struct RecoveringHandler; + + #[async_trait] + impl MessageHandler for RecoveringHandler { + async fn handle_request( + &self, + request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + if request.method == "fail" { + return Err(ServerError::InvalidRequest("bad request".into())); + } + + Ok(Some(JsonRpcResponse::success( + serde_json::json!({ + "method": request.method, + }), + request.id, + ))) + } + } + + struct RecordingLifecycle { + disconnect_reasons: Mutex>>, + } + + impl RecordingLifecycle { + fn new() -> Self { + Self { + disconnect_reasons: Mutex::new(Vec::new()), + } + } + + fn disconnect_reasons(&self) -> Vec> { + self.disconnect_reasons + .lock() + .expect("disconnect reasons lock") + .clone() + } + } + + #[async_trait] + impl MessageHandler for RecordingLifecycle { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } + + async fn on_disconnect( + &self, + _context: Arc, + reason: Option, + ) -> ServerResult<()> { + self.disconnect_reasons + .lock() + .expect("disconnect reasons lock") + .push(reason); + Ok(()) + } + } + + struct InMemorySocket { + incoming: VecDeque, + outgoing: Vec, + close_when_empty: bool, + } + + impl InMemorySocket { + fn closing(incoming: impl IntoIterator) -> Self { + Self { + incoming: incoming.into_iter().collect(), + outgoing: Vec::new(), + close_when_empty: true, + } + } + + fn pending() -> Self { + Self { + incoming: VecDeque::new(), + outgoing: Vec::new(), + close_when_empty: false, + } + } + } + + #[async_trait] + impl WebSocketIo for InMemorySocket { + async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()> { + self.outgoing.push(message); + Ok(()) + } + + async fn recv(&mut self) -> Option> { + if let Some(message) = self.incoming.pop_front() { + return Some(Ok(message)); + } + + if self.close_when_empty { + None + } else { + std::future::pending::>>().await + } + } + } + fn ctx() -> Arc { let id = ConnectionId::new(); let (tx, _rx) = mpsc::channel(4); @@ -420,4 +642,262 @@ mod tests { // None reason path too. h.on_disconnect(c, None).await.unwrap(); } + + #[tokio::test] + async fn handler_loop_processes_jsonrpc_request_without_socket() { + let request = JsonRpcRequest::new( + "echo".into(), + Some(serde_json::json!({"value": 42})), + Some(serde_json::json!(7)), + ); + let incoming = serde_json::to_string(&BidirectionalMessage::Request(request)).unwrap(); + let mut socket = InMemorySocket::closing([WebSocketIoMessage::Text(incoming)]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(RespondingHandler), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + + let response = match &messages[1] { + BidirectionalMessage::Response(response) => response, + other => panic!("expected response, got {other:?}"), + }; + assert_eq!(response.id, Some(serde_json::json!(7))); + assert_eq!(response.result.as_ref().unwrap()["method"], "echo"); + assert_eq!(response.result.as_ref().unwrap()["params"]["value"], 42); + + assert!(matches!( + messages[2], + BidirectionalMessage::ConnectionClosed { .. } + )); + } + + #[tokio::test] + async fn handler_loop_sends_jsonrpc_error_and_continues_without_socket() { + let fail = JsonRpcRequest::new( + "fail".into(), + Some(serde_json::json!({})), + Some(serde_json::json!(1)), + ); + let ok = JsonRpcRequest::new( + "ok".into(), + Some(serde_json::json!({})), + Some(serde_json::json!(2)), + ); + let mut socket = InMemorySocket::closing([ + WebSocketIoMessage::Text( + serde_json::to_string(&BidirectionalMessage::Request(fail)).unwrap(), + ), + WebSocketIoMessage::Text( + serde_json::to_string(&BidirectionalMessage::Request(ok)).unwrap(), + ), + ]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(RecoveringHandler), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + + let error_response = match &messages[1] { + BidirectionalMessage::Response(response) => response, + other => panic!("expected error response, got {other:?}"), + }; + assert_eq!(error_response.id, Some(serde_json::json!(1))); + let error = error_response.error.as_ref().expect("JSON-RPC error"); + assert_eq!(error.code, ras_jsonrpc_types::error_codes::INVALID_REQUEST); + assert_eq!(error.message, "Invalid request: bad request"); + + let success_response = match &messages[2] { + BidirectionalMessage::Response(response) => response, + other => panic!("expected success response, got {other:?}"), + }; + assert_eq!(success_response.id, Some(serde_json::json!(2))); + assert_eq!(success_response.result.as_ref().unwrap()["method"], "ok"); + + assert!(matches!( + messages[3], + BidirectionalMessage::ConnectionClosed { .. } + )); + } + + #[tokio::test] + async fn handler_loop_processes_control_messages_without_socket() { + let context = ctx(); + let subscribe = serde_json::to_string(&BidirectionalMessage::Subscribe { + topics: vec!["room:1".into()], + }) + .unwrap(); + let unsubscribe = serde_json::to_string(&BidirectionalMessage::Unsubscribe { + topics: vec!["room:1".into()], + }) + .unwrap(); + let mut socket = InMemorySocket::closing([ + WebSocketIoMessage::Text(subscribe), + WebSocketIoMessage::Text(unsubscribe), + WebSocketIoMessage::Ping(vec![1, 2, 3]), + ]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(PassThrough), context.clone(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(!context.is_subscribed_to("room:1").await); + assert!( + socket + .outgoing + .contains(&WebSocketIoMessage::Pong(vec![1, 2, 3])) + ); + } + + #[tokio::test] + async fn handler_loop_sends_manager_messages_without_socket() { + let notification = BidirectionalMessage::ServerNotification( + ras_jsonrpc_bidirectional_types::ServerNotification { + method: "server.note".into(), + params: serde_json::json!({"ok": true}), + metadata: None, + }, + ); + let (tx, rx) = mpsc::channel(4); + tx.send(notification).await.unwrap(); + drop(tx); + + let mut socket = InMemorySocket::pending(); + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + + match &messages[1] { + BidirectionalMessage::ServerNotification(notification) => { + assert_eq!(notification.method, "server.note"); + assert_eq!(notification.params["ok"], true); + } + other => panic!("expected server notification, got {other:?}"), + } + + assert!(matches!( + messages[2], + BidirectionalMessage::ConnectionClosed { .. } + )); + } + + #[tokio::test] + async fn handler_loop_closes_malformed_text_without_response() { + let mut socket = + InMemorySocket::closing([WebSocketIoMessage::Text("not json-rpc".to_string())]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert_eq!(messages.len(), 2); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + assert!(matches!( + messages[1], + BidirectionalMessage::ConnectionClosed { .. } + )); + } + + #[tokio::test] + async fn handler_loop_closes_oversized_text_without_response() { + let mut socket = InMemorySocket::closing([WebSocketIoMessage::Text("too large".into())]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 4) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert_eq!(messages.len(), 2); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + assert!(matches!( + messages[1], + BidirectionalMessage::ConnectionClosed { .. } + )); + } + + #[tokio::test] + async fn handler_loop_ignores_non_utf8_binary_without_response() { + let mut socket = InMemorySocket::closing([WebSocketIoMessage::Binary(vec![0xff, 0xfe])]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert_eq!(messages.len(), 2); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + assert!(matches!( + messages[1], + BidirectionalMessage::ConnectionClosed { .. } + )); + } + + #[tokio::test] + async fn handler_loop_records_close_reason_without_socket() { + let handler = Arc::new(RecordingLifecycle::new()); + let mut socket = + InMemorySocket::closing([WebSocketIoMessage::Close(Some("client bye".to_string()))]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(handler.clone(), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!( + handler + .disconnect_reasons() + .contains(&Some("client bye".to_string())) + ); + } + + fn bidirectional_outgoing(socket: &InMemorySocket) -> Vec { + socket + .outgoing + .iter() + .filter_map(|message| match message { + WebSocketIoMessage::Text(text) => serde_json::from_str(text).ok(), + _ => None, + }) + .collect() + } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/manager.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/manager.rs index ea9ddf0..2e74c55 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/manager.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/manager.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; -/// Thread-safe connection manager using DashMap for high-performance concurrent access +/// Thread-safe connection manager using DashMap for concurrent access #[derive(Debug, Default)] pub struct DefaultConnectionManager { /// Active connections indexed by ConnectionId @@ -84,7 +84,8 @@ impl DefaultConnectionManager { #[async_trait] impl ConnectionManager for DefaultConnectionManager { async fn add_connection(&self, info: ConnectionInfo) -> Result<()> { - // Create a dummy sender - real senders should be added via add_connection_with_sender + // Connections without an erased sender receive a closed internal channel. + // Runtime transports should call add_connection_with_sender. let (tx, _rx) = mpsc::channel(1); let sender = ChannelMessageSender::new(info.id, tx); self.connections.insert(info.id, (info.clone(), sender)); @@ -104,7 +105,7 @@ impl ConnectionManager for DefaultConnectionManager { info!("Added connection with sender: {}", info.id); Ok(()) } else { - // Fallback to dummy sender if downcast fails + // Store the connection even when the erased sender has an unexpected type. self.add_connection(info).await } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/service.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/service.rs index 2ac6df4..a614b03 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/service.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/service.rs @@ -2,7 +2,9 @@ use crate::{ ConnectionContext, DefaultConnectionManager, MessageHandler, MessageRouter, ServerError, - ServerResult, WebSocketHandler, WebSocketUpgrade, connection::ChannelMessageSender, + ServerResult, WebSocketHandler, WebSocketUpgrade, + connection::ChannelMessageSender, + handler::{AxumWebSocketIo, WebSocketIo}, }; use axum::{ extract::{State, ws::WebSocketUpgrade as AxumWebSocketUpgrade}, @@ -83,56 +85,78 @@ pub trait WebSocketService: Clone + Send + Sync + 'static { ) -> impl std::future::Future> + Send { let service = self.clone(); async move { - let connection_id = ConnectionId::new(); - info!("New WebSocket connection: {}", connection_id); - - // Create message channel for this connection - let channel_capacity = service.message_channel_capacity().max(1); - let (message_tx, message_rx) = mpsc::channel(channel_capacity); - let sender = ChannelMessageSender::new(connection_id, message_tx); - - // Create connection info and add to manager - let mut info = ConnectionInfo::new(connection_id); - if let Some(user) = user.clone() { - info.set_user(user); - } + let mut socket = AxumWebSocketIo::new(socket); + run_connection_with_io(service, &mut socket, user).await + } + } - // Create connection context - let context = Arc::new(ConnectionContext::new(connection_id, sender.clone())); - if let Some(user) = user { - context.set_user(user).await; - } + /// Handle an individual WebSocket connection over an injected socket implementation. + /// + /// This runs the same service lifecycle as the Axum upgrade path while letting tests and + /// alternate transports exercise the connection without binding a real socket. + fn handle_connection_with_io<'a, S>( + &'a self, + socket: &'a mut S, + user: Option, + ) -> impl std::future::Future> + Send + 'a + where + S: WebSocketIo + ?Sized + 'a, + { + let service = self.clone(); + async move { run_connection_with_io(service, socket, user).await } + } +} - // Add connection to manager with the real sender - service - .connection_manager() - .add_connection_with_sender(info, Box::new(sender.clone())) - .await - .map_err(ServerError::ConnectionError)?; - - // Create and run WebSocket handler - let handler = WebSocketHandler::new( - service.handler(), - context.clone(), - message_rx, - service.max_message_size(), - ); - - // Handle the connection (this will block until connection closes) - let result = handler.run(socket).await; - - // Remove connection from manager - if let Err(e) = service - .connection_manager() - .remove_connection(connection_id) - .await - { - error!("Failed to remove connection {}: {}", connection_id, e); - } +async fn run_connection_with_io( + service: Svc, + socket: &mut S, + user: Option, +) -> ServerResult<()> +where + Svc: WebSocketService, + S: WebSocketIo + ?Sized, +{ + let connection_id = ConnectionId::new(); + info!("New WebSocket connection: {}", connection_id); - result - } + let channel_capacity = service.message_channel_capacity().max(1); + let (message_tx, message_rx) = mpsc::channel(channel_capacity); + let sender = ChannelMessageSender::new(connection_id, message_tx); + + let mut info = ConnectionInfo::new(connection_id); + if let Some(user) = user.clone() { + info.set_user(user); + } + + let context = Arc::new(ConnectionContext::new(connection_id, sender.clone())); + if let Some(user) = user { + context.set_user(user).await; } + + service + .connection_manager() + .add_connection_with_sender(info, Box::new(sender.clone())) + .await + .map_err(ServerError::ConnectionError)?; + + let handler = WebSocketHandler::new( + service.handler(), + context.clone(), + message_rx, + service.max_message_size(), + ); + + let result = handler.run_with_io(socket).await; + + if let Err(e) = service + .connection_manager() + .remove_connection(connection_id) + .await + { + error!("Failed to remove connection {}: {}", connection_id, e); + } + + result } /// Builder for creating WebSocket services @@ -284,8 +308,12 @@ where #[cfg(test)] mod tests { use super::*; + use crate::handler::{WebSocketIo, WebSocketIoMessage}; + use async_trait::async_trait; use ras_auth_core::{AuthError, AuthenticatedUser}; - use std::collections::HashSet; + use ras_jsonrpc_bidirectional_types::BidirectionalMessage; + use serde_json::json; + use std::collections::{HashSet, VecDeque}; // Mock auth provider for testing #[derive(Clone)] @@ -307,6 +335,47 @@ mod tests { } } + fn test_user() -> AuthenticatedUser { + AuthenticatedUser { + user_id: "test_user".to_string(), + permissions: HashSet::new(), + metadata: None, + } + } + + struct InMemorySocket { + incoming: VecDeque, + outgoing: Vec, + } + + impl InMemorySocket { + fn closing(incoming: impl IntoIterator) -> Self { + Self { + incoming: incoming.into_iter().collect(), + outgoing: Vec::new(), + } + } + + fn outgoing_messages(&self) -> impl Iterator + '_ { + self.outgoing.iter().filter_map(|message| match message { + WebSocketIoMessage::Text(text) => serde_json::from_str(text).ok(), + _ => None, + }) + } + } + + #[async_trait] + impl WebSocketIo for InMemorySocket { + async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()> { + self.outgoing.push(message); + Ok(()) + } + + async fn recv(&mut self) -> Option> { + self.incoming.pop_front().map(Ok) + } + } + #[tokio::test] async fn test_service_builder() { let router = MessageRouter::new(); @@ -332,4 +401,58 @@ mod tests { assert!(service.require_auth()); } + + #[tokio::test] + async fn handle_connection_with_io_round_trips_and_cleans_up_without_socket() { + let mut router = MessageRouter::new(); + router.register_value("whoami", |_req, context| async move { + let user = context.get_user().await.expect("authenticated user"); + Ok::<_, ServerError>(json!({ "user_id": user.user_id })) + }); + + let manager = Arc::new(DefaultConnectionManager::new()); + let builder = WebSocketServiceBuilder::builder() + .handler(Arc::new(router)) + .auth_provider(Arc::new(MockAuthProvider)) + .message_channel_capacity(2) + .max_message_size(16 * 1024) + .build(); + let service = builder.build_with_manager(manager.clone()); + + let request = + ras_jsonrpc_types::JsonRpcRequest::new("whoami".to_string(), None, Some(json!(1))); + let mut socket = InMemorySocket::closing([WebSocketIoMessage::Text( + serde_json::to_string(&request).unwrap(), + )]); + + service + .handle_connection_with_io(&mut socket, Some(test_user())) + .await + .unwrap(); + + assert_eq!(manager.connection_count(), 0); + + let messages = socket.outgoing_messages().collect::>(); + assert!(matches!( + messages.first(), + Some(BidirectionalMessage::ConnectionEstablished { .. }) + )); + assert!(matches!( + messages.last(), + Some(BidirectionalMessage::ConnectionClosed { .. }) + )); + + let response = messages + .iter() + .find_map(|message| match message { + BidirectionalMessage::Response(response) => Some(response), + _ => None, + }) + .expect("JSON-RPC response"); + assert_eq!(response.id, Some(json!(1))); + assert_eq!( + response.result.as_ref().expect("result"), + &json!({ "user_id": "test_user" }) + ); + } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/upgrade.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/upgrade.rs index 98b34a4..673a9c9 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/upgrade.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/upgrade.rs @@ -25,35 +25,7 @@ impl WebSocketUpgrade { /// Extract authentication token from headers pub fn extract_auth_token(&self) -> Option { - // Try Authorization header first (Bearer token) - if let Some(auth_header) = self.headers.get("authorization") - && let Ok(auth_str) = auth_header.to_str() - { - if let Some(token) = auth_str.strip_prefix("Bearer ") { - return Some(token.to_string()); - } - // Also support just the token without "Bearer " prefix - return Some(auth_str.to_string()); - } - - // Try custom WebSocket auth headers - if let Some(token_header) = self.headers.get("sec-websocket-protocol") - && let Ok(token_str) = token_header.to_str() - { - // Support protocols like "token.{jwt_token}" - if let Some(token) = token_str.strip_prefix("token.") { - return Some(token.to_string()); - } - } - - // Try X-Auth-Token header - if let Some(token_header) = self.headers.get("x-auth-token") - && let Ok(token_str) = token_header.to_str() - { - return Some(token_str.to_string()); - } - - None + extract_auth_token_from_headers(&self.headers) } /// Authenticate the connection using the provided auth provider @@ -61,25 +33,7 @@ impl WebSocketUpgrade { &self, auth_provider: &A, ) -> ServerResult> { - if let Some(token) = self.extract_auth_token() { - debug!("Attempting to authenticate WebSocket connection"); - match auth_provider.authenticate(token).await { - Ok(user) => { - info!( - "WebSocket connection authenticated for user: {}", - user.user_id - ); - Ok(Some(user)) - } - Err(e) => { - warn!("WebSocket authentication failed: {}", e); - Err(ServerError::AuthenticationFailed(e)) - } - } - } else { - debug!("No authentication token found in WebSocket headers"); - Ok(None) - } + authenticate_headers(&self.headers, auth_provider).await } /// Complete the WebSocket upgrade @@ -145,36 +99,12 @@ impl WebSocketUpgrade { /// Get a header value as string pub fn get_header(&self, name: &str) -> Option { - self.headers - .get(name) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) + get_header_value(&self.headers, name) } /// Extract client IP from headers (useful for logging/security) pub fn extract_client_ip(&self) -> Option { - // Try various headers in order of preference - let ip_headers = [ - "x-forwarded-for", - "x-real-ip", - "cf-connecting-ip", // Cloudflare - "x-client-ip", - "x-forwarded", - "forwarded-for", - "forwarded", - ]; - - for header_name in &ip_headers { - if let Some(value) = self.get_header(header_name) { - // For X-Forwarded-For, take the first IP - let ip = value.split(',').next().unwrap_or(value.as_str()).trim(); - if !ip.is_empty() { - return Some(ip.to_string()); - } - } - } - - None + extract_client_ip_from_headers(&self.headers) } /// Extract user agent @@ -184,76 +114,307 @@ impl WebSocketUpgrade { /// Create connection metadata from headers pub fn create_metadata(&self) -> serde_json::Value { - let mut metadata = serde_json::Map::new(); + create_metadata_from_headers(&self.headers) + } +} - // Add client IP if available - if let Some(ip) = self.extract_client_ip() { - metadata.insert("client_ip".to_string(), serde_json::Value::String(ip)); +fn extract_auth_token_from_headers(headers: &HeaderMap) -> Option { + if let Some(auth_header) = headers.get("authorization") + && let Ok(auth_str) = auth_header.to_str() + { + if let Some(token) = auth_str.strip_prefix("Bearer ") { + return Some(token.to_string()); } + return Some(auth_str.to_string()); + } + + if let Some(token_header) = headers.get("sec-websocket-protocol") + && let Ok(token_str) = token_header.to_str() + && let Some(token) = token_str.strip_prefix("token.") + { + return Some(token.to_string()); + } + + if let Some(token_header) = headers.get("x-auth-token") + && let Ok(token_str) = token_header.to_str() + { + return Some(token_str.to_string()); + } + + None +} - // Add user agent if available - if let Some(user_agent) = self.extract_user_agent() { - metadata.insert( - "user_agent".to_string(), - serde_json::Value::String(user_agent), - ); +async fn authenticate_headers( + headers: &HeaderMap, + auth_provider: &A, +) -> ServerResult> { + if let Some(token) = extract_auth_token_from_headers(headers) { + debug!("Attempting to authenticate WebSocket connection"); + match auth_provider.authenticate(token).await { + Ok(user) => { + info!( + "WebSocket connection authenticated for user: {}", + user.user_id + ); + Ok(Some(user)) + } + Err(e) => { + warn!("WebSocket authentication failed: {}", e); + Err(ServerError::AuthenticationFailed(e)) + } } + } else { + debug!("No authentication token found in WebSocket headers"); + Ok(None) + } +} + +fn get_header_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) +} + +fn extract_client_ip_from_headers(headers: &HeaderMap) -> Option { + let ip_headers = [ + "x-forwarded-for", + "x-real-ip", + "cf-connecting-ip", + "x-client-ip", + "x-forwarded", + "forwarded-for", + "forwarded", + ]; + + for header_name in &ip_headers { + if let Some(value) = get_header_value(headers, header_name) { + let ip = value.split(',').next().unwrap_or(value.as_str()).trim(); + if !ip.is_empty() { + return Some(ip.to_string()); + } + } + } + + None +} + +fn create_metadata_from_headers(headers: &HeaderMap) -> serde_json::Value { + let mut metadata = serde_json::Map::new(); - // Add connection timestamp + if let Some(ip) = extract_client_ip_from_headers(headers) { + metadata.insert("client_ip".to_string(), serde_json::Value::String(ip)); + } + + if let Some(user_agent) = get_header_value(headers, "user-agent") { metadata.insert( - "connected_at".to_string(), - serde_json::Value::String(chrono::Utc::now().to_rfc3339()), + "user_agent".to_string(), + serde_json::Value::String(user_agent), ); - - serde_json::Value::Object(metadata) } + + metadata.insert( + "connected_at".to_string(), + serde_json::Value::String(chrono::Utc::now().to_rfc3339()), + ); + + serde_json::Value::Object(metadata) } #[cfg(test)] mod tests { use super::*; + use std::{collections::HashSet, sync::Mutex}; - #[test] - fn test_header_parsing_logic() { - // Test just the header parsing logic without WebSocketUpgrade - let mut headers = HeaderMap::new(); + use axum::http::HeaderValue; + use ras_auth_core::{AuthError, AuthFuture}; + + struct RecordingAuthProvider { + result: AuthResult, + tokens: Mutex>, + } + + type AuthResult = Result; + + impl RecordingAuthProvider { + fn returning(result: AuthResult) -> Self { + Self { + result, + tokens: Mutex::new(Vec::new()), + } + } + + fn tokens(&self) -> Vec { + self.tokens.lock().expect("tokens lock").clone() + } + } - // Test Bearer token extraction logic - headers.insert("authorization", "Bearer abc123".parse().unwrap()); - if let Some(auth_header) = headers.get("authorization") - && let Ok(auth_str) = auth_header.to_str() - && let Some(token) = auth_str.strip_prefix("Bearer ") - { - assert_eq!(token, "abc123"); + impl AuthProvider for RecordingAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + self.tokens.lock().expect("tokens lock").push(token); + let result = self.result.clone(); + Box::pin(async move { result }) } + } - // Test X-Forwarded-For parsing logic - headers.clear(); - headers.insert("x-forwarded-for", "192.168.1.1, 10.0.0.1".parse().unwrap()); - if let Some(header_value) = headers.get("x-forwarded-for") - && let Ok(value) = header_value.to_str() - { - let ip = value.split(',').next().unwrap_or(value).trim(); - assert_eq!(ip, "192.168.1.1"); + fn test_user() -> AuthenticatedUser { + AuthenticatedUser { + user_id: "user-1".to_string(), + permissions: HashSet::from(["chat:read".to_string()]), + metadata: None, } } #[test] - fn test_metadata_creation() { - // Test metadata creation without needing WebSocketUpgrade - let mut metadata = serde_json::Map::new(); - metadata.insert( - "client_ip".to_string(), - serde_json::Value::String("127.0.0.1".to_string()), + fn extracts_authorization_bearer_token_first() { + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("Bearer abc123")); + headers.insert("x-auth-token", HeaderValue::from_static("fallback")); + + assert_eq!( + extract_auth_token_from_headers(&headers), + Some("abc123".to_string()) ); - metadata.insert( - "user_agent".to_string(), - serde_json::Value::String("test-agent".to_string()), + } + + #[test] + fn extracts_authorization_raw_token() { + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("raw-token")); + + assert_eq!( + extract_auth_token_from_headers(&headers), + Some("raw-token".to_string()) + ); + } + + #[test] + fn extracts_websocket_protocol_token_before_x_auth_token() { + let mut headers = HeaderMap::new(); + headers.insert( + "sec-websocket-protocol", + HeaderValue::from_static("token.ws-token"), + ); + headers.insert("x-auth-token", HeaderValue::from_static("fallback")); + + assert_eq!( + extract_auth_token_from_headers(&headers), + Some("ws-token".to_string()) + ); + } + + #[test] + fn extracts_x_auth_token_when_other_headers_are_absent() { + let mut headers = HeaderMap::new(); + headers.insert("x-auth-token", HeaderValue::from_static("x-token")); + + assert_eq!( + extract_auth_token_from_headers(&headers), + Some("x-token".to_string()) + ); + } + + #[test] + fn ignores_websocket_protocol_values_without_token_prefix() { + let mut headers = HeaderMap::new(); + headers.insert( + "sec-websocket-protocol", + HeaderValue::from_static("jsonrpc.v1"), + ); + + assert_eq!(extract_auth_token_from_headers(&headers), None); + } + + #[test] + fn extracts_first_forwarded_client_ip_and_trims_whitespace() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static(" 192.168.1.1, 10.0.0.1"), + ); + + assert_eq!( + extract_client_ip_from_headers(&headers), + Some("192.168.1.1".to_string()) + ); + } + + #[test] + fn falls_back_to_next_ip_header_when_first_candidate_is_empty() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", HeaderValue::from_static(" , 10.0.0.1")); + headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.7")); + + assert_eq!( + extract_client_ip_from_headers(&headers), + Some("203.0.113.7".to_string()) + ); + } + + #[test] + fn returns_no_client_ip_when_known_headers_are_missing() { + assert_eq!(extract_client_ip_from_headers(&HeaderMap::new()), None); + } + + #[test] + fn creates_metadata_from_available_headers_and_timestamp() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", HeaderValue::from_static("127.0.0.1")); + headers.insert("user-agent", HeaderValue::from_static("test-agent")); + + let metadata = create_metadata_from_headers(&headers); + + assert_eq!(metadata.get("client_ip").expect("client ip"), "127.0.0.1"); + assert_eq!( + metadata.get("user_agent").expect("user agent"), + "test-agent" ); + let connected_at = metadata + .get("connected_at") + .and_then(serde_json::Value::as_str) + .expect("connected_at timestamp"); + chrono::DateTime::parse_from_rfc3339(connected_at).expect("valid timestamp"); + } + + #[tokio::test] + async fn authenticate_headers_returns_user_and_records_token() { + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("Bearer secret")); + let provider = RecordingAuthProvider::returning(Ok(test_user())); + + let user = authenticate_headers(&headers, &provider) + .await + .expect("authentication succeeds") + .expect("authenticated user"); + + assert_eq!(user.user_id, "user-1"); + assert_eq!(provider.tokens(), vec!["secret".to_string()]); + } + + #[tokio::test] + async fn authenticate_headers_returns_none_without_token() { + let provider = RecordingAuthProvider::returning(Ok(test_user())); + + let user = authenticate_headers(&HeaderMap::new(), &provider) + .await + .expect("missing token is allowed"); + + assert!(user.is_none()); + assert!(provider.tokens().is_empty()); + } + + #[tokio::test] + async fn authenticate_headers_wraps_provider_errors() { + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("expired")); + let provider = RecordingAuthProvider::returning(Err(AuthError::TokenExpired)); + + let error = authenticate_headers(&headers, &provider) + .await + .expect_err("auth failure is propagated"); - let metadata_value = serde_json::Value::Object(metadata); - assert!(metadata_value.is_object()); - assert_eq!(metadata_value.get("client_ip").unwrap(), "127.0.0.1"); - assert_eq!(metadata_value.get("user_agent").unwrap(), "test-agent"); + assert_eq!(error.to_status_code(), StatusCode::UNAUTHORIZED); + assert_eq!(error.to_string(), "Authentication failed: Token expired"); + assert_eq!(provider.tokens(), vec!["expired".to_string()]); } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/tests/manager_unit.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/tests/manager_unit.rs index aa58808..9debe66 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/tests/manager_unit.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/tests/manager_unit.rs @@ -1,11 +1,10 @@ //! Direct unit tests for `DefaultConnectionManager`. //! -//! The end-to-end suite in `examples/bidirectional-chat` and -//! `ras-jsonrpc-bidirectional-macro/tests/bidirectional_integration.rs` +//! The socketless generated-handler suite in `ras-jsonrpc-bidirectional-macro` //! covers the manager's happy path indirectly. This file pins down the -//! manager's contract on its own — subscriptions, broadcast counts, -//! permission filtering, and pending-request lifecycle — without spinning -//! up a real WebSocket. +//! manager's contract on its own: subscriptions, broadcast counts, permission +//! filtering, and pending-request lifecycle, without spinning up a real +//! WebSocket. use std::collections::HashSet; use std::sync::Arc; @@ -80,11 +79,11 @@ async fn add_connection_with_sender_box_downcasts() { } #[tokio::test] -async fn add_connection_with_unknown_sender_falls_back_to_dummy() { +async fn add_connection_with_unknown_sender_uses_fallback_channel() { let mgr = DefaultConnectionManager::new(); let id = ConnectionId::new(); - let bogus: Box = Box::new(123u32); - mgr.add_connection_with_sender(ConnectionInfo::new(id), bogus) + let unexpected_sender: Box = Box::new(123u32); + mgr.add_connection_with_sender(ConnectionInfo::new(id), unexpected_sender) .await .unwrap(); assert!(mgr.connection_exists(id).await.unwrap()); diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml index 7b1818c..27d3ede 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml @@ -2,22 +2,28 @@ name = "ras-jsonrpc-bidirectional-types" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Shared types for bidirectional JSON-RPC clients and servers" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] serde = { workspace = true } serde_json = { workspace = true } -tokio = { workspace = true } +tokio = { version = "1.0", default-features = false, features = ["sync"] } futures = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } tracing = { workspace = true } -ras-auth-core = { path = "../../../core/ras-auth-core" } -ras-jsonrpc-types = { path = "../../ras-jsonrpc-types" } -uuid = { workspace = true } +ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.1.0" } +ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } +uuid = { version = "1.11", features = ["v4", "serde", "js"] } chrono = { workspace = true } -# WebSocket dependencies +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio-tungstenite = { workspace = true } [dev-dependencies] -tokio-test = { workspace = true } \ No newline at end of file +tokio = { version = "1.0", default-features = false, features = ["macros", "rt", "sync"] } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/README.md b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/README.md index 7b5513e..f54557b 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/README.md +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/README.md @@ -55,9 +55,9 @@ Sends messages over a WebSocket connection with convenience methods for: ```rust use ras_jsonrpc_bidirectional_types::{ - ConnectionId, ConnectionInfo, BidirectionalMessage, - MessageSender, MessageSenderExt + ConnectionId, ConnectionInfo, MessageSender, MessageSenderExt, NoOpMessageSender, }; +use serde_json::json; // Create a connection let conn_id = ConnectionId::new(); @@ -67,8 +67,10 @@ let mut info = ConnectionInfo::new(conn_id); info.subscribe("updates".to_string()); info.subscribe("notifications".to_string()); -// Send messages (with a MessageSender implementation) -let sender: impl MessageSender = ...; +// Send messages through any MessageSender implementation. NoOpMessageSender is +// useful for tests or dry-run flows; production code usually uses a WebSocket sender. +let sender = NoOpMessageSender::with_connection_id(conn_id); +assert_eq!(sender.connection_id(), conn_id); sender.send_ping().await?; sender.send_notification("user.updated", json!({"id": 123})).await?; ``` @@ -80,4 +82,11 @@ sender.send_notification("user.updated", json!({"id": 123})).await?; - Built-in authentication and permission checking - Topic-based publish/subscribe pattern - WebSocket integration with tokio-tungstenite -- Extensible traits for custom implementations \ No newline at end of file +- Extensible traits for custom implementations + +## Checks + +```bash +cargo test -p ras-jsonrpc-bidirectional-types --locked +cargo clippy -p ras-jsonrpc-bidirectional-types --all-targets --all-features --locked -- -D warnings +``` diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/lib.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/lib.rs index 43fd86c..5478ebe 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/lib.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/lib.rs @@ -18,7 +18,9 @@ pub mod sender; pub use error::BidirectionalError; pub use manager::ConnectionManager; -pub use sender::{MessageSender, NoOpMessageSender}; +#[cfg(not(target_arch = "wasm32"))] +pub use sender::WebSocketMessageSender; +pub use sender::{MessageSender, MessageSenderExt, NoOpMessageSender}; /// Unique identifier for a WebSocket connection #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/manager.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/manager.rs index b78829f..c62da3c 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/manager.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/manager.rs @@ -196,9 +196,10 @@ mod tests { use std::sync::Mutex; use tokio::sync::oneshot; - /// Tiny stub manager: enough state to exercise the default `connection_*` - /// methods and the `ConnectionManagerExt` helpers without dragging in the - /// full `DefaultConnectionManager`. Uses sync `Mutex` for simplicity. + /// Minimal in-memory manager used to exercise the default `connection_*` + /// methods and the `ConnectionManagerExt` helpers without depending on the + /// server crate's full manager implementation. Uses sync `Mutex` for + /// simplicity. #[derive(Default)] struct StubManager { conns: Mutex>, @@ -392,8 +393,8 @@ mod tests { // The default `add_connection_with_sender` must fall through to // `add_connection`. let id3 = ConnectionId::new(); - let dummy: Box = Box::new(()) as _; - mgr.add_connection_with_sender(ConnectionInfo::new(id3), dummy) + let unexpected_sender: Box = Box::new(()) as _; + mgr.add_connection_with_sender(ConnectionInfo::new(id3), unexpected_sender) .await .unwrap(); assert!(mgr.connection_exists(id3).await.unwrap()); @@ -410,13 +411,14 @@ mod tests { mgr.notify_connection(id, "evt", serde_json::json!({"k": 1})) .await .unwrap(); - let sent = mgr.sent.lock().unwrap(); - assert_eq!(sent.len(), 1); - match &sent[0].1 { - BidirectionalMessage::ServerNotification(n) => assert_eq!(n.method, "evt"), - other => panic!("unexpected: {other:?}"), + { + let sent = mgr.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + match &sent[0].1 { + BidirectionalMessage::ServerNotification(n) => assert_eq!(n.method, "evt"), + other => panic!("unexpected: {other:?}"), + } } - drop(sent); // notify_topic broadcasts to the topic with one subscriber. let n = mgr @@ -424,12 +426,13 @@ mod tests { .await .unwrap(); assert_eq!(n, 1); - let bs = mgr.broadcasts.lock().unwrap(); - assert!(matches!( - &bs[0].1, - BidirectionalMessage::Broadcast(BroadcastMessage { method, .. }) if method == "msg" - )); - drop(bs); + { + let bs = mgr.broadcasts.lock().unwrap(); + assert!(matches!( + &bs[0].1, + BidirectionalMessage::Broadcast(BroadcastMessage { method, .. }) if method == "msg" + )); + } // ping_connection should produce a Ping payload. mgr.ping_connection(id).await.unwrap(); @@ -471,4 +474,80 @@ mod tests { // Bob unaffected. assert!(mgr.connection_exists(bob).await.unwrap()); } + + #[tokio::test] + async fn subscriptions_users_and_broadcast_filters_update_connection_state() { + let mgr = StubManager::default(); + let id = ConnectionId::new(); + mgr.add_connection(ConnectionInfo::new(id)).await.unwrap(); + + assert!(mgr.get_subscriptions(id).await.unwrap().is_empty()); + mgr.add_subscription(id, "room:1".to_string()) + .await + .unwrap(); + mgr.add_subscription(id, "alerts".to_string()) + .await + .unwrap(); + + let mut subscriptions = mgr.get_subscriptions(id).await.unwrap(); + subscriptions.sort(); + assert_eq!(subscriptions, vec!["alerts", "room:1"]); + assert_eq!( + mgr.get_subscribed_connections("room:1") + .await + .unwrap() + .len(), + 1 + ); + + mgr.remove_subscription(id, "room:1").await.unwrap(); + let info = mgr.get_connection(id).await.unwrap().unwrap(); + assert!(!info.is_subscribed_to("room:1")); + assert!(info.is_subscribed_to("alerts")); + + mgr.set_connection_user(id, user("alice", &["read", "write"])) + .await + .unwrap(); + assert_eq!( + mgr.broadcast_to_authenticated(BidirectionalMessage::Ping) + .await + .unwrap(), + 1 + ); + assert_eq!( + mgr.broadcast_to_permission("read", BidirectionalMessage::Ping) + .await + .unwrap(), + 1 + ); + assert_eq!( + mgr.broadcast_to_permission("admin", BidirectionalMessage::Ping) + .await + .unwrap(), + 0 + ); + + mgr.clear_connection_user(id).await.unwrap(); + assert_eq!(mgr.authenticated_connection_count().await.unwrap(), 0); + } + + #[tokio::test] + async fn missing_connection_mutations_return_not_found() { + let mgr = StubManager::default(); + let missing = ConnectionId::new(); + + assert!(matches!( + mgr.remove_connection(missing).await, + Err(BidirectionalError::ConnectionNotFound(id)) if id == missing + )); + assert!(matches!( + mgr.set_connection_user(missing, user("alice", &[])).await, + Err(BidirectionalError::ConnectionNotFound(id)) if id == missing + )); + assert!(matches!( + mgr.clear_connection_user(missing).await, + Err(BidirectionalError::ConnectionNotFound(id)) if id == missing + )); + assert!(!mgr.connection_exists(missing).await.unwrap()); + } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender.rs index 5d26954..39287c8 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender.rs @@ -1,10 +1,16 @@ //! Message sender trait for bidirectional JSON-RPC -use crate::{BidirectionalError, BidirectionalMessage, ConnectionId, Result}; +#[cfg(not(target_arch = "wasm32"))] +use crate::BidirectionalError; +use crate::{BidirectionalMessage, ConnectionId, Result}; use async_trait::async_trait; +#[cfg(not(target_arch = "wasm32"))] use futures::sink::SinkExt; +#[cfg(not(target_arch = "wasm32"))] use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] use tokio::sync::Mutex; +#[cfg(not(target_arch = "wasm32"))] use tokio_tungstenite::tungstenite::Message as WsMessage; /// Trait for sending messages over WebSocket connections @@ -24,6 +30,7 @@ pub trait MessageSender: Send + Sync { } /// A message sender implementation using tokio-tungstenite +#[cfg(not(target_arch = "wasm32"))] pub struct WebSocketMessageSender where S: SinkExt + Send + Unpin, @@ -33,6 +40,7 @@ where is_closed: Arc>, } +#[cfg(not(target_arch = "wasm32"))] impl WebSocketMessageSender where S: SinkExt + Send + Unpin, @@ -48,6 +56,7 @@ where } } +#[cfg(not(target_arch = "wasm32"))] #[async_trait] impl MessageSender for WebSocketMessageSender where @@ -170,12 +179,12 @@ impl Default for NoOpMessageSender { #[async_trait] impl MessageSender for NoOpMessageSender { async fn send_message(&self, _message: BidirectionalMessage) -> Result<()> { - // No-op implementation - just return success + // No-op senders acknowledge messages without producing side effects. Ok(()) } async fn close(&self) -> Result<()> { - // No-op implementation - just return success + // Closing a no-op sender has no external state to update. Ok(()) } @@ -192,6 +201,8 @@ impl MessageSender for NoOpMessageSender { #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; + use tokio::sync::Mutex; #[tokio::test] async fn test_message_sender_ext() { @@ -319,6 +330,7 @@ mod tests { assert_ne!(s2.connection_id(), s3.connection_id()); } + #[cfg(not(target_arch = "wasm32"))] #[tokio::test] async fn websocket_sender_drives_real_sink() { use futures::channel::mpsc; diff --git a/crates/rpc/ras-jsonrpc-core/Cargo.toml b/crates/rpc/ras-jsonrpc-core/Cargo.toml index fc3e2c2..8af1df1 100644 --- a/crates/rpc/ras-jsonrpc-core/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-core/Cargo.toml @@ -2,15 +2,17 @@ name = "ras-jsonrpc-core" version = "0.1.2" edition = "2024" +rust-version = "1.88" description = "Core types and traits for the ras-jsonrpc crate family" license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -ras-jsonrpc-types = { path = "../ras-jsonrpc-types" } -ras-auth-core = { path = "../../core/ras-auth-core" } -ras-version-core = { path = "../../core/ras-version-core" } +ras-jsonrpc-types = { path = "../ras-jsonrpc-types", version = "0.1.1" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-version-core = { path = "../../core/ras-version-core", version = "0.1.0" } diff --git a/crates/rpc/ras-jsonrpc-core/README.md b/crates/rpc/ras-jsonrpc-core/README.md index 932e206..4ca4ab5 100644 --- a/crates/rpc/ras-jsonrpc-core/README.md +++ b/crates/rpc/ras-jsonrpc-core/README.md @@ -8,13 +8,13 @@ This crate provides the foundational authentication, authorization, and version ## Features -- ✅ **Async Authentication**: Full async/await support for authentication operations -- ✅ **Permission-Based Authorization**: Fine-grained permission checking -- ✅ **Flexible Auth Providers**: Support for JWT, API keys, or custom authentication -- ✅ **Comprehensive Error Handling**: Detailed error types for all authentication scenarios -- ✅ **Extension Traits**: Optional authentication helpers -- ✅ **Version Migration**: Re-exports `VersionMigration` for opt-in API compatibility paths -- ✅ **Integration Ready**: Re-exports JSON-RPC types for convenience +- **Async authentication**: Full async/await support for authentication operations +- **Permission-based authorization**: Fine-grained permission checking +- **Flexible auth providers**: Support for JWT, API keys, or custom authentication +- **Typed error handling**: Explicit error variants for common authentication scenarios +- **Permission helpers**: Default `check_permissions` logic on `AuthProvider` +- **Version migration**: Re-exports `VersionMigration` for opt-in API compatibility paths +- **Integration ready**: Re-exports JSON-RPC types for convenience ## Usage @@ -28,56 +28,51 @@ ras-jsonrpc-core = "0.1.2" ### Implementing an Auth Provider ```rust -use ras_jsonrpc_core::{AuthProvider, AuthenticatedUser, AuthFuture, AuthError}; -use std::collections::HashSet; +use ras_jsonrpc_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use std::collections::{HashMap, HashSet}; -struct JwtAuthProvider { - secret_key: String, +struct DemoAuthProvider { + users_by_token: HashMap, } -impl AuthProvider for JwtAuthProvider { +impl AuthProvider for DemoAuthProvider { fn authenticate(&self, token: String) -> AuthFuture<'_> { Box::pin(async move { - // Validate JWT token (simplified example) - if self.validate_jwt(&token)? { - let claims = self.decode_jwt(&token)?; - - Ok(AuthenticatedUser { - user_id: claims.user_id, - permissions: claims.permissions, - metadata: Some(serde_json::json!({ - "iat": claims.issued_at, - "exp": claims.expires_at - })), - }) - } else { - Err(AuthError::InvalidToken) - } + self.users_by_token + .get(&token) + .cloned() + .ok_or(AuthError::InvalidToken) }) } } + +let mut users_by_token = HashMap::new(); +users_by_token.insert( + "admin-token".to_string(), + AuthenticatedUser { + user_id: "admin-user".to_string(), + permissions: HashSet::from(["admin".to_string(), "user".to_string()]), + metadata: None, + }, +); + +let auth_provider = DemoAuthProvider { users_by_token }; ``` ### Using with Permissions ```rust -use ras_jsonrpc_core::{AuthProvider, AuthProviderExt}; +use ras_jsonrpc_core::{AuthProvider, AuthResult}; -async fn example_usage() { - let auth_provider = JwtAuthProvider::new("secret".to_string()); - - // Authenticate and authorize in one step - let user = auth_provider - .authenticate_and_authorize( - "jwt-token".to_string(), - vec!["admin".to_string()] - ) - .await?; - - // Optional authentication - let maybe_user = auth_provider - .authenticate_optional(Some("jwt-token".to_string())) - .await?; +async fn example_usage(auth_provider: &impl AuthProvider) -> AuthResult<()> { + let user = auth_provider.authenticate("admin-token".to_string()).await?; + + auth_provider.check_permissions( + &user, + &["admin".to_string()] + )?; + + Ok(()) } ``` @@ -101,18 +96,15 @@ auth_provider.check_permissions( )?; ``` -### 3. Combined Auth + Authz +### 3. Authenticate Then Authorize ```rust -// Authenticate and authorize in one step -let user = auth_provider.authenticate_and_authorize( - token, - vec!["admin".to_string()] -).await?; +let user = auth_provider.authenticate(token).await?; +auth_provider.check_permissions(&user, &["admin".to_string()])?; ``` ## Error Types -The crate provides comprehensive error handling: +The crate provides typed authentication and authorization errors: ```rust use ras_jsonrpc_core::AuthError; @@ -249,25 +241,74 @@ impl VersionMigration for RenameComp ## Example Auth Providers -### JWT Authentication +### Bearer Token Authentication ```rust -struct JwtAuthProvider { /* ... */ } -// Full JWT validation with claims extraction +use ras_jsonrpc_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use std::collections::{HashMap, HashSet}; + +struct StaticBearerAuthProvider { + users_by_token: HashMap, +} + +impl AuthProvider for StaticBearerAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + self.users_by_token + .get(&token) + .cloned() + .ok_or(AuthError::InvalidToken) + }) + } +} + +let mut users_by_token = HashMap::new(); +users_by_token.insert( + "admin-token".to_string(), + AuthenticatedUser { + user_id: "admin-user".to_string(), + permissions: HashSet::from(["admin".to_string(), "user".to_string()]), + metadata: None, + }, +); + +let auth_provider = StaticBearerAuthProvider { users_by_token }; ``` ### API Key Authentication ```rust -struct ApiKeyAuthProvider { /* ... */ } -// Simple API key lookup with rate limiting +use ras_jsonrpc_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use std::collections::{HashMap, HashSet}; + +struct ApiKeyAuthProvider { + keys: HashMap>, +} + +impl AuthProvider for ApiKeyAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + let permissions = self + .keys + .get(&token) + .cloned() + .ok_or(AuthError::InvalidToken)?; + + Ok(AuthenticatedUser { + user_id: format!("api-key:{}", token), + permissions, + metadata: None, + }) + }) + } +} ``` ### Composite Authentication -```rust -struct CompositeAuthProvider { /* ... */ } -// Try multiple auth methods in sequence -``` +Compose providers by implementing `AuthProvider` on a type that holds several +providers and tries each one in order, returning the first successful +`AuthenticatedUser`. Keep the error generic, such as `AuthError::InvalidToken`, +so clients cannot distinguish which auth method failed. -See the [`examples/`](../../examples/) directory for complete implementations. +See the [`examples/`](../../../examples/) directory for runnable service examples. ## Re-exports @@ -277,6 +318,13 @@ For convenience, this crate re-exports all types from `ras-jsonrpc-types`: use ras_jsonrpc_core::{JsonRpcRequest, JsonRpcResponse, JsonRpcError}; ``` +## Checks + +```bash +cargo test -p ras-jsonrpc-core --locked +cargo clippy -p ras-jsonrpc-core --all-targets --all-features --locked -- -D warnings +``` + ## License -This project is licensed under the MIT License. +This project is licensed under either MIT or Apache-2.0. diff --git a/crates/rpc/ras-jsonrpc-core/src/lib.rs b/crates/rpc/ras-jsonrpc-core/src/lib.rs index a13936e..8c54cb8 100644 --- a/crates/rpc/ras-jsonrpc-core/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-core/src/lib.rs @@ -12,3 +12,187 @@ pub use ras_jsonrpc_types::*; // Re-export version migration traits for generated compatibility dispatch. pub use ras_version_core::*; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashSet; + + struct TestAuthProvider; + + impl AuthProvider for TestAuthProvider { + fn authenticate(&self, _token: String) -> AuthFuture<'_> { + Box::pin(async { Err(AuthError::InvalidToken) }) + } + } + + #[derive(Debug, PartialEq)] + struct RenameV1 { + name: String, + } + + #[derive(Debug, PartialEq)] + struct RenameV2 { + display_name: String, + notify: bool, + } + + #[derive(Debug, PartialEq)] + struct RenameResponseV1 { + name: String, + } + + #[derive(Debug, PartialEq)] + struct RenameResponseV2 { + display_name: String, + notified: bool, + } + + struct RenameCompat; + + impl VersionMigration for RenameCompat { + type Error = std::convert::Infallible; + + fn migrate(value: RenameV1) -> Result { + Ok(RenameV2 { + display_name: value.name, + notify: false, + }) + } + } + + impl VersionMigration for RenameCompat { + type Error = std::convert::Infallible; + + fn migrate(value: RenameResponseV2) -> Result { + Ok(RenameResponseV1 { + name: value.display_name, + }) + } + } + + #[test] + fn reexported_auth_types_support_permission_checks() { + let provider = TestAuthProvider; + let user = AuthenticatedUser { + user_id: "user-1".to_string(), + permissions: HashSet::from(["widgets:read".to_string()]), + metadata: Some(json!({ "tenant": "demo" })), + }; + + let allowed = provider.check_permissions(&user, &["widgets:read".to_string()]); + assert!(allowed.is_ok()); + + let denied = provider + .check_permissions(&user, &["widgets:write".to_string()]) + .expect_err("missing permission should fail"); + + let AuthError::InsufficientPermissions { required, has } = denied else { + panic!("expected insufficient permissions"); + }; + assert_eq!(required, vec!["widgets:write"]); + assert_eq!( + has.into_iter().collect::>(), + HashSet::from(["widgets:read".to_string()]) + ); + } + + #[test] + fn reexported_jsonrpc_types_build_canonical_error_response() { + let request = JsonRpcRequest::new( + "missing_method".to_string(), + Some(json!({ "id": "widget-1" })), + Some(json!(7)), + ); + let error = JsonRpcError::method_not_found(&request.method); + let response = JsonRpcResponse::error(error, request.id); + + assert_eq!(request.jsonrpc, "2.0"); + assert_eq!(response.jsonrpc, "2.0"); + assert_eq!(response.id, Some(json!(7))); + assert!(response.result.is_none()); + + let error = response.error.expect("error response"); + assert_eq!(error.code, error_codes::METHOD_NOT_FOUND); + assert_eq!(error.message, "Method not found: missing_method"); + } + + #[test] + fn reexported_jsonrpc_error_encodes_permission_details() { + let error = JsonRpcError::insufficient_permissions( + vec!["widgets:write".to_string()], + vec!["widgets:read".to_string()], + ); + + assert_eq!(error.code, error_codes::INSUFFICIENT_PERMISSIONS); + assert_eq!(error.message, "Insufficient permissions"); + assert_eq!( + error.data, + Some(json!({ + "required": ["widgets:write"], + "has": ["widgets:read"] + })) + ); + } + + #[test] + fn reexported_jsonrpc_success_response_preserves_result_and_id() { + let response = JsonRpcResponse::success(json!({ "ok": true }), Some(json!("req-1"))); + + assert_eq!(response.jsonrpc, "2.0"); + assert_eq!(response.id, Some(json!("req-1"))); + assert_eq!(response.result, Some(json!({ "ok": true }))); + assert!(response.error.is_none()); + } + + #[test] + fn reexported_auth_error_serializes_structured_permission_details() { + let error = AuthError::InsufficientPermissions { + required: vec!["widgets:write".to_string()], + has: vec!["widgets:read".to_string()], + }; + + assert_eq!( + serde_json::to_value(error).unwrap(), + json!({ + "InsufficientPermissions": { + "required": ["widgets:write"], + "has": ["widgets:read"] + } + }) + ); + } + + #[test] + fn reexported_version_migration_trait_can_be_implemented() { + let canonical = RenameCompat::migrate(RenameV1 { + name: "Updated widget".to_string(), + }) + .expect("infallible migration"); + + assert_eq!( + canonical, + RenameV2 { + display_name: "Updated widget".to_string(), + notify: false, + } + ); + } + + #[test] + fn reexported_version_migration_trait_supports_response_downgrade() { + let legacy = RenameCompat::migrate(RenameResponseV2 { + display_name: "Updated widget".to_string(), + notified: true, + }) + .expect("infallible response migration"); + + assert_eq!( + legacy, + RenameResponseV1 { + name: "Updated widget".to_string() + } + ); + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index b4f8603..0e4dcf1 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -2,10 +2,12 @@ name = "ras-jsonrpc-macro" version = "0.2.0" edition = "2024" +rust-version = "1.88" description = "Procedural macro for type-safe JSON-RPC interfaces with auth integration and OpenRPC document generation" license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [lib] proc-macro = true @@ -26,29 +28,28 @@ schemars = { workspace = true } # Server dependencies axum = { workspace = true, optional = true } -ras-jsonrpc-core = { path = "../ras-jsonrpc-core", optional = true } +ras-jsonrpc-core = { path = "../ras-jsonrpc-core", version = "0.1.2", optional = true } # Client dependencies reqwest = { workspace = true, optional = true } # Always needed for types -ras-jsonrpc-types = { path = "../ras-jsonrpc-types" } +ras-jsonrpc-types = { path = "../ras-jsonrpc-types", version = "0.1.1" } [dev-dependencies] tokio = { workspace = true } reqwest = { workspace = true } tower = { workspace = true } rand = { workspace = true } -ras-identity-session = { path = "../../identity/ras-identity-session" } +ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.1.1" } futures = { workspace = true } # Server dependencies for tests axum = { workspace = true } -ras-jsonrpc-core = { path = "../ras-jsonrpc-core" } -ras-auth-core = { path = "../../core/ras-auth-core" } +ras-jsonrpc-core = { path = "../ras-jsonrpc-core", version = "0.1.2" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -ras-test-helpers = { path = "../../test-utils/ras-test-helpers" } axum-test = { workspace = true } criterion = { workspace = true, features = ["async_tokio"] } diff --git a/crates/rpc/ras-jsonrpc-macro/README.md b/crates/rpc/ras-jsonrpc-macro/README.md index 6c1edc6..2303832 100644 --- a/crates/rpc/ras-jsonrpc-macro/README.md +++ b/crates/rpc/ras-jsonrpc-macro/README.md @@ -4,19 +4,19 @@ Procedural macros for generating type-safe JSON-RPC services with authentication ## Overview -This crate provides the `jsonrpc_service!` procedural macro that generates type-safe JSON-RPC services with built-in authentication, authorization, and seamless axum integration. It transforms a declarative service definition into a fully functional JSON-RPC server with compile-time safety guarantees. +This crate provides the `jsonrpc_service!` procedural macro that generates type-safe JSON-RPC services with built-in authentication, authorization, and axum integration. It transforms a declarative service definition into a JSON-RPC router with compile-time checks for the generated service trait. ## Features -- ✅ **Declarative Service Definition**: Clean, readable syntax for defining JSON-RPC methods -- ✅ **Authentication Integration**: Built-in support for `UNAUTHORIZED` and `WITH_PERMISSIONS` methods -- ✅ **Type Safety**: Compile-time validation of request/response types -- ✅ **Axum Integration**: Generates standard axum `Router` for easy composition -- ✅ **Trait-Based Service Wiring**: Implement one generated trait and pass it to the service builder -- ✅ **Versioned Methods**: Optional request/response migrations for legacy wire methods -- ✅ **Async Support**: Full async/await support throughout -- ✅ **JSON-RPC 2.0 Compliant**: Complete protocol compliance with proper error handling -- ✅ **OpenRPC Document Generation**: Automatic API documentation generation +- **Declarative service definition**: Clean, readable syntax for defining JSON-RPC methods +- **Authentication integration**: Built-in support for `UNAUTHORIZED` and `WITH_PERMISSIONS` methods +- **Type safety**: Compile-time validation of request/response types +- **Axum integration**: Generates standard axum `Router` for easy composition +- **Trait-based service wiring**: Implement one generated trait and pass it to the service builder +- **Versioned methods**: Optional request/response migrations for legacy wire methods +- **Async support**: Full async/await support throughout +- **JSON-RPC 2.0 responses**: Generates standard success and error envelopes +- **OpenRPC document generation**: Automatic API documentation generation ## Usage @@ -24,14 +24,30 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -ras-jsonrpc-macro = "0.2.0" -ras-jsonrpc-core = "0.1.2" # For AuthProvider and VersionMigration traits -axum = "0.8" # For web server integration +ras-jsonrpc-macro = { version = "0.2.0", default-features = false } +ras-jsonrpc-core = { version = "0.1.2", optional = true } +ras-jsonrpc-types = "0.1.1" serde = { version = "1.0", features = ["derive"] } -tokio = { version = "1.0", features = ["full"] } - -# Optional: For OpenRPC document generation -schemars = "0.8" # Required if using openrpc feature +serde_json = "1.0" +schemars = "1.0.0-alpha.20" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +axum = { version = "0.8", optional = true } +tokio = { version = "1.0", features = ["full"], optional = true } +reqwest = { version = "0.12", features = ["json"], optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json"], optional = true } + +[features] +default = ["server"] +server = [ + "ras-jsonrpc-macro/server", + "dep:ras-jsonrpc-core", + "dep:axum", + "dep:tokio", +] +client = ["ras-jsonrpc-macro/client", "dep:reqwest"] ``` ## Quick Start @@ -75,7 +91,7 @@ struct MyAuthProvider; impl AuthProvider for MyAuthProvider { fn authenticate(&self, token: String) -> AuthFuture<'_> { Box::pin(async move { - // Validate JWT token (simplified) + // Validate the bearer token (simplified) if token.starts_with("valid_") { let mut permissions = HashSet::new(); permissions.insert("user".to_string()); @@ -163,7 +179,9 @@ jsonrpc_service!({ service_name: ServiceName, // Name of the generated service openrpc: true, // Optional: Enable OpenRPC generation methods: [ - // Method definitions... + UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, + WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile, + WITH_PERMISSIONS(["admin"]) delete_user(UserId) -> (), ] }); ``` @@ -182,7 +200,8 @@ UNAUTHORIZED method_name(RequestType) -> ResponseType, WITH_PERMISSIONS(["perm1", "perm2"]) method_name(RequestType) -> ResponseType, ``` - Requires valid authentication -- Checks for specified permissions +- Requires all listed permissions in the group +- Use `WITH_PERMISSIONS(["admin"] | ["moderator", "editor"])` for OR between permission groups - Trait method signature: `fn method(&self, &AuthenticatedUser, RequestType) -> impl Future> + Send` #### Empty Permissions (Any Valid Token) @@ -200,18 +219,29 @@ The macro generates: ### Service Builder ```rust pub trait MyServiceTrait: Send + Sync + 'static { - // One method per JSON-RPC method definition. -} + async fn sign_in( + &self, + request: SignInRequest, + ) -> Result>; -pub struct MyServiceBuilder { - // Internal fields... + async fn get_profile( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + request: (), + ) -> Result>; + + async fn delete_user( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + request: UserId, + ) -> Result<(), Box>; } impl MyServiceBuilder { - pub fn new(service: T) -> Self { /* ... */ } - pub fn base_url(self, base_url: impl Into) -> Self { /* ... */ } - pub fn auth_provider(self, provider: T) -> Self { /* ... */ } - pub fn build(self) -> Result { /* ... */ } + pub fn new(service: T) -> Self; + pub fn base_url(self, base_url: impl Into) -> Self; + pub fn auth_provider(self, provider: A) -> Self; + pub fn build(self) -> Result; } ``` @@ -366,7 +396,7 @@ curl -X POST http://localhost:3000/api/rpc \ ## Error Handling -The macro generates comprehensive error handling: +The macro generates typed error handling for: - **Parse Errors**: Invalid JSON (-32700) - **Invalid Request**: Malformed JSON-RPC (-32600) @@ -379,7 +409,7 @@ The macro generates comprehensive error handling: ## OpenRPC Document Generation -The macro can automatically generate OpenRPC specification documents for your JSON-RPC API. This provides machine-readable API documentation that can be used by tools like the openrpc-to-bruno converter. +The macro can automatically generate OpenRPC specification documents for your JSON-RPC API. This provides machine-readable API documentation for clients, API explorers, and external tooling. ### Enabling OpenRPC @@ -389,7 +419,8 @@ jsonrpc_service!({ service_name: MyService, openrpc: true, // Generates to target/openrpc/myservice.json methods: [ - // ... method definitions ... + UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, + WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile, ] }); ``` @@ -400,7 +431,8 @@ jsonrpc_service!({ service_name: MyService, openrpc: { output: "docs/api/myservice.json" }, methods: [ - // ... method definitions ... + UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, + WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile, ] }); ``` @@ -440,7 +472,7 @@ The generated OpenRPC document includes: - **Service metadata**: Title, version, description - **Method specifications**: Name, parameters, results -- **JSON Schemas**: Complete type definitions with descriptions +- **JSON Schemas**: Type definitions with descriptions - **Authentication metadata**: `x-authentication` and `x-permissions` extensions for each method - **Version metadata**: `x-ras-version`, `x-ras-canonical-version`, and `x-ras-canonical-method` extensions for versioned methods @@ -484,23 +516,14 @@ fn main() { ``` This generates an OpenRPC document at `target/openrpc/userservice.json` with: -- Complete method documentation +- Method documentation - JSON schemas for all types - Authentication requirements (`x-authentication: true`) - Permission requirements (`x-permissions: ["admin"]`) -### Converting to Bruno Collections - -The generated OpenRPC documents can be converted to Bruno API testing collections using the `openrpc-to-bruno` tool: - -```bash -cargo install openrpc-to-bruno -openrpc-to-bruno -i target/openrpc/userservice.json -o bruno-collection -``` - ## Integration -This crate works seamlessly with: +This crate works with: - [`ras-jsonrpc-core`](../ras-jsonrpc-core) - Authentication traits and types - [`ras-jsonrpc-types`](../ras-jsonrpc-types) - JSON-RPC protocol types @@ -508,12 +531,19 @@ This crate works seamlessly with: ## Examples -See the [`examples/`](../../examples/) directory for complete working examples: +See the [`examples/`](../../../examples/) directory for usage examples: -- [`basic-jsonrpc-service`](../../examples/basic-jsonrpc-service) - Complete service with authentication +- [`basic-jsonrpc-service`](../../../examples/basic-jsonrpc/service) - Runnable service with authentication - [`usage.rs`](examples/usage.rs) - Standalone usage example - [`openrpc_demo.rs`](examples/openrpc_demo.rs) - OpenRPC document generation example +## Checks + +```bash +cargo test -p ras-jsonrpc-macro --locked +cargo clippy -p ras-jsonrpc-macro --all-targets --all-features --locked -- -D warnings +``` + ## License -This project is licensed under the MIT License. +This project is licensed under either MIT or Apache-2.0. diff --git a/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs b/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs index c7049c2..eafed17 100644 --- a/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs +++ b/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs @@ -1,14 +1,20 @@ //! Criterion bench measuring per-call latency of an authenticated JSON-RPC -//! method through the full stack: generated client → axum router → handler. +//! method through the in-memory axum-test stack: request -> axum router -> +//! handler. //! //! Run with `cargo bench -p ras-jsonrpc-macro`. use criterion::{Criterion, criterion_group, criterion_main}; use ras_jsonrpc_macro::jsonrpc_service; -use ras_test_helpers::{MockAuthProvider, spawn_http}; use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::sync::Arc; use tokio::runtime::Runtime; +#[path = "../tests/support/mod.rs"] +mod support; +use support::{MockAuthProvider, mock_http_server}; + #[derive(Debug, Clone, Serialize, Deserialize)] struct AddRequest { a: i64, @@ -50,24 +56,26 @@ fn build_router() -> axum::Router { fn bench_dispatch(c: &mut Criterion) { let rt = Runtime::new().unwrap(); - - // Spin the server up once and reuse across every iteration. - let (client, _server) = rt.block_on(async { - let server = spawn_http(build_router()); - let url = server.server_url("/rpc").unwrap().to_string(); - let mut client = BenchSvcClientBuilder::new() - .server_url(url) - .build() - .expect("client build"); - client.set_bearer_token(Some("user-token".to_string())); - (client, server) - }); + let server = Arc::new(mock_http_server(build_router())); c.bench_function("jsonrpc_add_dispatch", |b| { b.to_async(&rt).iter(|| { - let client = client.clone(); + let server = Arc::clone(&server); async move { - let r = client.add(AddRequest { a: 1, b: 2 }).await.expect("add ok"); + let response = server + .post("/rpc") + .authorization_bearer("user-token") + .json(&json!({ + "jsonrpc": "2.0", + "method": "add", + "params": AddRequest { a: 1, b: 2 }, + "id": 1, + })) + .await; + response.assert_status_ok(); + let payload: Value = response.json(); + let r: AddResponse = + serde_json::from_value(payload["result"].clone()).expect("result json"); std::hint::black_box(r); } }); diff --git a/crates/rpc/ras-jsonrpc-macro/examples/comprehensive_demo.rs b/crates/rpc/ras-jsonrpc-macro/examples/comprehensive_demo.rs index eb262ef..56fc231 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/comprehensive_demo.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/comprehensive_demo.rs @@ -1,4 +1,4 @@ -//! Comprehensive example showing all features of the jsonrpc_service macro +//! Feature tour example for the jsonrpc_service macro //! //! This example demonstrates: //! - Service without OpenRPC @@ -50,7 +50,7 @@ mod basic_service { }); } -// Service with OpenRPC enabled using default path (target/openrpc/{service_name}.json) +// Service with OpenRPC enabled using default path (target/openrpc/{lowercase-service-name}.json) mod api_service { use super::*; use ras_jsonrpc_macro::jsonrpc_service; @@ -237,7 +237,7 @@ impl documented_service::DocumentedServiceTrait for DocumentedServiceImpl { } fn main() { - println!("=== Comprehensive JSON-RPC Service Demo ===\n"); + println!("=== JSON-RPC Service Feature Tour ===\n"); // Test basic service (no OpenRPC) println!("1. Basic Service (no OpenRPC):"); @@ -245,8 +245,8 @@ fn main() { .base_url("/basic") .auth_provider(DemoAuthProvider); let _basic_router = basic_builder.build().expect("Failed to build BasicService"); - println!(" ✓ BasicService compiled successfully"); - println!(" ✓ No OpenRPC functions generated\n"); + println!(" OK BasicService compiled successfully"); + println!(" OK No OpenRPC functions generated\n"); // Test API service with default OpenRPC println!("2. API Service (OpenRPC enabled, default path):"); @@ -257,8 +257,8 @@ fn main() { // Generate OpenRPC document let openrpc_doc = api_service::generate_apiservice_openrpc(); - println!(" ✓ ApiService compiled successfully"); - println!(" ✓ OpenRPC document generated:"); + println!(" OK ApiService compiled successfully"); + println!(" OK OpenRPC document generated:"); println!(" - OpenRPC version: {}", openrpc_doc["openrpc"]); println!(" - API title: {}", openrpc_doc["info"]["title"]); println!( @@ -268,8 +268,8 @@ fn main() { // Write to default path match api_service::generate_apiservice_openrpc_to_file() { - Ok(()) => println!(" ✓ Written to: target/openrpc/apiservice.json"), - Err(e) => println!(" ✗ Error writing file: {}", e), + Ok(()) => println!(" OK Written to: target/openrpc/apiservice.json"), + Err(e) => println!(" ERROR writing file: {}", e), } println!(); @@ -284,8 +284,8 @@ fn main() { // Generate OpenRPC document let doc_openrpc = documented_service::generate_documentedservice_openrpc(); - println!(" ✓ DocumentedService compiled successfully"); - println!(" ✓ OpenRPC document generated with custom path"); + println!(" OK DocumentedService compiled successfully"); + println!(" OK OpenRPC document generated with custom path"); println!( " - Methods count: {}", doc_openrpc["methods"].as_array().unwrap().len() @@ -293,8 +293,8 @@ fn main() { // Write to custom path match documented_service::generate_documentedservice_openrpc_to_file() { - Ok(()) => println!(" ✓ Written to: docs/api/service.openrpc.json"), - Err(e) => println!(" ✗ Error writing file: {}", e), + Ok(()) => println!(" OK Written to: docs/api/service.openrpc.json"), + Err(e) => println!(" ERROR writing file: {}", e), } println!(); diff --git a/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs b/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs index 4d20dcd..d6755c4 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs @@ -236,5 +236,7 @@ async fn main() { #[cfg(not(all(feature = "server", feature = "client")))] fn main() { println!("This example requires both 'server' and 'client' features to be enabled."); - println!("Run with: cargo run --example explorer_params_demo --features server,client"); + println!( + "Run with: cargo run --locked --example explorer_params_demo --features server,client" + ); } diff --git a/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs b/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs index 274cf72..d7b178f 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs @@ -102,17 +102,15 @@ fn main() { println!("=== JSON-RPC Service Trait Demo ===\n"); - println!("Building service from a complete trait implementation..."); + println!("Building service from a trait implementation that covers every method..."); let complete_builder = CalculatorServiceBuilder::new(CalculatorServiceImpl) .base_url("/api/calc") .auth_provider(DemoAuthProvider); // This should succeed - let _router = complete_builder - .build() - .expect("Failed to build complete service"); - println!("✓ Build succeeded! All handlers are configured."); + let _router = complete_builder.build().expect("Failed to build service"); + println!("Build succeeded. All handlers are configured."); println!("\nSummary:"); println!("- The JSON-RPC service builder accepts a generated trait implementation"); diff --git a/crates/rpc/ras-jsonrpc-macro/examples/openrpc_demo.rs b/crates/rpc/ras-jsonrpc-macro/examples/openrpc_demo.rs index d7a7fff..50dfa8d 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/openrpc_demo.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/openrpc_demo.rs @@ -1,6 +1,6 @@ //! Example demonstrating OpenRPC document generation //! -//! Run with: cargo run --example openrpc_demo +//! Run with: cargo run --locked --example openrpc_demo use ras_jsonrpc_macro::jsonrpc_service; use schemars::JsonSchema; diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index 7cc27d1..fe24859 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -448,8 +448,8 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result static_hosting::StaticHostingConfig::default(), }; - // Extract base path from server code (we'll need to pass this to explorer) - // For now, use the default empty string since JSON-RPC typically uses a single endpoint + // JSON-RPC services in this macro expose the explorer next to a single endpoint. + // The static host generator still accepts a base path for future reuse. static_hosting::generate_static_hosting_code( &explorer_config, &service_def.service_name, diff --git a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs index 5dc0807..2503d1e 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs @@ -32,7 +32,7 @@ pub fn generate_static_hosting_code( const TEMPLATE_CONTENT: &str = include_str!("../../../rest/ras-rest-macro/src/api_explorer_template.html"); - let explorer_path_suffix = &config.explorer_path; + let explorer_path_suffix = normalize_explorer_path(&config.explorer_path); let service_name_str = service_name.to_string(); let service_name_lower = service_name_str.to_lowercase(); let openrpc_fn_name_str = ["generate_", &service_name_lower, "_openrpc"].concat(); @@ -48,7 +48,7 @@ pub fn generate_static_hosting_code( pub fn #explorer_routes_fn(base_path: &str) -> ::axum::Router { use ::axum::{response::Html, routing::get, Json}; - let explorer_path = format!("{}{}", base_path, #explorer_path_suffix); + let explorer_path = format!("{}{}", base_path.trim_end_matches('/'), #explorer_path_suffix); let openrpc_path = format!("{}/openrpc.json", &explorer_path); let explorer_html = { @@ -86,3 +86,56 @@ pub fn generate_static_hosting_code( } } } + +fn normalize_explorer_path(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/{trimmed}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use quote::format_ident; + + #[test] + fn default_config_disables_explorer_on_default_path() { + let config = StaticHostingConfig::default(); + assert!(!config.serve_explorer); + assert_eq!(config.explorer_path, "/explorer"); + } + + #[test] + fn disabled_config_generates_no_tokens() { + let config = StaticHostingConfig::default(); + let tokens = generate_static_hosting_code(&config, &format_ident!("UserService"), ""); + assert!(tokens.is_empty()); + } + + #[test] + fn explorer_path_is_normalized_before_code_generation() { + assert_eq!(normalize_explorer_path("api/docs/"), "/api/docs"); + assert_eq!(normalize_explorer_path("/api/docs/"), "/api/docs"); + assert_eq!(normalize_explorer_path("/explorer"), "/explorer"); + } + + #[test] + fn enabled_config_generates_explorer_and_openrpc_routes() { + let config = StaticHostingConfig { + serve_explorer: true, + explorer_path: "api/docs/".to_string(), + }; + + let tokens = + generate_static_hosting_code(&config, &format_ident!("UserService"), "").to_string(); + + assert!(tokens.contains("userservice_explorer_routes")); + assert!(tokens.contains("generate_userservice_openrpc")); + assert!(tokens.contains("/api/docs")); + assert!(tokens.contains("openrpc.json")); + assert!(!tokens.contains("api/docs/")); + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs index 2f0ae22..c1fc721 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs @@ -1,11 +1,15 @@ -//! End-to-end test that exercises the full chain: -//! generated reqwest client → axum router → handler → response → client. +//! End-to-end test that exercises the full in-memory chain: +//! axum-test request -> axum router -> handler -> response. //! //! Covers: success path, missing-permission rejection, malformed input. use ras_jsonrpc_macro::jsonrpc_service; -use ras_test_helpers::{MockAuthProvider, spawn_http}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +mod support; +use support::{MockAuthProvider, mock_http_server}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] struct EchoRequest { @@ -149,24 +153,54 @@ fn router() -> axum::Router { .expect("build router") } -fn client(url: String) -> DemoClient { - DemoClientBuilder::new() - .server_url(url) - .build() - .expect("client build") +fn server() -> axum_test::TestServer { + mock_http_server(router()) +} + +async fn call_rpc( + server: &axum_test::TestServer, + method: &str, + params: Value, + token: Option<&str>, +) -> Result +where + T: DeserializeOwned, +{ + let body = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + }); + + let mut request = server.post("/rpc").json(&body); + if let Some(token) = token { + request = request.authorization_bearer(token); + } + + let payload: Value = request.await.json(); + + if let Some(error) = payload.get("error") { + Err(error.clone()) + } else { + Ok(serde_json::from_value(payload["result"].clone()).expect("result should deserialize")) + } } #[tokio::test] async fn legacy_version_round_trips_through_canonical_handler() { - let server = spawn_http(router()); - let url = server.server_url("/rpc").expect("server url").to_string(); + let server = server(); - let resp = client(url) - .rename_user_v1(RenameUserV1 { + let resp: RenameUserResponseV1 = call_rpc( + &server, + "rename_user.v1", + json!(RenameUserV1 { name: "Ada".to_string(), - }) - .await - .expect("legacy rename ok"); + }), + None, + ) + .await + .expect("legacy rename ok"); assert_eq!( resp, @@ -178,16 +212,19 @@ async fn legacy_version_round_trips_through_canonical_handler() { #[tokio::test] async fn canonical_version_uses_declared_wire_method() { - let server = spawn_http(router()); - let url = server.server_url("/rpc").expect("server url").to_string(); + let server = server(); - let resp = client(url) - .rename_user(RenameUserV2 { + let resp: RenameUserResponseV2 = call_rpc( + &server, + "rename_user.v2", + json!(RenameUserV2 { display_name: "Grace".to_string(), notify: true, - }) - .await - .expect("canonical rename ok"); + }), + None, + ) + .await + .expect("canonical rename ok"); assert_eq!( resp, @@ -200,18 +237,18 @@ async fn canonical_version_uses_declared_wire_method() { #[tokio::test] async fn unauth_method_round_trips() { - let server = spawn_http(router()); - let url = server.server_url("/rpc").expect("server url").to_string(); - - let mut c = client(url); - c.set_bearer_token(Option::::None); + let server = server(); - let resp = c - .ping(EchoRequest { + let resp: EchoResponse = call_rpc( + &server, + "ping", + json!(EchoRequest { msg: "hello".to_string(), - }) - .await - .expect("ping ok"); + }), + None, + ) + .await + .expect("ping ok"); assert_eq!(resp.msg, "hello"); assert_eq!(resp.user_id, None); @@ -219,14 +256,9 @@ async fn unauth_method_round_trips() { #[tokio::test] async fn permission_required_method_rejects_anonymous() { - let server = spawn_http(router()); - let url = server.server_url("/rpc").unwrap().to_string(); + let server = server(); - let mut c = client(url); - c.set_bearer_token(Option::::None); - - let err = c - .add(AddRequest { a: 2, b: 3 }) + let err = call_rpc::(&server, "add", json!(AddRequest { a: 2, b: 3 }), None) .await .expect_err("anonymous add must be rejected"); @@ -239,16 +271,16 @@ async fn permission_required_method_rejects_anonymous() { #[tokio::test] async fn permission_required_method_rejects_wrong_perms() { - let server = spawn_http(router()); - let url = server.server_url("/rpc").unwrap().to_string(); - - let mut c = client(url); - c.set_bearer_token(Some("readonly-token".to_string())); - - let err = c - .add(AddRequest { a: 2, b: 3 }) - .await - .expect_err("readonly user must not be allowed to call add"); + let server = server(); + + let err = call_rpc::( + &server, + "add", + json!(AddRequest { a: 2, b: 3 }), + Some("readonly-token"), + ) + .await + .expect_err("readonly user must not be allowed to call add"); let s = err.to_string(); assert!( s.contains("permission") || s.contains("Permission") || s.contains("PERMISSION"), @@ -258,30 +290,33 @@ async fn permission_required_method_rejects_wrong_perms() { #[tokio::test] async fn permission_required_method_succeeds_with_correct_perms() { - let server = spawn_http(router()); - let url = server.server_url("/rpc").unwrap().to_string(); - - let mut c = client(url); - c.set_bearer_token(Some("user-token".to_string())); - - let resp = c.add(AddRequest { a: 7, b: 35 }).await.expect("add ok"); + let server = server(); + + let resp: AddResponse = call_rpc( + &server, + "add", + json!(AddRequest { a: 7, b: 35 }), + Some("user-token"), + ) + .await + .expect("add ok"); assert_eq!(resp.sum, 42); } #[tokio::test] async fn admin_method_succeeds_with_admin_token() { - let server = spawn_http(router()); - let url = server.server_url("/rpc").unwrap().to_string(); + let server = server(); - let mut c = client(url); - c.set_bearer_token(Some("admin-token".to_string())); - - let resp = c - .admin_only(EchoRequest { + let resp: EchoResponse = call_rpc( + &server, + "admin_only", + json!(EchoRequest { msg: "secret".to_string(), - }) - .await - .expect("admin call ok"); + }), + Some("admin-token"), + ) + .await + .expect("admin call ok"); assert_eq!(resp.msg, "secret"); assert_eq!(resp.user_id.as_deref(), Some("admin-1")); @@ -291,8 +326,7 @@ async fn admin_method_succeeds_with_admin_token() { async fn malformed_params_yield_jsonrpc_error() { // Bypass the typed client to send a malformed body and confirm the // server returns a JSON-RPC `invalid_params` error rather than a panic. - let server = spawn_http(router()); - let url = server.server_url("/rpc").unwrap().to_string(); + let server = server(); let body = serde_json::json!({ "jsonrpc": "2.0", @@ -301,15 +335,7 @@ async fn malformed_params_yield_jsonrpc_error() { "id": 1, }); - let resp: serde_json::Value = reqwest::Client::new() - .post(url) - .json(&body) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); + let resp: serde_json::Value = server.post("/rpc").json(&body).await.json(); assert!( resp.get("error").is_some(), diff --git a/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs index 6aedeb7..a90e557 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs @@ -1,11 +1,7 @@ #[cfg(test)] mod tests { - use axum::Router; - use reqwest::Client; use serde::{Deserialize, Serialize}; - use serde_json::json; - use std::net::SocketAddr; - use tokio::net::TcpListener; + use serde_json::{Value, json}; // Test types #[derive(Serialize, Deserialize, Debug)] @@ -49,47 +45,38 @@ mod tests { } } - async fn setup_test_server() -> (SocketAddr, Router) { + fn setup_test_server() -> axum_test::TestServer { let router = TestServiceBuilder::new(TestServiceImpl) .base_url("/api/rpc") .build() .expect("Failed to build router"); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); + axum_test::TestServer::builder() + .mock_transport() + .build(router) + .unwrap() + } - (addr, router) + async fn rpc(server: &axum_test::TestServer, body: Value) -> Value { + server.post("/api/rpc").json(&body).await.json() } #[tokio::test] async fn test_internal_error_sanitization() { - let (addr, router) = setup_test_server().await; - - // Spawn the server - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - - // Give the server a moment to start - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let server = setup_test_server(); - let client = Client::new(); - let response = client - .post(format!("http://{}/api/rpc", addr)) - .json(&json!({ + let json_response = rpc( + &server, + json!({ "jsonrpc": "2.0", "method": "test_internal_error", "params": { "value": "test" }, "id": 1 - })) - .send() - .await - .unwrap(); - - let json_response: serde_json::Value = response.json().await.unwrap(); + }), + ) + .await; println!( "Response: {}", serde_json::to_string_pretty(&json_response).unwrap() @@ -105,33 +92,20 @@ mod tests { #[tokio::test] async fn test_invalid_params_error_sanitization() { - let (addr, router) = setup_test_server().await; + let server = setup_test_server(); - // Spawn the server - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - - // Give the server a moment to start - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - let client = Client::new(); - let response = client - .post(format!("http://{}/api/rpc", addr)) - .json(&json!({ + let json_response = rpc( + &server, + json!({ "jsonrpc": "2.0", "method": "test_internal_error", "params": { "wrong_field": "test" // Invalid field name }, "id": 2 - })) - .send() - .await - .unwrap(); - - let json_response: serde_json::Value = response.json().await.unwrap(); + }), + ) + .await; // Verify error is sanitized assert_eq!(json_response["jsonrpc"], "2.0"); @@ -143,35 +117,21 @@ mod tests { #[tokio::test] async fn test_authentication_error_sanitization() { - let (addr, router) = setup_test_server().await; - - // Spawn the server - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - - // Give the server a moment to start - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - let client = Client::new(); + let server = setup_test_server(); // Test missing auth header - let response = client - .post(format!("http://{}/api/rpc", addr)) - .json(&json!({ + let json_response = rpc( + &server, + json!({ "jsonrpc": "2.0", "method": "test_auth_error", "params": { "value": "test" }, "id": 3 - })) - .send() - .await - .unwrap(); - - let json_response: serde_json::Value = response.json().await.unwrap(); + }), + ) + .await; // Verify error is sanitized assert_eq!(json_response["jsonrpc"], "2.0"); @@ -183,31 +143,18 @@ mod tests { #[tokio::test] async fn test_method_not_found_includes_method_name() { - let (addr, router) = setup_test_server().await; - - // Spawn the server - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); + let server = setup_test_server(); - // Give the server a moment to start - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - let client = Client::new(); - let response = client - .post(format!("http://{}/api/rpc", addr)) - .json(&json!({ + let json_response = rpc( + &server, + json!({ "jsonrpc": "2.0", "method": "non_existent_method", "params": {}, "id": 4 - })) - .send() - .await - .unwrap(); - - let json_response: serde_json::Value = response.json().await.unwrap(); + }), + ) + .await; // Verify error includes method name (this is safe to expose) assert_eq!(json_response["jsonrpc"], "2.0"); @@ -222,27 +169,13 @@ mod tests { #[tokio::test] async fn test_parse_error_sanitization() { - let (addr, router) = setup_test_server().await; - - // Spawn the server - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - - // Give the server a moment to start - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - let client = Client::new(); - let response = client - .post(format!("http://{}/api/rpc", addr)) - .header("Content-Type", "application/json") - .body("invalid json {") - .send() + let server = setup_test_server(); + let json_response: Value = server + .post("/api/rpc") + .text("invalid json {") + .content_type("application/json") .await - .unwrap(); - - let json_response: serde_json::Value = response.json().await.unwrap(); + .json(); // Verify error is sanitized assert_eq!(json_response["jsonrpc"], "2.0"); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs index 31ebd6d..ab5db24 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs @@ -44,36 +44,27 @@ mod tests { // The router should have routes for /explorer and /explorer/openrpc.json let app = Router::new().merge(explorer_routes); - - // Create test server - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - // Spawn the server - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); + let server = axum_test::TestServer::builder() + .mock_transport() + .build(app) + .unwrap(); // Test that the explorer page is accessible - let response = reqwest::get(format!("http://{}/explorer", addr)) - .await - .unwrap(); - assert_eq!(response.status(), 200); + let response = server.get("/explorer").await; + response.assert_status_ok(); - let content = response.text().await.unwrap(); + let content = response.text(); assert!(content.contains("\"UserService\"")); - assert!(content.contains("id=\"jwt-token\"")); + assert!(content.contains("id=\"bearer-token\"")); assert!(content.contains("id=\"saved-list\"")); assert!(content.contains("id=\"history-list\"")); assert!(content.contains("\"jsonrpc\"")); // Test that the OpenRPC document is accessible - let response = reqwest::get(format!("http://{}/explorer/openrpc.json", addr)) - .await - .unwrap(); - assert_eq!(response.status(), 200); + let response = server.get("/explorer/openrpc.json").await; + response.assert_status_ok(); - let openrpc_doc: serde_json::Value = response.json().await.unwrap(); + let openrpc_doc: serde_json::Value = response.json(); assert_eq!(openrpc_doc["info"]["title"], "UserService JSON-RPC API"); assert!(openrpc_doc["methods"].is_array()); } @@ -101,6 +92,48 @@ mod tests { custom_path_service::test_routes(); } + #[tokio::test] + async fn explorer_custom_path_is_normalized_and_nested_under_base_url() { + mod normalized_path_service { + use ras_jsonrpc_macro::jsonrpc_service; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] + pub struct PingRequest; + + #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] + pub struct PingResponse; + + jsonrpc_service!({ + service_name: NormalizedService, + openrpc: true, + explorer: { path: "api/docs/" }, + methods: [ + UNAUTHORIZED ping(PingRequest) -> PingResponse, + ] + }); + + pub fn routes(base_url: &str) -> axum::Router { + normalizedservice_explorer_routes(base_url) + } + } + + let app = normalized_path_service::routes("/rpc/"); + let server = axum_test::TestServer::builder() + .mock_transport() + .build(app) + .unwrap(); + + let docs_response = server.get("/rpc/api/docs").await; + docs_response.assert_status_ok(); + assert!(docs_response.text().contains("\"NormalizedService\"")); + + let spec_response = server.get("/rpc/api/docs/openrpc.json").await; + spec_response.assert_status_ok(); + let spec: serde_json::Value = spec_response.json(); + assert_eq!(spec["info"]["title"], "NormalizedService JSON-RPC API"); + } + #[test] fn test_explorer_requires_openrpc() { mod no_openrpc_service { diff --git a/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs index 6d51852..90bf485 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs @@ -1,9 +1,9 @@ #[test] -fn test_generated_explorer_does_not_store_jwt_in_local_storage() { +fn test_generated_explorer_does_not_store_bearer_token_in_local_storage() { let template = include_str!("../../../rest/ras-rest-macro/src/api_explorer_template.html"); - assert!(!template.contains("localStorage.getItem('jwt-token')")); - assert!(!template.contains("localStorage.setItem('jwt-token'")); - assert!(!template.contains("localStorage.removeItem('jwt-token'")); + assert!(!template.contains("localStorage.getItem('bearer-token')")); + assert!(!template.contains("localStorage.setItem('bearer-token'")); + assert!(!template.contains("localStorage.removeItem('bearer-token'")); assert!(!template.contains("localStorage.setItem(`${storagePrefix}:bearer-token`")); assert!(template.contains("sessionStorage.setItem(`${storagePrefix}:${key}`")); assert!(template.contains("localStorage.setItem(\"ras-explorer-theme\"")); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs index 4af21c9..e07d850 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs @@ -4,7 +4,6 @@ use ras_jsonrpc_macro::jsonrpc_service; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::HashSet; -use tokio::net::TcpListener as TokioTcpListener; // Test data structures for various scenarios #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] @@ -101,7 +100,7 @@ impl AuthProvider for TestAuthProvider { } } -// Generate comprehensive test service +// Generate a broad test service jsonrpc_service!({ service_name: TestService, openrpc: true, @@ -252,39 +251,24 @@ impl TestServiceTrait for TestServiceImpl { } } -async fn create_test_server() -> (String, tokio::task::JoinHandle<()>) { - let tokio_listener = TokioTcpListener::bind("127.0.0.1:0") - .await - .expect("Failed to bind to port"); - let addr = tokio_listener - .local_addr() - .expect("Failed to get local addr"); - let base_url = format!("http://127.0.0.1:{}", addr.port()); - +fn create_test_server() -> axum_test::TestServer { let builder = TestServiceBuilder::new(TestServiceImpl) .base_url("/rpc") .auth_provider(TestAuthProvider::new()); let app = builder.build().expect("Failed to build app"); - - let handle = tokio::spawn(async move { - axum::serve(tokio_listener, app) - .await - .expect("Server failed"); - }); - - // Give the server a moment to start - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - (base_url, handle) + axum_test::TestServer::builder() + .mock_transport() + .build(app) + .unwrap() } async fn make_jsonrpc_request( - base_url: &str, + server: &axum_test::TestServer, method: &str, params: Value, token: Option<&str>, -) -> Result { +) -> Value { let request_body = json!({ "jsonrpc": "2.0", "method": method, @@ -292,27 +276,22 @@ async fn make_jsonrpc_request( "id": 1 }); - let mut request_builder = reqwest::Client::new() - .post(format!("{}/rpc", base_url)) - .header("Content-Type", "application/json") - .json(&request_body); + let mut request = server.post("/rpc").json(&request_body); if let Some(token) = token { - request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); + request = request.authorization_bearer(token); } - let response = request_builder.send().await?; - let json_response: Value = response.json().await?; - Ok(json_response) + request.await.json() } #[tokio::test] async fn test_unauthorized_methods() { - let (base_url, _handle) = create_test_server().await; + let server = create_test_server(); // Test sign_in with valid credentials let response = make_jsonrpc_request( - &base_url, + &server, "sign_in", json!({ "email": "admin@test.com", @@ -320,8 +299,7 @@ async fn test_unauthorized_methods() { }), None, ) - .await - .unwrap(); + .await; assert_eq!(response["jsonrpc"], "2.0"); assert_eq!(response["id"], 1); @@ -333,7 +311,7 @@ async fn test_unauthorized_methods() { // Test sign_in with invalid credentials let response = make_jsonrpc_request( - &base_url, + &server, "sign_in", json!({ "email": "wrong@test.com", @@ -341,15 +319,12 @@ async fn test_unauthorized_methods() { }), None, ) - .await - .unwrap(); + .await; assert!(response.get("error").is_some()); // Test get_public_info - let response = make_jsonrpc_request(&base_url, "get_public_info", json!(()), None) - .await - .unwrap(); + let response = make_jsonrpc_request(&server, "get_public_info", json!(()), None).await; assert_eq!(response["result"], "This is public information"); @@ -365,21 +340,17 @@ async fn test_unauthorized_methods() { } }); - let response = make_jsonrpc_request(&base_url, "echo_complex", complex_data.clone(), None) - .await - .unwrap(); + let response = make_jsonrpc_request(&server, "echo_complex", complex_data.clone(), None).await; assert_eq!(response["result"], complex_data); } #[tokio::test] async fn test_authentication_required_methods() { - let (base_url, _handle) = create_test_server().await; + let server = create_test_server(); // Test without token - should fail - let response = make_jsonrpc_request(&base_url, "sign_out", json!(()), None) - .await - .unwrap(); + let response = make_jsonrpc_request(&server, "sign_out", json!(()), None).await; assert!(response.get("error").is_some()); let error = &response["error"]; @@ -387,22 +358,19 @@ async fn test_authentication_required_methods() { // Test with valid token - should succeed let response = - make_jsonrpc_request(&base_url, "sign_out", json!(()), Some("valid-admin-token")) - .await - .unwrap(); + make_jsonrpc_request(&server, "sign_out", json!(()), Some("valid-admin-token")).await; assert!(response.get("error").is_none()); assert_eq!(response["result"], json!(())); // Test get_user_info with valid token let response = make_jsonrpc_request( - &base_url, + &server, "get_user_info", json!(()), Some("valid-user-token"), ) - .await - .unwrap(); + .await; assert!(response.get("error").is_none()); let result = &response["result"]; @@ -411,33 +379,31 @@ async fn test_authentication_required_methods() { // Test process_data let response = make_jsonrpc_request( - &base_url, + &server, "process_data", json!(["item1", "item2", "item3"]), Some("valid-empty-perms-token"), ) - .await - .unwrap(); + .await; assert!(response.get("error").is_none()); let result = &response["result"]; assert_eq!(result["processed_count"], 3); - assert_eq!(result["success"], true); + assert_eq!(result["success"].as_bool(), Some(true)); } #[tokio::test] async fn test_admin_permission_methods() { - let (base_url, _handle) = create_test_server().await; + let server = create_test_server(); // Test with user token (insufficient permissions) - should fail let response = make_jsonrpc_request( - &base_url, + &server, "delete_everything", json!(()), Some("valid-user-token"), ) - .await - .unwrap(); + .await; assert!(response.get("error").is_some()); let error = &response["error"]; @@ -445,19 +411,18 @@ async fn test_admin_permission_methods() { // Test with admin token - should succeed let response = make_jsonrpc_request( - &base_url, + &server, "delete_everything", json!(()), Some("valid-admin-token"), ) - .await - .unwrap(); + .await; assert!(response.get("error").is_none()); // Test create_user with admin token let response = make_jsonrpc_request( - &base_url, + &server, "create_user", json!({ "name": "New User", @@ -466,8 +431,7 @@ async fn test_admin_permission_methods() { }), Some("valid-admin-token"), ) - .await - .unwrap(); + .await; assert!(response.get("error").is_none()); let result = &response["result"]; @@ -478,11 +442,11 @@ async fn test_admin_permission_methods() { #[tokio::test] async fn test_user_permission_methods() { - let (base_url, _handle) = create_test_server().await; + let server = create_test_server(); // Test with empty permissions token - should fail let response = make_jsonrpc_request( - &base_url, + &server, "update_profile", json!({ "name": "Updated User", @@ -491,14 +455,13 @@ async fn test_user_permission_methods() { }), Some("valid-empty-perms-token"), ) - .await - .unwrap(); + .await; assert!(response.get("error").is_some()); // Test with user token - should succeed let response = make_jsonrpc_request( - &base_url, + &server, "update_profile", json!({ "name": "Updated User", @@ -507,8 +470,7 @@ async fn test_user_permission_methods() { }), Some("valid-user-token"), ) - .await - .unwrap(); + .await; assert!(response.get("error").is_none()); let result = &response["result"]; @@ -517,13 +479,12 @@ async fn test_user_permission_methods() { // Test get_user_data with existing user let response = make_jsonrpc_request( - &base_url, + &server, "get_user_data", json!(123), Some("valid-user-token"), ) - .await - .unwrap(); + .await; assert!(response.get("error").is_none()); let result = &response["result"]; @@ -531,13 +492,12 @@ async fn test_user_permission_methods() { // Test get_user_data with non-existing user let response = make_jsonrpc_request( - &base_url, + &server, "get_user_data", json!(999), Some("valid-user-token"), ) - .await - .unwrap(); + .await; assert!(response.get("error").is_none()); assert_eq!(response["result"], json!(null)); @@ -545,12 +505,10 @@ async fn test_user_permission_methods() { #[tokio::test] async fn test_invalid_requests() { - let (base_url, _handle) = create_test_server().await; + let server = create_test_server(); // Test method not found - let response = make_jsonrpc_request(&base_url, "non_existent_method", json!(()), None) - .await - .unwrap(); + let response = make_jsonrpc_request(&server, "non_existent_method", json!(()), None).await; assert!(response.get("error").is_some()); let error = &response["error"]; @@ -563,36 +521,26 @@ async fn test_invalid_requests() { "id": 1 }); - let response = reqwest::Client::new() - .post(format!("{}/rpc", base_url)) - .header("Content-Type", "application/json") - .json(&invalid_request) - .send() - .await - .unwrap(); - - let json_response: Value = response.json().await.unwrap(); + let json_response: Value = server.post("/rpc").json(&invalid_request).await.json(); assert!(json_response.get("error").is_some()); // Test invalid parameters for a method - let response = make_jsonrpc_request(&base_url, "sign_in", json!("invalid_params"), None) - .await - .unwrap(); + let response = make_jsonrpc_request(&server, "sign_in", json!("invalid_params"), None).await; assert!(response.get("error").is_some()); } #[tokio::test] async fn test_concurrent_requests() { - let (base_url, _handle) = create_test_server().await; + let server = std::sync::Arc::new(create_test_server()); // Test multiple concurrent requests let mut handles = vec![]; for _ in 0..10 { - let base_url = base_url.clone(); + let server = std::sync::Arc::clone(&server); let handle = tokio::spawn(async move { - make_jsonrpc_request(&base_url, "get_public_info", json!(()), None).await + make_jsonrpc_request(&server, "get_public_info", json!(()), None).await }); handles.push(handle); } @@ -602,7 +550,7 @@ async fn test_concurrent_requests() { // All requests should succeed for result in results { - let response = result.unwrap().unwrap(); + let response = result.unwrap(); assert_eq!(response["result"], "This is public information"); } } @@ -627,7 +575,10 @@ async fn test_openrpc_generation() { .iter() .find(|m| m["name"] == "delete_everything") .unwrap(); - assert_eq!(delete_method["x-authentication"]["required"], true); + assert_eq!( + delete_method["x-authentication"]["required"].as_bool(), + Some(true) + ); assert_eq!(delete_method["x-permissions"][0], "admin"); // Check that methods with multiple permissions are correct @@ -642,11 +593,11 @@ async fn test_openrpc_generation() { } #[cfg(feature = "client")] -#[tokio::test] -async fn test_client_generation() { +#[test] +fn test_client_generation() { // Test that client generation compiles and produces valid API let client_result = TestServiceClientBuilder::new() - .server_url("http://localhost:9999/rpc") + .server_url("http://example.invalid/rpc") .with_timeout(std::time::Duration::from_millis(1000)) .build(); @@ -655,20 +606,4 @@ async fn test_client_generation() { let mut client = client_result.unwrap(); client.set_bearer_token(Some("test-token")); assert_eq!(client.bearer_token(), Some("test-token")); - - // Try to call a method - this should fail with connection error but proves the API works - let request = SignInRequest { - email: "test@example.com".to_string(), - password: "password".to_string(), - }; - - let result = client.sign_in(request.clone()).await; - // Should get a connection error since server doesn't exist - assert!(result.is_err()); - - // Test timeout version - let result = client - .sign_in_with_timeout(request, std::time::Duration::from_millis(100)) - .await; - assert!(result.is_err()); } diff --git a/crates/rpc/ras-jsonrpc-macro/tests/integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/integration.rs index 57fdc6f..73609b6 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/integration.rs @@ -238,7 +238,10 @@ async fn test_openrpc_generation() { // Check create_user method (requires admin permission) let create_user_method = methods.iter().find(|m| m["name"] == "create_user").unwrap(); - assert_eq!(create_user_method["x-authentication"]["required"], true); + assert_eq!( + create_user_method["x-authentication"]["required"].as_bool(), + Some(true) + ); assert_eq!(create_user_method["x-permissions"][0], "admin"); assert_eq!(create_user_method["summary"], "Create a user account."); assert_eq!( diff --git a/crates/rpc/ras-jsonrpc-macro/tests/support/mod.rs b/crates/rpc/ras-jsonrpc-macro/tests/support/mod.rs new file mode 100644 index 0000000..ab7e833 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/tests/support/mod.rs @@ -0,0 +1,52 @@ +use std::collections::{HashMap, HashSet}; + +use axum::Router; +use axum_test::TestServer; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; + +#[derive(Clone, Debug)] +pub struct MockAuthProvider { + table: HashMap, +} + +impl Default for MockAuthProvider { + fn default() -> Self { + let mut table = HashMap::new(); + table.insert("user-token".to_string(), mock_user("user-1", &["user"])); + table.insert( + "admin-token".to_string(), + mock_user("admin-1", &["admin", "user"]), + ); + table.insert("readonly-token".to_string(), mock_user("ro-1", &["read"])); + Self { table } + } +} + +impl AuthProvider for MockAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + let result = self + .table + .get(&token) + .cloned() + .ok_or(AuthError::InvalidToken); + Box::pin(async move { result }) + } +} + +pub fn mock_user(user_id: &str, permissions: &[&str]) -> AuthenticatedUser { + AuthenticatedUser { + user_id: user_id.to_string(), + permissions: permissions + .iter() + .map(|p| (*p).to_string()) + .collect::>(), + metadata: None, + } +} + +pub fn mock_http_server(router: Router) -> TestServer { + TestServer::builder() + .mock_transport() + .build(router) + .expect("failed to start axum-test TestServer with in-memory transport") +} diff --git a/crates/rpc/ras-jsonrpc-types/Cargo.toml b/crates/rpc/ras-jsonrpc-types/Cargo.toml index 233ff2a..db009bb 100644 --- a/crates/rpc/ras-jsonrpc-types/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-types/Cargo.toml @@ -2,11 +2,13 @@ name = "ras-jsonrpc-types" version = "0.1.1" edition = "2024" +rust-version = "1.88" description = "JSON-RPC 2.0 protocol types and utilities" license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] serde = { workspace = true } -serde_json = { workspace = true } \ No newline at end of file +serde_json = { workspace = true } diff --git a/crates/rpc/ras-jsonrpc-types/README.md b/crates/rpc/ras-jsonrpc-types/README.md index f40f095..c4f2993 100644 --- a/crates/rpc/ras-jsonrpc-types/README.md +++ b/crates/rpc/ras-jsonrpc-types/README.md @@ -8,11 +8,11 @@ This crate provides type-safe representations of JSON-RPC 2.0 protocol structure ## Features -- ✅ **JSON-RPC 2.0 Compliant**: Full support for the JSON-RPC 2.0 specification -- ✅ **Type Safe**: Strong typing with serde serialization/deserialization -- ✅ **Minimal Dependencies**: Only depends on `serde` and `serde_json` -- ✅ **Standard Error Codes**: Predefined error codes following the JSON-RPC 2.0 spec -- ✅ **Convenience Methods**: Helper methods for creating requests, responses, and errors +- **JSON-RPC 2.0 compliant**: Full support for the JSON-RPC 2.0 specification +- **Type safe**: Strong typing with serde serialization/deserialization +- **Minimal dependencies**: Only depends on `serde` and `serde_json` +- **Standard error codes**: Predefined error codes following the JSON-RPC 2.0 spec +- **Convenience methods**: Helper methods for creating requests, responses, and errors ## Usage @@ -20,7 +20,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -ras-jsonrpc-types = "0.1.0" +ras-jsonrpc-types = "0.1.1" ``` ### Basic Types @@ -71,7 +71,7 @@ let token_expired = JsonRpcError::token_expired(); ## JSON-RPC 2.0 Specification -This crate implements the complete [JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification): +This crate provides the core [JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification) request, response, and error types used by RAS services: ### Request Structure ```json @@ -121,12 +121,19 @@ The crate provides all standard JSON-RPC 2.0 error codes plus extension codes fo ## Integration -This crate is designed to work seamlessly with: +This crate is designed to work with: - [`ras-jsonrpc-core`](../ras-jsonrpc-core) - Authentication and authorization traits - [`ras-jsonrpc-macro`](../ras-jsonrpc-macro) - Procedural macros for service generation - Any JSON-RPC client or server implementation +## Checks + +```bash +cargo test -p ras-jsonrpc-types --locked +cargo clippy -p ras-jsonrpc-types --all-targets --all-features --locked -- -D warnings +``` + ## License -This project is licensed under the MIT License. +This project is licensed under either MIT or Apache-2.0. diff --git a/crates/rpc/ras-jsonrpc-types/src/lib.rs b/crates/rpc/ras-jsonrpc-types/src/lib.rs index 6ec268a..22f1be0 100644 --- a/crates/rpc/ras-jsonrpc-types/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-types/src/lib.rs @@ -274,4 +274,65 @@ mod tests { assert!(!s.contains("\"id\"")); assert!(!s.contains("\"params\"")); } + + #[test] + fn request_serializes_canonical_jsonrpc_wire_shape() { + let request = JsonRpcRequest::new( + "subtract".to_string(), + Some(serde_json::json!([42, 23])), + Some(serde_json::json!(1)), + ); + + assert_eq!( + serde_json::to_value(request).unwrap(), + serde_json::json!({ + "jsonrpc": "2.0", + "method": "subtract", + "params": [42, 23], + "id": 1 + }) + ); + } + + #[test] + fn success_response_omits_error_field() { + let response = JsonRpcResponse::success( + serde_json::json!({ "value": 19 }), + Some(serde_json::json!("req-1")), + ); + + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({ + "jsonrpc": "2.0", + "result": { "value": 19 }, + "id": "req-1" + }) + ); + } + + #[test] + fn error_response_omits_result_field_and_keeps_error_data() { + let response = JsonRpcResponse::error( + JsonRpcError::new( + error_codes::INVALID_PARAMS, + "Invalid params".to_string(), + Some(serde_json::json!({ "field": "name" })), + ), + Some(serde_json::json!("req-2")), + ); + + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({ + "jsonrpc": "2.0", + "error": { + "code": -32602, + "message": "Invalid params", + "data": { "field": "name" } + }, + "id": "req-2" + }) + ); + } } diff --git a/crates/specs/openrpc-types/Cargo.toml b/crates/specs/openrpc-types/Cargo.toml index 0e7c80b..4525f4a 100644 --- a/crates/specs/openrpc-types/Cargo.toml +++ b/crates/specs/openrpc-types/Cargo.toml @@ -2,23 +2,21 @@ name = "openrpc-types" version = "0.1.1" edition = "2024" -description = "Complete Rust types for the OpenRPC 1.3.2 specification with serde support, bon builders, and validation" +rust-version = "1.88" +description = "Rust types for the OpenRPC 1.3.2 specification with serde support, bon builders, and validation helpers" keywords = ["openrpc", "json-rpc", "api", "specification", "types"] categories = ["api-bindings", "data-structures", "web-programming"] license = "MIT OR Apache-2.0" -repository = "https://github.com/example/rust-agent-stack" -homepage = "https://github.com/example/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +readme = "README.md" [dependencies] serde = { workspace = true } serde_json = { workspace = true } -thiserror = { workspace = true } bon = { workspace = true } schemars = { workspace = true, optional = true } [features] default = [] json-schema = ["schemars"] - -[dev-dependencies] -tokio-test = { workspace = true } \ No newline at end of file diff --git a/crates/specs/openrpc-types/README.md b/crates/specs/openrpc-types/README.md index 6b18bfa..5c50f70 100644 --- a/crates/specs/openrpc-types/README.md +++ b/crates/specs/openrpc-types/README.md @@ -1,17 +1,17 @@ # OpenRPC Types -Complete Rust types for the OpenRPC 1.3.2 specification with serde support, bon builders, and comprehensive validation. +Rust types for the OpenRPC 1.3.2 specification with serde support, bon builders, and runtime validation helpers. ## Features -- **Complete OpenRPC 1.3.2 specification types** - All objects and fields from the specification +- **OpenRPC 1.3.2 specification types** - Objects and fields from the specification - **Serde serialization/deserialization** - Full JSON support with proper field naming - **Bon builder patterns** - Ergonomic API construction with type-safe builders -- **Comprehensive validation** - Validates against OpenRPC specification constraints -- **JSON Schema Draft 7 compatibility** - For Schema objects with full validation +- **Validation helpers** - Checks OpenRPC specification constraints +- **JSON Schema Draft 7 compatibility** - For Schema objects used in OpenRPC documents - **Reference resolution support** - For $ref within components - **Specification extensions** - Support for x-* extension fields -- **Type safety** - Prevents invalid OpenRPC documents at compile time +- **Type safety** - Helps construct OpenRPC documents with strongly typed Rust values ## Quick Start @@ -19,7 +19,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -openrpc-types = "0.1.0" +openrpc-types = "0.1.1" ``` ## Example Usage @@ -81,7 +81,7 @@ println!("{}", json); - **`ContentDescriptor`** - Describes parameters and results with schemas - **`Schema`** - JSON Schema Draft 7 compliant schema definitions - **`Example`** - Example values with embedded or external references -- **`ExamplePairing`** - Complete request/response example pairs +- **`ExamplePairing`** - Request/response example pairs ### Linking and Documentation @@ -97,9 +97,9 @@ println!("{}", json); ## Validation -The crate provides comprehensive validation that ensures: +The crate provides validation helpers for: -- **Specification compliance** - All OpenRPC 1.3.2 constraints are enforced +- **Specification compliance** - OpenRPC 1.3.2 constraints represented by this crate - **Unique constraints** - Method names, parameter names, error codes are unique - **Type consistency** - Schemas and examples match their expected types - **URL and email format validation** - Proper format checking for contact info @@ -107,9 +107,12 @@ The crate provides comprehensive validation that ensures: - **Reference validity** - Internal references point to valid components ```rust -use openrpc_types::{OpenRpc, validation::Validate}; +use openrpc_types::{Info, OpenRpc, validation::Validate}; -let openrpc = // ... build your OpenRPC document +let openrpc = OpenRpc::v1_3_2( + Info::new("Validation Example API", "1.0.0"), + Vec::new(), +); // Validate returns detailed error information match openrpc.validate() { @@ -120,7 +123,7 @@ match openrpc.validate() { ## JSON Schema Integration -Schema objects are fully compatible with JSON Schema Draft 7: +Schema objects model the JSON Schema Draft 7 shapes used by OpenRPC: ```rust use openrpc_types::Schema; @@ -163,7 +166,7 @@ let method = Method::builder() ## Error Handling -Comprehensive error types provide detailed information about validation failures: +Error types provide detailed information about validation failures: ```rust use openrpc_types::{OpenRpcError, OpenRpcResult}; @@ -188,13 +191,21 @@ fn validate_document(openrpc: &OpenRpc) -> OpenRpcResult<()> { ```toml [dependencies] -openrpc-types = { version = "0.1.0", features = ["json-schema"] } +openrpc-types = { version = "0.1.1", features = ["json-schema"] } +``` + +## Checks + +```bash +cargo test -p openrpc-types --locked +cargo clippy -p openrpc-types --all-targets --all-features --locked -- -D warnings ``` ## License -This project is licensed under the same terms as the workspace. +This project is licensed under either MIT or Apache-2.0. See +[LICENSE-MIT](../../../LICENSE-MIT) and [LICENSE-APACHE](../../../LICENSE-APACHE). ## Contributing -This crate is part of the Rust Agent Stack workspace. Please see the main repository for contributing guidelines. \ No newline at end of file +This crate is part of the Rust Agent Stack workspace. Please see the main repository for contributing guidelines. diff --git a/crates/specs/openrpc-types/src/components.rs b/crates/specs/openrpc-types/src/components.rs index 65f9f8c..f379f7e 100644 --- a/crates/specs/openrpc-types/src/components.rs +++ b/crates/specs/openrpc-types/src/components.rs @@ -197,7 +197,9 @@ impl Components { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } @@ -494,4 +496,107 @@ mod tests { assert!(components.get_schema("Missing").is_none()); assert!(components.get_content_descriptor("Missing").is_none()); } + + #[test] + fn map_setters_replace_each_component_collection() { + let components = Components::new() + .with_content_descriptors(HashMap::from([( + "UserParam".to_string(), + ContentDescriptor::new("user", Schema::string()), + )])) + .with_schemas(HashMap::from([("User".to_string(), Schema::object())])) + .with_examples(HashMap::from([( + "UserExample".to_string(), + Example::with_value(json!({"id": "user-1"})), + )])) + .with_links(HashMap::from([( + "ProfileLink".to_string(), + Link::new("profile").with_method("getProfile"), + )])) + .with_errors(HashMap::from([( + "UserNotFound".to_string(), + ErrorObject::new(1000, "User not found"), + )])) + .with_example_pairings(HashMap::from([( + "UserPairing".to_string(), + ExamplePairing::new("userPairing", vec![]), + )])) + .with_tags(HashMap::from([("Users".to_string(), Tag::new("users"))])); + + assert!(components.validate().is_ok()); + assert!(components.get_content_descriptor("UserParam").is_some()); + assert!(components.get_schema("User").is_some()); + assert!(components.get_example("UserExample").is_some()); + assert!(components.get_link("ProfileLink").is_some()); + assert!(components.get_error("UserNotFound").is_some()); + assert!(components.get_example_pairing("UserPairing").is_some()); + assert!(components.get_tag("Users").is_some()); + } + + #[test] + fn validation_errors_include_component_collection_paths() { + let cases = [ + ( + Components::new().with_content_descriptor( + "BadParam", + ContentDescriptor::new("bad name", Schema::string()), + ), + "Validation error at contentDescriptors.BadParam", + ), + ( + Components::new().with_schema( + "BadSchema", + Schema::string().with_min_length(10).with_max_length(1), + ), + "Validation error at schemas.BadSchema", + ), + ( + Components::new().with_example("BadExample", { + let mut example = Example::with_value("inline"); + example.external_value = Some("https://example.test/value.json".to_string()); + example + }), + "Validation error at examples.BadExample", + ), + ( + Components::new() + .with_link("BadLink", Link::new("badLink").with_method("rpc.private")), + "Validation error at links.BadLink", + ), + ( + Components::new().with_error("BadError", ErrorObject::new(1000, "")), + "Validation error at errors.BadError", + ), + ( + Components::new() + .with_example_pairing("BadPairing", ExamplePairing::new("", vec![])), + "Validation error at examplePairingObjects.BadPairing", + ), + ( + Components::new().with_tag("BadTag", Tag::new("")), + "Validation error at tags.BadTag", + ), + ]; + + for (components, path) in cases { + let error = components.validate().unwrap_err().to_string(); + assert!( + error.starts_with(path), + "expected `{error}` to start with `{path}`" + ); + } + } + + #[test] + fn component_key_validation_runs_before_nested_value_validation() { + let components = Components::new().with_schema( + "invalid key", + Schema::string().with_min_length(10).with_max_length(1), + ); + + assert_eq!( + components.validate().unwrap_err().to_string(), + "Validation error: Invalid component key character ' ' in key 'invalid key'" + ); + } } diff --git a/crates/specs/openrpc-types/src/content_descriptor.rs b/crates/specs/openrpc-types/src/content_descriptor.rs index b6e01d0..c2d1a05 100644 --- a/crates/specs/openrpc-types/src/content_descriptor.rs +++ b/crates/specs/openrpc-types/src/content_descriptor.rs @@ -126,7 +126,9 @@ impl ContentDescriptor { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } @@ -271,7 +273,7 @@ mod tests { // Check that required fields are present assert_eq!(json["name"], "username"); assert_eq!(json["description"], "Username field"); - assert_eq!(json["required"], true); + assert_eq!(json["required"].as_bool(), Some(true)); assert!(json["schema"].is_object()); let deserialized: ContentDescriptor = serde_json::from_value(json).unwrap(); diff --git a/crates/specs/openrpc-types/src/error.rs b/crates/specs/openrpc-types/src/error.rs index fb8c131..c19ee42 100644 --- a/crates/specs/openrpc-types/src/error.rs +++ b/crates/specs/openrpc-types/src/error.rs @@ -1,12 +1,11 @@ //! Error types for OpenRPC specification validation and processing. -use thiserror::Error; +use std::fmt; /// Errors that can occur when working with OpenRPC specifications. -#[derive(Error, Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub enum OpenRpcError { /// Validation error when OpenRPC specification constraints are violated - #[error("Validation error: {message}")] ValidationError { /// Human-readable error message message: String, @@ -15,14 +14,12 @@ pub enum OpenRpcError { }, /// Error when parsing or serializing JSON - #[error("JSON error: {message}")] JsonError { /// JSON parsing/serialization error message message: String, }, /// Error when resolving references ($ref) - #[error("Reference resolution error: {message}")] ReferenceError { /// Reference resolution error message message: String, @@ -31,14 +28,12 @@ pub enum OpenRpcError { }, /// Error when a required field is missing - #[error("Missing required field: {field_name}")] MissingField { /// Name of the missing required field field_name: String, }, /// Error when a field has an invalid value - #[error("Invalid field value for '{field_name}': {message}")] InvalidField { /// Name of the field with invalid value field_name: String, @@ -47,7 +42,6 @@ pub enum OpenRpcError { }, /// Error when an object has duplicate keys that should be unique - #[error("Duplicate key '{key}' found in {context}")] DuplicateKey { /// The duplicate key name key: String, @@ -56,35 +50,30 @@ pub enum OpenRpcError { }, /// Error when URL format is invalid - #[error("Invalid URL format: {url}")] InvalidUrl { /// The invalid URL string url: String, }, /// Error when email format is invalid - #[error("Invalid email format: {email}")] InvalidEmail { /// The invalid email string email: String, }, /// Error when regex pattern is invalid - #[error("Invalid regex pattern: {pattern}")] InvalidRegex { /// The invalid regex pattern pattern: String, }, /// Error when OpenRPC version is unsupported - #[error("Unsupported OpenRPC version: {version}")] UnsupportedVersion { /// The unsupported version string version: String, }, /// Error when JSON Schema Draft 7 constraints are violated - #[error("JSON Schema validation error: {message}")] SchemaError { /// Schema validation error message message: String, @@ -93,6 +82,54 @@ pub enum OpenRpcError { }, } +impl fmt::Display for OpenRpcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ValidationError { + message, + field_path: Some(field_path), + } => write!(f, "Validation error at {field_path}: {message}"), + Self::ValidationError { + message, + field_path: None, + } => write!(f, "Validation error: {message}"), + Self::JsonError { message } => write!(f, "JSON error: {message}"), + Self::ReferenceError { message, .. } => { + write!(f, "Reference resolution error: {message}") + } + Self::MissingField { field_name } => { + write!(f, "Missing required field: {field_name}") + } + Self::InvalidField { + field_name, + message, + } => write!(f, "Invalid field value for '{field_name}': {message}"), + Self::DuplicateKey { key, context } => { + write!(f, "Duplicate key '{key}' found in {context}") + } + Self::InvalidUrl { url } => write!(f, "Invalid URL format: {url}"), + Self::InvalidEmail { email } => write!(f, "Invalid email format: {email}"), + Self::InvalidRegex { pattern } => write!(f, "Invalid regex pattern: {pattern}"), + Self::UnsupportedVersion { version } => { + write!(f, "Unsupported OpenRPC version: {version}") + } + Self::SchemaError { + message, + schema_path: Some(schema_path), + } => write!( + f, + "JSON Schema validation error at {schema_path}: {message}" + ), + Self::SchemaError { + message, + schema_path: None, + } => write!(f, "JSON Schema validation error: {message}"), + } + } +} + +impl std::error::Error for OpenRpcError {} + impl OpenRpcError { /// Create a new validation error pub fn validation(message: impl Into) -> Self { @@ -222,10 +259,78 @@ mod tests { let err = OpenRpcError::validation("test validation error"); assert_eq!(err.to_string(), "Validation error: test validation error"); + let err = OpenRpcError::validation_with_path("nested failure", "methods[0].params[1]"); + assert_eq!( + err.to_string(), + "Validation error at methods[0].params[1]: nested failure" + ); + + let err = OpenRpcError::schema_with_path("expected string", "properties.name"); + assert_eq!( + err.to_string(), + "JSON Schema validation error at properties.name: expected string" + ); + let err = OpenRpcError::missing_field("required_field"); assert_eq!(err.to_string(), "Missing required field: required_field"); } + #[test] + fn display_messages_cover_all_error_variants() { + let cases = [ + ( + OpenRpcError::json("unexpected token"), + "JSON error: unexpected token", + ), + ( + OpenRpcError::reference("not found", "#/components/schemas/Missing"), + "Reference resolution error: not found", + ), + ( + OpenRpcError::invalid_field("name", "must not be blank"), + "Invalid field value for 'name': must not be blank", + ), + ( + OpenRpcError::duplicate_key("id", "method parameters"), + "Duplicate key 'id' found in method parameters", + ), + ( + OpenRpcError::invalid_url("not-a-url"), + "Invalid URL format: not-a-url", + ), + ( + OpenRpcError::invalid_email("not-an-email"), + "Invalid email format: not-an-email", + ), + (OpenRpcError::invalid_regex("["), "Invalid regex pattern: ["), + ( + OpenRpcError::unsupported_version("2.0.0"), + "Unsupported OpenRPC version: 2.0.0", + ), + ( + OpenRpcError::schema("invalid schema"), + "JSON Schema validation error: invalid schema", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } + } + + #[test] + fn reference_error_keeps_reference_for_callers() { + let error = OpenRpcError::reference("not found", "#/components/schemas/Missing"); + + assert_eq!( + error, + OpenRpcError::ReferenceError { + message: "not found".to_string(), + reference: "#/components/schemas/Missing".to_string(), + } + ); + } + #[test] fn test_json_error_conversion() { let json_err = serde_json::from_str::("invalid json"); diff --git a/crates/specs/openrpc-types/src/error_object.rs b/crates/specs/openrpc-types/src/error_object.rs index b4c7327..473c7f4 100644 --- a/crates/specs/openrpc-types/src/error_object.rs +++ b/crates/specs/openrpc-types/src/error_object.rs @@ -50,7 +50,9 @@ impl ErrorObject { /// Add an extension field pub fn with_extension(mut self, key: impl Into, value: impl Into) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } diff --git a/crates/specs/openrpc-types/src/example.rs b/crates/specs/openrpc-types/src/example.rs index d48939f..6ab9a55 100644 --- a/crates/specs/openrpc-types/src/example.rs +++ b/crates/specs/openrpc-types/src/example.rs @@ -111,7 +111,9 @@ impl Example { /// Add an extension field pub fn with_extension(mut self, key: impl Into, value: impl Into) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } } @@ -214,7 +216,9 @@ impl ExamplePairing { /// Add an extension field pub fn with_extension(mut self, key: impl Into, value: impl Into) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } @@ -284,6 +288,7 @@ impl From for ExampleOrReference { mod tests { use super::*; use serde_json::json; + use std::collections::HashMap; #[test] fn test_example_creation() { @@ -432,4 +437,84 @@ mod tests { assert!(!example.extensions.is_empty()); assert_eq!(example.extensions.get("x-custom"), Some(&json!("value"))); } + + #[test] + fn example_setters_keep_value_and_external_value_mutually_exclusive() { + let example = Example::default() + .with_name("sample") + .with_summary("short") + .with_description("long") + .set_external_value("https://example.com/value.json"); + + assert_eq!(example.name.as_deref(), Some("sample")); + assert_eq!(example.summary.as_deref(), Some("short")); + assert_eq!(example.description.as_deref(), Some("long")); + assert_eq!( + example.external_value.as_deref(), + Some("https://example.com/value.json") + ); + assert!(example.value.is_none()); + + let example = example.set_value(json!({"inline": true})); + assert_eq!(example.value, Some(json!({"inline": true}))); + assert!(example.external_value.is_none()); + assert!(example.validate().is_ok()); + } + + #[test] + fn example_validation_rejects_invalid_extension_maps() { + let invalid_extensions: Extensions = + HashMap::from([("x-".to_string(), json!("missing suffix"))]).into(); + let example = Example { + extensions: invalid_extensions, + ..Example::with_value("test") + }; + + let err = example.validate().unwrap_err(); + assert!(err.to_string().contains("Extension key must have content")); + } + + #[test] + fn example_pairing_helpers_set_summary_result_and_extensions() { + let pairing = ExamplePairing::new("pairing", vec![Example::with_value("param").into()]) + .with_summary("short") + .with_result(Example::with_value("result").into()) + .with_extension("x-scope", "docs"); + + assert_eq!(pairing.summary.as_deref(), Some("short")); + assert!(pairing.result.is_some()); + assert_eq!(pairing.extensions.get("x-scope"), Some(&json!("docs"))); + assert!(!pairing.is_notification()); + assert!(pairing.validate().is_ok()); + } + + #[test] + fn example_pairing_validation_reports_nested_param_and_result_paths() { + let pairing = ExamplePairing::new( + "bad-param", + vec![Example::with_external_value("not a url").into()], + ); + let err = pairing.validate().unwrap_err(); + assert!(err.to_string().contains("params[0]")); + + let pairing = ExamplePairing::new("bad-result", vec![]) + .with_result(Example::with_external_value("not a url").into()); + let err = pairing.validate().unwrap_err(); + assert!(err.to_string().contains("result")); + } + + #[test] + fn example_or_reference_from_conversions_validate_both_variants() { + let example: ExampleOrReference = Example::with_value(json!({"id": 1})).into(); + assert!(matches!(example, ExampleOrReference::Example(_))); + assert!(example.validate().is_ok()); + + let reference: ExampleOrReference = Reference::example("CreatedUser").into(); + assert!(matches!(reference, ExampleOrReference::Reference(_))); + assert!(reference.validate().is_ok()); + + let invalid_reference = ExampleOrReference::Reference(Reference::new("")); + let err = invalid_reference.validate().unwrap_err(); + assert!(err.to_string().contains("Reference string cannot be empty")); + } } diff --git a/crates/specs/openrpc-types/src/extensions.rs b/crates/specs/openrpc-types/src/extensions.rs index a50ffc0..5693e63 100644 --- a/crates/specs/openrpc-types/src/extensions.rs +++ b/crates/specs/openrpc-types/src/extensions.rs @@ -3,7 +3,7 @@ //! While the OpenRPC Specification tries to accommodate most use cases, //! additional data can be added to extend the specification at certain points. -use crate::error::OpenRpcResult; +use crate::error::{OpenRpcError, OpenRpcResult}; use crate::validation::Validate; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -23,14 +23,18 @@ impl Extensions { Self::default() } - /// Insert an extension field - pub fn insert(&mut self, key: impl Into, value: impl Into) -> &mut Self { + /// Insert an extension field. + /// + /// Extension keys must start with `x-`. + pub fn insert( + &mut self, + key: impl Into, + value: impl Into, + ) -> OpenRpcResult<&mut Self> { let key = key.into(); - if !key.starts_with("x-") { - panic!("Extension keys must start with 'x-': {}", key); - } + validate_extension_key(&key)?; self.0.insert(key, value.into()); - self + Ok(self) } /// Get an extension field value @@ -79,11 +83,9 @@ impl Extensions { } /// Create an Extensions map from a HashMap - pub fn from_map(map: HashMap) -> Result { + pub fn from_map(map: HashMap) -> OpenRpcResult { for key in map.keys() { - if !key.starts_with("x-") { - return Err(format!("Extension key must start with 'x-': {}", key)); - } + validate_extension_key(key)?; } Ok(Self(map)) } @@ -94,9 +96,19 @@ impl Extensions { } /// Builder pattern for adding extensions - pub fn with(mut self, key: impl Into, value: impl Into) -> Self { - self.insert(key, value); - self + pub fn with(mut self, key: impl Into, value: impl Into) -> OpenRpcResult { + self.insert(key, value)?; + Ok(self) + } +} + +fn validate_extension_key(key: &str) -> OpenRpcResult<()> { + if key.starts_with("x-") { + Ok(()) + } else { + Err(OpenRpcError::validation(format!( + "Extension key must start with 'x-': {key}" + ))) } } @@ -160,7 +172,7 @@ macro_rules! extensions { ($($key:expr => $value:expr),+ $(,)?) => {{ let mut ext = $crate::Extensions::new(); $( - ext.insert($key, $value); + ext.insert($key, $value).expect("extension keys must start with 'x-'"); )+ ext }}; @@ -174,8 +186,8 @@ mod tests { #[test] fn test_extensions_creation() { let mut ext = Extensions::new(); - ext.insert("x-custom", "value"); - ext.insert("x-number", 42); + ext.insert("x-custom", "value").unwrap(); + ext.insert("x-number", 42).unwrap(); assert_eq!( ext.get("x-custom"), @@ -186,16 +198,17 @@ mod tests { } #[test] - #[should_panic(expected = "Extension keys must start with 'x-'")] fn test_invalid_extension_key() { let mut ext = Extensions::new(); - ext.insert("invalid-key", "value"); + let error = ext.insert("invalid-key", "value").unwrap_err(); + assert!(matches!(error, OpenRpcError::ValidationError { .. })); + assert!(ext.is_empty()); } #[test] fn test_extensions_validation() { let mut ext = Extensions::new(); - ext.insert("x-valid", "value"); + ext.insert("x-valid", "value").unwrap(); assert!(ext.validate().is_ok()); // Manually create invalid extension (bypassing insert validation) @@ -212,10 +225,10 @@ mod tests { #[test] fn test_extensions_merge() { let mut ext1 = Extensions::new(); - ext1.insert("x-first", "value1"); + ext1.insert("x-first", "value1").unwrap(); let mut ext2 = Extensions::new(); - ext2.insert("x-second", "value2"); + ext2.insert("x-second", "value2").unwrap(); ext1.merge(ext2); @@ -228,7 +241,9 @@ mod tests { fn test_extensions_with_builder() { let ext = Extensions::new() .with("x-first", "value1") - .with("x-second", 42); + .unwrap() + .with("x-second", 42) + .unwrap(); assert_eq!(ext.len(), 2); assert_eq!( @@ -256,8 +271,8 @@ mod tests { #[test] fn test_extensions_serialization() { let mut ext = Extensions::new(); - ext.insert("x-custom", "value"); - ext.insert("x-number", 42); + ext.insert("x-custom", "value").unwrap(); + ext.insert("x-number", 42).unwrap(); let json = serde_json::to_value(&ext).unwrap(); let expected = json!({ @@ -289,8 +304,8 @@ mod tests { #[test] fn test_extensions_iterator() { let mut ext = Extensions::new(); - ext.insert("x-first", "value1"); - ext.insert("x-second", "value2"); + ext.insert("x-first", "value1").unwrap(); + ext.insert("x-second", "value2").unwrap(); let mut count = 0; for (key, _value) in &ext { diff --git a/crates/specs/openrpc-types/src/external_docs.rs b/crates/specs/openrpc-types/src/external_docs.rs index 8b6f8df..7bbd5a3 100644 --- a/crates/specs/openrpc-types/src/external_docs.rs +++ b/crates/specs/openrpc-types/src/external_docs.rs @@ -45,7 +45,9 @@ impl ExternalDocumentation { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } } diff --git a/crates/specs/openrpc-types/src/info.rs b/crates/specs/openrpc-types/src/info.rs index 535970c..092e7b1 100644 --- a/crates/specs/openrpc-types/src/info.rs +++ b/crates/specs/openrpc-types/src/info.rs @@ -87,7 +87,9 @@ impl Info { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } } @@ -186,7 +188,9 @@ impl Contact { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } } @@ -256,7 +260,9 @@ impl License { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } } diff --git a/crates/specs/openrpc-types/src/lib.rs b/crates/specs/openrpc-types/src/lib.rs index e373a70..02dbedf 100644 --- a/crates/specs/openrpc-types/src/lib.rs +++ b/crates/specs/openrpc-types/src/lib.rs @@ -1,13 +1,13 @@ //! OpenRPC Types //! -//! Complete Rust types for the OpenRPC 1.3.2 specification with serde support, -//! bon builders, and comprehensive validation. +//! Rust types for the OpenRPC 1.3.2 specification with serde support, +//! bon builders, and runtime validation helpers. //! //! This crate provides: -//! - Complete OpenRPC 1.3.2 specification types +//! - OpenRPC 1.3.2 specification types //! - Serde serialization/deserialization support //! - Bon builder patterns for ergonomic API construction -//! - Comprehensive validation against OpenRPC constraints +//! - Validation helpers for OpenRPC constraints //! - JSON Schema Draft 7 compatibility for Schema objects //! - Reference resolution support for $ref within components //! - Specification extensions support for x-* extension fields diff --git a/crates/specs/openrpc-types/src/link.rs b/crates/specs/openrpc-types/src/link.rs index 5e555ad..677fa30 100644 --- a/crates/specs/openrpc-types/src/link.rs +++ b/crates/specs/openrpc-types/src/link.rs @@ -104,7 +104,9 @@ impl Link { /// Add an extension field pub fn with_extension(mut self, key: impl Into, value: impl Into) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } } diff --git a/crates/specs/openrpc-types/src/method.rs b/crates/specs/openrpc-types/src/method.rs index c1ee17c..603cc71 100644 --- a/crates/specs/openrpc-types/src/method.rs +++ b/crates/specs/openrpc-types/src/method.rs @@ -293,7 +293,9 @@ impl Method { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } @@ -402,8 +404,8 @@ impl Validate for Method { // Error codes must be unique errors.validate_unique( |error| match error { - ErrorOrReference::Error(e) => e.code, - ErrorOrReference::Reference(r) => r.reference.clone().parse().unwrap_or(0), + ErrorOrReference::Error(e) => e.code.to_string(), + ErrorOrReference::Reference(r) => r.reference.clone(), }, "method errors", )?; @@ -761,4 +763,67 @@ mod tests { assert!(method.errors.is_some()); assert!(method.links.is_some()); } + + #[test] + fn helper_methods_set_and_append_optional_collections() { + let method = Method::new("complexMethod", vec![]) + .with_tags(vec![Tag::new("users").into()]) + .with_tag(Reference::tag("AdminMethods").into()) + .with_external_docs(ExternalDocumentation::new( + "https://docs.example.com/methods", + )) + .with_deprecated(false) + .with_servers(vec![Server::new("primary", "https://api.example.com")]) + .with_server(Server::new("backup", "https://backup.example.com")) + .with_errors(vec![ErrorObject::new(1000, "Quota exceeded").into()]) + .with_error(Reference::error("UserNotFound").into()) + .with_links(vec![Link::new("getUser").into()]) + .with_link(Reference::link("AuditLog").into()) + .with_param_structure(ParameterStructure::ByPosition) + .with_examples(vec![ExamplePairing::new("created", vec![]).into()]) + .with_example(Reference::example_pairing("failed").into()); + + assert!(method.validate().is_ok()); + assert!(!method.is_deprecated()); + assert_eq!(method.get_param_structure(), ParameterStructure::ByPosition); + assert_eq!(method.tags.as_ref().unwrap().len(), 2); + assert_eq!(method.servers.as_ref().unwrap().len(), 2); + assert_eq!(method.errors.as_ref().unwrap().len(), 2); + assert_eq!(method.links.as_ref().unwrap().len(), 2); + assert_eq!(method.examples.as_ref().unwrap().len(), 2); + assert!(method.external_docs.is_some()); + } + + #[test] + fn distinct_error_references_do_not_collide_during_validation() { + let method = Method::new("methodWithReferencedErrors", vec![]).with_errors(vec![ + Reference::error("UserNotFound").into(), + Reference::error("RateLimited").into(), + ]); + + assert!(method.validate().is_ok()); + + let duplicate = Method::new("methodWithDuplicateErrorReference", vec![]).with_errors(vec![ + Reference::error("UserNotFound").into(), + Reference::error("UserNotFound").into(), + ]); + + assert!(duplicate.validate().is_err()); + } + + #[test] + fn nested_validation_errors_keep_their_field_path() { + let method = Method::new("methodWithInvalidResult", vec![]).with_result( + Reference { + reference: String::new(), + } + .into(), + ); + + let error = method.validate().unwrap_err(); + assert_eq!( + error.to_string(), + "Validation error at result: Validation error: Reference string cannot be empty" + ); + } } diff --git a/crates/specs/openrpc-types/src/openrpc.rs b/crates/specs/openrpc-types/src/openrpc.rs index 55cf285..abd9066 100644 --- a/crates/specs/openrpc-types/src/openrpc.rs +++ b/crates/specs/openrpc-types/src/openrpc.rs @@ -111,7 +111,9 @@ impl OpenRpc { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } @@ -245,6 +247,7 @@ mod tests { use super::*; use crate::{ContentDescriptor, Schema}; use serde_json::json; + use std::collections::HashMap; #[test] fn test_openrpc_creation() { @@ -453,4 +456,97 @@ mod tests { assert!(openrpc.servers.is_some()); assert!(openrpc.components.is_some()); } + + #[test] + fn server_helpers_replace_append_and_expose_default_server() { + let info = Info::new("Test API", "1.0.0"); + let production = Server::new("production", "https://api.example.com"); + let staging = Server::new("staging", "https://staging.example.com"); + + let openrpc = OpenRpc::v1_3_2(info, vec![]) + .with_servers(vec![production.clone()]) + .with_server(staging.clone()); + + let servers = openrpc.get_servers(); + assert_eq!(servers, vec![&production, &staging]); + + let empty_servers = OpenRpc::v1_3_2(Info::new("Test API", "1.0.0"), vec![]); + assert!(empty_servers.get_servers().is_empty()); + + let default_server = OpenRpc::get_default_server(); + assert_eq!(default_server.name, "default"); + assert_eq!(default_server.url, "localhost"); + } + + #[test] + fn method_or_reference_from_conversions_validate_both_variants() { + let method: MethodOrReference = Method::new("direct", vec![]).into(); + assert!(matches!(method, MethodOrReference::Method(_))); + assert!(method.validate().is_ok()); + + let reference: MethodOrReference = Reference::schema("SharedSchema").into(); + assert!(matches!(reference, MethodOrReference::Reference(_))); + assert!(reference.validate().is_ok()); + + let invalid_reference = MethodOrReference::Reference(Reference::new("")); + let err = invalid_reference.validate().unwrap_err(); + assert!(err.to_string().contains("Reference string cannot be empty")); + } + + #[test] + fn validation_errors_include_paths_for_nested_openrpc_objects() { + let invalid_info = OpenRpc::v1_3_2(Info::new("", "1.0.0"), vec![]); + let err = invalid_info.validate().unwrap_err(); + assert!(err.to_string().contains("info")); + + let invalid_server = OpenRpc::v1_3_2(Info::new("Test API", "1.0.0"), vec![]) + .with_server(Server::new("", "https://api.example.com")); + let err = invalid_server.validate().unwrap_err(); + assert!(err.to_string().contains("servers[0]")); + + let invalid_method = OpenRpc::v1_3_2( + Info::new("Test API", "1.0.0"), + vec![Method::new("bad method", vec![]).into()], + ); + let err = invalid_method.validate().unwrap_err(); + assert!(err.to_string().contains("methods[0]")); + + let invalid_components = OpenRpc::v1_3_2(Info::new("Test API", "1.0.0"), vec![]) + .with_components(Components::new().with_schema("invalid key", Schema::string())); + let err = invalid_components.validate().unwrap_err(); + assert!(err.to_string().contains("components")); + + let invalid_external_docs = OpenRpc::v1_3_2(Info::new("Test API", "1.0.0"), vec![]) + .with_external_docs(ExternalDocumentation::new("not-a-url")); + let err = invalid_external_docs.validate().unwrap_err(); + assert!(err.to_string().contains("externalDocs")); + } + + #[test] + fn validation_rejects_duplicate_reference_method_keys() { + let openrpc = OpenRpc::v1_3_2( + Info::new("Test API", "1.0.0"), + vec![ + MethodOrReference::Reference(Reference::schema("Shared")), + MethodOrReference::Reference(Reference::schema("Shared")), + ], + ); + + let err = openrpc.validate().unwrap_err(); + assert!(err.to_string().contains("Duplicate key")); + assert!(err.to_string().contains("methods")); + } + + #[test] + fn validation_rejects_invalid_openrpc_extensions() { + let invalid_extensions: Extensions = + HashMap::from([("x-".to_string(), json!("missing suffix"))]).into(); + let openrpc = OpenRpc { + extensions: invalid_extensions, + ..OpenRpc::v1_3_2(Info::new("Test API", "1.0.0"), vec![]) + }; + + let err = openrpc.validate().unwrap_err(); + assert!(err.to_string().contains("Extension key must have content")); + } } diff --git a/crates/specs/openrpc-types/src/schema.rs b/crates/specs/openrpc-types/src/schema.rs index 68c6431..cd2762a 100644 --- a/crates/specs/openrpc-types/src/schema.rs +++ b/crates/specs/openrpc-types/src/schema.rs @@ -422,7 +422,9 @@ impl Schema { /// Add an extension field pub fn with_extension(mut self, key: impl Into, value: impl Into) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } } @@ -542,10 +544,15 @@ impl Validate for Schema { // Validate items schema if let Some(ref items) = self.items { - match items.as_ref() { - SchemaOrBool::Schema(schema) => schema.as_ref().validate()?, - SchemaOrBool::Bool(_) => {} // Boolean is always valid - } + validate_schema_or_bool(items.as_ref())?; + } + + if let Some(ref additional_items) = self.additional_items { + validate_schema_or_bool(additional_items.as_ref())?; + } + + if let Some(ref additional_properties) = self.additional_properties { + validate_schema_or_bool(additional_properties.as_ref())?; } // Validate properties @@ -556,38 +563,48 @@ impl Validate for Schema { "property name cannot be empty", )); } - match schema_or_ref { - SchemaOrReference::Schema(schema) => schema.as_ref().validate()?, - SchemaOrReference::Reference(reference) => reference.validate()?, + validate_schema_or_reference(schema_or_ref)?; + } + } + + if let Some(ref pattern_properties) = self.pattern_properties { + for (pattern, schema_or_ref) in pattern_properties { + if pattern.is_empty() { + return Err(crate::error::OpenRpcError::validation( + "pattern property name cannot be empty", + )); } + validate_schema_or_reference(schema_or_ref)?; + } + } + + if let Some(ref dependencies) = self.dependencies { + for (property, dependency) in dependencies { + if property.is_empty() { + return Err(crate::error::OpenRpcError::validation( + "dependency property name cannot be empty", + )); + } + validate_schema_dependency(dependency)?; } } // Validate composition schemas if let Some(ref all_of) = self.all_of { for schema_or_ref in all_of { - match schema_or_ref { - SchemaOrReference::Schema(schema) => schema.as_ref().validate()?, - SchemaOrReference::Reference(reference) => reference.validate()?, - } + validate_schema_or_reference(schema_or_ref)?; } } if let Some(ref any_of) = self.any_of { for schema_or_ref in any_of { - match schema_or_ref { - SchemaOrReference::Schema(schema) => schema.as_ref().validate()?, - SchemaOrReference::Reference(reference) => reference.validate()?, - } + validate_schema_or_reference(schema_or_ref)?; } } if let Some(ref one_of) = self.one_of { for schema_or_ref in one_of { - match schema_or_ref { - SchemaOrReference::Schema(schema) => schema.as_ref().validate()?, - SchemaOrReference::Reference(reference) => reference.validate()?, - } + validate_schema_or_reference(schema_or_ref)?; } } @@ -613,10 +630,7 @@ impl Validate for Schema { } if let Some(ref not) = self.not { - match not.as_ref() { - SchemaOrReference::Schema(schema) => schema.as_ref().validate()?, - SchemaOrReference::Reference(reference) => reference.validate()?, - } + validate_schema_or_reference(not.as_ref())?; } // Validate definitions @@ -634,6 +648,34 @@ impl Validate for Schema { } } +fn validate_schema_or_bool(schema_or_bool: &SchemaOrBool) -> OpenRpcResult<()> { + match schema_or_bool { + SchemaOrBool::Schema(schema) => schema.as_ref().validate(), + SchemaOrBool::Bool(_) => Ok(()), + } +} + +fn validate_schema_or_reference(schema_or_ref: &SchemaOrReference) -> OpenRpcResult<()> { + match schema_or_ref { + SchemaOrReference::Schema(schema) => schema.as_ref().validate(), + SchemaOrReference::Reference(reference) => reference.validate(), + } +} + +fn validate_schema_dependency(dependency: &SchemaOrStringArray) -> OpenRpcResult<()> { + match dependency { + SchemaOrStringArray::Schema(schema) => schema.as_ref().validate(), + SchemaOrStringArray::StringArray(required_properties) => { + if required_properties.iter().any(String::is_empty) { + return Err(crate::error::OpenRpcError::validation( + "dependency property names cannot be empty", + )); + } + Ok(()) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -753,4 +795,144 @@ mod tests { assert!(!schema.extensions.is_empty()); assert_eq!(schema.extensions.get("x-custom"), Some(&json!("value"))); } + + #[test] + fn convenience_methods_cover_common_schema_fields() { + let mut properties = HashMap::new(); + properties.insert( + "manager".to_string(), + SchemaOrReference::Reference(Reference::schema("User")), + ); + + let schema = Schema::any() + .with_title("User") + .with_description("A user object") + .with_default(json!({"name": "Alice"})) + .with_enum(vec![json!("admin"), json!("user")]) + .with_properties(properties) + .with_required(vec!["name".to_string()]) + .require_property("email") + .with_minimum(1.0) + .with_maximum(10.0) + .with_pattern("^[a-z]+$") + .with_format("email"); + + assert_eq!(schema.schema_type, None); + assert_eq!(schema.title.as_deref(), Some("User")); + assert_eq!(schema.description.as_deref(), Some("A user object")); + assert_eq!(schema.default, Some(json!({"name": "Alice"}))); + assert_eq!(schema.enum_values.as_ref().unwrap().len(), 2); + assert_eq!(schema.properties.as_ref().unwrap().len(), 1); + assert_eq!( + schema.required, + Some(vec!["name".to_string(), "email".to_string()]) + ); + assert_eq!(schema.minimum, Some(1.0)); + assert_eq!(schema.maximum, Some(10.0)); + assert_eq!(schema.pattern.as_deref(), Some("^[a-z]+$")); + assert_eq!(schema.format.as_deref(), Some("email")); + } + + #[test] + fn validate_accepts_nested_schema_containers() { + let valid_nested = Schema::string().with_min_length(1); + let mut pattern_properties = HashMap::new(); + pattern_properties.insert( + "^x-".to_string(), + SchemaOrReference::Schema(Box::new(valid_nested.clone())), + ); + let mut dependencies = HashMap::new(); + dependencies.insert( + "credit_card".to_string(), + SchemaOrStringArray::StringArray(vec!["billing_address".to_string()]), + ); + dependencies.insert( + "billing_address".to_string(), + SchemaOrStringArray::Schema(Box::new(Schema::object())), + ); + let mut definitions = HashMap::new(); + definitions.insert("Address".to_string(), Schema::object()); + + let mut schema = Schema::object() + .with_property("name", valid_nested.clone()) + .with_items(valid_nested.clone()); + schema.additional_items = Some(Box::new(SchemaOrBool::Bool(false))); + schema.additional_properties = Some(Box::new(SchemaOrBool::Schema(Box::new( + valid_nested.clone(), + )))); + schema.pattern_properties = Some(pattern_properties); + schema.dependencies = Some(dependencies); + schema.contains = Some(Box::new(valid_nested.clone())); + schema.property_names = Some(Box::new(Schema::string())); + schema.if_schema = Some(Box::new(Schema::object())); + schema.then_schema = Some(Box::new(Schema::object())); + schema.else_schema = Some(Box::new(Schema::object())); + schema.all_of = Some(vec![SchemaOrReference::Schema(Box::new(Schema::object()))]); + schema.any_of = Some(vec![SchemaOrReference::Reference(Reference::schema( + "Address", + ))]); + schema.one_of = Some(vec![SchemaOrReference::Schema(Box::new(Schema::string()))]); + schema.not = Some(Box::new(SchemaOrReference::Schema( + Box::new(Schema::null()), + ))); + schema.definitions = Some(definitions); + + assert!(schema.validate().is_ok()); + } + + #[test] + fn validate_rejects_invalid_nested_schema_containers() { + let invalid_nested = Schema::string().with_min_length(10).with_max_length(1); + + let mut schema = Schema::array(); + schema.additional_items = Some(Box::new(SchemaOrBool::Schema(Box::new( + invalid_nested.clone(), + )))); + assert!(schema.validate().is_err()); + + let mut schema = Schema::object(); + schema.additional_properties = Some(Box::new(SchemaOrBool::Schema(Box::new( + invalid_nested.clone(), + )))); + assert!(schema.validate().is_err()); + + let mut pattern_properties = HashMap::new(); + pattern_properties.insert( + "".to_string(), + SchemaOrReference::Schema(Box::new(Schema::string())), + ); + let mut schema = Schema::object(); + schema.pattern_properties = Some(pattern_properties); + assert!(schema.validate().is_err()); + + let mut dependencies = HashMap::new(); + dependencies.insert( + "".to_string(), + SchemaOrStringArray::StringArray(vec!["name".to_string()]), + ); + let mut schema = Schema::object(); + schema.dependencies = Some(dependencies); + assert!(schema.validate().is_err()); + + let mut dependencies = HashMap::new(); + dependencies.insert( + "name".to_string(), + SchemaOrStringArray::StringArray(vec!["".to_string()]), + ); + let mut schema = Schema::object(); + schema.dependencies = Some(dependencies); + assert!(schema.validate().is_err()); + + let mut schema = Schema::object(); + schema.all_of = Some(vec![SchemaOrReference::Schema(Box::new( + invalid_nested.clone(), + ))]); + assert!(schema.validate().is_err()); + + let mut schema = Schema::object(); + schema.not = Some(Box::new(SchemaOrReference::Reference(Reference::new( + "#/components/schemas/invalid name", + )))); + assert!(schema.validate().is_err()); + } } diff --git a/crates/specs/openrpc-types/src/server.rs b/crates/specs/openrpc-types/src/server.rs index 57515d3..534def0 100644 --- a/crates/specs/openrpc-types/src/server.rs +++ b/crates/specs/openrpc-types/src/server.rs @@ -87,7 +87,9 @@ impl Server { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } @@ -195,7 +197,9 @@ impl ServerVariable { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } } diff --git a/crates/specs/openrpc-types/src/tag.rs b/crates/specs/openrpc-types/src/tag.rs index 22701e9..7e2d5df 100644 --- a/crates/specs/openrpc-types/src/tag.rs +++ b/crates/specs/openrpc-types/src/tag.rs @@ -67,7 +67,9 @@ impl Tag { key: impl Into, value: impl Into, ) -> Self { - self.extensions.insert(key, value); + self.extensions + .insert(key, value) + .expect("extension keys must start with 'x-'"); self } } diff --git a/crates/test-utils/ras-test-helpers/Cargo.toml b/crates/test-utils/ras-test-helpers/Cargo.toml deleted file mode 100644 index 68bab77..0000000 --- a/crates/test-utils/ras-test-helpers/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "ras-test-helpers" -version = "0.0.0" -edition = "2024" -publish = false -description = "Internal test helpers for the rust-agent-stack workspace (dev-dependency only)" - -[dependencies] -ras-auth-core = { path = "../../core/ras-auth-core" } -axum = { workspace = true } -axum-test = { workspace = true } -tokio = { workspace = true } diff --git a/crates/test-utils/ras-test-helpers/src/auth.rs b/crates/test-utils/ras-test-helpers/src/auth.rs deleted file mode 100644 index 5c18644..0000000 --- a/crates/test-utils/ras-test-helpers/src/auth.rs +++ /dev/null @@ -1,133 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; - -/// A small fixed-token auth provider for tests. -/// -/// The default token table: -/// - `"user-token"` → user `user-1`, perms `["user"]` -/// - `"admin-token"` → user `admin-1`, perms `["admin", "user"]` -/// - `"readonly-token"` → user `ro-1`, perms `["read"]` -/// -/// Any other (or empty) token returns [`AuthError::InvalidToken`]. -#[derive(Clone, Debug)] -pub struct MockAuthProvider { - table: HashMap, -} - -impl Default for MockAuthProvider { - fn default() -> Self { - let mut table = HashMap::new(); - table.insert("user-token".to_string(), mock_user("user-1", &["user"])); - table.insert( - "admin-token".to_string(), - mock_user("admin-1", &["admin", "user"]), - ); - table.insert("readonly-token".to_string(), mock_user("ro-1", &["read"])); - Self { table } - } -} - -impl MockAuthProvider { - /// New empty auth provider with no recognized tokens. - pub fn empty() -> Self { - Self { - table: HashMap::new(), - } - } - - /// Insert or replace a token → user mapping. Useful for adding bespoke - /// fixtures on top of the default table. - pub fn with_token(mut self, token: impl Into, user: AuthenticatedUser) -> Self { - self.table.insert(token.into(), user); - self - } -} - -impl AuthProvider for MockAuthProvider { - fn authenticate(&self, token: String) -> AuthFuture<'_> { - let result = self - .table - .get(&token) - .cloned() - .ok_or(AuthError::InvalidToken); - Box::pin(async move { result }) - } -} - -/// Build an [`AuthenticatedUser`] from a string id and a slice of permission -/// names. Convenience for tests that need to construct a user by hand. -pub fn mock_user(user_id: &str, perms: &[&str]) -> AuthenticatedUser { - AuthenticatedUser { - user_id: user_id.to_string(), - permissions: perms - .iter() - .map(|p| (*p).to_string()) - .collect::>(), - metadata: None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mock_user_builds_expected_fields() { - let u = mock_user("alice", &["a", "b"]); - assert_eq!(u.user_id, "alice"); - assert!(u.permissions.contains("a")); - assert!(u.permissions.contains("b")); - assert!(u.metadata.is_none()); - } - - #[tokio::test] - async fn default_provider_resolves_well_known_tokens() { - let p = MockAuthProvider::default(); - let user = p.authenticate("user-token".to_string()).await.unwrap(); - assert_eq!(user.user_id, "user-1"); - assert!(user.permissions.contains("user")); - - let admin = p.authenticate("admin-token".to_string()).await.unwrap(); - assert!(admin.permissions.contains("admin")); - assert!(admin.permissions.contains("user")); - - let ro = p.authenticate("readonly-token".to_string()).await.unwrap(); - assert!(ro.permissions.contains("read")); - - let err = p - .authenticate("totally-bogus".to_string()) - .await - .unwrap_err(); - assert!(matches!(err, ras_auth_core::AuthError::InvalidToken)); - } - - #[tokio::test] - async fn empty_provider_rejects_everything() { - let p = MockAuthProvider::empty(); - let err = p.authenticate("user-token".to_string()).await.unwrap_err(); - assert!(matches!(err, ras_auth_core::AuthError::InvalidToken)); - } - - #[tokio::test] - async fn with_token_extends_table() { - let p = MockAuthProvider::empty().with_token("custom", mock_user("zed", &["god"])); - let user = p.authenticate("custom".to_string()).await.unwrap(); - assert_eq!(user.user_id, "zed"); - assert!(user.permissions.contains("god")); - } - - #[test] - fn check_permissions_returns_specific_error() { - let p = MockAuthProvider::default(); - let user = mock_user("u", &["read"]); - // Has the permission → ok. - p.check_permissions(&user, &["read".into()]).unwrap(); - // Missing → InsufficientPermissions. - let err = p.check_permissions(&user, &["admin".into()]).unwrap_err(); - assert!(matches!( - err, - ras_auth_core::AuthError::InsufficientPermissions { .. } - )); - } -} diff --git a/crates/test-utils/ras-test-helpers/src/lib.rs b/crates/test-utils/ras-test-helpers/src/lib.rs deleted file mode 100644 index 222ceb8..0000000 --- a/crates/test-utils/ras-test-helpers/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Internal test helpers shared across the rust-agent-stack workspace. -//! -//! This crate is `publish = false` and intended only as a `dev-dependency` for -//! integration tests and benches. It exists to avoid duplicating mock auth -//! providers and server-spawn boilerplate across crates. - -mod auth; -mod server; - -pub use auth::{MockAuthProvider, mock_user}; -pub use server::{spawn_http, spawn_tcp}; diff --git a/crates/test-utils/ras-test-helpers/src/server.rs b/crates/test-utils/ras-test-helpers/src/server.rs deleted file mode 100644 index 397ac16..0000000 --- a/crates/test-utils/ras-test-helpers/src/server.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::net::SocketAddr; - -use axum::Router; -use axum_test::TestServer; -use tokio::net::TcpListener; -use tokio::task::JoinHandle; - -/// Spawn the given router behind an `axum-test::TestServer` configured with a -/// real TCP listener on a random port. The returned [`TestServer`] exposes a -/// real `http://127.0.0.1:PORT` URL via [`TestServer::server_address`], which -/// lets generated reqwest-based clients talk to it. -/// -/// Use this for HTTP / JSON-RPC over HTTP / file service tests. -pub fn spawn_http(router: Router) -> TestServer { - TestServer::builder() - .http_transport() - .build(router) - .expect("failed to start axum-test TestServer with http transport") -} - -/// Spawn the given router on a freshly-bound `127.0.0.1` port using a real -/// `axum::serve` task. Returns the bound address and the join handle for the -/// server task. Drop the handle to abort the server. -/// -/// Use this for WebSocket tests where the generated client uses -/// `tokio-tungstenite` and needs a genuine TCP socket. -pub async fn spawn_tcp(router: Router) -> (SocketAddr, JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("failed to bind ephemeral test port"); - let addr = listener - .local_addr() - .expect("failed to read local addr from test listener"); - - let handle = tokio::spawn(async move { - let _ = axum::serve(listener, router).await; - }); - - (addr, handle) -} diff --git a/crates/tools/openrpc-to-bruno/Cargo.toml b/crates/tools/openrpc-to-bruno/Cargo.toml deleted file mode 100644 index 79e730b..0000000 --- a/crates/tools/openrpc-to-bruno/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "openrpc-to-bruno" -version = "0.1.0" -edition = "2024" -description = "CLI tool to convert OpenRPC specifications to Bruno API collections" -authors = ["Rust Agent Stack Contributors"] - -[[bin]] -name = "openrpc-to-bruno" -path = "src/main.rs" - -[lib] -name = "openrpc_to_bruno" -path = "src/lib.rs" - -[dependencies] -# CLI parsing -clap = { workspace = true } - -# Error handling -thiserror = { workspace = true } -anyhow = { workspace = true } - -# Serialization -serde = { workspace = true } -serde_json = { workspace = true } - -# OpenRPC types -openrpc-types = { path = "../../specs/openrpc-types" } - -# File system operations -tokio = { workspace = true } - -[dev-dependencies] -tokio-test = { workspace = true } -tempfile = { workspace = true } \ No newline at end of file diff --git a/crates/tools/openrpc-to-bruno/README.md b/crates/tools/openrpc-to-bruno/README.md deleted file mode 100644 index 2737965..0000000 --- a/crates/tools/openrpc-to-bruno/README.md +++ /dev/null @@ -1,172 +0,0 @@ -# OpenRPC to Bruno Converter - -A CLI tool that converts OpenRPC specifications to Bruno API collections, enabling you to quickly create API testing collections from your OpenRPC documentation. - -## Features - -- ✅ **Complete OpenRPC Support**: Parses OpenRPC 1.3.2 specification files -- ✅ **Bruno Collection Generation**: Creates valid Bruno collection structures -- ✅ **JSON-RPC Request Generation**: Generates proper JSON-RPC 2.0 requests for each method -- ✅ **Environment Variables**: Creates environment files with configurable base URLs -- ✅ **Example Parameter Generation**: Automatically generates example parameters based on schema types -- ✅ **Custom Collection Names**: Support for custom collection names -- ✅ **Comprehensive CLI**: Full command-line interface with verbose output -- ✅ **Authentication Awareness**: Detects authentication requirements from OpenRPC extensions -- ✅ **Workspace Integration**: Fully integrated with the Rust Agent Stack workspace - -## Installation - -From the workspace root: - -```bash -cargo build -p openrpc-to-bruno -``` - -## Usage - -### Basic Usage - -```bash -# Convert an OpenRPC file to Bruno collection -cargo run -p openrpc-to-bruno -- \ - --input api.json \ - --output ./bruno-collection - -# With custom settings -cargo run -p openrpc-to-bruno -- \ - --input api.json \ - --output ./my-api \ - --base-url https://api.example.com \ - --name "My API Collection" \ - --verbose \ - --force -``` - -### CLI Options - -- `-i, --input `: Path to the OpenRPC specification file (JSON) -- `-o, --output `: Output directory for the Bruno collection -- `-b, --base-url `: Base URL for the API server (optional) -- `-n, --name `: Collection name (defaults to OpenRPC info.title) -- `-f, --force`: Force overwrite existing Bruno collection -- `-v, --verbose`: Enable verbose output - -## Generated Structure - -The tool generates a complete Bruno collection: - -``` -output-directory/ -├── bruno.json # Collection metadata -├── environments/ -│ └── default.bru # Environment variables -├── method1.bru # Generated request for method1 -├── method2.bru # Generated request for method2 -└── ... -``` - -### Example Generated Files - -**bruno.json**: -```json -{ - "version": "1", - "name": "My API Collection", - "type": "collection", - "ignore": ["node_modules", ".git"] -} -``` - -**environments/default.bru**: -``` -vars { - base_url: https://api.example.com - api_path: /api/v1/rpc -} -``` - -**hello.bru**: -``` -meta { - name: hello - type: http - seq: 1 -} - -post { - url: {{base_url}}{{api_path}} - body: json -} - -headers { - Content-Type: application/json -} - -body:json { - { - "id": 1, - "jsonrpc": "2.0", - "method": "hello", - "params": { - "name": "example_string" - } - } -} -``` - -## Supported OpenRPC Features - -- ✅ Basic method definitions -- ✅ Parameter schemas (string, number, integer, boolean, array, object) -- ✅ Result schemas -- ✅ Method descriptions and summaries -- ✅ Server definitions -- ✅ OpenRPC 1.3.2 specification -- ⚠️ Extensions (partial support - x-authentication detection) -- ❌ Reference objects (not yet supported) -- ❌ YAML input files (not yet supported) -- ❌ Complex schema validation (not yet supported) - -## Authentication Support - -The tool detects authentication requirements from OpenRPC extensions: - -- Methods with `x-authentication: true` will include Bearer token authentication -- Methods with `x-permissions` will include Bearer token authentication -- Authentication tokens use `{{auth_token}}` variable placeholder - -## Testing - -Run the test suite: - -```bash -# Run all tests -cargo test -p openrpc-to-bruno - -# Run with verbose output -cargo test -p openrpc-to-bruno -- --nocapture -``` - -Test fixtures are available in `tests/fixtures/` for testing various OpenRPC scenarios. - -## Integration - -This tool is part of the Rust Agent Stack and integrates with: - -- **openrpc-types**: Uses the workspace's OpenRPC type definitions -- **Workspace dependencies**: Shares common dependencies like clap, serde, tokio -- **Error handling**: Uses thiserror for structured error handling -- **Testing**: Comprehensive test coverage with integration tests - -## Future Enhancements - -- YAML input file support -- Reference object resolution -- More sophisticated schema-to-example generation -- Advanced authentication scheme support -- Custom template support -- Bulk conversion support - -## Contributing - -This tool follows the workspace's development guidelines. See the main workspace README and CLAUDE.md for development practices and testing requirements. \ No newline at end of file diff --git a/crates/tools/openrpc-to-bruno/src/bruno.rs b/crates/tools/openrpc-to-bruno/src/bruno.rs deleted file mode 100644 index e2bf7a1..0000000 --- a/crates/tools/openrpc-to-bruno/src/bruno.rs +++ /dev/null @@ -1,310 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Bruno collection metadata file (bruno.json) -#[derive(Debug, Serialize, Deserialize)] -pub struct BrunoCollection { - pub version: String, - pub name: String, - #[serde(rename = "type")] - pub collection_type: String, - pub ignore: Vec, -} - -impl Default for BrunoCollection { - fn default() -> Self { - Self { - version: "1".to_string(), - collection_type: "collection".to_string(), - ignore: vec!["node_modules".to_string(), ".git".to_string()], - name: "generated-collection".to_string(), - } - } -} - -/// Bruno environment variables file (.bru) -#[derive(Debug)] -pub struct BrunoEnvironment { - #[allow(dead_code)] - pub name: String, - pub variables: HashMap, -} - -impl BrunoEnvironment { - pub fn new(name: String) -> Self { - Self { - name, - variables: HashMap::new(), - } - } - - pub fn add_variable, V: Into>(&mut self, key: K, value: V) { - self.variables.insert(key.into(), value.into()); - } - - pub fn to_bru_format(&self) -> String { - let mut content = String::new(); - - if !self.variables.is_empty() { - content.push_str("vars {\n"); - for (key, value) in &self.variables { - content.push_str(&format!(" {}: {}\n", key, value)); - } - content.push_str("}\n"); - } - - content - } -} - -/// Bruno request file (.bru) -#[derive(Debug)] -pub struct BrunoRequest { - pub name: String, - pub method: String, - pub url: String, - pub headers: HashMap, - pub body: Option, - pub auth: Option, - pub sequence: Option, -} - -#[derive(Debug)] -pub enum BrunoRequestBody { - Json(String), - #[allow(dead_code)] - Text(String), -} - -#[derive(Debug)] -pub enum BrunoAuth { - Bearer { - token: String, - }, - #[allow(dead_code)] - Basic { - username: String, - password: String, - }, - #[allow(dead_code)] - None, -} - -impl BrunoRequest { - pub fn new>(name: S, method: S, url: S) -> Self { - Self { - name: name.into(), - method: method.into(), - url: url.into(), - headers: HashMap::new(), - body: None, - auth: None, - sequence: None, - } - } - - pub fn with_sequence(mut self, seq: u32) -> Self { - self.sequence = Some(seq); - self - } - - pub fn with_json_body>(mut self, body: S) -> Self { - self.body = Some(BrunoRequestBody::Json(body.into())); - self - } - - pub fn with_auth(mut self, auth: BrunoAuth) -> Self { - self.auth = Some(auth); - self - } - - pub fn add_header, V: Into>(&mut self, key: K, value: V) { - self.headers.insert(key.into(), value.into()); - } - - pub fn to_bru_format(&self) -> String { - let mut content = String::new(); - - // Meta section - content.push_str("meta {\n"); - content.push_str(&format!(" name: {}\n", self.name)); - content.push_str(" type: http\n"); - if let Some(seq) = self.sequence { - content.push_str(&format!(" seq: {}\n", seq)); - } - content.push_str("}\n\n"); - - // Request section - content.push_str(&format!("{} {{\n", self.method.to_lowercase())); - content.push_str(&format!(" url: {}\n", self.url)); - - if self.body.is_some() { - content.push_str(" body: json\n"); - } - - if self.auth.is_some() { - match &self.auth { - Some(BrunoAuth::Bearer { .. }) => content.push_str(" auth: bearer\n"), - Some(BrunoAuth::Basic { .. }) => content.push_str(" auth: basic\n"), - _ => {} - } - } - - content.push_str("}\n"); - - // Headers section - if !self.headers.is_empty() { - content.push_str("\nheaders {\n"); - for (key, value) in &self.headers { - content.push_str(&format!(" {}: {}\n", key, value)); - } - content.push_str("}\n"); - } - - // Auth section - if let Some(auth) = &self.auth { - match auth { - BrunoAuth::Bearer { token } => { - content.push_str("\nauth:bearer {\n"); - content.push_str(&format!(" token: {}\n", token)); - content.push_str("}\n"); - } - BrunoAuth::Basic { username, password } => { - content.push_str("\nauth:basic {\n"); - content.push_str(&format!(" username: {}\n", username)); - content.push_str(&format!(" password: {}\n", password)); - content.push_str("}\n"); - } - _ => {} - } - } - - // Body section - if let Some(body) = &self.body { - match body { - BrunoRequestBody::Json(json) => { - content.push_str("\nbody:json {\n"); - // Indent each line of the JSON with 2 spaces - for line in json.lines() { - content.push_str(&format!(" {}\n", line)); - } - content.push_str("}\n"); - } - BrunoRequestBody::Text(text) => { - content.push_str("\nbody:text {\n"); - content.push_str(&format!(" {}\n", text)); - content.push_str("}\n"); - } - } - } - - content - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_bruno_collection_default() { - let collection = BrunoCollection::default(); - assert_eq!(collection.version, "1"); - assert_eq!(collection.collection_type, "collection"); - assert_eq!(collection.name, "generated-collection"); - assert_eq!(collection.ignore, vec!["node_modules", ".git"]); - } - - #[test] - fn test_bruno_environment_to_bru_format() { - let mut env = BrunoEnvironment::new("test".to_string()); - env.add_variable("host", "localhost:3000"); - env.add_variable("api_path", "/api/v1/rpc"); - - let output = env.to_bru_format(); - assert!(output.contains("vars {")); - assert!(output.contains("host: localhost:3000")); - assert!(output.contains("api_path: /api/v1/rpc")); - assert!(output.contains("}")); - } - - #[test] - fn test_bruno_request_basic() { - let request = - BrunoRequest::new("test_method", "post", "http://localhost:3000/api").with_sequence(1); - - let output = request.to_bru_format(); - assert!(output.contains("meta {")); - assert!(output.contains("name: test_method")); - assert!(output.contains("type: http")); - assert!(output.contains("seq: 1")); - assert!(output.contains("post {")); - assert!(output.contains("url: http://localhost:3000/api")); - } - - #[test] - fn test_bruno_request_with_json_body() { - let request = BrunoRequest::new("test", "post", "http://localhost:3000") - .with_json_body(r#"{"test": "value"}"#); - - let output = request.to_bru_format(); - assert!(output.contains("body: json")); - assert!(output.contains("body:json {")); - assert!(output.contains(r#"{"test": "value"}"#)); - } - - #[test] - fn test_bruno_request_with_bearer_auth() { - let request = BrunoRequest::new("test", "post", "http://localhost:3000").with_auth( - BrunoAuth::Bearer { - token: "{{auth_token}}".to_string(), - }, - ); - - let output = request.to_bru_format(); - assert!(output.contains("auth: bearer")); - assert!(output.contains("auth:bearer {")); - assert!(output.contains("token: {{auth_token}}")); - } - - #[test] - fn test_bruno_request_with_headers() { - let mut request = BrunoRequest::new("test", "post", "http://localhost:3000"); - request.add_header("Content-Type", "application/json"); - request.add_header("Accept", "application/json"); - - let output = request.to_bru_format(); - assert!(output.contains("headers {")); - assert!(output.contains("Content-Type: application/json")); - assert!(output.contains("Accept: application/json")); - } - - #[test] - fn test_bruno_request_json_indentation() { - // Test that multi-line JSON is properly indented - let json_body = r#"{ - "jsonrpc": "2.0", - "method": "test_method", - "params": { - "name": "value", - "number": 42 - }, - "id": 1 -}"#; - - let request = - BrunoRequest::new("test", "post", "http://localhost:3000").with_json_body(json_body); - - let output = request.to_bru_format(); - - // Each line of JSON should be indented with 2 spaces inside body:json { } - assert!(output.contains("body:json {\n {\n \"jsonrpc\": \"2.0\",")); - assert!(output.contains(" \"method\": \"test_method\",")); - assert!(output.contains(" \"params\": {")); - assert!(output.contains(" \"name\": \"value\",")); - assert!(output.contains(" \"number\": 42")); - assert!(output.contains(" },")); - assert!(output.contains(" \"id\": 1")); - assert!(output.contains(" }\n}")); - } -} diff --git a/crates/tools/openrpc-to-bruno/src/cli.rs b/crates/tools/openrpc-to-bruno/src/cli.rs deleted file mode 100644 index e170652..0000000 --- a/crates/tools/openrpc-to-bruno/src/cli.rs +++ /dev/null @@ -1,66 +0,0 @@ -use crate::converter::OpenRpcToBrunoConverter; -use crate::error::ToolError; -use clap::Parser; -use std::path::PathBuf; - -/// Convert OpenRPC specifications to Bruno API collections -#[derive(Parser)] -#[command(name = "openrpc-to-bruno")] -#[command(about = "Convert OpenRPC specifications to Bruno API collections")] -#[command(version = "0.1.0")] -pub struct Args { - /// Path to the OpenRPC specification file (JSON or YAML) - #[arg(short, long, value_name = "FILE")] - pub input: PathBuf, - - /// Output directory for the Bruno collection - #[arg(short, long, value_name = "DIR")] - pub output: PathBuf, - - /// Base URL for the API server (e.g., http://localhost:3000) - #[arg(short, long, value_name = "URL")] - pub base_url: Option, - - /// Collection name (defaults to OpenRPC info.title) - #[arg(short, long, value_name = "NAME")] - pub name: Option, - - /// Force overwrite existing Bruno collection - #[arg(short, long)] - pub force: bool, - - /// Enable verbose output - #[arg(short, long)] - pub verbose: bool, -} - -impl Args { - pub async fn run(&self) -> Result<(), ToolError> { - if self.verbose { - println!("🔍 Input file: {}", self.input.display()); - println!("📁 Output directory: {}", self.output.display()); - if let Some(base_url) = &self.base_url { - println!("🌐 Base URL: {}", base_url); - } - if let Some(name) = &self.name { - println!("📝 Collection name: {}", name); - } - } - - let converter = OpenRpcToBrunoConverter::new(self.clone()); - converter.convert().await - } -} - -impl Clone for Args { - fn clone(&self) -> Self { - Self { - input: self.input.clone(), - output: self.output.clone(), - base_url: self.base_url.clone(), - name: self.name.clone(), - force: self.force, - verbose: self.verbose, - } - } -} diff --git a/crates/tools/openrpc-to-bruno/src/converter.rs b/crates/tools/openrpc-to-bruno/src/converter.rs deleted file mode 100644 index 9a02f56..0000000 --- a/crates/tools/openrpc-to-bruno/src/converter.rs +++ /dev/null @@ -1,391 +0,0 @@ -use crate::bruno::{BrunoAuth, BrunoCollection, BrunoEnvironment, BrunoRequest}; -use crate::cli::Args; -use crate::error::ToolError; -use openrpc_types::{ - ContentDescriptorOrReference, ContentDescriptorSchema, Method, MethodOrReference, OpenRpc, - Schema, SchemaType, -}; -use serde_json::{Map, Value}; -use std::path::Path; -use tokio::fs; - -pub struct OpenRpcToBrunoConverter { - args: Args, -} - -impl OpenRpcToBrunoConverter { - pub fn new(args: Args) -> Self { - Self { args } - } - - pub async fn convert(&self) -> Result<(), ToolError> { - // Step 1: Parse OpenRPC specification - let openrpc = self.parse_openrpc().await?; - - // Step 2: Validate output directory - self.prepare_output_directory().await?; - - // Step 3: Generate Bruno collection structure - self.generate_bruno_collection(&openrpc).await?; - - Ok(()) - } - - async fn parse_openrpc(&self) -> Result { - if self.args.verbose { - println!("📖 Reading OpenRPC file..."); - } - - let content = - fs::read_to_string(&self.args.input) - .await - .map_err(|e| ToolError::InputFileRead { - path: self.args.input.clone(), - source: e, - })?; - - let openrpc: OpenRpc = if self.args.input.extension().and_then(|s| s.to_str()) - == Some("yaml") - || self.args.input.extension().and_then(|s| s.to_str()) == Some("yml") - { - // Parse YAML (we'll need to add serde_yaml dependency for this) - return Err(ToolError::UnsupportedFeature( - "YAML parsing not yet implemented. Please use JSON format.".to_string(), - )); - } else { - serde_json::from_str(&content)? - }; - - if self.args.verbose { - println!("✅ Successfully parsed OpenRPC specification"); - println!(" Title: {}", openrpc.info.title); - println!(" Version: {}", openrpc.info.version); - println!(" Methods: {}", openrpc.methods.len()); - } - - // Basic validation - if openrpc.methods.is_empty() { - return Err(ToolError::NoMethodsDefined); - } - - Ok(openrpc) - } - - async fn prepare_output_directory(&self) -> Result<(), ToolError> { - if self.args.output.exists() { - if !self.args.force { - return Err(ToolError::OutputDirExists { - path: self.args.output.clone(), - }); - } - if self.args.verbose { - println!("🗑️ Removing existing output directory..."); - } - fs::remove_dir_all(&self.args.output).await.map_err(|e| { - ToolError::OutputDirCreate { - path: self.args.output.clone(), - source: e, - } - })?; - } - - if self.args.verbose { - println!("📁 Creating output directory..."); - } - - fs::create_dir_all(&self.args.output) - .await - .map_err(|e| ToolError::OutputDirCreate { - path: self.args.output.clone(), - source: e, - })?; - - // Create environments subdirectory - let env_dir = self.args.output.join("environments"); - fs::create_dir_all(&env_dir) - .await - .map_err(|e| ToolError::OutputDirCreate { - path: env_dir.clone(), - source: e, - })?; - - Ok(()) - } - - async fn generate_bruno_collection(&self, openrpc: &OpenRpc) -> Result<(), ToolError> { - // Step 1: Generate bruno.json - self.generate_collection_metadata(openrpc).await?; - - // Step 2: Generate environment file - self.generate_environment_file(openrpc).await?; - - // Step 3: Generate request files for each method - self.generate_method_files(openrpc).await?; - - Ok(()) - } - - async fn generate_collection_metadata(&self, openrpc: &OpenRpc) -> Result<(), ToolError> { - if self.args.verbose { - println!("📝 Generating collection metadata..."); - } - - let collection_name = self - .args - .name - .clone() - .unwrap_or_else(|| openrpc.info.title.clone()); - - let collection = BrunoCollection { - name: collection_name, - ..Default::default() - }; - - let content = serde_json::to_string_pretty(&collection)?; - let path = self.args.output.join("bruno.json"); - - fs::write(&path, content) - .await - .map_err(|e| ToolError::BrunoFileWrite { - path: path.clone(), - source: e, - })?; - - Ok(()) - } - - async fn generate_environment_file(&self, openrpc: &OpenRpc) -> Result<(), ToolError> { - if self.args.verbose { - println!("🌍 Generating environment file..."); - } - - let mut environment = BrunoEnvironment::new("default".to_string()); - - // Add base URL from args or try to extract from OpenRPC servers - if let Some(base_url) = &self.args.base_url { - environment.add_variable("base_url", base_url); - } else if let Some(servers) = &openrpc.servers { - if !servers.is_empty() { - environment.add_variable("base_url", &servers[0].url); - } - } else { - // Default to localhost - environment.add_variable("base_url", "http://localhost:3000"); - } - - // Add common variables - environment.add_variable("api_path", "/api/v1/rpc"); - environment.add_variable("auth_token", "PUT YOUR TOKEN HERE"); - - let content = environment.to_bru_format(); - let path = self.args.output.join("environments").join("default.bru"); - - fs::write(&path, content) - .await - .map_err(|e| ToolError::BrunoFileWrite { - path: path.clone(), - source: e, - })?; - - Ok(()) - } - - async fn generate_method_files(&self, openrpc: &OpenRpc) -> Result<(), ToolError> { - if self.args.verbose { - println!("🔧 Generating method files..."); - } - - for (index, method_ref) in openrpc.methods.iter().enumerate() { - match method_ref { - MethodOrReference::Method(method) => { - self.generate_method_file(method, index as u32 + 1).await?; - } - MethodOrReference::Reference(_) => { - return Err(ToolError::UnsupportedFeature( - "Method references are not yet supported".to_string(), - )); - } - } - } - - Ok(()) - } - - async fn generate_method_file(&self, method: &Method, sequence: u32) -> Result<(), ToolError> { - if self.args.verbose { - println!(" 📄 Generating method: {}", method.name); - } - - // Create JSON-RPC request body - let request_body = self.create_jsonrpc_request_body(method)?; - - // Create Bruno request - let mut bruno_request = BrunoRequest::new( - method.name.clone(), - "post".to_string(), - "{{base_url}}{{api_path}}".to_string(), - ) - .with_sequence(sequence) - .with_json_body(serde_json::to_string_pretty(&request_body)?); - - // Add Content-Type header - bruno_request.add_header("Content-Type", "application/json"); - - // Check if method requires authentication based on extensions - if method.extensions.contains_key("x-authentication") - || method.extensions.contains_key("x-permissions") - { - bruno_request = bruno_request.with_auth(BrunoAuth::Bearer { - token: "{{auth_token}}".to_string(), - }); - } - - let content = bruno_request.to_bru_format(); - let file_name = safe_method_file_name(&method.name, sequence)?; - let path = self.safe_output_path(&file_name).await?; - - fs::write(&path, content) - .await - .map_err(|e| ToolError::BrunoFileWrite { - path: path.clone(), - source: e, - })?; - - Ok(()) - } - - async fn safe_output_path(&self, file_name: &str) -> Result { - let output_dir = - fs::canonicalize(&self.args.output) - .await - .map_err(|e| ToolError::OutputDirCreate { - path: self.args.output.clone(), - source: e, - })?; - - let candidate = output_dir.join(file_name); - let parent = candidate.parent().unwrap_or(&output_dir); - let parent = fs::canonicalize(parent) - .await - .map_err(|e| ToolError::OutputDirCreate { - path: parent.to_path_buf(), - source: e, - })?; - - if !parent.starts_with(&output_dir) { - return Err(ToolError::UnsafeMethodName(file_name.to_string())); - } - - Ok(candidate) - } - - fn create_jsonrpc_request_body(&self, method: &Method) -> Result { - let mut request = Map::new(); - request.insert("jsonrpc".to_string(), Value::String("2.0".to_string())); - request.insert("method".to_string(), Value::String(method.name.clone())); - request.insert("id".to_string(), Value::Number(1.into())); - - // Generate example parameters - let params = self.generate_example_params(method)?; - if !params.is_null() { - request.insert("params".to_string(), params); - } - - Ok(Value::Object(request)) - } - - fn generate_example_params(&self, method: &Method) -> Result { - if method.params.is_empty() { - return Ok(Value::Null); - } - - let mut param_values = Map::new(); - - for param_ref in &method.params { - match param_ref { - ContentDescriptorOrReference::ContentDescriptor(content_desc) => { - let example_value = self.generate_example_value(&content_desc.schema)?; - param_values.insert(content_desc.name.clone(), example_value); - } - ContentDescriptorOrReference::Reference(_) => { - return Err(ToolError::UnsupportedFeature( - "Parameter references are not yet supported".to_string(), - )); - } - } - } - - Ok(Value::Object(param_values)) - } - - fn generate_example_value(&self, schema: &ContentDescriptorSchema) -> Result { - match schema { - ContentDescriptorSchema::Schema(schema) => self.generate_example_from_schema(schema), - ContentDescriptorSchema::Reference(_) => Err(ToolError::UnsupportedFeature( - "Schema references are not yet supported".to_string(), - )), - } - } - - fn generate_example_from_schema(&self, schema: &Schema) -> Result { - // Simple example generation based on schema type - // This could be extended to be more sophisticated - match &schema.schema_type { - Some(SchemaType::String) => Ok(Value::String("example_string".to_string())), - Some(SchemaType::Number) => Ok(Value::Number(42.into())), - Some(SchemaType::Integer) => Ok(Value::Number(42.into())), - Some(SchemaType::Boolean) => Ok(Value::Bool(true)), - Some(SchemaType::Array) => Ok(Value::Array(vec![Value::String( - "example_item".to_string(), - )])), - Some(SchemaType::Object) => { - let mut obj = Map::new(); - if let Some(props) = &schema.properties { - for (key, _) in props.iter().take(3) { - // Limit to first 3 properties - obj.insert(key.clone(), Value::String("example_value".to_string())); - } - } else { - obj.insert( - "example_key".to_string(), - Value::String("example_value".to_string()), - ); - } - Ok(Value::Object(obj)) - } - Some(SchemaType::Null) => Ok(Value::Null), - None => Ok(Value::String("any_value".to_string())), - } - } -} - -fn safe_method_file_name(method_name: &str, sequence: u32) -> Result { - let name = method_name.trim(); - if name.is_empty() - || name == "." - || name == ".." - || name.contains('\0') - || name.contains('/') - || name.contains('\\') - || Path::new(name).is_absolute() - { - return Err(ToolError::UnsafeMethodName(method_name.to_string())); - } - - let sanitized: String = name - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { - ch - } else { - '_' - } - }) - .collect(); - - if sanitized.is_empty() || sanitized == "." || sanitized == ".." { - return Err(ToolError::UnsafeMethodName(method_name.to_string())); - } - - Ok(format!("{sequence:03}_{sanitized}.bru")) -} diff --git a/crates/tools/openrpc-to-bruno/src/error.rs b/crates/tools/openrpc-to-bruno/src/error.rs deleted file mode 100644 index 7f3309a..0000000 --- a/crates/tools/openrpc-to-bruno/src/error.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::path::PathBuf; -use thiserror::Error; - -/// Errors that can occur during OpenRPC to Bruno conversion -#[derive(Error, Debug)] -pub enum ToolError { - #[error("Failed to read OpenRPC file at {path}: {source}")] - InputFileRead { - path: PathBuf, - source: std::io::Error, - }, - - #[error("Failed to parse OpenRPC specification: {0}")] - OpenRpcParse(#[from] serde_json::Error), - - #[error("Invalid OpenRPC specification: {0}")] - #[allow(dead_code)] - OpenRpcValidation(String), - - #[error("Failed to create output directory {path}: {source}")] - OutputDirCreate { - path: PathBuf, - source: std::io::Error, - }, - - #[error("Output directory {path} already exists (use --force to overwrite)")] - OutputDirExists { path: PathBuf }, - - #[error("Failed to write Bruno file {path}: {source}")] - BrunoFileWrite { - path: PathBuf, - source: std::io::Error, - }, - - #[error("Unsafe OpenRPC method name for file generation: {0}")] - UnsafeMethodName(String), - - #[error("Invalid base URL: {0}")] - #[allow(dead_code)] - InvalidBaseUrl(String), - - #[error("OpenRPC specification has no methods defined")] - NoMethodsDefined, - - #[error("Failed to resolve OpenRPC references: {0}")] - #[allow(dead_code)] - ReferenceResolution(String), - - #[error("Unsupported OpenRPC feature: {0}")] - UnsupportedFeature(String), -} diff --git a/crates/tools/openrpc-to-bruno/src/lib.rs b/crates/tools/openrpc-to-bruno/src/lib.rs deleted file mode 100644 index 38ffeda..0000000 --- a/crates/tools/openrpc-to-bruno/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod bruno; -pub mod cli; -pub mod converter; -pub mod error; diff --git a/crates/tools/openrpc-to-bruno/src/main.rs b/crates/tools/openrpc-to-bruno/src/main.rs deleted file mode 100644 index 3483d88..0000000 --- a/crates/tools/openrpc-to-bruno/src/main.rs +++ /dev/null @@ -1,25 +0,0 @@ -use clap::Parser; - -mod bruno; -mod cli; -mod converter; -mod error; - -use crate::cli::Args; -use crate::error::ToolError; - -#[tokio::main] -async fn main() -> Result<(), ToolError> { - let args = Args::parse(); - - match args.run().await { - Ok(_) => { - println!("✅ Successfully converted OpenRPC to Bruno collection"); - Ok(()) - } - Err(e) => { - eprintln!("❌ Error: {}", e); - std::process::exit(1); - } - } -} diff --git a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/create_document.bru b/crates/tools/openrpc-to-bruno/tests/fixtures/googal/create_document.bru deleted file mode 100644 index 2c8f2b6..0000000 --- a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/create_document.bru +++ /dev/null @@ -1,30 +0,0 @@ -meta { - name: create_document - type: http - seq: 3 -} - -post { - url: {{base_url}}{{api_path}} - body: json - auth: bearer -} - -headers { - Content-Type: application/json -} - -auth:bearer { - token: {{auth_token}} -} - -body:json { - { - "id": 1, - "jsonrpc": "2.0", - "method": "create_document", - "params": { - "params": "any_value" - } - } -} diff --git a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/delete_document.bru b/crates/tools/openrpc-to-bruno/tests/fixtures/googal/delete_document.bru deleted file mode 100644 index 85565a2..0000000 --- a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/delete_document.bru +++ /dev/null @@ -1,30 +0,0 @@ -meta { - name: delete_document - type: http - seq: 4 -} - -post { - url: {{base_url}}{{api_path}} - body: json - auth: bearer -} - -headers { - Content-Type: application/json -} - -auth:bearer { - token: {{auth_token}} -} - -body:json { - { - "id": 1, - "jsonrpc": "2.0", - "method": "delete_document", - "params": { - "params": "any_value" - } - } -} diff --git a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/environments/default.bru b/crates/tools/openrpc-to-bruno/tests/fixtures/googal/environments/default.bru deleted file mode 100644 index 3f7cb0f..0000000 --- a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/environments/default.bru +++ /dev/null @@ -1,5 +0,0 @@ -vars { - auth_token: PUT YOUR TOKEN HERE - base_url: http://localhost:3000 - api_path: /api/v1/rpc -} diff --git a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_beta_features.bru b/crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_beta_features.bru deleted file mode 100644 index bd4f58c..0000000 --- a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_beta_features.bru +++ /dev/null @@ -1,30 +0,0 @@ -meta { - name: get_beta_features - type: http - seq: 6 -} - -post { - url: {{base_url}}{{api_path}} - body: json - auth: bearer -} - -headers { - Content-Type: application/json -} - -auth:bearer { - token: {{auth_token}} -} - -body:json { - { - "id": 1, - "jsonrpc": "2.0", - "method": "get_beta_features", - "params": { - "params": "any_value" - } - } -} diff --git a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_system_status.bru b/crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_system_status.bru deleted file mode 100644 index 698b33b..0000000 --- a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_system_status.bru +++ /dev/null @@ -1,30 +0,0 @@ -meta { - name: get_system_status - type: http - seq: 5 -} - -post { - url: {{base_url}}{{api_path}} - body: json - auth: bearer -} - -headers { - Content-Type: application/json -} - -auth:bearer { - token: {{auth_token}} -} - -body:json { - { - "id": 1, - "jsonrpc": "2.0", - "method": "get_system_status", - "params": { - "params": "any_value" - } - } -} diff --git a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_user_info.bru b/crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_user_info.bru deleted file mode 100644 index 3c6660d..0000000 --- a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/get_user_info.bru +++ /dev/null @@ -1,30 +0,0 @@ -meta { - name: get_user_info - type: http - seq: 1 -} - -post { - url: {{base_url}}{{api_path}} - body: json - auth: bearer -} - -headers { - Content-Type: application/json -} - -auth:bearer { - token: {{auth_token}} -} - -body:json { - { - "id": 1, - "jsonrpc": "2.0", - "method": "get_user_info", - "params": { - "params": "any_value" - } - } -} diff --git a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/list_documents.bru b/crates/tools/openrpc-to-bruno/tests/fixtures/googal/list_documents.bru deleted file mode 100644 index ddb3db7..0000000 --- a/crates/tools/openrpc-to-bruno/tests/fixtures/googal/list_documents.bru +++ /dev/null @@ -1,30 +0,0 @@ -meta { - name: list_documents - type: http - seq: 2 -} - -post { - url: {{base_url}}{{api_path}} - body: json - auth: bearer -} - -headers { - Content-Type: application/json -} - -auth:bearer { - token: {{auth_token}} -} - -body:json { - { - "id": 1, - "jsonrpc": "2.0", - "method": "list_documents", - "params": { - "params": "any_value" - } - } -} diff --git a/crates/tools/openrpc-to-bruno/tests/fixtures/google-oauth2.openrpc.json b/crates/tools/openrpc-to-bruno/tests/fixtures/google-oauth2.openrpc.json deleted file mode 100644 index 731b177..0000000 --- a/crates/tools/openrpc-to-bruno/tests/fixtures/google-oauth2.openrpc.json +++ /dev/null @@ -1,476 +0,0 @@ -{ - "components": { - "errors": { - "AuthenticationRequired": { - "code": -32001, - "message": "Authentication required" - }, - "InsufficientPermissions": { - "code": -32002, - "message": "Insufficient permissions" - }, - "InternalError": { - "code": -32603, - "message": "Internal error" - }, - "InvalidParams": { - "code": -32602, - "message": "Invalid params" - }, - "InvalidRequest": { - "code": -32600, - "message": "Invalid Request" - }, - "MethodNotFound": { - "code": -32601, - "message": "Method not found" - }, - "ParseError": { - "code": -32700, - "message": "Parse error" - }, - "TokenExpired": { - "code": -32003, - "message": "Token expired" - } - }, - "schemas": { - "CreateDocumentRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Request to create a new document (admin only)", - "properties": { - "content": { - "type": "string" - }, - "tags": { - "items": { - "type": "string" - }, - "type": "array" - }, - "title": { - "type": "string" - } - }, - "required": [ - "title", - "content", - "tags" - ], - "title": "CreateDocumentRequest", - "type": "object" - }, - "CreateDocumentResponse": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Response for document creation", - "properties": { - "created_at": { - "type": "string" - }, - "document_id": { - "type": "string" - } - }, - "required": [ - "document_id", - "created_at" - ], - "title": "CreateDocumentResponse", - "type": "object" - }, - "DeleteDocumentRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Request to delete a document (admin only)", - "properties": { - "document_id": { - "type": "string" - } - }, - "required": [ - "document_id" - ], - "title": "DeleteDocumentRequest", - "type": "object" - }, - "DeleteDocumentResponse": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Response for document deletion", - "properties": { - "message": { - "type": "string" - }, - "success": { - "type": "boolean" - } - }, - "required": [ - "success", - "message" - ], - "title": "DeleteDocumentResponse", - "type": "object" - }, - "GetBetaFeaturesRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Request to access beta features", - "title": "GetBetaFeaturesRequest", - "type": "object" - }, - "GetBetaFeaturesResponse": { - "$defs": { - "BetaFeature": { - "description": "Beta feature information", - "properties": { - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name", - "description", - "enabled" - ], - "type": "object" - } - }, - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Response for beta features", - "properties": { - "features": { - "items": { - "$ref": "#/$defs/BetaFeature" - }, - "type": "array" - } - }, - "required": [ - "features" - ], - "title": "GetBetaFeaturesResponse", - "type": "object" - }, - "GetSystemStatusRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Request to get system status (system admin only)", - "title": "GetSystemStatusRequest", - "type": "object" - }, - "GetSystemStatusResponse": { - "$defs": { - "SystemStatus": { - "description": "System status information", - "properties": { - "active_sessions": { - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "memory_usage_mb": { - "format": "uint64", - "minimum": 0, - "type": "integer" - }, - "uptime_seconds": { - "format": "uint64", - "minimum": 0, - "type": "integer" - }, - "version": { - "type": "string" - } - }, - "required": [ - "uptime_seconds", - "memory_usage_mb", - "active_sessions", - "version" - ], - "type": "object" - } - }, - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Response for system status", - "properties": { - "status": { - "$ref": "#/$defs/SystemStatus" - } - }, - "required": [ - "status" - ], - "title": "GetSystemStatusResponse", - "type": "object" - }, - "GetUserInfoRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Request to get current user information", - "title": "GetUserInfoRequest", - "type": "object" - }, - "GetUserInfoResponse": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Response containing user information", - "properties": { - "metadata": { - "type": "object" - }, - "permissions": { - "items": { - "type": "string" - }, - "type": "array" - }, - "user_id": { - "type": "string" - } - }, - "required": [ - "user_id", - "permissions", - "metadata" - ], - "title": "GetUserInfoResponse", - "type": "object" - }, - "ListDocumentsRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Request to list documents", - "format": "uint32", - "minimum": 0, - "title": "ListDocumentsRequest", - "type": "object" - }, - "ListDocumentsResponse": { - "$defs": { - "DocumentInfo": { - "description": "Document information", - "properties": { - "created_at": { - "type": "string" - }, - "id": { - "type": "string" - }, - "tags": { - "items": { - "type": "string" - }, - "type": "array" - }, - "title": { - "type": "string" - } - }, - "required": [ - "id", - "title", - "created_at", - "tags" - ], - "type": "object" - } - }, - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Response for listing documents", - "properties": { - "documents": { - "items": { - "$ref": "#/$defs/DocumentInfo" - }, - "type": "array" - }, - "total": { - "format": "uint32", - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "documents", - "total" - ], - "title": "ListDocumentsResponse", - "type": "object" - } - } - }, - "info": { - "description": "OpenRPC specification for the GoogleOAuth2Service service", - "title": "GoogleOAuth2Service JSON-RPC API", - "version": "1.0.0" - }, - "methods": [ - { - "description": "Calls the get_user_info method", - "name": "get_user_info", - "params": [ - { - "description": "Request parameters of type GetUserInfoRequest", - "name": "params", - "required": true, - "schema": { - "$ref": "#/components/schemas/GetUserInfoRequest" - } - } - ], - "result": { - "description": "Response of type GetUserInfoResponse", - "name": "result", - "schema": { - "$ref": "#/components/schemas/GetUserInfoResponse" - } - }, - "x-authentication": { - "required": true, - "type": "bearer" - } - }, - { - "description": "Calls the list_documents method", - "name": "list_documents", - "params": [ - { - "description": "Request parameters of type ListDocumentsRequest", - "name": "params", - "required": true, - "schema": { - "$ref": "#/components/schemas/ListDocumentsRequest" - } - } - ], - "result": { - "description": "Response of type ListDocumentsResponse", - "name": "result", - "schema": { - "$ref": "#/components/schemas/ListDocumentsResponse" - } - }, - "x-authentication": { - "required": true, - "type": "bearer" - }, - "x-permissions": [ - "user:read" - ] - }, - { - "description": "Calls the create_document method", - "name": "create_document", - "params": [ - { - "description": "Request parameters of type CreateDocumentRequest", - "name": "params", - "required": true, - "schema": { - "$ref": "#/components/schemas/CreateDocumentRequest" - } - } - ], - "result": { - "description": "Response of type CreateDocumentResponse", - "name": "result", - "schema": { - "$ref": "#/components/schemas/CreateDocumentResponse" - } - }, - "x-authentication": { - "required": true, - "type": "bearer" - }, - "x-permissions": [ - "content:create" - ] - }, - { - "description": "Calls the delete_document method", - "name": "delete_document", - "params": [ - { - "description": "Request parameters of type DeleteDocumentRequest", - "name": "params", - "required": true, - "schema": { - "$ref": "#/components/schemas/DeleteDocumentRequest" - } - } - ], - "result": { - "description": "Response of type DeleteDocumentResponse", - "name": "result", - "schema": { - "$ref": "#/components/schemas/DeleteDocumentResponse" - } - }, - "x-authentication": { - "required": true, - "type": "bearer" - }, - "x-permissions": [ - "admin:write" - ] - }, - { - "description": "Calls the get_system_status method", - "name": "get_system_status", - "params": [ - { - "description": "Request parameters of type GetSystemStatusRequest", - "name": "params", - "required": true, - "schema": { - "$ref": "#/components/schemas/GetSystemStatusRequest" - } - } - ], - "result": { - "description": "Response of type GetSystemStatusResponse", - "name": "result", - "schema": { - "$ref": "#/components/schemas/GetSystemStatusResponse" - } - }, - "x-authentication": { - "required": true, - "type": "bearer" - }, - "x-permissions": [ - "system:admin" - ] - }, - { - "description": "Calls the get_beta_features method", - "name": "get_beta_features", - "params": [ - { - "description": "Request parameters of type GetBetaFeaturesRequest", - "name": "params", - "required": true, - "schema": { - "$ref": "#/components/schemas/GetBetaFeaturesRequest" - } - } - ], - "result": { - "description": "Response of type GetBetaFeaturesResponse", - "name": "result", - "schema": { - "$ref": "#/components/schemas/GetBetaFeaturesResponse" - } - }, - "x-authentication": { - "required": true, - "type": "bearer" - }, - "x-permissions": [ - "beta:access" - ] - } - ], - "openrpc": "1.3.2" -} \ No newline at end of file diff --git a/crates/tools/openrpc-to-bruno/tests/fixtures/simple-api-basic.json b/crates/tools/openrpc-to-bruno/tests/fixtures/simple-api-basic.json deleted file mode 100644 index 11e2a8d..0000000 --- a/crates/tools/openrpc-to-bruno/tests/fixtures/simple-api-basic.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "openrpc": "1.3.2", - "info": { - "title": "Simple API", - "version": "1.0.0", - "description": "A simple API for testing Bruno conversion" - }, - "servers": [ - { - "name": "Development", - "url": "http://localhost:3000" - } - ], - "methods": [ - { - "name": "hello", - "summary": "Says hello", - "description": "Returns a greeting message", - "params": [ - { - "name": "name", - "description": "The name to greet", - "required": true, - "schema": { - "type": "string" - } - } - ], - "result": { - "name": "greeting", - "description": "The greeting response", - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } - } - } - } - ] -} \ No newline at end of file diff --git a/crates/tools/openrpc-to-bruno/tests/integration.rs b/crates/tools/openrpc-to-bruno/tests/integration.rs deleted file mode 100644 index 15f62a7..0000000 --- a/crates/tools/openrpc-to-bruno/tests/integration.rs +++ /dev/null @@ -1,220 +0,0 @@ -use std::path::Path; -use tempfile::tempdir; -use tokio::fs; - -async fn test_conversion( - input_file: &str, - expected_methods: &[&str], -) -> Result<(), Box> { - let input_path = Path::new("tests/fixtures").join(input_file); - let output_dir = tempdir()?; - - // Run the conversion - let args = vec![ - "openrpc-to-bruno", - "--input", - input_path.to_str().unwrap(), - "--output", - output_dir.path().to_str().unwrap(), - "--force", - ]; - - // For testing, we'll directly test the conversion logic - use clap::Parser; - use openrpc_to_bruno::cli::Args; - - let args = Args::try_parse_from(args)?; - args.run().await?; - - // Check that bruno.json was created - let bruno_json = output_dir.path().join("bruno.json"); - assert!(bruno_json.exists(), "bruno.json should be created"); - - // Check that environment file was created - let env_file = output_dir.path().join("environments/default.bru"); - assert!(env_file.exists(), "environment file should be created"); - - // Check that method files were created - for (index, method) in expected_methods.iter().enumerate() { - let method_file = output_dir - .path() - .join(format!("{:03}_{}.bru", index + 1, method)); - assert!( - method_file.exists(), - "method file {} should be created", - method - ); - - // Verify the file has valid content - let content = fs::read_to_string(&method_file).await?; - assert!( - content.contains("meta {"), - "method file should have meta section" - ); - assert!( - content.contains("post {"), - "method file should have post section" - ); - assert!( - content.contains("body:json {"), - "method file should have body section" - ); - } - - Ok(()) -} - -#[tokio::test] -async fn test_rejects_path_traversal_method_name() { - use clap::Parser; - use openrpc_to_bruno::{cli::Args, error::ToolError}; - - let temp = tempdir().unwrap(); - let input_path = temp.path().join("openrpc.json"); - let output_dir = temp.path().join("out"); - let escaped = temp.path().join("evil.bru"); - - let spec = serde_json::json!({ - "openrpc": "1.3.2", - "info": { - "title": "Unsafe API", - "version": "1.0.0" - }, - "methods": [{ - "name": "../evil", - "params": [], - "result": { - "name": "result", - "schema": { "type": "string" } - } - }] - }); - fs::write(&input_path, serde_json::to_vec(&spec).unwrap()) - .await - .unwrap(); - - let args = Args::try_parse_from(vec![ - "openrpc-to-bruno", - "--input", - input_path.to_str().unwrap(), - "--output", - output_dir.to_str().unwrap(), - "--force", - ]) - .unwrap(); - - let err = args.run().await.unwrap_err(); - assert!(matches!(err, ToolError::UnsafeMethodName(_))); - assert!(!escaped.exists()); -} - -#[tokio::test] -async fn test_sanitizes_safe_method_filename() { - use clap::Parser; - use openrpc_to_bruno::cli::Args; - - let temp = tempdir().unwrap(); - let input_path = temp.path().join("openrpc.json"); - let output_dir = temp.path().join("out"); - - let spec = serde_json::json!({ - "openrpc": "1.3.2", - "info": { - "title": "Safe API", - "version": "1.0.0" - }, - "methods": [{ - "name": "system status", - "params": [], - "result": { - "name": "result", - "schema": { "type": "string" } - } - }] - }); - fs::write(&input_path, serde_json::to_vec(&spec).unwrap()) - .await - .unwrap(); - - let args = Args::try_parse_from(vec![ - "openrpc-to-bruno", - "--input", - input_path.to_str().unwrap(), - "--output", - output_dir.to_str().unwrap(), - "--force", - ]) - .unwrap(); - - args.run().await.unwrap(); - assert!(output_dir.join("001_system_status.bru").exists()); -} - -#[tokio::test] -async fn test_simple_conversion() { - test_conversion("simple-api-basic.json", &["hello"]) - .await - .expect("Simple conversion should work"); -} - -#[tokio::test] -async fn test_collection_metadata() { - let input_path = Path::new("tests/fixtures/simple-api-basic.json"); - let output_dir = tempdir().unwrap(); - - use clap::Parser; - use openrpc_to_bruno::cli::Args; - - let args = Args::try_parse_from(vec![ - "openrpc-to-bruno", - "--input", - input_path.to_str().unwrap(), - "--output", - output_dir.path().to_str().unwrap(), - "--name", - "Custom Collection Name", - "--force", - ]) - .unwrap(); - - args.run().await.unwrap(); - - // Check bruno.json content - let bruno_json = output_dir.path().join("bruno.json"); - let content = fs::read_to_string(bruno_json).await.unwrap(); - assert!( - content.contains("Custom Collection Name"), - "Should use custom collection name" - ); -} - -#[tokio::test] -async fn test_environment_variables() { - let input_path = Path::new("tests/fixtures/simple-api-basic.json"); - let output_dir = tempdir().unwrap(); - - use clap::Parser; - use openrpc_to_bruno::cli::Args; - - let args = Args::try_parse_from(vec![ - "openrpc-to-bruno", - "--input", - input_path.to_str().unwrap(), - "--output", - output_dir.path().to_str().unwrap(), - "--base-url", - "https://api.example.com", - "--force", - ]) - .unwrap(); - - args.run().await.unwrap(); - - // Check environment file content - let env_file = output_dir.path().join("environments/default.bru"); - let content = fs::read_to_string(env_file).await.unwrap(); - assert!( - content.contains("https://api.example.com"), - "Should use custom base URL" - ); -} diff --git a/deny.toml b/deny.toml index d731ee0..4f0cbe2 100644 --- a/deny.toml +++ b/deny.toml @@ -1,53 +1,19 @@ -# deny.toml - Cargo deny configuration for supply chain security -# https://embarkstudios.github.io/cargo-deny/ - -# This section is considered when running `cargo deny check advisories` [advisories] -# The path where the advisory database is cloned/fetched into db-path = "~/.cargo/advisory-db" -# The url(s) of the advisory databases to use db-urls = ["https://github.com/rustsec/advisory-db"] -# The lint level for security vulnerabilities -vulnerability = "deny" -# The lint level for unmaintained crates -unmaintained = "warn" -# The lint level for crates with security notices -notice = "warn" -# The lint level for crates that have been yanked from their source registry +unmaintained = "workspace" yanked = "deny" -# A list of advisory IDs to ignore -ignore = [ - # Example: "RUSTSEC-2020-0001", -] +ignore = [] -# This section is considered when running `cargo deny check bans` [bans] -# Lint level for when multiple versions of the same crate are detected multiple-versions = "warn" -# Lint level for when a crate marked as 'deny' is detected -deny = [ - # Example: { name = "openssl" }, # Use rustls instead -] -# Skip certain crates when checking for duplicates -skip = [ - # Example: { name = "winapi" }, # Commonly has multiple versions -] -# Similarly named crates that are allowed to coexist -skip-tree = [ - # Example: { name = "windows-sys", version = "0.42" }, -] -# Features to disable to avoid certain dependencies +deny = [] +skip = [] +skip-tree = [] features = [] -# Allow specific duplicate versions -allow = [ - # Example: { name = "anyhow", version = "1.0" }, -] +allow = [] -# This section is considered when running `cargo deny check licenses` [licenses] -# The lint level for crates which do not have a detectable license -unlicensed = "deny" -# List of allowed licenses allow = [ "MIT", "Apache-2.0", @@ -55,44 +21,19 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "ISC", - "Unicode-DFS-2016", + "Unicode-3.0", "CC0-1.0", "MPL-2.0", # Mozilla Public License, used by some crypto libraries "Zlib", ] -# List of denied licenses -deny = [ - "GPL-2.0", - "GPL-3.0", - "AGPL-3.0", - "LGPL-2.0", - "LGPL-2.1", - "LGPL-3.0", -] -# Lint level for licenses considered copyleft -copyleft = "warn" -# Lint level for licenses with confidence scores below the threshold confidence-threshold = 0.8 -# Allow 1 or more licenses on a per-crate basis -exceptions = [ - # Example: { allow = ["ISC", "MIT", "OpenSSL"], name = "openssl" }, -] +exceptions = [] -# This section is considered when running `cargo deny check sources` [sources] -# Lint level for what to happen when a crate from a crate registry that is not in the allow list is encountered unknown-registry = "warn" -# Lint level for what to happen when a crate from a git repository that is not in the allow list is encountered unknown-git = "warn" -# 1 or more crates.io alternative registries to allow allow-registry = ["https://github.com/rust-lang/crates.io-index"] -# 1 or more git repositories to allow -allow-git = [ - # Example: "https://github.com/rust-lang/cargo", -] +allow-git = [] [sources.allow-org] -# GitHub organizations to allow git sources from -github = [ - # Example: "rust-lang", -] \ No newline at end of file +github = [] diff --git a/docs_and_help/llm-txt/dwind_dominator.md b/docs_and_help/llm-txt/dwind_dominator.md deleted file mode 100644 index 8eaeade..0000000 --- a/docs_and_help/llm-txt/dwind_dominator.md +++ /dev/null @@ -1,113 +0,0 @@ -# DWIND - Type-Safe CSS Utilities for DOMINATOR - -DWIND brings Tailwind-like utilities to DOMINATOR web apps with compile-time validation. - -## Quick Start - -```rust -use dwind::prelude::*; -use dwind_macros::dwclass; -use dwui::prelude::*; - -// Initialize CSS (once at app start) -dwind::stylesheet(); - -// Use utilities -html!("div", { - .dwclass!("flex justify-center p-4 bg-apple-500 @md:bg-charm-700") -}) -``` - -## Discovery Guide for LLMs - -### 1. Find Available Styles -```bash -# List all CSS utility modules -ls crates/dwind/src/modules/ - -# Search for specific utilities (e.g., flexbox) -grep -r "flex" crates/dwind/resources/css/ - -# View generated docs -# https://jedimemo.github.io/dwind/doc/dwind/index.html -``` - -### 2. Component Library (dwui) -```bash -# List available components -ls crates/dwui/src/components/ - -# Components include: button, card, text_input, select, toggle, dropdown -``` - -### 3. Key Patterns - -**Responsive Design**: `@xs:`, `@sm:`, `@md:`, `@lg:`, `@xl:` prefixes -```rust -.dwclass!("text-sm @md:text-lg @xl:text-2xl") -``` - -**Pseudo-classes**: `hover:`, `focus:`, `disabled:` prefixes -```rust -.dwclass!("bg-gray-200 hover:bg-gray-300 disabled:opacity-50") -``` - -**Theming**: Use `is(.light *)` or `is(.dark *)` selectors -```rust -.dwclass!("is(.light *):bg-white is(.dark *):bg-black") -``` - -**Reactive Classes**: Apply classes conditionally based on signals -```rust -.dwclass_signal!("bg-blue-500", is_active.signal()) -.dwclass_signal!("text-xl", size.signal().eq(TextSize::ExtraLarge)) -``` - -## Architecture - -- **dwind**: Core utilities (spacing, colors, typography, layout) -- **dwind-macros**: `dwclass!` and `dwclass_signal!` macros -- **dwui**: Pre-built components using dwind utilities -- **dominator-css-bindgen**: CSS-to-Rust code generator (build-time) - -## Component Usage (dwui) - -Components use macro syntax generated by `futures-signals-component-macro`: - -```rust -use dwui::prelude::*; - -// Button component -button!({ - .content(Some(text("Click me"))) - .button_type(ButtonType::Flat) - .disabled(false) - .on_click(|_| println!("Clicked!")) -}) - -// Card with nested components -card!({ - .scheme(ColorScheme::Void) - .apply(|b| { - dwclass!(b, "p-4 w-64 flex flex-col gap-4") - .children([ - text_input!({ - .value(some_mutable) - .label("Enter name".to_string()) - }), - button!({ - .content(Some(text("Submit"))) - .button_type_signal(button_type.signal()) - }) - ]) - }) -}) -``` - -## Signal-based Properties - -Component properties with `#[signal]` attribute support both static and dynamic values: -- `.property(value)` - static value -- `.property_signal(signal)` - dynamic signal value - -All CSS classes are validated at compile-time. Invalid classes cause compilation errors. \ No newline at end of file diff --git a/documentation/ras-file-macro.md b/documentation/ras-file-macro.md index 5c748e0..cfe8c46 100644 --- a/documentation/ras-file-macro.md +++ b/documentation/ras-file-macro.md @@ -1,6 +1,6 @@ # ras-file-macro Usage Documentation -The `ras-file-macro` crate provides a powerful procedural macro for building type-safe file upload and download services with built-in authentication, cross-platform client support, and TypeScript bindings. +The `ras-file-macro` crate provides a procedural macro for building type-safe file upload and download services with built-in authentication, native Rust clients, OpenAPI documents, and optional browser bindings. ## Table of Contents - [Overview](#overview) @@ -9,11 +9,11 @@ The `ras-file-macro` crate provides a powerful procedural macro for building typ - [Macro Syntax](#macro-syntax) - [Server Implementation](#server-implementation) - [Client Usage](#client-usage) -- [TypeScript Client Generation](#typescript-client-generation) +- [TypeScript and WASM Clients](#typescript-and-wasm-clients) - [Authentication and Permissions](#authentication-and-permissions) - [Error Handling](#error-handling) - [Advanced Features](#advanced-features) -- [Complete Example](#complete-example) +- [File API Example](#file-api-example) ## Overview @@ -21,11 +21,10 @@ The `file_service!` macro generates: - A trait for implementing file operations - Axum router with upload/download endpoints - Native Rust client with streaming support -- OpenAPI 3.0 specification for the file service -- TypeScript client generation from OpenAPI (preferred approach) -- WASM client for browser environments (legacy approach) +- OpenAPI 3.0 specification for TypeScript client generation +- Optional WASM client bindings for direct browser file APIs - Built-in authentication and permission handling -- Comprehensive error types +- File-specific error types ## Installation @@ -33,16 +32,34 @@ Add to your `Cargo.toml`: ```toml [dependencies] -ras-file-macro = { path = "../../crates/rest/ras-file-macro" } -ras-auth-core = { path = "../../crates/core/ras-auth-core" } -axum = { workspace = true } -tokio = { workspace = true, features = ["full"] } -tower = { workspace = true } - -# For WASM client generation +ras-file-macro = "0.1.0" +ras-auth-core = "0.1.0" +serde = { version = "1.0", features = ["derive"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +axum = { version = "0.8", features = ["multipart"] } +tokio = { version = "1.0", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } +serde_json = "1.0" +schemars = "1.0.0-alpha.20" +async-trait = "0.1" +thiserror = "2" +reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } +uuid = { version = "1", features = ["v4"] } + +# Optional: only when compiling direct WASM bindings or the generated +# browser-oriented client for wasm32. [target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = { workspace = true } -web-sys = { workspace = true, features = ["File", "FormData", "Blob"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"] } +wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +js-sys = { version = "0.3", optional = true } +web-sys = { version = "0.3", features = ["File", "FormData", "Blob"], optional = true } +serde-wasm-bindgen = { version = "0.6", optional = true } + +[features] +default = [] +wasm-client = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys", "serde-wasm-bindgen"] ``` ## Basic Usage @@ -77,23 +94,37 @@ file_service!({ ### 2. Implement the Service Trait ```rust -use axum::response::IntoResponse; use axum::extract::Multipart; +use axum::response::IntoResponse; use async_trait::async_trait; +use ras_auth_core::AuthenticatedUser; +use std::path::PathBuf; pub struct MyFileStorage { - // Your storage implementation + upload_dir: PathBuf, +} + +impl MyFileStorage { + pub fn new(upload_dir: impl Into) -> Self { + Self { + upload_dir: upload_dir.into(), + } + } } #[async_trait] impl FileStorageTrait for MyFileStorage { async fn upload( &self, - user: &AuthenticatedUser, + _user: &AuthenticatedUser, mut multipart: Multipart, ) -> Result { // Extract file from multipart - while let Some(field) = multipart.next_field().await.unwrap() { + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))? + { if field.name() == Some("file") { let filename = field.file_name() .ok_or(FileStorageFileError::InvalidFormat)? @@ -106,7 +137,12 @@ impl FileStorageTrait for MyFileStorage { // Store file and return metadata let id = uuid::Uuid::new_v4().to_string(); - // ... store file logic ... + tokio::fs::create_dir_all(&self.upload_dir) + .await + .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))?; + tokio::fs::write(self.upload_dir.join(&id), &data) + .await + .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))?; return Ok(FileMetadata { id, @@ -124,8 +160,7 @@ impl FileStorageTrait for MyFileStorage { file_id: String, ) -> Result { // Retrieve file - let file_path = format!("./uploads/{}", file_id); - let file = tokio::fs::File::open(&file_path).await + let file = tokio::fs::File::open(self.upload_dir.join(&file_id)).await .map_err(|_| FileStorageFileError::NotFound)?; let stream = tokio_util::io::ReaderStream::new(file); @@ -144,12 +179,11 @@ impl FileStorageTrait for MyFileStorage { ```rust use axum::Router; -use std::sync::Arc; #[tokio::main] async fn main() { - let storage = Arc::new(MyFileStorage::new()); - let auth_provider = Arc::new(MyAuthProvider::new()); + let storage = MyFileStorage::new("./uploads"); + let auth_provider = MyAuthProvider::new(); let file_router = FileStorageBuilder::new(storage) .auth_provider(auth_provider) @@ -239,10 +273,10 @@ Configure your service with authentication and observability: ```rust let router = FileStorageBuilder::new(my_service) .auth_provider(auth_provider) - .usage_callback(|headers, method, path| { + .with_usage_tracker(|_headers, method, path| { // Track API usage }) - .duration_callback(|method, path, duration| { + .with_duration_tracker(|method, path, duration| { // Track request duration }) .build(); @@ -276,11 +310,11 @@ let client = FileStorageClient::builder("http://localhost:3000") .build()?; // Set authentication -client.set_bearer_token(Some("your-jwt-token")); +client.set_bearer_token(Some("validtoken")); // Upload file let metadata = client.upload( - "/path/to/file.pdf", + "./fixtures/report.pdf", None, // Optional: override filename None // Optional: override content type ).await?; @@ -299,49 +333,100 @@ let client = FileStorageClient::builder("http://localhost:3000") .build()?; ``` -## TypeScript Client Generation +## TypeScript and WASM Clients + +### OpenAPI-Generated TypeScript Client + +For browser apps, the recommended path is to generate a TypeScript fetch client from the OpenAPI document emitted by the Rust API crate. + +Enable OpenAPI generation in the service definition: + +```rust +file_service!({ + service_name: DocumentService, + base_path: "/api/documents", + openapi: true, + endpoints: [ + UPLOAD UNAUTHORIZED upload() -> UploadResponse, + UPLOAD WITH_PERMISSIONS(["user"]) upload_profile_picture() -> UploadResponse, + DOWNLOAD UNAUTHORIZED download/{file_id: String}() -> (), + DOWNLOAD WITH_PERMISSIONS(["user"]) download_secure/{file_id: String}() -> (), + ] +}); +``` + +Build the API or backend crate so the build script writes the OpenAPI document: + +```bash +cargo check -p file-service-backend --locked +``` + +Generate a TypeScript fetch client from that OpenAPI document with your +preferred OpenAPI generator. The examples below assume the generated client +exports methods from `./generated`. + +Use the generated functions directly: -### 1. Configure for WASM +```typescript +import { + downloadDownloadFileId, + uploadUpload, +} from './generated'; + +const file = new File(['hello from TypeScript'], 'hello.txt', { + type: 'text/plain', +}); + +const baseUrl = 'http://localhost:3000/api/documents'; + +const uploaded = await uploadUpload({ + baseUrl, + body: { file }, +}); +if (uploaded.error || !uploaded.data) throw uploaded.error; + +const downloaded = await downloadDownloadFileId({ + baseUrl, + path: { file_id: uploaded.data.file_id }, +}); +if (downloaded.error || !downloaded.data) throw downloaded.error; +``` -In your API crate's `Cargo.toml`: +The runnable usage sample lives in `examples/file-service-wasm/typescript-example` +and intentionally avoids a frontend framework or npm project. + +### Optional WASM Bindings + +If you need direct `wasm-bindgen` bindings instead of an OpenAPI-generated fetch client, add a `wasm-client` feature to your API crate and enable it when building for `wasm32`. The feature belongs to the API crate because the generated WASM module compiles inside that crate. ```toml [lib] crate-type = ["cdylib", "rlib"] [dependencies] -ras-file-macro = { path = "../path/to/ras-file-macro", features = ["wasm-client"] } +ras-file-macro = "0.1.0" +serde = { version = "1.0", features = ["derive"] } [target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = "0.2" -web-sys = { version = "0.3", features = ["File", "FormData"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"] } +wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +js-sys = { version = "0.3", optional = true } +web-sys = { version = "0.3", features = ["File", "Blob", "FormData"], optional = true } +serde-wasm-bindgen = { version = "0.6", optional = true } + +[features] +default = [] +wasm-client = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys", "serde-wasm-bindgen"] ``` -### 2. Build WASM Module - -Create a `package.json`: - -```json -{ - "name": "my-file-api", - "version": "1.0.0", - "scripts": { - "build": "wasm-pack build --target web --out-dir pkg --features wasm-client" - }, - "devDependencies": { - "wasm-pack": "^0.12.0" - } -} -``` +Build the module with `wasm-pack`: -Build the module: ```bash -npm run build +wasm-pack build --target web --out-dir pkg --features wasm-client ``` -### 3. Generated TypeScript API - -The build process generates TypeScript definitions: +The generated definitions expose a WASM client: ```typescript // pkg/my_file_api.d.ts @@ -353,7 +438,7 @@ export class WasmFileStorageClient { } ``` -### 4. Use in TypeScript/JavaScript +Use it from browser code: ```typescript import init, { WasmFileStorageClient } from './pkg/my_file_api'; @@ -363,7 +448,7 @@ await init(); // Create client const client = new WasmFileStorageClient('http://localhost:3000'); -client.set_bearer_token('your-jwt-token'); +client.set_bearer_token('validtoken'); // Upload file from input const fileInput = document.getElementById('file-input') as HTMLInputElement; @@ -378,54 +463,17 @@ const url = URL.createObjectURL(blob); window.open(url); ``` -### 5. React/SolidJS Example - -```tsx -import { createSignal } from 'solid-js'; -import { client } from './lib/client'; - -export function FileUpload() { - const [uploading, setUploading] = createSignal(false); - - const handleUpload = async (file: File) => { - setUploading(true); - try { - const metadata = await client.upload(file); - console.log('File uploaded:', metadata); - } catch (error) { - console.error('Upload failed:', error); - } finally { - setUploading(false); - } - }; - - return ( - { - const file = e.target.files?.[0]; - if (file) handleUpload(file); - }} - disabled={uploading()} - /> - ); -} -``` - ## Authentication and Permissions ### Integration with ras-auth-core -The macro integrates seamlessly with the `ras-auth-core` authentication system: +The macro integrates with the `ras-auth-core` authentication system: ```rust -use ras_auth_core::{AuthProvider, AuthenticatedUser}; -use std::sync::Arc; +use ras_identity_session::JwtAuthProvider; -// Use any AuthProvider implementation -let auth_provider: Arc = Arc::new(JwtAuthProvider::new( - session_service, -)); +// Use any cloneable AuthProvider implementation. +let auth_provider = JwtAuthProvider::new(session_service.clone()); let router = FileStorageBuilder::new(storage) .auth_provider(auth_provider) @@ -437,7 +485,7 @@ let router = FileStorageBuilder::new(storage) Permissions are automatically validated before calling your trait methods: ```rust -// Single permission (OR logic) +// Any listed permission (OR logic) UPLOAD WITH_PERMISSIONS(["upload", "admin"]) upload() -> FileMetadata // Multiple permissions required (AND logic) @@ -452,7 +500,7 @@ UPLOAD WITH_PERMISSIONS([["admin"], ["upload", "premium"]]) special_upload() -> Tokens are extracted from the `Authorization` header: ``` -Authorization: Bearer +Authorization: Bearer validtoken ``` ## Error Handling @@ -561,41 +609,57 @@ async fn upload( let mut file_data = None; let mut metadata = HashMap::new(); - while let Some(field) = multipart.next_field().await.unwrap() { + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))? + { let name = field.name().unwrap_or(""); match name { "file" => { let filename = field.file_name().unwrap_or("unnamed").to_string(); let content_type = field.content_type().unwrap_or("application/octet-stream").to_string(); - let data = field.bytes().await?; + let data = field + .bytes() + .await + .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))?; file_data = Some((filename, content_type, data)); } "description" => { - let value = field.text().await?; + let value = field + .text() + .await + .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))?; metadata.insert("description".to_string(), value); } _ => {} } } - // Process file_data and metadata - Ok(FileMetadata { /* ... */ }) + let (filename, content_type, data) = file_data.ok_or(FileStorageFileError::InvalidFormat)?; + Ok(FileMetadata { + id: uuid::Uuid::new_v4().to_string(), + filename, + size: data.len(), + content_type, + }) } ``` -## Complete Example +## File API Example -Here's a complete working example of a file service with authentication: +Here is a compact file service example with authentication: ### API Definition ```rust // file-api/src/lib.rs use ras_file_macro::file_service; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, JsonSchema, Clone)] pub struct UploadResponse { pub id: String, pub filename: String, @@ -606,6 +670,7 @@ pub struct UploadResponse { file_service!({ service_name: DocumentService, base_path: "/api/documents", + openapi: true, body_limit: 104857600, // 100 MB endpoints: [ UPLOAD UNAUTHORIZED upload() -> UploadResponse, @@ -614,10 +679,6 @@ file_service!({ DOWNLOAD WITH_PERMISSIONS(["user"]) download_secure/{file_id: String}(), ] }); - -// Enable WASM client -#[cfg(target_arch = "wasm32")] -pub use wasm::*; ``` ### Backend Implementation @@ -625,19 +686,43 @@ pub use wasm::*; ```rust // backend/src/main.rs use axum::Router; -use tower_http::cors::CorsLayer; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use std::collections::HashSet; use std::sync::Arc; +use tower_http::cors::CorsLayer; + +use crate::{file_service::FileServiceImpl, storage::FileStorage}; + +#[derive(Clone)] +struct DemoAuthProvider; + +impl AuthProvider for DemoAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + if token != "validtoken" { + return Err(AuthError::InvalidToken); + } + + Ok(AuthenticatedUser { + user_id: "demo-user".to_string(), + permissions: HashSet::from(["user".to_string()]), + metadata: None, + }) + }) + } +} #[tokio::main] async fn main() { // Initialize storage and auth - let storage = Arc::new(FileSystemStorage::new("./uploads")); - let auth_provider = Arc::new(JwtAuthProvider::new(/* ... */)); + let storage = Arc::new(FileStorage::new("./uploads")); + let service = FileServiceImpl::new(storage); + let auth_provider = DemoAuthProvider; // Build file service - let file_router = DocumentServiceBuilder::new(storage.clone()) + let file_router = DocumentServiceBuilder::new(service) .auth_provider(auth_provider) - .usage_callback(|headers, method, path| { + .with_usage_tracker(|_headers, method, path| { println!("File API accessed: {} {}", method, path); }) .build(); @@ -660,75 +745,39 @@ async fn main() { ### Frontend Usage (TypeScript) ```typescript -// frontend/src/lib/fileClient.ts -import init, { WasmDocumentServiceClient } from '@/pkg/file_api'; +import { + downloadDownloadSecureFileId, + uploadUploadProfilePicture, +} from './generated'; + +const baseUrl = 'http://localhost:3000/api/documents'; + +export async function uploadSelectedFile(file: File, token: string) { + const { data, error } = await uploadUploadProfilePicture({ + baseUrl, + headers: { Authorization: `Bearer ${token}` }, + body: { file }, + }); + + if (error || !data) { + throw new Error(`Upload failed: ${JSON.stringify(error)}`); + } -let client: WasmDocumentServiceClient | null = null; + return data; +} -export async function getFileClient(): Promise { - if (!client) { - await init(); - client = new WasmDocumentServiceClient(import.meta.env.VITE_API_URL); - - // Set token from auth store - const token = localStorage.getItem('auth_token'); - if (token) { - client.set_bearer_token(token); - } +export async function downloadPrivateFile(fileId: string, token: string) { + const { data, error } = await downloadDownloadSecureFileId({ + baseUrl, + headers: { Authorization: `Bearer ${token}` }, + path: { file_id: fileId }, + }); + + if (error || !data) { + throw new Error(`Download failed: ${JSON.stringify(error)}`); } - return client; -} -// frontend/src/components/FileUpload.tsx -export function FileUpload() { - const [files, setFiles] = useState([]); - - const handleUpload = async (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - try { - const client = await getFileClient(); - const response = await client.upload_secure(file); - setFiles([...files, response]); - } catch (error) { - console.error('Upload failed:', error); - } - }; - - const handleDownload = async (fileId: string) => { - try { - const client = await getFileClient(); - const data = await client.download_secure(fileId); - - // Create download link - const blob = new Blob([data]); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'file'; - a.click(); - URL.revokeObjectURL(url); - } catch (error) { - console.error('Download failed:', error); - } - }; - - return ( -
- -
    - {files.map(file => ( -
  • - {file.filename} ({file.size} bytes) - -
  • - ))} -
-
- ); + return data; } ``` @@ -764,4 +813,4 @@ export function FileUpload() { - Check server logs for detailed error messages - Verify file permissions on upload directory -This documentation provides everything needed to implement a production-ready file service using the `ras-file-macro`. The macro handles the complex boilerplate while providing flexibility for custom storage implementations and business logic. \ No newline at end of file +This guide covers the core pieces needed to implement a file service with `ras-file-macro`. Production deployments still need project-specific storage, retention, scanning, authentication, and CORS decisions. diff --git a/documentation/ras-identity.md b/documentation/ras-identity.md index 907e12a..f0e165c 100644 --- a/documentation/ras-identity.md +++ b/documentation/ras-identity.md @@ -1,6 +1,6 @@ # RAS Identity System Usage Guide -This guide provides everything you need to add authentication and authorization to your RAS stack application using the identity crates. +This guide covers the common setup for adding authentication and authorization to a RAS stack application using the identity crates. ## Overview @@ -31,42 +31,44 @@ The RAS identity system provides a flexible, secure authentication framework wit ```toml [dependencies] # Core authentication traits -ras-auth-core = { path = "../crates/core/ras-auth-core" } -ras-identity-core = { path = "../crates/core/ras-identity-core" } +ras-auth-core = "0.1.0" +ras-identity-core = "0.1.1" # Session management (required) -ras-identity-session = { path = "../crates/identity/ras-identity-session" } +ras-identity-session = "0.1.1" # Identity providers (choose what you need) -ras-identity-local = { path = "../crates/identity/ras-identity-local" } -ras-identity-oauth2 = { path = "../crates/identity/ras-identity-oauth2" } +ras-identity-local = "0.2.0" +ras-identity-oauth2 = "0.1.2" # For JSON-RPC services -ras-jsonrpc-core = { path = "../crates/rpc/ras-jsonrpc-core" } +ras-jsonrpc-core = "0.1.2" ``` ### 2. Basic Setup with Local Authentication ```rust -use ras_identity_session::{SessionService, SessionConfig, JwtAuthProvider}; +use ras_identity_session::{JwtAuthProvider, SessionConfig, SessionService}; use ras_identity_local::LocalUserProvider; use ras_auth_core::AuthProvider; use std::sync::Arc; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Create session service with default config - let session_service = SessionService::new(SessionConfig::default()); + // Create session service with an example-length secret. Real services + // should load a random secret from environment or secret storage. + let session_config = SessionConfig::new("use-at-least-32-bytes-of-random-secret")?; + let session_service = SessionService::new(session_config)?; // Create and configure local user provider let local_provider = LocalUserProvider::new(); // Add some users local_provider.add_user( - "admin", - "secure_password123", - Some("admin@example.com"), - Some("Administrator") + "admin".to_string(), + "secure_password123".to_string(), + Some("admin@example.com".to_string()), + Some("Administrator".to_string()) ).await?; // Register the provider with session service @@ -87,6 +89,7 @@ async fn main() -> anyhow::Result<()> { The local user provider handles username/password authentication with secure password hashing: ```rust +use ras_identity_core::IdentityProvider; use ras_identity_local::LocalUserProvider; use serde_json::json; @@ -94,9 +97,11 @@ use serde_json::json; let provider = LocalUserProvider::new(); // Add users -provider.add_user("alice", "password123", - Some("alice@example.com"), - Some("Alice Smith") +provider.add_user( + "alice".to_string(), + "password123".to_string(), + Some("alice@example.com".to_string()), + Some("Alice Smith".to_string()), ).await?; // Authenticate @@ -111,8 +116,8 @@ println!("Authenticated: {}", identity.display_name.unwrap_or_default()); **Security Features:** - Argon2 password hashing -- Timing attack resistance -- Username enumeration prevention +- Timing attack mitigation for missing users +- Uniform invalid-credentials errors - Rate limiting (5 concurrent attempts) ### OAuth2 Provider @@ -120,26 +125,32 @@ println!("Authenticated: {}", identity.display_name.unwrap_or_default()); The OAuth2 provider supports external authentication providers like Google: ```rust -use ras_identity_oauth2::{OAuth2Provider, OAuth2Config, ProviderConfig}; -use oauth2::{ClientId, ClientSecret, AuthUrl, TokenUrl}; +use ras_identity_core::{IdentityError, IdentityProvider}; +use ras_identity_oauth2::{ + InMemoryStateStore, OAuth2Config, OAuth2Provider, OAuth2ProviderConfig, OAuth2Response, +}; +use std::{collections::HashMap, sync::Arc}; // Configure OAuth2 provider -let google_config = ProviderConfig { - client_id: ClientId::new("your-client-id".to_string()), - client_secret: ClientSecret::new("your-client-secret".to_string()), - auth_url: AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())?, - token_url: TokenUrl::new("https://oauth2.googleapis.com/token".to_string())?, - user_info_url: "https://www.googleapis.com/oauth2/v2/userinfo".to_string(), - redirect_url: "http://localhost:3000/auth/callback".to_string(), +let google_config = OAuth2ProviderConfig { + provider_id: "google".to_string(), + client_id: std::env::var("GOOGLE_CLIENT_ID") + .expect("GOOGLE_CLIENT_ID must be set"), + client_secret: std::env::var("GOOGLE_CLIENT_SECRET") + .expect("GOOGLE_CLIENT_SECRET must be set"), + authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth".to_string(), + token_endpoint: "https://oauth2.googleapis.com/token".to_string(), + userinfo_endpoint: Some("https://www.googleapis.com/oauth2/v2/userinfo".to_string()), + redirect_uri: "http://localhost:3000/auth/callback".to_string(), scopes: vec!["openid".to_string(), "email".to_string(), "profile".to_string()], - user_info_mapping: Default::default(), // Uses standard mapping -}; - -let config = OAuth2Config { - providers: vec![("google".to_string(), google_config)].into_iter().collect(), + auth_params: HashMap::new(), + use_pkce: true, + user_info_mapping: None, }; -let oauth_provider = OAuth2Provider::new(config); +let config = OAuth2Config::new().add_provider(google_config); +let state_store = Arc::new(InMemoryStateStore::new()); +let oauth_provider = OAuth2Provider::new(config, state_store); ``` **OAuth2 Flow:** @@ -151,13 +162,16 @@ let start_payload = json!({ "provider_id": "google" }); -match provider.verify(start_payload).await { - Err(IdentityError::OAuth2(OAuth2Response::AuthorizationUrl { url, state })) => { - // Redirect user to authorization URL - println!("Redirect to: {}", url); - // Store state for CSRF protection +match oauth_provider.verify(start_payload).await { + Err(IdentityError::ProviderError(response_json)) => { + let response: OAuth2Response = serde_json::from_str(&response_json)?; + if let OAuth2Response::AuthorizationUrl { url, state } = response { + // Redirect user to authorization URL and keep state for the callback. + println!("Redirect to: {url}, state: {state}"); + } } - _ => panic!("Unexpected response"), + Ok(_) => eprintln!("OAuth2 start flow completed without a redirect"), + Err(err) => eprintln!("OAuth2 start flow failed: {err}"), } ``` @@ -170,27 +184,27 @@ let callback_payload = json!({ "state": "stored_csrf_state" }); -let identity = provider.verify(callback_payload).await?; +let identity = oauth_provider.verify(callback_payload).await?; ``` ## Session Management -The `SessionService` orchestrates the complete authentication flow: +The `SessionService` orchestrates the login-to-session flow: ```rust -use ras_identity_session::{SessionService, SessionConfig}; -use std::time::Duration; +use chrono::Duration; +use ras_identity_session::{JwtAlgorithm, SessionConfig, SessionService}; // Configure session service let config = SessionConfig { - jwt_secret: "your-secret-key".to_string(), - jwt_ttl: Duration::from_secs(3600), // 1 hour - jwt_algorithm: "HS256".to_string(), + jwt_secret: "use-at-least-32-bytes-of-random-secret".to_string(), + jwt_ttl: Duration::hours(1), refresh_enabled: false, - refresh_ttl: None, + enforce_active_sessions: true, + algorithm: JwtAlgorithm::HS256, }; -let session_service = SessionService::new(config); +let session_service = SessionService::new(config)?; // Register multiple providers session_service.register_provider(Box::new(local_provider)).await; @@ -210,12 +224,12 @@ let jwt_token = session_service.begin_session("local", auth_payload).await?; println!("JWT Token: {}", jwt_token); // Verify session -let authenticated_user = session_service.verify_session(&jwt_token).await?; -println!("User ID: {}", authenticated_user.user_id); -println!("Permissions: {:?}", authenticated_user.permissions); +let claims = session_service.verify_session(&jwt_token).await?; +println!("Subject: {}", claims.sub); +println!("Permissions: {:?}", claims.permissions); // End session (logout) -session_service.end_session(&jwt_token).await?; +session_service.end_session(&claims.jti).await; ``` ## Permission Management @@ -223,8 +237,9 @@ session_service.end_session(&jwt_token).await?; Implement custom permission logic using the `UserPermissions` trait: ```rust -use ras_identity_core::{UserPermissions, VerifiedIdentity}; use async_trait::async_trait; +use ras_identity_core::{IdentityResult, UserPermissions, VerifiedIdentity}; +use std::sync::Arc; struct RoleBasedPermissions { // Your permission logic @@ -232,21 +247,21 @@ struct RoleBasedPermissions { #[async_trait] impl UserPermissions for RoleBasedPermissions { - async fn get_permissions(&self, identity: &VerifiedIdentity) -> Vec { + async fn get_permissions(&self, identity: &VerifiedIdentity) -> IdentityResult> { // Example: Grant permissions based on email domain match &identity.email { Some(email) if email.ends_with("@admin.com") => { - vec!["admin".to_string(), "user".to_string()] + Ok(vec!["admin".to_string(), "user".to_string()]) } - Some(_) => vec!["user".to_string()], - None => vec![], + Some(_) => Ok(vec!["user".to_string()]), + None => Ok(vec![]), } } } -// Use with session service -let permissions = Arc::new(RoleBasedPermissions {}); -session_service.with_permissions(permissions); +// Configure the session service before sharing it with handlers. +let mut session_service = SessionService::new(session_config)?; +session_service.set_permissions_provider(Arc::new(RoleBasedPermissions {})); ``` ## Integration with Services @@ -330,14 +345,13 @@ rest_service!({ }); ``` -## Complete Example +## Service Composition Example -Here's a complete example showing a typical setup: +Here is a typical setup sketch showing how the identity pieces fit together with generated service routes. The request/response DTOs and handler bodies are application-specific. ```rust -use ras_identity_session::{SessionService, SessionConfig, JwtAuthProvider}; +use ras_identity_session::{JwtAlgorithm, JwtAuthProvider, SessionConfig, SessionService}; use ras_identity_local::LocalUserProvider; -use ras_identity_oauth2::{OAuth2Provider, OAuth2Config, ProviderConfig}; use ras_jsonrpc_macro::jsonrpc_service; use axum::{Router, routing::get}; use std::sync::Arc; @@ -358,39 +372,54 @@ jsonrpc_service!({ async fn main() -> anyhow::Result<()> { // 1. Set up session service let session_config = SessionConfig { - jwt_secret: std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()), - jwt_ttl: std::time::Duration::from_secs(3600), - jwt_algorithm: "HS256".to_string(), + jwt_secret: std::env::var("JWT_SECRET") + .expect("JWT_SECRET must be at least 32 bytes"), + jwt_ttl: chrono::Duration::hours(1), refresh_enabled: false, - refresh_ttl: None, + enforce_active_sessions: true, + algorithm: JwtAlgorithm::HS256, }; - - let session_service = Arc::new(SessionService::new(session_config)); - - // 2. Set up local authentication - let local_provider = LocalUserProvider::new(); - local_provider.add_user("user", "password", Some("user@example.com"), Some("User")).await?; - local_provider.add_user("admin", "admin123", Some("admin@example.com"), Some("Admin")).await?; - - session_service.register_provider(Box::new(local_provider)).await; - - // 3. Set up permissions - use ras_identity_core::{UserPermissions, VerifiedIdentity}; + + // 2. Set up permissions use async_trait::async_trait; - + use ras_identity_core::{IdentityResult, UserPermissions, VerifiedIdentity}; + struct SimplePermissions; - + #[async_trait] impl UserPermissions for SimplePermissions { - async fn get_permissions(&self, identity: &VerifiedIdentity) -> Vec { + async fn get_permissions(&self, identity: &VerifiedIdentity) -> IdentityResult> { match identity.subject.as_str() { - "admin" => vec!["user".to_string(), "admin".to_string()], - _ => vec!["user".to_string()], + "admin" => Ok(vec!["user".to_string(), "admin".to_string()]), + _ => Ok(vec!["user".to_string()]), } } } - - session_service.with_permissions(Arc::new(SimplePermissions)); + + let mut session_service = SessionService::new(session_config)?; + session_service.set_permissions_provider(Arc::new(SimplePermissions)); + let session_service = Arc::new(session_service); + + // 3. Set up local authentication + let local_provider = LocalUserProvider::new(); + local_provider + .add_user( + "user".to_string(), + "password123".to_string(), + Some("user@example.com".to_string()), + Some("User".to_string()), + ) + .await?; + local_provider + .add_user( + "admin".to_string(), + "admin12345".to_string(), + Some("admin@example.com".to_string()), + Some("Admin".to_string()), + ) + .await?; + + session_service.register_provider(Box::new(local_provider)).await; // 4. Create authentication endpoints let auth_router = Router::new() @@ -414,9 +443,8 @@ async fn main() -> anyhow::Result<()> { .with_state(session_service); // 7. Start server - axum::Server::bind(&"0.0.0.0:3000".parse()?) - .serve(app.into_make_service()) - .await?; + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; + axum::serve(listener, app).await?; Ok(()) } @@ -436,31 +464,31 @@ async fn main() -> anyhow::Result<()> { ```rust // Use environment variables for sensitive config -let config = SessionConfig { - jwt_secret: std::env::var("JWT_SECRET") - .expect("JWT_SECRET must be set"), - jwt_ttl: Duration::from_secs( - std::env::var("JWT_TTL_SECONDS") - .unwrap_or_else(|_| "3600".to_string()) - .parse()? - ), - // ... -}; +let mut config = SessionConfig::new( + std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"), +)?; +config.jwt_ttl = chrono::Duration::seconds( + std::env::var("JWT_TTL_SECONDS") + .unwrap_or_else(|_| "3600".to_string()) + .parse()?, +); +config.refresh_enabled = false; ``` ### 3. Error Handling ```rust use ras_identity_core::IdentityError; +use ras_identity_session::SessionError; match session_service.begin_session("local", payload).await { Ok(token) => { // Success } - Err(IdentityError::InvalidCredentials) => { + Err(SessionError::IdentityError(IdentityError::InvalidCredentials)) => { // Wrong username/password } - Err(IdentityError::ProviderNotFound(_)) => { + Err(SessionError::IdentityError(IdentityError::ProviderNotFound(_))) => { // Provider not registered } Err(e) => { @@ -480,9 +508,14 @@ mod tests { async fn test_authentication_flow() { // Set up test providers let provider = LocalUserProvider::new(); - provider.add_user("test", "test123", None, None).await.unwrap(); - - let session_service = SessionService::new(SessionConfig::default()); + provider + .add_user("test".to_string(), "test123".to_string(), None, None) + .await + .unwrap(); + + let session_config = + SessionConfig::new("test-secret-key-that-is-at-least-32-bytes").unwrap(); + let session_service = SessionService::new(session_config).unwrap(); session_service.register_provider(Box::new(provider)).await; // Test authentication @@ -492,8 +525,8 @@ mod tests { })).await.unwrap(); // Verify token - let user = session_service.verify_session(&token).await.unwrap(); - assert_eq!(user.user_id, "test"); + let claims = session_service.verify_session(&token).await.unwrap(); + assert_eq!(claims.sub, "test"); } } ``` @@ -508,7 +541,7 @@ mod tests { 2. **JWT validation failures** - Verify the JWT secret is consistent across services - - Check token hasn't expired (default TTL is 1 hour) + - Check token hasn't expired; `SessionConfig::new` defaults to 24 hours, while the examples above configure one hour explicitly - Ensure the token is passed in the correct format 3. **Permission denied errors** @@ -528,7 +561,7 @@ mod tests { Implement the `IdentityProvider` trait for custom authentication: ```rust -use ras_identity_core::{IdentityProvider, VerifiedIdentity, IdentityError}; +use ras_identity_core::{IdentityError, IdentityProvider, IdentityResult, VerifiedIdentity}; use async_trait::async_trait; struct LdapProvider { @@ -537,13 +570,23 @@ struct LdapProvider { #[async_trait] impl IdentityProvider for LdapProvider { - fn id(&self) -> &str { + fn provider_id(&self) -> &str { "ldap" } - - async fn verify(&self, payload: serde_json::Value) -> Result { - // Implement LDAP authentication - todo!() + + async fn verify(&self, payload: serde_json::Value) -> IdentityResult { + let username = payload + .get("username") + .and_then(|value| value.as_str()) + .ok_or(IdentityError::InvalidPayload)?; + + Ok(VerifiedIdentity { + provider_id: self.provider_id().to_string(), + subject: username.to_string(), + email: None, + display_name: Some(username.to_string()), + metadata: None, + }) } } ``` @@ -554,7 +597,8 @@ Implement immediate session revocation: ```rust // End specific session -session_service.end_session(&jwt_token).await?; +let claims = session_service.verify_session(&jwt_token).await?; +session_service.end_session(&claims.jti).await; // End all sessions for a user // (Requires custom implementation tracking user->session mapping) @@ -562,24 +606,21 @@ session_service.end_session(&jwt_token).await?; ### Refresh Tokens -Enable refresh tokens for long-lived sessions: +`SessionConfig::refresh_enabled` is reserved for applications that add their own refresh-token storage and rotation. The current `SessionService` issues and verifies access JWTs; long-lived refresh tokens should be implemented as an application-level flow with server-side persistence and token rotation. ```rust -let config = SessionConfig { - refresh_enabled: true, - refresh_ttl: Some(Duration::from_days(30)), - // ... -}; - -// Use refresh token to get new access token -let new_token = session_service.refresh_session(&refresh_token).await?; +let mut config = SessionConfig::new("use-at-least-32-bytes-of-random-secret")?; +config.refresh_enabled = false; ``` ## Conclusion -The RAS identity system provides a robust foundation for authentication in your applications. Start with basic local authentication and progressively add OAuth2 providers and custom permission logic as needed. The modular design ensures you can adapt the system to your specific requirements while maintaining security best practices. +The RAS identity crates provide local authentication, OAuth2 callbacks, JWT +sessions, and permission lookup traits that can be composed for application +authentication flows. Start with basic local authentication, then add OAuth2 +providers and custom permission logic as needed. For more examples, check out: -- `/examples/google-oauth-example/` - Complete OAuth2 integration -- `/examples/basic-jsonrpc-service/` - JSON-RPC with authentication -- `/examples/bidirectional-chat/` - WebSocket authentication +- [`examples/oauth2-demo`](../examples/oauth2-demo/) - OAuth2 integration demo +- [`examples/basic-jsonrpc`](../examples/basic-jsonrpc/) - JSON-RPC with authentication +- [`examples/bidirectional-chat`](../examples/bidirectional-chat/) - WebSocket authentication diff --git a/documentation/ras-observability.md b/documentation/ras-observability.md index dc5751c..683550c 100644 --- a/documentation/ras-observability.md +++ b/documentation/ras-observability.md @@ -1,32 +1,35 @@ # RAS Observability Guide -This guide provides everything you need to add observability to your RAS stack applications using the built-in OpenTelemetry-based observability system. +This guide covers the common setup for adding observability to RAS stack applications using the built-in OpenTelemetry-based observability crates. ## Overview -The RAS observability system provides production-ready metrics and monitoring for your applications with: -- Zero-configuration setup with sensible defaults +The RAS observability system provides operational metrics and monitoring for your applications with: +- Convenience setup with sensible defaults - Support for REST, JSON-RPC, and WebSocket protocols - OpenTelemetry metrics with Prometheus export - Built-in cardinality protection -- Seamless integration with RAS service macros +- Integration with RAS service macros ## Quick Start -Add observability to your service in one line: +Set up observability with the convenience builder and merge the metrics router +into your Axum application: ```rust +use axum::{Router, routing::get}; use ras_observability_otel::standard_setup; +async fn handler() -> &'static str { + "ok" +} + #[tokio::main] async fn main() -> Result<(), Box> { // Initialize observability with your service name let otel = standard_setup("my-service")?; - // Your service now has: - // - Prometheus metrics endpoint at /metrics - // - Request and duration tracking - // - Standard service metrics + // The setup provides a Prometheus registry, trackers, and a metrics router. // Add the metrics endpoint to your router let app = Router::new() @@ -43,7 +46,8 @@ async fn main() -> Result<(), Box> { ## Metrics Exposed -The observability system automatically exposes the following metrics: +When the trackers are wired into service builders, or metrics are recorded +manually, the Prometheus endpoint exposes the following metrics: ### Counters - **`requests_started_total`** - Total number of requests initiated @@ -52,23 +56,25 @@ The observability system automatically exposes the following metrics: - Labels: `method`, `protocol`, `success` (true/false) ### Histograms -- **`method_duration_seconds`** - Method execution time in seconds +- **`method_duration_milliseconds`** - Method execution time in milliseconds - Labels: `method`, `protocol` - - Buckets: 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0 seconds + - Histogram bucket boundaries are reported in milliseconds by the Prometheus exporter ### Labels Labels are kept minimal to avoid cardinality explosion: - **`method`** - The method being called (e.g., "GET /users", "createUser") -- **`protocol`** - One of: "rest", "jsonrpc", "websocket" +- **`protocol`** - One of: "REST", "JSON-RPC", "WebSocket" - **`success`** - "true" or "false" (only on completion counter) ## Integration with RAS Services ### JSON-RPC Service Integration -The RAS JSON-RPC macro supports automatic observability integration: +The RAS JSON-RPC macro exposes observability hooks on the generated service builder: ```rust +use axum::Router; +use ras_observability_core::{MethodDurationTracker, RequestContext, UsageTracker}; use ras_observability_otel::OtelSetupBuilder; use ras_jsonrpc_macro::jsonrpc_service; @@ -77,8 +83,6 @@ jsonrpc_service!({ service_name: MyService, methods: [ UNAUTHORIZED health(()) -> String, - WITH_PERMISSIONS(["user"]) create_user(CreateUserRequest) -> User, - WITH_PERMISSIONS(["admin"]) delete_user(DeleteUserRequest) -> bool, ] }); @@ -89,22 +93,6 @@ impl MyServiceTrait for MyServiceImpl { async fn health(&self, _params: ()) -> Result> { Ok("healthy".to_string()) } - - async fn create_user( - &self, - _user: &ras_jsonrpc_core::AuthenticatedUser, - req: CreateUserRequest, - ) -> Result> { - // Your implementation - } - - async fn delete_user( - &self, - _user: &ras_jsonrpc_core::AuthenticatedUser, - req: DeleteUserRequest, - ) -> Result> { - // Your implementation - } } #[tokio::main] @@ -162,8 +150,17 @@ async fn main() -> Result<(), Box> { For REST services using the RAS REST macro: ```rust +use ras_observability_core::{MethodDurationTracker, RequestContext, UsageTracker}; use ras_observability_otel::OtelSetupBuilder; +use ras_rest_core::{RestResponse, RestResult}; use ras_rest_macro::rest_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +struct HealthResponse { + status: String, +} // Define your REST service rest_service!({ @@ -171,12 +168,19 @@ rest_service!({ base_path: "/api/v1", endpoints: [ GET UNAUTHORIZED health() -> HealthResponse, - GET WITH_PERMISSIONS(["user"]) users/{id: UserId}() -> User, - DELETE WITH_PERMISSIONS(["admin"]) users/{id: UserId}() -> DeleteResponse, ] }); -// Implementation... +struct UserServiceImpl; + +#[async_trait::async_trait] +impl UserServiceTrait for UserServiceImpl { + async fn get_health(&self) -> RestResult { + Ok(RestResponse::ok(HealthResponse { + status: "healthy".to_string(), + })) + } +} #[tokio::main] async fn main() -> Result<(), Box> { @@ -184,7 +188,7 @@ async fn main() -> Result<(), Box> { let otel = OtelSetupBuilder::new("my-rest-service").build()?; // Build your service with observability hooks - let app = UserServiceBuilder::new(service_impl) + let app = UserServiceBuilder::new(UserServiceImpl) .with_usage_tracker({ let usage_tracker = otel.usage_tracker(); move |headers, user, method, path| { @@ -230,32 +234,30 @@ async fn main() -> Result<(), Box> { For bidirectional WebSocket services: ```rust -use ras_observability_otel::OtelSetupBuilder; -use ras_observability_core::{RequestContext, Protocol}; +use axum::http::HeaderMap; +use ras_auth_core::AuthenticatedUser; +use ras_observability_core::{MethodDurationTracker, RequestContext, UsageTracker}; +use ras_observability_otel::OtelSetup; +use std::{sync::Arc, time::Duration}; -// In your WebSocket connection handler -async fn handle_connection( - socket: WebSocket, +async fn record_websocket_activity( otel: Arc, + headers: &HeaderMap, + user: Option<&AuthenticatedUser>, + connection_id: &str, + method: &str, + duration: Duration, ) { - // Track WebSocket connection - let context = RequestContext::websocket(); + let context = RequestContext::websocket("connect") + .with_metadata("connection_id", connection_id); otel.usage_tracker() - .track_request(&headers, user.as_ref(), &context) + .track_request(headers, user, &context) .await; - // Handle messages... - - // Track individual WebSocket method calls - let method_context = RequestContext::websocket() - .with_metadata("method", "sendMessage"); - - let start = Instant::now(); - // Process message... - let duration = start.elapsed(); - + let method_context = RequestContext::websocket(method.to_string()) + .with_metadata("connection_id", connection_id); otel.method_duration_tracker() - .track_duration(&method_context, user.as_ref(), duration) + .track_duration(&method_context, user, duration) .await; } ``` @@ -265,8 +267,8 @@ async fn handle_connection( For custom metrics or manual tracking outside of the service macros: ```rust +use ras_observability_core::{RequestContext, ServiceMetrics}; use ras_observability_otel::standard_setup; -use ras_observability_core::RequestContext; use std::time::{Duration, Instant}; let otel = standard_setup("my-service")?; @@ -277,11 +279,8 @@ let context = RequestContext::rest("POST", "/api/v1/process"); metrics.increment_requests_started(&context); let start = Instant::now(); -// Do some work... -let success = match do_work().await { - Ok(_) => true, - Err(_) => false, -}; +tokio::time::sleep(Duration::from_millis(25)).await; +let success = true; // Track completion metrics.increment_requests_completed(&context, success); @@ -314,7 +313,7 @@ let otel = OtelSetupBuilder::new("my-service") Use metadata for request-specific information that shouldn't be in metrics: ```rust -use ras_observability_core::RequestContext; +use ras_observability_core::{RequestContext, UsageTracker}; let context = RequestContext::rest("POST", "/api/orders") .with_metadata("request_id", request_id) @@ -359,7 +358,7 @@ receivers: exporters: otlp: - endpoint: "your-otlp-backend:4317" + endpoint: "tempo:4317" service: pipelines: @@ -376,12 +375,15 @@ Protect the metrics endpoint in production: use axum::middleware; use tower_http::auth::RequireAuthorizationLayer; +let metrics_token = std::env::var("METRICS_BEARER_TOKEN") + .expect("METRICS_BEARER_TOKEN must be set"); + let app = Router::new() .merge(api_routes) .nest( "/metrics", otel.metrics_router() - .layer(RequireAuthorizationLayer::bearer("your-metrics-token")) + .layer(RequireAuthorizationLayer::bearer(metrics_token.as_str())) ); ``` @@ -399,7 +401,7 @@ sum(rate(requests_completed_total[5m])) # P95 latency by method histogram_quantile(0.95, - sum(rate(method_duration_seconds_bucket[5m])) by (method, le) + sum(rate(method_duration_milliseconds_bucket[5m])) by (method, le) ) # Error rate by protocol @@ -408,7 +410,7 @@ sum(rate(requests_completed_total{success="false"}[5m])) by (protocol) ## Best Practices -1. **Use standard context types**: Always use `RequestContext::rest()`, `RequestContext::jsonrpc()`, or `RequestContext::websocket()` for consistency. +1. **Use standard context types**: Always use `RequestContext::rest(method, path)`, `RequestContext::jsonrpc(method)`, or `RequestContext::websocket(method)` for consistency. 2. **Avoid custom labels**: Keep user-specific or high-cardinality data in structured logs, not metrics. @@ -444,9 +446,9 @@ The system tracks authenticated vs anonymous requests. Ensure your `AuthProvider ## Examples -Complete working examples are available in the repository: -- `examples/basic-jsonrpc-service` - JSON-RPC service with metrics -- `examples/rest-service-example` - REST API with Prometheus metrics +Runnable examples are available in the repository: +- `examples/basic-jsonrpc/service` - JSON-RPC service with metrics +- `examples/rest-wasm-example/rest-backend` - REST API with generated OpenAPI docs - `crates/observability/ras-observability-otel/examples/` - Standalone examples ## Dependencies @@ -455,8 +457,8 @@ Add these to your `Cargo.toml`: ```toml [dependencies] -ras-observability-core = { path = "../crates/core/ras-observability-core" } -ras-observability-otel = { path = "../crates/observability/ras-observability-otel" } +ras-observability-core = "0.1.0" +ras-observability-otel = "0.1.0" ``` -The observability system is designed to be lightweight with minimal dependencies while providing production-ready metrics for your RAS stack applications. +The observability system is designed to be lightweight with minimal dependencies while providing useful runtime metrics for your RAS stack applications. diff --git a/documentation/ras-rest-macro.md b/documentation/ras-rest-macro.md index add0686..e47227a 100644 --- a/documentation/ras-rest-macro.md +++ b/documentation/ras-rest-macro.md @@ -1,6 +1,6 @@ # RAS REST Macro Documentation -The `ras-rest-macro` crate provides a powerful procedural macro for building type-safe REST APIs in Rust with automatic client generation for both native Rust and TypeScript environments. +The `ras-rest-macro` crate provides a procedural macro for building type-safe REST APIs in Rust with generated native Rust clients and OpenAPI documents for TypeScript client generation. ## Table of Contents @@ -15,7 +15,7 @@ The `ras-rest-macro` crate provides a powerful procedural macro for building typ 9. [OpenAPI Documentation](#openapi-documentation) 10. [Error Handling](#error-handling) 11. [Advanced Features](#advanced-features) -12. [Complete Example](#complete-example) +12. [Task API Example](#task-api-example) ## Overview @@ -24,7 +24,7 @@ The `rest_service!` macro generates: - An Axum router builder with authentication support - Native Rust client with async/await support - OpenAPI 3.0 specification for TypeScript client generation -- Built-in Swagger UI hosting (optional) +- Built-in API explorer hosting (optional) - Optional compatibility routes that migrate legacy request/response shapes ## Installation @@ -33,17 +33,35 @@ Add to your `Cargo.toml`: ```toml [dependencies] -ras-rest-macro = "0.2.1" -ras-rest-core = "0.1.1" -ras-auth-core = "0.1.0" # For authentication +ras-rest-macro = { version = "0.2.1", default-features = false } +ras-rest-core = { version = "0.1.1", optional = true } +ras-auth-core = { version = "0.1.0", optional = true } serde = { version = "1.0", features = ["derive"] } -schemars = "0.8" # Required for OpenAPI generation -axum = "0.7" # Web framework -tokio = { version = "1", features = ["full"] } +serde_json = "1.0" +schemars = "1.0.0-alpha.20" +async-trait = { version = "0.1", optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +axum = { version = "0.8", optional = true } +axum-extra = { version = "0.10", features = ["query"], optional = true } +tokio = { version = "1.0", features = ["full"], optional = true } +reqwest = { version = "0.12", features = ["json"], optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json"], optional = true } [features] -server = [] # Enable server-side code generation -client = [] # Enable native client generation +default = ["server"] +server = [ + "ras-rest-macro/server", + "dep:ras-rest-core", + "dep:ras-auth-core", + "dep:async-trait", + "dep:axum", + "dep:axum-extra", + "dep:tokio", +] +client = ["ras-rest-macro/client", "dep:reqwest"] ``` ## Basic Usage @@ -69,6 +87,12 @@ pub struct CreateUserRequest { pub email: String, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct UpdateUserRequest { + pub name: String, + pub email: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct UsersResponse { pub users: Vec, @@ -104,40 +128,94 @@ rest_service!({ ```rust use ras_auth_core::AuthenticatedUser; -use ras_rest_core::{RestResult, RestResponse, RestError}; +use ras_rest_core::{RestError, RestResponse, RestResult}; +use std::collections::HashMap; +use std::sync::Mutex; struct UserServiceImpl { - // Your service state + users: Mutex>, +} + +impl UserServiceImpl { + fn new() -> Self { + Self { + users: Mutex::new(HashMap::new()), + } + } } #[async_trait::async_trait] impl UserServiceTrait for UserServiceImpl { async fn get_users(&self) -> RestResult { - // Implementation + let users: Vec = self + .users + .lock() + .expect("users lock") + .values() + .cloned() + .collect(); + Ok(RestResponse::ok(UsersResponse { - users: vec![], - total: 0, + total: users.len(), + users, })) } async fn get_users_by_id(&self, id: String) -> RestResult { - // Implementation - users.get(&id) + self.users + .lock() + .expect("users lock") + .get(&id) .cloned() - .map(|user| RestResponse::ok(user)) + .map(RestResponse::ok) .ok_or_else(|| RestError::not_found("User not found")) } async fn post_users( &self, - user: &AuthenticatedUser, // Auto-injected for authenticated endpoints + _user: &AuthenticatedUser, // Auto-injected for authenticated endpoints request: CreateUserRequest, ) -> RestResult { - // Implementation with access to authenticated user - Ok(RestResponse::created(new_user)) + let mut users = self.users.lock().expect("users lock"); + let id = format!("user-{}", users.len() + 1); + let user = User { + id: id.clone(), + name: request.name, + email: request.email, + }; + users.insert(id, user.clone()); + + Ok(RestResponse::created(user)) + } + + async fn put_users_by_id( + &self, + _user: &AuthenticatedUser, + id: String, + request: UpdateUserRequest, + ) -> RestResult { + let mut users = self.users.lock().expect("users lock"); + let user = users + .get_mut(&id) + .ok_or_else(|| RestError::not_found("User not found"))?; + user.name = request.name; + user.email = request.email; + + Ok(RestResponse::ok(user.clone())) + } + + async fn delete_users_by_id( + &self, + _user: &AuthenticatedUser, + id: String, + ) -> RestResult<()> { + let removed = self.users.lock().expect("users lock").remove(&id); + if removed.is_some() { + Ok(RestResponse::no_content()) + } else { + Err(RestError::not_found("User not found")) + } } - - // ... implement other methods } ``` @@ -145,19 +223,48 @@ impl UserServiceTrait for UserServiceImpl { ```rust use axum::Router; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use std::collections::HashSet; + +struct DemoAuthProvider; + +impl AuthProvider for DemoAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + if token != "admin-token" { + return Err(AuthError::InvalidToken); + } + + Ok(AuthenticatedUser { + user_id: "admin-user".to_string(), + permissions: HashSet::from(["admin".to_string()]), + metadata: None, + }) + }) + } +} #[tokio::main] -async fn main() { - let service = UserServiceImpl { /* ... */ }; - let auth_provider = MyAuthProvider { /* ... */ }; +async fn main() -> Result<(), Box> { + let service = UserServiceImpl::new(); + let auth_provider = DemoAuthProvider; let api_router = UserServiceBuilder::new(service) .auth_provider(auth_provider) - .with_usage_tracker(|headers, user, method, path| async move { - // Log API usage + .with_usage_tracker(|_headers, user, method, path| { + let method = method.to_string(); + let path = path.to_string(); + let user_id = user.map(|user| user.user_id.clone()); + async move { + println!("{} {} user={:?}", method, path, user_id); + } }) - .with_method_duration_tracker(|method, path, user, duration| async move { - // Track performance metrics + .with_method_duration_tracker(|method, path, _user, duration| { + let method = method.to_string(); + let path = path.to_string(); + async move { + println!("{} {} took {:?}", method, path, duration); + } }) .build(); @@ -165,6 +272,7 @@ async fn main() { let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; axum::serve(listener, app).await?; + Ok(()) } ``` @@ -177,16 +285,19 @@ rest_service!({ service_name: ServiceName, // Required: Name for generated types base_path: "/api/v1", // Required: Base URL path openapi: true, // Optional: Enable OpenAPI generation - openapi: { output: "api.json" }, // Optional: Custom OpenAPI output path - serve_docs: true, // Optional: Enable Swagger UI - docs_path: "/docs", // Optional: Swagger UI path (default: "/docs") - ui_theme: "dark", // Optional: Swagger UI theme + serve_docs: true, // Optional: Enable the built-in API explorer + docs_path: "/docs", // Optional: API explorer path (default: "/docs") + ui_theme: "dark", // Optional: retained for compatibility endpoints: [ - // Endpoint definitions + GET UNAUTHORIZED users() -> UsersResponse, + POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User, ] }); ``` +Use `openapi: { output: "api.json" }` instead of `openapi: true` when you +want a custom OpenAPI output path. + ### Endpoint Syntax ``` @@ -218,22 +329,45 @@ PUT WITH_PERMISSIONS(["admin"]) posts/{post_id: i32}/comments/{comment_id: i32}( The macro integrates with `ras-auth-core` for authentication: ```rust -use ras_auth_core::{AuthProvider, AuthenticatedUser, AuthResult}; - -struct MyAuthProvider; - -#[async_trait::async_trait] -impl AuthProvider for MyAuthProvider { - async fn authenticate(&self, token: String) -> AuthResult { - // Validate JWT token and return user info +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser, AuthResult}; +use std::collections::HashSet; + +struct DemoAuthProvider; + +impl AuthProvider for DemoAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + if token != "admin-token" { + return Err(AuthError::InvalidToken); + } + + Ok(AuthenticatedUser { + user_id: "admin-user".to_string(), + permissions: HashSet::from(["admin".to_string()]), + metadata: None, + }) + }) } - async fn check_permissions( + fn check_permissions( &self, user: &AuthenticatedUser, required_permissions: &[String], ) -> AuthResult<()> { - // Check if user has required permissions + let missing: Vec = required_permissions + .iter() + .filter(|permission| !user.permissions.contains(*permission)) + .cloned() + .collect(); + + if missing.is_empty() { + Ok(()) + } else { + Err(AuthError::InsufficientPermissions { + required: required_permissions.to_vec(), + has: user.permissions.iter().cloned().collect(), + }) + } } } ``` @@ -342,17 +476,14 @@ pub trait UserServiceTrait: Send + Sync + 'static { async fn get_users(&self) -> RestResult; async fn get_users_by_id(&self, id: String) -> RestResult; async fn post_users(&self, user: &AuthenticatedUser, request: CreateUserRequest) -> RestResult; - // ... other methods + async fn put_users_by_id(&self, user: &AuthenticatedUser, id: String, request: UpdateUserRequest) -> RestResult; + async fn delete_users_by_id(&self, user: &AuthenticatedUser, id: String) -> RestResult<()>; } ``` ### 2. Service Builder ```rust -pub struct UserServiceBuilder { - // ... -} - impl UserServiceBuilder { pub fn new(service: T) -> Self; pub fn auth_provider(self, provider: A) -> Self; @@ -365,10 +496,6 @@ impl UserServiceBuilder { ### 3. Native Rust Client ```rust -pub struct UserServiceClient { - // ... -} - impl UserServiceClient { pub fn builder(server_url: impl Into) -> UserServiceClientBuilder; pub fn set_bearer_token(&mut self, token: Option>); @@ -389,8 +516,8 @@ The macro generates an OpenAPI 3.0 specification that can be used to generate Ty ```rust // Generated function to create OpenAPI spec -pub fn generate_userservice_openapi() -> String { - // Returns OpenAPI 3.0 JSON specification +pub fn generate_userservice_openapi() -> serde_json::Value { + // Returns the OpenAPI 3.0 JSON document } // Generated function to write OpenAPI spec to file @@ -419,68 +546,34 @@ fn main() { This creates `target/openapi/userservice.json` during compilation. -### 2. Set Up TypeScript Client Generation - -Install dependencies: +### 2. TypeScript Usage -```bash -cd typescript-example -npm install @hey-api/openapi-ts @hey-api/client-fetch --save-dev -``` - -Create `openapi-ts.config.ts`: +Generate a TypeScript fetch client from the OpenAPI document with your preferred +OpenAPI generator. The examples below assume the generated client exports +methods and schemas from `./generated`. ```typescript -import { defineConfig } from '@hey-api/openapi-ts'; - -export default defineConfig({ - client: '@hey-api/client-fetch', - input: '../backend/target/openapi/userservice.json', - output: { - path: './src/generated', - format: 'prettier', - lint: 'eslint', - }, -}); -``` - -Update your `package.json`: - -```json -{ - "scripts": { - "generate": "openapi-ts", - "dev": "npm run generate && vite", - "build": "npm run generate && vite build" - } -} -``` +import * as api from './generated'; +import type { CreateUserRequest } from './generated'; -### 3. TypeScript Usage - -```typescript -import * as api from './generated/services.gen'; -import type { User, CreateUserRequest, UsersResponse } from './generated/types.gen'; - -// Configuration object for all requests -const config = { +// Shared configuration object for all requests +const baseConfig = { baseUrl: 'http://localhost:3000/api/v1', headers: { - Authorization: 'Bearer jwt-token' + Authorization: 'Bearer admin-token' } }; // Make API calls with named methods -const response = await api.getUsers(config); +const response = await api.getUsers(baseConfig); if (response.data) { const users = response.data.users; } // GET with path parameter -const userResponse = await api.getUsersId({ - ...config, - path: { id: '123' } -}); +const userResponse = await api.getUsersId( + Object.assign({}, baseConfig, { path: { id: '123' } }) +); // POST with typed body const newUser: CreateUserRequest = { @@ -488,24 +581,22 @@ const newUser: CreateUserRequest = { email: 'john@example.com' }; -const created = await api.postUsers({ - ...config, - body: newUser -}); +const created = await api.postUsers( + Object.assign({}, baseConfig, { body: newUser }) +); // DELETE request -await api.deleteUsersId({ - ...config, - path: { id: '123' } -}); +await api.deleteUsersId( + Object.assign({}, baseConfig, { path: { id: '123' } }) +); ``` -### 4. Benefits Over WASM +### Why Use An OpenAPI-Generated Fetch Client -- **Smaller Bundle Size**: ~10KB vs ~200KB+ for WASM -- **Better Developer Experience**: Standard TypeScript/JavaScript -- **Universal Compatibility**: Works in Node.js, Deno, Bun, and browsers -- **Better Tree-shaking**: Standard JavaScript optimization applies +- **Small browser surface**: Standard fetch client code instead of a full app scaffold +- **Better developer experience**: Standard TypeScript/JavaScript +- **Runtime flexibility**: Fetch-based clients can be used in common JavaScript runtimes and browsers +- **Tree-shaking friendly**: Standard JavaScript optimization applies - **Easier Debugging**: Standard network requests in DevTools ## OpenAPI Documentation @@ -516,27 +607,31 @@ await api.deleteUsersId({ rest_service!({ service_name: UserService, base_path: "/api/v1", - openapi: true, // Generate to target/openapi/UserService.json - openapi: { output: "api.json" }, // Custom output path - serve_docs: true, // Enable Swagger UI - docs_path: "/docs", // Swagger UI path - // ... + openapi: true, // Generate to target/openapi/userservice.json + serve_docs: true, // Enable the built-in API explorer + docs_path: "/docs", // API explorer path + endpoints: [ + GET UNAUTHORIZED users() -> UsersResponse, + POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User, + ] }); ``` +Use `openapi: { output: "api.json" }` instead when you need a custom output path. + ### Generated OpenAPI Features -- Full endpoint documentation with request/response schemas +- Endpoint documentation with request/response schemas - Authentication requirements via `x-authentication` extension - Permission requirements via `x-permissions` extension - JSON Schema generation for all types -- Swagger UI integration +- Built-in API explorer integration ### Accessing OpenAPI Documentation -1. **Swagger UI**: Navigate to `http://localhost:3000/api/v1/docs` -2. **OpenAPI JSON**: Available at `http://localhost:3000/api/v1/openapi.json` -3. **Generated File**: Check `target/openapi/ServiceName.json` or custom path +1. **API explorer**: Navigate to `http://localhost:3000/api/v1/docs` +2. **OpenAPI JSON**: Available at `http://localhost:3000/api/v1/docs/openapi.json` +3. **Generated File**: Check `target/openapi/.json` or custom path ## Error Handling @@ -548,23 +643,29 @@ The macro uses `RestResult` for all endpoints, allowing explicit HTTP status use ras_rest_core::{RestResult, RestResponse, RestError}; async fn get_user(&self, id: String) -> RestResult { - // Success with 200 OK + if id.trim().is_empty() { + return Err(RestError::bad_request("Invalid user ID")); + } + + let user = self + .users + .lock() + .expect("users lock") + .get(&id) + .cloned() + .ok_or_else(|| RestError::not_found("User not found"))?; + Ok(RestResponse::ok(user)) - - // Success with 201 Created +} + +async fn create_user(&self, request: CreateUserRequest) -> RestResult { + let user = User { + id: "user-1".to_string(), + name: request.name, + email: request.email, + }; + Ok(RestResponse::created(user)) - - // Success with custom status - Ok(RestResponse::with_status(202, user)) - - // Error responses - Err(RestError::not_found("User not found")) - Err(RestError::bad_request("Invalid user ID")) - Err(RestError::unauthorized("Invalid token")) - Err(RestError::forbidden("Insufficient permissions")) - - // Error with internal details (logged but not sent to client) - Err(RestError::with_internal(500, "Database error", db_error)) } ``` @@ -586,9 +687,13 @@ try { Track API usage for analytics or rate limiting: ```rust -.with_usage_tracker(|headers, user, method, path| async move { - println!("API call: {} {} by {:?}", method, path, user); - // Log to database, increment counters, etc. +.with_usage_tracker(|_headers, user, method, path| { + let method = method.to_string(); + let path = path.to_string(); + let user_id = user.map(|user| user.user_id.clone()); + async move { + println!("API call: {} {} by {:?}", method, path, user_id); + } }) ``` @@ -597,9 +702,12 @@ Track API usage for analytics or rate limiting: Track endpoint execution time: ```rust -.with_method_duration_tracker(|method, path, user, duration| async move { - println!("{} {} took {:?}", method, path, duration); - // Send metrics to monitoring system +.with_method_duration_tracker(|method, path, _user, duration| { + let method = method.to_string(); + let path = path.to_string(); + async move { + println!("{} {} took {:?}", method, path, duration); + } }) ``` @@ -625,9 +733,10 @@ OR logic between groups, AND logic within: WITH_PERMISSIONS(["admin"] | ["moderator", "editor"] | ["viewer", "commenter", "subscriber"]) ``` -## Complete Example +## Task API Example -Here's a complete example of a task management API: +This example shows a task management API definition with public, authenticated, +and permission-gated routes: ```rust use ras_rest_macro::rest_service; @@ -692,34 +801,36 @@ rest_service!({ // TypeScript usage /* -import * as api from './generated/services.gen'; +import * as api from './generated'; -const config = { +const userToken = 'user-token'; +const baseConfig = { baseUrl: 'http://localhost:3000/api/v1', headers: { Authorization: `Bearer ${userToken}` } }; // Create a task -const newTask = await api.postTasks({ - ...config, - body: { - title: 'Complete documentation', - description: 'Write comprehensive REST macro docs' - } -}); +const newTask = await api.postTasks( + Object.assign({}, baseConfig, { + body: { + title: 'Complete documentation', + description: 'Document REST endpoints' + } + }) +); // Update task -await api.putTasksId({ - ...config, - path: { id: newTask.data.id }, - body: { completed: true } -}); +await api.putTasksId( + Object.assign({}, baseConfig, { + path: { id: newTask.data.id }, + body: { completed: true } + }) +); // Get user's tasks -const myTasks = await api.getUsersUserIdTasks({ - ...config, - path: { user_id: userId } -}); +const myTasks = await api.getUsersUserIdTasks( + Object.assign({}, baseConfig, { path: { user_id: userId } }) +); */ ``` @@ -727,14 +838,14 @@ const myTasks = await api.getUsersUserIdTasks({ 1. **Type Safety**: Always use strongly-typed request/response objects 2. **Error Handling**: Use appropriate HTTP status codes via `RestError` -3. **Authentication**: Implement proper JWT validation in your `AuthProvider` +3. **Authentication**: Implement proper bearer token validation in your `AuthProvider` 4. **Documentation**: Enable OpenAPI generation for API documentation 5. **Monitoring**: Use usage and duration trackers for observability 6. **CORS**: Configure CORS appropriately for frontend clients 7. **Validation**: Validate request data in your service implementation 8. **Logging**: Log internal errors while keeping client messages generic -9. **Client Generation**: Use build.rs to generate OpenAPI spec at compile time -10. **TypeScript Setup**: Configure openapi-ts to generate clients from OpenAPI spec +9. **OpenAPI Output**: Use `build.rs` when you want to emit the OpenAPI spec at compile time +10. **Client Generation**: Generate clients from the OpenAPI document when you need frontend bindings ## Troubleshooting @@ -753,19 +864,27 @@ Control code generation with feature flags: ```toml [features] default = ["server"] -server = [] # Generate server-side code -client = [] # Generate native Rust client +server = [ + "ras-rest-macro/server", + "dep:ras-rest-core", + "dep:ras-auth-core", + "dep:async-trait", + "dep:axum", + "dep:axum-extra", + "dep:tokio", +] +client = ["ras-rest-macro/client", "dep:reqwest"] ``` ## Conclusion -The `ras-rest-macro` provides a comprehensive solution for building type-safe REST APIs in Rust with automatic client generation. By defining your API once, you get: +The `ras-rest-macro` provides a typed workflow for building REST APIs in Rust with automatic client generation. By defining your API once, you get: - Type-safe server implementation - Native Rust client - OpenAPI specification for TypeScript client generation -- Full type safety in TypeScript with standard JavaScript +- Typed TypeScript clients when generated from the OpenAPI document - Built-in authentication and authorization - Performance monitoring and usage tracking -This approach eliminates the need for manual client maintenance and ensures your API clients are always in sync with your server implementation. The shift from WASM to OpenAPI-based TypeScript generation provides significant benefits in bundle size (95% reduction), developer experience, and debugging capabilities. +This approach avoids hand-maintained client DTOs and keeps browser clients aligned with the server contract. OpenAPI-based TypeScript generation also keeps the browser path easy to inspect and debug with standard network tooling. diff --git a/examples/README.md b/examples/README.md index fb62f81..169e537 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,6 +2,10 @@ This directory contains example applications demonstrating various features of the Rust Agent Stack. +Prerequisites: +- Rust 1.88 or newer for the Rust 2024 example crates +- Node.js 22.13 or newer only for `wasm-ui-demo` + ## Overview The examples are organized to showcase different aspects of the framework: @@ -26,8 +30,7 @@ Demonstrates core JSON-RPC functionality with a simple task management service. **Quick Start:** ```bash -cd examples/basic-jsonrpc/service -cargo run +cargo run -p basic-jsonrpc-service --locked # API available at http://localhost:3000 # Metrics at http://localhost:3000/metrics ``` @@ -47,12 +50,10 @@ Real-time chat application showcasing WebSocket-based bidirectional JSON-RPC. **Quick Start:** ```bash # Terminal 1: Start server -cd examples/bidirectional-chat/server -cargo run +cargo run -p bidirectional-chat-server --locked # Terminal 2: Start TUI client -cd examples/bidirectional-chat/tui -cargo run +cargo run -p bidirectional-chat-tui --locked ``` ### OAuth2 Demo (`oauth2-demo/`) @@ -60,7 +61,7 @@ cargo run Full OAuth2 authentication flow implementation with Google as the provider. - **api/**: OAuth2-protected API definitions -- **server/**: Complete OAuth2 server with: +- **server/**: Runnable OAuth2 server with: - Authorization code flow with PKCE - State management for security - JWT session creation after successful auth @@ -70,37 +71,64 @@ Full OAuth2 authentication flow implementation with Google as the provider. **Quick Start:** ```bash # 1. Set up Google OAuth2 credentials at https://console.cloud.google.com/ -# 2. Configure credentials in examples/oauth2-demo/.env -cd examples/oauth2-demo/server -cargo run +# 2. Configure credentials in examples/oauth2-demo/server/.env +cargo run -p oauth2-demo-server --locked # Open browser to http://localhost:3000 ``` -### REST API Demo (`rest-api-demo/`) +### File Service Example (`file-service-example/`) + +Focused file upload/download service generated from the `file_service!` macro. + +- Streaming upload and download endpoints +- Bearer-token authentication +- OpenAPI document generation +- Minimal single-crate setup for learning the file-service macro + +**Quick Start:** +```bash +cargo run -p file-service-example --locked +# API available at http://localhost:3000 +``` + +### File Service WASM (`file-service-wasm/`) -Demonstrates the REST macro for building type-safe REST APIs. +File-service example with a Rust backend and an OpenAPI-generated TypeScript +fetch-client usage sample. + +- **file-service-api/**: Shared file-service definition +- **file-service-backend/**: Axum server with filesystem storage and OpenAPI output +- **typescript-example/**: Minimal TypeScript usage sample for a generated client + +**Quick Start:** +```bash +cargo check -p file-service-backend --locked +``` + +### REST WASM Example (`rest-wasm-example/`) + +Demonstrates the REST macro for building type-safe REST APIs with a +TypeScript usage sample for an OpenAPI-generated fetch client. - OpenAPI 3.0 document generation -- JWT authentication with local users -- Prometheus metrics integration +- Mock bearer-token authentication for protected routes - CRUD operations for task management - Request/response validation +- Minimal TypeScript usage sample for an OpenAPI-generated fetch client **Quick Start:** ```bash -cd examples/rest-api-demo -cargo run -# API at http://localhost:3000 -# OpenAPI docs generated at startup +cargo check -p rest-backend --locked ``` ### WASM UI Demo (`wasm-ui-demo/`) -Full-stack WebAssembly application using the Dominator reactive framework. +Browser UI using the Dominator reactive framework and the generated JSON-RPC +client from the basic service example. -- Glass morphism UI design with dwind styling +- Rust UI components styled with dwind - Real-time task management -- WebSocket connection to basic-jsonrpc-service +- Browser JSON-RPC client connected to `basic-jsonrpc-service` - Reactive state management with futures-signals - Dark theme support - Responsive design @@ -108,34 +136,33 @@ Full-stack WebAssembly application using the Dominator reactive framework. **Quick Start:** ```bash # Terminal 1: Start the backend service -cd examples/basic-jsonrpc/service -cargo run +cargo run -p basic-jsonrpc-service --locked # Terminal 2: Build and serve the WASM app -cd examples/wasm-ui-demo -npm ci -npm start +npm --prefix examples/wasm-ui-demo ci +npm --prefix examples/wasm-ui-demo start # Open browser to http://localhost:8080 ``` ## Architecture Patterns ### Multi-Crate Examples -Examples like `basic-jsonrpc/`, `bidirectional-chat/`, and `oauth2-demo/` are structured as multi-crate workspaces: +Examples like `basic-jsonrpc/`, `bidirectional-chat/`, `file-service-wasm/`, +`oauth2-demo/`, and `rest-wasm-example/` are structured as multi-crate workspaces: - `api/`: Shared type definitions and service traits - `server/`: Backend implementation -- `client/` or `tui/`: Frontend implementation +- `tui/`, `typescript-example/`, or a browser UI crate: Client-side example This separation allows: - Code reuse between client and server - Independent versioning - Clear API contracts -### Single-Crate Examples -Examples like `rest-api-demo/` and `wasm-ui-demo/` are self-contained: -- Simpler structure for focused demonstrations -- All code in one crate -- Easier to understand for specific features +### Focused Single-Crate Examples +`file-service-example/` keeps a runnable service in one crate for a focused +file-macro demonstration. `wasm-ui-demo/` is a single frontend crate, but it +intentionally depends on `basic-jsonrpc-api` and expects +`basic-jsonrpc-service` to be running. ## Common Features @@ -167,12 +194,17 @@ API documentation is auto-generated: ## Testing -Each example includes various levels of testing: +Examples use focused tests where they protect the demonstrated contract or +runtime behavior: - Unit tests in the source files - Integration tests in `tests/` directories - Manual testing instructions in example-specific READMEs -Run all example tests: +Run the workspace test suite, including examples: ```bash -cargo test --workspace --examples -``` \ No newline at end of file +cargo test --workspace --all-targets --all-features --locked +``` + +Browser-facing examples are covered by the root CI as well: +- The Dominator WASM UI builds with `npm --prefix examples/wasm-ui-demo run build`. +- API explorer flows run under `tests/playwright`. diff --git a/examples/basic-jsonrpc/README.md b/examples/basic-jsonrpc/README.md new file mode 100644 index 0000000..592082e --- /dev/null +++ b/examples/basic-jsonrpc/README.md @@ -0,0 +1,27 @@ +# Basic JSON-RPC Example + +This example is a two-crate JSON-RPC service that demonstrates shared API +definitions, authentication, generated OpenRPC documentation, and Prometheus +metrics. + +## Crates + +- `api/` defines the JSON-RPC methods and shared request/response types. +- `service/` implements the handlers, authentication, metrics, and HTTP server. + +## Run + +From the workspace root: + +```bash +cargo run -p basic-jsonrpc-service --locked +``` + +The service starts at `http://localhost:3000` with: + +- JSON-RPC endpoint: `POST /rpc` +- Explorer UI: `/rpc/explorer` +- OpenRPC document: `/rpc/explorer/openrpc.json` +- Prometheus metrics: `/metrics` + +See [service/README.md](service/README.md) for credentials and request examples. diff --git a/examples/basic-jsonrpc/api/Cargo.toml b/examples/basic-jsonrpc/api/Cargo.toml index 6163dd5..363be3d 100644 --- a/examples/basic-jsonrpc/api/Cargo.toml +++ b/examples/basic-jsonrpc/api/Cargo.toml @@ -2,7 +2,13 @@ name = "basic-jsonrpc-api" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Shared JSON-RPC API contract for the Rust Agent Stack basic example" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" publish = false +readme = "README.md" [features] default = ["server"] @@ -10,11 +16,11 @@ server = ["ras-jsonrpc-macro/server", "axum", "ras-jsonrpc-core"] client = ["ras-jsonrpc-macro/client", "reqwest"] [dependencies] -ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro" } -ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", optional = true } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types" } +ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0" } +ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2", optional = true } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } serde = { workspace = true } serde_json = { workspace = true } -schemars.workspace = true -axum = { workspace = true, optional = true} -reqwest = { workspace = true, optional = true} \ No newline at end of file +schemars = { workspace = true } +axum = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } diff --git a/examples/basic-jsonrpc/api/README.md b/examples/basic-jsonrpc/api/README.md new file mode 100644 index 0000000..10dcc2a --- /dev/null +++ b/examples/basic-jsonrpc/api/README.md @@ -0,0 +1,29 @@ +# Basic JSON-RPC API + +Shared API contract for the [basic JSON-RPC example](../README.md). This crate defines the request and response types and invokes `ras-jsonrpc-macro` to generate the `MyService` server trait, router builder, optional client, OpenRPC document, and explorer routes. + +## Generated Service + +The contract is defined in [src/lib.rs](src/lib.rs). It includes: + +- `sign_in`, `sign_out`, and `delete_everything` +- task CRUD methods +- profile read/update methods +- dashboard statistics + +The service route is selected by the server crate when it builds the generated router. See [../service/README.md](../service/README.md) for the runnable server. + +## Features + +- `server` - enables generated server-side types and Axum integration. This is the default feature. +- `client` - enables the generated HTTP client. + +## Checks + +```bash +cargo check -p basic-jsonrpc-api --locked +cargo check -p basic-jsonrpc-api --features client --locked +cargo test -p basic-jsonrpc-api --locked +cargo test -p basic-jsonrpc-api --features client --locked +cargo clippy -p basic-jsonrpc-api --all-targets --all-features --locked -- -D warnings +``` diff --git a/examples/basic-jsonrpc/api/src/lib.rs b/examples/basic-jsonrpc/api/src/lib.rs index b90335d..9a07677 100644 --- a/examples/basic-jsonrpc/api/src/lib.rs +++ b/examples/basic-jsonrpc/api/src/lib.rs @@ -104,3 +104,175 @@ jsonrpc_service!({ WITH_PERMISSIONS([]) get_dashboard_stats(()) -> DashboardStats, ] }); + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::BTreeSet; + + #[test] + fn sign_in_request_serializes_with_stable_variant_name() { + let request = SignInRequest::WithCredentials { + username: "alice".to_string(), + password: "secret".to_string(), + }; + + assert_eq!( + serde_json::to_value(&request).unwrap(), + json!({ + "WithCredentials": { + "username": "alice", + "password": "secret" + } + }) + ); + } + + #[test] + fn default_sign_in_response_is_empty_success_token() { + assert_eq!( + serde_json::to_value(SignInResponse::default()).unwrap(), + json!({ "Success": { "jwt": "" } }) + ); + } + + #[test] + fn create_task_request_serializes_priority_wire_value() { + let request = CreateTaskRequest { + title: "Ship the example".to_string(), + description: "Exercise the generated client".to_string(), + priority: TaskPriority::High, + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "title": "Ship the example", + "description": "Exercise the generated client", + "priority": "High" + }) + ); + } + + #[test] + fn update_task_request_preserves_partial_update_nulls() { + let request = UpdateTaskRequest { + id: "task-1".to_string(), + title: None, + description: Some("Updated through JSON-RPC".to_string()), + completed: Some(true), + priority: None, + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "id": "task-1", + "title": null, + "description": "Updated through JSON-RPC", + "completed": true, + "priority": null + }) + ); + } + + #[test] + fn task_list_response_preserves_total_and_task_wire_shape() { + let response = TaskListResponse { + total: 1, + tasks: vec![Task { + id: "task-1".to_string(), + title: "Document generated client".to_string(), + description: "Keep the example minimal".to_string(), + completed: false, + priority: TaskPriority::Medium, + created_at: "2026-05-23T12:00:00Z".to_string(), + updated_at: "2026-05-23T12:30:00Z".to_string(), + }], + }; + + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "tasks": [{ + "id": "task-1", + "title": "Document generated client", + "description": "Keep the example minimal", + "completed": false, + "priority": "Medium", + "created_at": "2026-05-23T12:00:00Z", + "updated_at": "2026-05-23T12:30:00Z" + }], + "total": 1 + }) + ); + } + + #[test] + fn dashboard_stats_serializes_counter_fields() { + let stats = DashboardStats { + total_tasks: 4, + completed_tasks: 1, + pending_tasks: 3, + high_priority_tasks: 2, + }; + + assert_eq!( + serde_json::to_value(stats).unwrap(), + json!({ + "total_tasks": 4, + "completed_tasks": 1, + "pending_tasks": 3, + "high_priority_tasks": 2 + }) + ); + } + + #[test] + fn generated_openrpc_documents_example_methods_and_permissions() { + let doc = generate_myservice_openrpc(); + + assert_eq!(doc["openrpc"], "1.3.2"); + assert_eq!(doc["info"]["title"], "MyService JSON-RPC API"); + + let methods = doc["methods"].as_array().expect("methods array"); + let method_names = methods + .iter() + .map(|method| method["name"].as_str().expect("method name")) + .collect::>(); + + assert_eq!( + method_names, + BTreeSet::from([ + "create_task", + "delete_everything", + "delete_task", + "get_dashboard_stats", + "get_profile", + "get_task", + "list_tasks", + "sign_in", + "sign_out", + "update_profile", + "update_task", + ]) + ); + + let sign_in = methods + .iter() + .find(|method| method["name"] == "sign_in") + .expect("sign_in method"); + assert!(sign_in.get("x-authentication").is_none()); + + let delete_everything = methods + .iter() + .find(|method| method["name"] == "delete_everything") + .expect("delete_everything method"); + assert_eq!( + delete_everything["x-authentication"]["required"].as_bool(), + Some(true) + ); + assert_eq!(delete_everything["x-permissions"], json!(["admin"])); + } +} diff --git a/examples/basic-jsonrpc/service/Cargo.toml b/examples/basic-jsonrpc/service/Cargo.toml index 9368f15..20efba5 100644 --- a/examples/basic-jsonrpc/service/Cargo.toml +++ b/examples/basic-jsonrpc/service/Cargo.toml @@ -2,7 +2,13 @@ name = "basic-jsonrpc-service" version = "0.1.1" edition = "2024" +rust-version = "1.88" +description = "Runnable JSON-RPC task service example for Rust Agent Stack" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" publish = false +readme = "README.md" [features] default = ["server"] @@ -10,9 +16,9 @@ server = [] client = [] [dependencies] -basic-jsonrpc-api = { path = "../api" } -ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types" } +basic-jsonrpc-api = { path = "../api", version = "0.1.0" } +ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } axum = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -24,5 +30,5 @@ uuid = { workspace = true, features = ["v4"] } anyhow = { workspace = true } # Observability -ras-observability-core = { path = "../../../crates/core/ras-observability-core" } -ras-observability-otel = { path = "../../../crates/observability/ras-observability-otel" } \ No newline at end of file +ras-observability-core = { path = "../../../crates/core/ras-observability-core", version = "0.1.0" } +ras-observability-otel = { path = "../../../crates/observability/ras-observability-otel", version = "0.1.0" } diff --git a/examples/basic-jsonrpc/service/README.md b/examples/basic-jsonrpc/service/README.md index 0a62d62..f415332 100644 --- a/examples/basic-jsonrpc/service/README.md +++ b/examples/basic-jsonrpc/service/README.md @@ -1,115 +1,33 @@ -# Basic JSON-RPC Service with Unified Observability +# Basic JSON-RPC Service -This example demonstrates a basic JSON-RPC service using the new unified observability crates (`ras-observability-core` and `ras-observability-otel`) for production-ready metrics collection. +Runnable JSON-RPC task service demonstrating the `jsonrpc_service!` macro, +authentication, generated OpenRPC documentation, and Prometheus-compatible +metrics. -## Features +## Run -- ✅ **JSON-RPC service** with authentication and permissions -- ✅ **Unified observability** using ras-observability-* crates -- ✅ **Prometheus metrics endpoint** at `/metrics` -- ✅ **Automatic metric collection** for RPC requests -- ✅ **Method duration tracking** with low-cardinality labels -- ✅ **Interactive Explorer** - Built-in JSON-RPC Explorer UI -- ✅ **OpenRPC Document** - Auto-generated API specification - -## Metrics Collected - -1. **`requests_started_total`** (Counter) - Total number of requests started - - Labels: `method`, `protocol` (JSON-RPC) - -2. **`requests_completed_total`** (Counter) - Total number of requests completed - - Labels: `method`, `protocol`, `success` - -3. **`method_duration_seconds`** (Histogram) - Method execution duration - - Labels: `method`, `protocol` (no user attributes to prevent cardinality explosion) - -### Design Principles - -This example follows best practices for production metrics: -- **Low cardinality**: No user-specific labels in metrics -- **Meaningful aggregations**: Duration percentiles (P50, P95, P99) are actually useful -- **Cost-effective**: Won't explode your metrics storage -- **User tracking via logs**: User details are logged but not in metrics - -## Running the Example - -```bash -cargo run -p basic-jsonrpc-service -``` - -The service will start on `http://localhost:3000` with the following endpoints: - -- **JSON-RPC endpoint**: http://localhost:3000/rpc -- **JSON-RPC Explorer**: http://localhost:3000/rpc/explorer -- **Prometheus metrics**: http://localhost:3000/metrics -- **OpenRPC Document**: http://localhost:3000/rpc/explorer/openrpc.json - -## Configuration - -### Environment Variables - -- `OTLP_ENDPOINT`: The endpoint for the OTLP exporter (default: `http://localhost:4317`) - -## Integration with OTLP - -While this example uses OpenTelemetry with a Prometheus exporter, you can integrate with OTLP (OpenTelemetry Protocol) backends by using an OpenTelemetry Collector: - -### 1. Create a Collector Configuration - -Create `collector-config.yaml`: - -```yaml -receivers: - prometheus: - config: - scrape_configs: - - job_name: 'jsonrpc-service' - scrape_interval: 10s - static_configs: - - targets: ['host.docker.internal:3000'] # or 'localhost:3000' if not using Docker - -processors: - batch: - -exporters: - otlp: - endpoint: "your-otlp-endpoint:4317" # Replace with your OTLP backend - tls: - insecure: true # Set to false in production with proper certs - logging: - verbosity: detailed - -service: - pipelines: - metrics: - receivers: [prometheus] - processors: [batch] - exporters: [otlp, logging] -``` - -### 2. Run the Collector +From the workspace root: ```bash -docker run -p 4317:4317 \ - -v $(pwd)/collector-config.yaml:/etc/otel-collector-config.yaml \ - otel/opentelemetry-collector:latest \ - --config=/etc/otel-collector-config.yaml +cargo run -p basic-jsonrpc-service --locked ``` -### 3. Start the Service +The service listens on `http://localhost:3000`: -The collector will scrape metrics from `http://localhost:3000/metrics` and forward them to your OTLP backend. +- JSON-RPC endpoint: `POST /rpc` +- Explorer UI: `/rpc/explorer` +- OpenRPC document: `/rpc/explorer/openrpc.json` +- Prometheus metrics: `/metrics` -## Using the Service +## Credentials -### Example Credentials +The example uses fixed demo credentials: -- Username: `user`, Password: `password` (basic user) -- Username: `admin`, Password: `secret` (admin user) +- User: `user` / `password`, returns bearer token `valid_token` +- Admin: `admin` / `secret`, returns bearer token `admin_token` -### 1. Sign In (Get a Token) +## Sign In -**Admin User:** ```bash curl -X POST http://localhost:3000/rpc \ -H "Content-Type: application/json" \ @@ -126,7 +44,8 @@ curl -X POST http://localhost:3000/rpc \ }' ``` -**Response:** +Successful admin response: + ```json { "jsonrpc": "2.0", @@ -139,7 +58,7 @@ curl -X POST http://localhost:3000/rpc \ } ``` -### 2. Make Authenticated Requests +## Authenticated Request ```bash curl -X POST http://localhost:3000/rpc \ @@ -153,96 +72,78 @@ curl -X POST http://localhost:3000/rpc \ }' ``` -### 3. Check Metrics +## Metrics -Visit `http://localhost:3000/metrics` to see collected metrics: +The service wires `ras-observability-otel` into the generated JSON-RPC builder +with `with_usage_tracker` and `with_method_duration_tracker`. -``` -# HELP rpc_requests_started_total Total number of RPC requests started -# TYPE rpc_requests_started_total counter -rpc_requests_started_total{method="sign_in",user_agent="curl/7.81.0",authenticated="false"} 2 -rpc_requests_started_total{method="delete_everything",user_agent="curl/7.81.0",authenticated="true",user_id="admin123",has_admin="true"} 1 - -# HELP rpc_requests_completed_total Total number of RPC requests completed (Note: This tracks usage_tracker completion, not actual method execution) -# TYPE rpc_requests_completed_total counter -rpc_requests_completed_total{method="sign_in",user_agent="curl/7.81.0",authenticated="false"} 2 -rpc_requests_completed_total{method="delete_everything",user_agent="curl/7.81.0",authenticated="true",user_id="admin123",has_admin="true"} 1 - -# HELP active_users Number of active users -# TYPE active_users counter -active_users{user_type="admin",action="sign_in"} 1 -active_users{user_type="user",action="sign_in"} 1 -active_users{user_type="user",action="sign_out"} -1 -``` +Prometheus metrics use low-cardinality labels: -## Architecture +- `requests_started_total`: labels `method`, `protocol` +- `requests_completed_total`: labels `method`, `protocol`, `success` +- `method_duration_milliseconds`: labels `method`, `protocol` -The example demonstrates: +The trackers log authenticated user details with `tracing`, but the metrics do +not include user ids, session ids, request ids, or arbitrary path values. -1. **Dual Metric Export**: Both push-based (OTLP) and pull-based (Prometheus) metrics -2. **Graceful Fallback**: Continues with Prometheus-only if OTLP collector is unavailable -3. **Request Interception**: Uses `with_usage_tracker` to capture all RPC requests -4. **Rich Labels**: Captures method, authentication status, user info, and user agent +Check metrics after making a request: -## Integration with Monitoring Systems - -### Prometheus +```bash +curl http://localhost:3000/metrics +``` -Configure Prometheus to scrape the `/metrics` endpoint: +Example output shape: -```yaml -scrape_configs: - - job_name: 'jsonrpc-service' - static_configs: - - targets: ['localhost:3000'] +```text +requests_started_total{method="sign_in",protocol="JSON-RPC"} 1 +requests_completed_total{method="sign_in",protocol="JSON-RPC",success="true"} 1 +method_duration_milliseconds_bucket{method="sign_in",protocol="JSON-RPC",le="5"} 1 ``` -### Grafana +## OpenTelemetry Collector -Import metrics from either Prometheus or the OTLP collector to visualize: -- Request rates by method and user -- Active user counts -- Authentication success/failure ratios +This example exposes Prometheus text metrics. To forward them to an OTLP backend, +run an OpenTelemetry Collector that scrapes `http://localhost:3000/metrics` and +exports to your OTLP destination. -### Jaeger/Tempo +Minimal collector sketch: -While this example focuses on metrics, the OTLP setup can be extended to support distributed tracing by adding: -- `opentelemetry-tracing` dependencies -- Trace context propagation -- Span creation in handlers +```yaml +receivers: + prometheus: + config: + scrape_configs: + - job_name: 'basic-jsonrpc-service' + static_configs: + - targets: ['host.docker.internal:3000'] -## Extending the Example +processors: + batch: -To add custom metrics: +exporters: + otlp: + endpoint: "tempo:4317" -1. **Add to the Metrics struct**: -```rust -struct Metrics { - // ... existing metrics - custom_operations: Counter, -} +service: + pipelines: + metrics: + receivers: [prometheus] + processors: [batch] + exporters: [otlp] ``` -2. **Initialize in `Metrics::new()`**: -```rust -custom_operations: meter - .u64_counter("custom_operations_total") - .with_description("Custom business operations") - .build(), -``` +## Tests -3. **Record in handlers**: -```rust -metrics.custom_operations.add(1, &[ - KeyValue::new("operation", "important_action"), -]); +The service has focused unit tests for authentication, task lifecycle behavior, +profile methods, and missing-task errors: + +```bash +cargo test -p basic-jsonrpc-service --locked ``` -## Production Considerations +## Checks -- **OTLP Authentication**: Configure TLS and authentication for secure metric export -- **Cardinality**: Be careful with label values to avoid metric explosion -- **Sampling**: Consider implementing adaptive sampling for high-traffic services -- **Resource Attributes**: Add more service metadata (version, environment, etc.) -- **Error Handling**: Implement proper error tracking metrics -- **Performance**: The metrics collection adds minimal overhead to request processing +```bash +cargo test -p basic-jsonrpc-service --locked +cargo clippy -p basic-jsonrpc-service --all-targets --all-features --locked -- -D warnings +``` diff --git a/examples/basic-jsonrpc/service/src/main.rs b/examples/basic-jsonrpc/service/src/main.rs index 50c263d..edecef6 100644 --- a/examples/basic-jsonrpc/service/src/main.rs +++ b/examples/basic-jsonrpc/service/src/main.rs @@ -278,14 +278,14 @@ impl MyServiceTrait for MyServiceImpl { } #[tokio::main] -async fn main() { +async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); // Initialize observability with the new crates info!("Initializing OpenTelemetry with unified observability..."); let otel = OtelSetupBuilder::new("basic-jsonrpc-service") .build() - .expect("Failed to set up OpenTelemetry"); + .map_err(|e| anyhow::anyhow!("Failed to set up OpenTelemetry: {e}"))?; // Note about OTLP: For OTLP export, you would typically run this service // alongside an OpenTelemetry Collector that scrapes the /metrics endpoint @@ -349,7 +349,7 @@ async fn main() { }) .auth_provider(MyAuthProvider) .build() - .expect("Failed to build JSON-RPC router"); + .map_err(|e| anyhow::anyhow!("Failed to build JSON-RPC router: {e}"))?; // Create the main app with metrics endpoint let app = Router::new().merge(rpc_router).merge(otel.metrics_router()); @@ -369,6 +369,238 @@ async fn main() { println!(); println!("{}", otlp_note); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - axum::serve(listener, app).await.unwrap(); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; + axum::serve(listener, app).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn service() -> MyServiceImpl { + MyServiceImpl { + storage: Arc::new(TaskStorage::new()), + } + } + + fn auth_user(user_id: &str, permissions: &[&str]) -> AuthenticatedUser { + AuthenticatedUser { + user_id: user_id.to_string(), + permissions: permissions + .iter() + .map(|permission| (*permission).to_string()) + .collect(), + metadata: None, + } + } + + #[tokio::test] + async fn auth_provider_maps_user_and_admin_tokens() { + let provider = MyAuthProvider; + + let user = provider + .authenticate("valid_token".to_string()) + .await + .expect("valid user token"); + assert_eq!(user.user_id, "user123"); + assert!(user.permissions.contains("user")); + assert!(!user.permissions.contains("admin")); + + let admin = provider + .authenticate("admin_token".to_string()) + .await + .expect("valid admin token"); + assert_eq!(admin.user_id, "admin123"); + assert!(admin.permissions.contains("user")); + assert!(admin.permissions.contains("admin")); + } + + #[tokio::test] + async fn auth_provider_rejects_unknown_tokens() { + let provider = MyAuthProvider; + + let error = provider + .authenticate("bad_token".to_string()) + .await + .expect_err("unknown token should be rejected"); + + assert!(matches!(error, ras_jsonrpc_core::AuthError::InvalidToken)); + } + + #[tokio::test] + async fn sign_in_returns_documented_demo_tokens() { + let service = service(); + + let admin = service + .sign_in(SignInRequest::WithCredentials { + username: "admin".to_string(), + password: "secret".to_string(), + }) + .await + .expect("admin sign in"); + assert!(matches!( + admin, + SignInResponse::Success { ref jwt } if jwt == "admin_token" + )); + + let user = service + .sign_in(SignInRequest::WithCredentials { + username: "user".to_string(), + password: "password".to_string(), + }) + .await + .expect("user sign in"); + assert!(matches!( + user, + SignInResponse::Success { ref jwt } if jwt == "valid_token" + )); + + let failure = service + .sign_in(SignInRequest::WithCredentials { + username: "user".to_string(), + password: "wrong".to_string(), + }) + .await + .expect("failed sign in response"); + assert!(matches!( + failure, + SignInResponse::Failure { ref msg } if msg == "Invalid credentials" + )); + } + + #[tokio::test] + async fn task_lifecycle_updates_dashboard_stats() { + let service = service(); + let user = auth_user("user123", &["user"]); + + let high = service + .create_task( + &user, + CreateTaskRequest { + title: "Write docs".to_string(), + description: "Document the JSON-RPC example".to_string(), + priority: TaskPriority::High, + }, + ) + .await + .expect("create high priority task"); + let low = service + .create_task( + &user, + CreateTaskRequest { + title: "Tidy examples".to_string(), + description: "Remove misleading snippets".to_string(), + priority: TaskPriority::Low, + }, + ) + .await + .expect("create low priority task"); + + let updated = service + .update_task( + &user, + UpdateTaskRequest { + id: high.id.clone(), + title: Some("Write verified docs".to_string()), + description: None, + completed: Some(true), + priority: Some(TaskPriority::Medium), + }, + ) + .await + .expect("update task"); + assert_eq!(updated.title, "Write verified docs"); + assert!(updated.completed); + assert!(matches!(updated.priority, TaskPriority::Medium)); + + let stats = service + .get_dashboard_stats(&user, ()) + .await + .expect("dashboard stats"); + assert_eq!(stats.total_tasks, 2); + assert_eq!(stats.completed_tasks, 1); + assert_eq!(stats.pending_tasks, 1); + assert_eq!(stats.high_priority_tasks, 0); + + let list = service.list_tasks(&user, ()).await.expect("list tasks"); + assert_eq!(list.total, 2); + + assert!( + service + .delete_task(&user, low.id) + .await + .expect("delete task") + ); + let after_delete = service + .get_dashboard_stats(&user, ()) + .await + .expect("stats after delete"); + assert_eq!(after_delete.total_tasks, 1); + } + + #[tokio::test] + async fn updating_missing_task_returns_not_found_error() { + let service = service(); + let user = auth_user("user123", &["user"]); + + let error = service + .update_task( + &user, + UpdateTaskRequest { + id: "missing".to_string(), + title: Some("Missing".to_string()), + description: None, + completed: None, + priority: None, + }, + ) + .await + .expect_err("missing task should be rejected"); + + assert_eq!(error.to_string(), "Task not found"); + } + + #[tokio::test] + async fn missing_task_lookup_and_delete_are_non_error_absences() { + let service = service(); + let user = auth_user("user123", &["user"]); + + let task = service + .get_task(&user, "missing".to_string()) + .await + .expect("missing get should be a successful absence"); + assert!(task.is_none()); + + let deleted = service + .delete_task(&user, "missing".to_string()) + .await + .expect("missing delete should report false"); + assert!(!deleted); + } + + #[tokio::test] + async fn profile_methods_use_authenticated_user_and_requested_email() { + let service = service(); + let admin = auth_user("admin123", &["admin", "user"]); + + let profile = service + .get_profile(&admin, ()) + .await + .expect("profile response"); + assert_eq!(profile.username, "admin"); + assert_eq!(profile.email, "admin123@example.com"); + assert!(profile.permissions.contains(&"admin".to_string())); + + let updated = service + .update_profile( + &admin, + UpdateProfileRequest { + email: Some("admin@example.test".to_string()), + }, + ) + .await + .expect("profile update"); + assert_eq!(updated.email, "admin@example.test"); + } } diff --git a/examples/bidirectional-chat/README.md b/examples/bidirectional-chat/README.md index 171f6e3..1c1235a 100644 --- a/examples/bidirectional-chat/README.md +++ b/examples/bidirectional-chat/README.md @@ -14,7 +14,7 @@ The example consists of three crates: 1. **bidirectional-chat-api**: Shared types and data structures 2. **bidirectional-chat-server**: WebSocket server with room management -3. **bidirectional-chat-client**: Interactive terminal client +3. **bidirectional-chat-tui**: Interactive terminal client ## Features @@ -24,7 +24,7 @@ The example consists of three crates: - Role-based permissions (user, moderator, admin) - Real-time message broadcasting - User presence tracking -- Kick/ban functionality for moderators +- Kick functionality for moderators - System-wide announcements for admins - Automatic cleanup on disconnect @@ -33,14 +33,14 @@ The example consists of three crates: - Real-time message display - Room navigation commands - Colored output for better readability -- Cross-platform WebSocket support (native + WASM) +- Uses the shared bidirectional client library; this example includes the native TUI client ## Quick Start ### 1. Start the Server ```bash -cargo run -p bidirectional-chat-server +cargo run -p bidirectional-chat-server --locked ``` The server will start on `http://localhost:3000` with WebSocket endpoint at `ws://localhost:3000/ws`. @@ -49,32 +49,27 @@ The server will start on `http://localhost:3000` with WebSocket endpoint at `ws: Register a new user: ```bash -cargo run -p bidirectional-chat-client register --username alice -# Enter password when prompted +cargo run -p bidirectional-chat-tui --locked +# Press Ctrl+R on the login screen, then enter username and password ``` -Pre-configured users: -- `admin` / `admin123` - Full admin privileges -- `moderator` / `mod123` - Moderator privileges +Development users created by debug builds: - `alice` / `alice123` - Regular user - `bob` / `bob123` - Regular user +Admin users from `server/config.example.toml`, if you load that file with +`CHAT_CONFIG_FILE`: +- `admin` / `admin123456` - Full admin privileges +- `moderator` / `moderator123` - Moderator privileges + ### 3. Start Chatting Login and start the interactive chat: ```bash -cargo run -p bidirectional-chat-client chat -# Select "Login with username/password" +cargo run -p bidirectional-chat-tui --locked # Enter credentials ``` -Or use a saved token: -```bash -cargo run -p bidirectional-chat-client chat -# Select "Use existing token" -# Paste your JWT token -``` - ## Chat Commands Once in the chat interface, you can use these commands: @@ -130,18 +125,25 @@ The chat uses bidirectional JSON-RPC 2.0 over WebSockets: ### Configuration The server supports configuration through: -1. Configuration file (`config.toml`) -2. Environment variables (take precedence) -3. Command-line arguments (for future extension) +1. Configuration file (`config.toml`, or the path in `CHAT_CONFIG_FILE`) +2. Environment variables, which take precedence over file values #### Configuration File -Copy `config.example.toml` to `config.toml` and modify as needed: +From the repository root, copy the example config and point the server at it +when starting with `cargo run -p bidirectional-chat-server --locked`: ```bash -cp config.example.toml config.toml +cp examples/bidirectional-chat/server/config.example.toml examples/bidirectional-chat/server/config.toml +CHAT_CONFIG_FILE=examples/bidirectional-chat/server/config.toml \ +CHAT_DATA_DIR=examples/bidirectional-chat/server/chat_data \ +cargo run -p bidirectional-chat-server --locked ``` +`config.toml` and `chat_data/` are ignored local runtime files. When starting +from the workspace root, set `CHAT_DATA_DIR` if you want persisted chat state +under the example directory rather than `./chat_data` at the root. + Key configuration sections: - **Server**: Host, port, and CORS settings - **Auth**: JWT configuration and session management @@ -152,15 +154,17 @@ Key configuration sections: #### Environment Variables -Create a `.env` file in the server directory: +Create a `.env` file in the directory you run the server from. For workspace +root commands, use paths relative to the repository root: ```env # Core settings -JWT_SECRET=your-secret-key-here +JWT_SECRET=change-this-to-at-least-32-random-bytes HOST=0.0.0.0 PORT=3000 +CHAT_CONFIG_FILE=examples/bidirectional-chat/server/config.toml # Chat settings -CHAT_DATA_DIR=./chat_data +CHAT_DATA_DIR=examples/bidirectional-chat/server/chat_data CHAT__CHAT__MAX_MESSAGE_LENGTH=1000 CHAT__CHAT__MAX_USERS_PER_ROOM=50 @@ -172,7 +176,13 @@ CHAT__ADMIN__USERS__0__USERNAME=admin CHAT__ADMIN__USERS__0__PASSWORD=secure_password ``` -See `config.example.toml` for a complete list of environment variables. +`JWT_SECRET` must be at least 32 bytes. Generate a random value for shared +or long-running environments. + +Nested values use the `CHAT__SECTION__FIELD` form, for example +`CHAT__SERVER__CORS__ALLOW_ANY_ORIGIN=false`. See +`examples/bidirectional-chat/server/config.example.toml` for the supported +environment variables. ### Production Configuration @@ -189,7 +199,7 @@ For production deployments: allowed_origins = ["https://yourchatapp.com"] ``` -2. **Rate Limiting**: +2. **Chat Message Rate Limiting**: ```toml [rate_limit] enabled = true @@ -197,6 +207,7 @@ For production deployments: connections_per_ip = 5 login_attempts_per_hour = 10 ``` + `messages_per_minute` is enforced for authenticated `send_message` calls. The connection and login-attempt fields are validated configuration hooks for deployment-level throttling. 3. **Persistence**: ```toml @@ -213,21 +224,33 @@ You can run multiple client instances to simulate multiple users: ```bash # Terminal 1 -cargo run -p bidirectional-chat-client chat +cargo run -p bidirectional-chat-tui --locked # Login as alice # Terminal 2 -cargo run -p bidirectional-chat-client chat +cargo run -p bidirectional-chat-tui --locked # Login as bob ``` ### Testing Admin Features +Copy `examples/bidirectional-chat/server/config.example.toml` to +`examples/bidirectional-chat/server/config.toml`, then start the server with +the config path set: + +```bash +CHAT_CONFIG_FILE=examples/bidirectional-chat/server/config.toml \ +CHAT_DATA_DIR=examples/bidirectional-chat/server/chat_data \ +cargo run -p bidirectional-chat-server --locked +``` + +The configured admin users are created on startup. + Login as admin to test moderation features: ```bash -cargo run -p bidirectional-chat-client chat +cargo run -p bidirectional-chat-tui --locked # Username: admin -# Password: admin123 +# Password: admin123456 ``` Then in another terminal as a regular user, you can be kicked by the admin. @@ -268,4 +291,4 @@ Then in another terminal as a regular user, you can be kicked by the admin. ### Message Not Sending - Ensure you've joined a room first - Check you have the required permissions -- Verify WebSocket connection is active \ No newline at end of file +- Verify WebSocket connection is active diff --git a/examples/bidirectional-chat/api/Cargo.toml b/examples/bidirectional-chat/api/Cargo.toml index 797d91b..2dde064 100644 --- a/examples/bidirectional-chat/api/Cargo.toml +++ b/examples/bidirectional-chat/api/Cargo.toml @@ -2,6 +2,13 @@ name = "bidirectional-chat-api" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Shared bidirectional JSON-RPC API contract for the chat example" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +publish = false +readme = "README.md" [features] default = ["server", "client"] @@ -14,14 +21,14 @@ serde_json = { workspace = true } schemars = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } -ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro" } -ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types" } -ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server", optional = true } -ras-jsonrpc-bidirectional-client = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client", optional = true } -ras-auth-core = { path = "../../../crates/core/ras-auth-core" } -ras-rest-core = { path = "../../../crates/rest/ras-rest-core" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types" } -ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro" } +ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro", version = "0.1.0" } +ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types", version = "0.1.0" } +ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server", version = "0.1.0", optional = true } +ras-jsonrpc-bidirectional-client = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client", version = "0.1.0", optional = true } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } +ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1" } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } +ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1" } reqwest = { workspace = true, features = ["json"] } -tracing.workspace = true +tracing = { workspace = true } axum = { workspace = true, optional = true } diff --git a/examples/bidirectional-chat/api/README.md b/examples/bidirectional-chat/api/README.md new file mode 100644 index 0000000..8802e7c --- /dev/null +++ b/examples/bidirectional-chat/api/README.md @@ -0,0 +1,37 @@ +# Bidirectional Chat API + +Shared contract crate for the [bidirectional chat example](../README.md). It contains the JSON-RPC request/response types, server-to-client notification payloads, REST auth payloads, and generated service code used by the chat server and TUI client. + +## Generated APIs + +The WebSocket JSON-RPC contract in [src/lib.rs](src/lib.rs) generates `ChatService` with authenticated methods for: + +- sending messages and typing notifications +- joining, leaving, and listing rooms +- reading and updating user profiles +- moderator kicks +- admin announcements + +The REST auth contract in [src/auth.rs](src/auth.rs) generates: + +- `GET /health` +- `POST /auth/register` +- `POST /auth/login` + +The runnable server is documented in [../server/README.md](../server/README.md), and the terminal client is documented in [../tui/README.md](../tui/README.md). + +## Features + +- `server` - enables generated server integration and Axum support. +- `client` - enables the generated bidirectional client. +- The default feature set enables both. + +## Checks + +```bash +cargo check -p bidirectional-chat-api --locked +cargo check -p bidirectional-chat-api --no-default-features --features client --locked +cargo test -p bidirectional-chat-api --locked +cargo test -p bidirectional-chat-api --no-default-features --features client --locked +cargo clippy -p bidirectional-chat-api --all-targets --all-features --locked -- -D warnings +``` diff --git a/examples/bidirectional-chat/api/src/auth.rs b/examples/bidirectional-chat/api/src/auth.rs index 73a8d54..5f401e1 100644 --- a/examples/bidirectional-chat/api/src/auth.rs +++ b/examples/bidirectional-chat/api/src/auth.rs @@ -78,3 +78,121 @@ rest_service!({ GET UNAUTHORIZED health() -> HealthResponse, ] }); + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn login_request_omits_default_provider_when_absent() { + let request = LoginRequest { + username: "alice".to_string(), + password: "alice123".to_string(), + provider: None, + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "username": "alice", + "password": "alice123" + }) + ); + } + + #[test] + fn register_response_omits_display_name_when_absent() { + let response = RegisterResponse { + message: "User registered successfully".to_string(), + username: "alice".to_string(), + display_name: None, + }; + + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "message": "User registered successfully", + "username": "alice" + }) + ); + } + + #[test] + fn login_response_serializes_token_expiry_and_user_id() { + let response = LoginResponse { + token: "jwt-token".to_string(), + expires_at: 1_779_552_000, + user_id: "alice".to_string(), + }; + + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "token": "jwt-token", + "expires_at": 1779552000, + "user_id": "alice" + }) + ); + } + + #[test] + fn register_request_omits_absent_profile_fields() { + let request = RegisterRequest { + username: "alice".to_string(), + password: "alice123".to_string(), + email: None, + display_name: None, + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "username": "alice", + "password": "alice123" + }) + ); + } + + #[cfg(feature = "server")] + fn operation<'a>( + doc: &'a serde_json::Value, + path: &str, + method: &str, + ) -> &'a serde_json::Value { + &doc["paths"][path][method] + } + + #[cfg(feature = "server")] + #[test] + fn generated_openapi_documents_public_auth_routes() { + let doc = generate_chatauthservice_openapi(); + + assert_eq!(doc["openapi"], "3.0.3"); + assert_eq!(doc["info"]["title"], "ChatAuthService REST API"); + + let login = operation(&doc, "/auth/login", "post"); + assert!(login.is_object()); + assert!(login.get("security").is_none()); + assert_eq!( + login["requestBody"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/LoginRequest" + ); + assert_eq!( + login["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/LoginResponse" + ); + + let register = operation(&doc, "/auth/register", "post"); + assert!(register.is_object()); + assert!(register.get("security").is_none()); + assert_eq!( + register["requestBody"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/RegisterRequest" + ); + + let health = operation(&doc, "/health", "get"); + assert!(health.is_object()); + assert!(health.get("security").is_none()); + } +} diff --git a/examples/bidirectional-chat/api/src/lib.rs b/examples/bidirectional-chat/api/src/lib.rs index 11f2e84..7ad7537 100644 --- a/examples/bidirectional-chat/api/src/lib.rs +++ b/examples/bidirectional-chat/api/src/lib.rs @@ -258,3 +258,114 @@ jsonrpc_bidirectional_service!({ server_to_client_calls: [ ] }); + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn cat_avatar_serializes_with_snake_case_wire_values() { + let avatar = CatAvatar { + breed: CatBreed::MaineCoon, + color: CatColor::Blue, + expression: CatExpression::Playful, + }; + + assert_eq!( + serde_json::to_value(avatar).unwrap(), + json!({ + "breed": "maine_coon", + "color": "blue", + "expression": "playful" + }) + ); + } + + #[test] + fn announcement_level_serializes_with_lowercase_wire_values() { + let request = BroadcastAnnouncementRequest { + message: "maintenance window".to_string(), + level: AnnouncementLevel::Warning, + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "message": "maintenance window", + "level": "warning" + }) + ); + } + + #[test] + fn user_profile_serializes_optional_display_name_and_avatar() { + let profile = UserProfile { + username: "alice".to_string(), + display_name: None, + avatar: CatAvatar { + breed: CatBreed::Tuxedo, + color: CatColor::Black, + expression: CatExpression::Curious, + }, + created_at: "2026-05-23T12:00:00Z".to_string(), + last_seen: "2026-05-23T12:30:00Z".to_string(), + }; + + assert_eq!( + serde_json::to_value(profile).unwrap(), + json!({ + "username": "alice", + "display_name": null, + "avatar": { + "breed": "tuxedo", + "color": "black", + "expression": "curious" + }, + "created_at": "2026-05-23T12:00:00Z", + "last_seen": "2026-05-23T12:30:00Z" + }) + ); + } + + #[test] + fn join_room_response_preserves_existing_user_order() { + let response = JoinRoomResponse { + room_id: "general".to_string(), + user_count: 2, + existing_users: vec!["alice".to_string(), "bob".to_string()], + }; + + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "room_id": "general", + "user_count": 2, + "existing_users": ["alice", "bob"] + }) + ); + } + + #[cfg(feature = "client")] + #[test] + fn generated_notification_enum_preserves_typed_payload_shape() { + let notification = ChatServiceServerToClientNotification::SystemAnnouncement( + SystemAnnouncementNotification { + message: "deployed".to_string(), + level: AnnouncementLevel::Info, + timestamp: "2026-05-23T12:00:00Z".to_string(), + }, + ); + + assert_eq!( + serde_json::to_value(notification).unwrap(), + json!({ + "SystemAnnouncement": { + "message": "deployed", + "level": "info", + "timestamp": "2026-05-23T12:00:00Z" + } + }) + ); + } +} diff --git a/examples/bidirectional-chat/server/Cargo.toml b/examples/bidirectional-chat/server/Cargo.toml index af58dfa..6c9e405 100644 --- a/examples/bidirectional-chat/server/Cargo.toml +++ b/examples/bidirectional-chat/server/Cargo.toml @@ -2,20 +2,27 @@ name = "bidirectional-chat-server" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Authenticated WebSocket chat server example for Rust Agent Stack" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +publish = false +readme = "README.md" [dependencies] # Local dependencies -bidirectional-chat-api = { path = "../api" } -ras-auth-core = { path = "../../../crates/core/ras-auth-core" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types" } -ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro" } -ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server" } -ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types" } -ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", features = ["server"] } -ras-rest-core = { path = "../../../crates/rest/ras-rest-core" } -ras-identity-core = { path = "../../../crates/core/ras-identity-core" } -ras-identity-local = { path = "../../../crates/identity/ras-identity-local" } -ras-identity-session = { path = "../../../crates/identity/ras-identity-session" } +bidirectional-chat-api = { path = "../api", version = "0.1.0" } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro", version = "0.1.0" } +ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server", version = "0.1.0" } +ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types", version = "0.1.0" } +ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1", features = ["server"] } +ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1" } +ras-identity-core = { path = "../../../crates/core/ras-identity-core", version = "0.1.1" } +ras-identity-local = { path = "../../../crates/identity/ras-identity-local", version = "0.2.0" } +ras-identity-session = { path = "../../../crates/identity/ras-identity-session", version = "0.1.1" } # Workspace dependencies axum = { workspace = true } @@ -32,17 +39,12 @@ dashmap = { workspace = true } anyhow = { workspace = true } tower-http = { workspace = true, features = ["cors"] } dotenvy = { workspace = true } -jsonwebtoken = { workspace = true } config = { workspace = true } [dev-dependencies] tempfile = { workspace = true } -reqwest = { workspace = true, features = ["json"] } - -[[bin]] -name = "test-config" -path = "src/bin/test_config.rs" +axum-test = { workspace = true } [features] default = ["server"] -server = ["ras-jsonrpc-bidirectional-macro/server"] \ No newline at end of file +server = ["ras-jsonrpc-bidirectional-macro/server"] diff --git a/examples/bidirectional-chat/server/README.md b/examples/bidirectional-chat/server/README.md new file mode 100644 index 0000000..90458d6 --- /dev/null +++ b/examples/bidirectional-chat/server/README.md @@ -0,0 +1,114 @@ +# Bidirectional Chat Server + +Authenticated Axum server for the [bidirectional chat example](../README.md). It combines: + +- REST auth endpoints generated from `bidirectional-chat-api::auth` +- JWT sessions from `ras-identity-session` +- Local username/password identity from `ras-identity-local` +- Bidirectional JSON-RPC over WebSocket at `/ws` +- File-backed chat room, message, and profile persistence + +## Run + +From the workspace root: + +```bash +CHAT_CONFIG_FILE=examples/bidirectional-chat/server/config.example.toml \ +CHAT_DATA_DIR=examples/bidirectional-chat/server/chat_data \ +cargo run -p bidirectional-chat-server --locked +``` + +The example config binds to `127.0.0.1:3000`. + +- HTTP base URL: `http://127.0.0.1:3000` +- WebSocket URL: `ws://127.0.0.1:3000/ws` +- Runtime data: `examples/bidirectional-chat/server/chat_data/` + +`chat_data/` is ignored by git. The server also loads `.env` from the current working directory before reading configuration. + +## Configuration + +The server reads `config.toml` by default. Set `CHAT_CONFIG_FILE` to use another file: + +```bash +CHAT_CONFIG_FILE=examples/bidirectional-chat/server/config.example.toml \ +cargo run -p bidirectional-chat-server --locked +``` + +Direct environment overrides supported by the server: + +- `HOST` +- `PORT` +- `JWT_SECRET` +- `CHAT_DATA_DIR` +- `RUST_LOG` + +Nested config values use the `CHAT__SECTION__FIELD` form, for example: + +```bash +CHAT__RATE_LIMIT__ENABLED=true \ +CHAT__RATE_LIMIT__MESSAGES_PER_MINUTE=10 \ +cargo run -p bidirectional-chat-server --locked +``` + +The checked-in [config.example.toml](config.example.toml) shows every supported section. Replace the example `jwt_secret` and admin passwords for any shared environment. + +## Auth Endpoints + +The REST endpoints are generated from the shared API crate and mounted at the root: + +- `GET /health` +- `POST /auth/register` +- `POST /auth/login` + +Register a user: + +```bash +curl -fsS -X POST http://127.0.0.1:3000/auth/register \ + -H 'content-type: application/json' \ + --data '{"username":"demo","password":"demo12345","email":"demo@example.com","display_name":"Demo User"}' +``` + +Login: + +```bash +curl -fsS -X POST http://127.0.0.1:3000/auth/login \ + -H 'content-type: application/json' \ + --data '{"username":"demo","password":"demo12345"}' +``` + +Debug builds also create `alice` / `alice123` and `bob` / `bob123`. When `config.example.toml` is loaded, it also creates the configured `admin` / `admin123456` and `moderator` / `moderator123` users. + +## WebSocket Auth + +The `/ws` endpoint requires a valid JWT. Clients can pass the token as: + +- `Authorization: Bearer ` +- `x-auth-token: ` +- `sec-websocket-protocol: token.` + +Once connected, the generated chat service handles methods such as `join_room`, `send_message`, `list_rooms`, `update_profile`, `kick_user`, and `broadcast_announcement`. + +## Tests + +Run the server package tests: + +```bash +cargo test -p bidirectional-chat-server --locked +``` + +Focused suites: + +```bash +cargo test -p bidirectional-chat-server --test server_tests --locked +cargo test -p bidirectional-chat-server --test auth_lifecycle_tests --locked +``` + +HTTP tests use `axum-test` mock transport. WebSocket flow tests use the in-memory `WebSocketIo` adapter instead of binding sockets. See [tests/README.md](tests/README.md) for the current coverage map and known gaps. + +## Checks + +```bash +cargo test -p bidirectional-chat-server --locked +cargo clippy -p bidirectional-chat-server --all-targets --all-features --locked -- -D warnings +``` diff --git a/examples/bidirectional-chat/server/chat_data/messages/General.jsonl b/examples/bidirectional-chat/server/chat_data/messages/General.jsonl deleted file mode 100644 index bcbecd0..0000000 --- a/examples/bidirectional-chat/server/chat_data/messages/General.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"id":2,"room_id":"General","username":"bob","text":"hellp","timestamp":"2025-06-14T13:41:55.282008348Z"} diff --git a/examples/bidirectional-chat/server/config.example.toml b/examples/bidirectional-chat/server/config.example.toml index 53ba827..7256cf5 100644 --- a/examples/bidirectional-chat/server/config.example.toml +++ b/examples/bidirectional-chat/server/config.example.toml @@ -15,8 +15,8 @@ allow_any_origin = true allowed_origins = ["http://localhost:3000", "https://example.com"] [auth] -# JWT secret key (REQUIRED for production, use JWT_SECRET env var) -jwt_secret = "change-me-in-production" +# JWT secret key. Must be at least 32 bytes; replace for shared environments. +jwt_secret = "change-this-to-at-least-32-random-bytes" # JWT TTL in seconds (24 hours) jwt_ttl_seconds = 86400 # Enable refresh tokens @@ -25,7 +25,8 @@ refresh_enabled = true jwt_algorithm = "HS256" [chat] -# Data directory for persistence (can also use CHAT_DATA_DIR env var) +# Data directory for persistence (can also use CHAT_DATA_DIR env var). +# Relative paths are resolved from the directory where the server process starts. data_dir = "./chat_data" # Maximum message length in characters max_message_length = 1000 @@ -92,13 +93,13 @@ display_name = "Moderator" permissions = ["moderator", "user"] [rate_limit] -# Enable rate limiting +# Enable chat message rate limiting enabled = false -# Maximum messages per minute per user +# Maximum chat messages per minute per authenticated user messages_per_minute = 30 -# Maximum concurrent connections per IP +# Reserved for deployment-level connection limiting; validated but not enforced by the chat service connections_per_ip = 10 -# Maximum login attempts per hour per IP +# Reserved for deployment-level login throttling; validated but not enforced by the chat service login_attempts_per_hour = 20 # Environment Variable Overrides @@ -136,4 +137,4 @@ login_attempts_per_hour = 20 # - CHAT__RATE_LIMIT__ENABLED: Enable rate limiting # - CHAT__RATE_LIMIT__MESSAGES_PER_MINUTE: Messages per minute # - CHAT__RATE_LIMIT__CONNECTIONS_PER_IP: Connections per IP -# - CHAT__RATE_LIMIT__LOGIN_ATTEMPTS_PER_HOUR: Login attempts per hour \ No newline at end of file +# - CHAT__RATE_LIMIT__LOGIN_ATTEMPTS_PER_HOUR: Login attempts per hour diff --git a/examples/bidirectional-chat/server/src/bin/test_config.rs b/examples/bidirectional-chat/server/src/bin/test_config.rs deleted file mode 100644 index 0b1028a..0000000 --- a/examples/bidirectional-chat/server/src/bin/test_config.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Test configuration loading - -use bidirectional_chat_server::config::Config; - -fn main() { - println!("Testing configuration loading...\n"); - - match Config::load() { - Ok(config) => { - println!("✓ Configuration loaded successfully!"); - println!("\nServer Configuration:"); - println!(" Host: {}", config.server.host); - println!(" Port: {}", config.server.port); - println!(" CORS: {:?}", config.server.cors.allow_any_origin); - - println!("\nAuth Configuration:"); - println!(" JWT TTL: {} seconds", config.auth.jwt_ttl_seconds); - println!(" JWT Algorithm: {}", config.auth.jwt_algorithm); - println!(" Refresh Enabled: {}", config.auth.refresh_enabled); - - println!("\nChat Configuration:"); - println!(" Data Directory: {:?}", config.chat.data_dir); - println!(" Max Message Length: {}", config.chat.max_message_length); - println!( - " Max Room Name Length: {}", - config.chat.max_room_name_length - ); - println!(" Max Users Per Room: {}", config.chat.max_users_per_room); - println!(" Default Rooms:"); - for room in &config.chat.default_rooms { - println!(" - {} ({})", room.name, room.id); - } - - println!("\nLogging Configuration:"); - println!(" Level: {}", config.logging.level); - println!(" Format: {}", config.logging.format); - - println!("\nAdmin Configuration:"); - println!(" Auto-create: {}", config.admin.auto_create); - println!(" Admin Users:"); - for user in &config.admin.users { - println!(" - {} ({:?})", user.username, user.permissions); - } - - println!("\nRate Limiting:"); - println!(" Enabled: {}", config.rate_limit.enabled); - if config.rate_limit.enabled { - println!( - " Messages/minute: {}", - config.rate_limit.messages_per_minute - ); - println!(" Connections/IP: {}", config.rate_limit.connections_per_ip); - } - - println!("\nSocket Address: {}", config.socket_addr()); - println!("Log Filter: {}", config.log_filter()); - } - Err(e) => { - eprintln!("✗ Failed to load configuration: {}", e); - std::process::exit(1); - } - } -} diff --git a/examples/bidirectional-chat/server/src/config.rs b/examples/bidirectional-chat/server/src/config.rs index 35ce69a..3fea2e1 100644 --- a/examples/bidirectional-chat/server/src/config.rs +++ b/examples/bidirectional-chat/server/src/config.rs @@ -1,6 +1,6 @@ //! Configuration module for the bidirectional chat server //! -//! This module provides a comprehensive configuration system that supports: +//! This module provides the configuration loading paths used by the example: //! - Environment variables (with CHAT_ prefix) //! - Configuration file (config.toml) //! - Default values @@ -16,7 +16,7 @@ use std::path::PathBuf; use tracing::{debug, info, warn}; /// Main configuration struct for the chat server -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct Config { /// Server configuration @@ -211,15 +211,15 @@ pub struct RateLimitConfig { #[serde(default)] pub enabled: bool, - /// Messages per minute per user + /// Messages per minute per authenticated chat user. #[serde(default = "default_messages_per_minute")] pub messages_per_minute: u32, - /// Connections per IP + /// Reserved for deployment-level connection limiting; validated but not enforced by the chat service. #[serde(default = "default_connections_per_ip")] pub connections_per_ip: u32, - /// Login attempts per hour per IP + /// Reserved for deployment-level login throttling; validated but not enforced by the chat service. #[serde(default = "default_login_attempts_per_hour")] pub login_attempts_per_hour: u32, } @@ -298,19 +298,6 @@ fn default_login_attempts_per_hour() -> u32 { 20 } -impl Default for Config { - fn default() -> Self { - Self { - server: ServerConfig::default(), - auth: AuthConfig::default(), - chat: ChatConfig::default(), - logging: LoggingConfig::default(), - admin: AdminConfig::default(), - rate_limit: RateLimitConfig::default(), - } - } -} - impl Default for ServerConfig { fn default() -> Self { Self { diff --git a/examples/bidirectional-chat/server/src/main.rs b/examples/bidirectional-chat/server/src/main.rs index 840cb04..860895e 100644 --- a/examples/bidirectional-chat/server/src/main.rs +++ b/examples/bidirectional-chat/server/src/main.rs @@ -21,7 +21,7 @@ use dashmap::DashMap; use ras_auth_core::AuthenticatedUser; use ras_identity_core::{UserPermissions, VerifiedIdentity}; use ras_identity_local::LocalUserProvider; -use ras_identity_session::{JwtAuthProvider, SessionConfig, SessionService}; +use ras_identity_session::{JwtAlgorithm, JwtAuthProvider, SessionConfig, SessionService}; use ras_jsonrpc_bidirectional_server::{ DefaultConnectionManager, WebSocketServiceBuilder, service::{BuiltWebSocketService, websocket_handler}, @@ -40,11 +40,8 @@ use tower_http::cors::CorsLayer; use tracing::{debug, error, info, instrument, warn}; use uuid::Uuid; -pub mod config; -mod persistence; - -use config::Config; -use persistence::{ +use bidirectional_chat_server::config::{self, Config}; +use bidirectional_chat_server::persistence::{ PersistedCatAvatar, PersistedMessage, PersistedRoom, PersistedUserProfile, PersistenceManager, }; @@ -61,18 +58,124 @@ struct ChatRoom { #[derive(Debug, Clone)] struct UserSession { username: String, - connection_id: ConnectionId, current_room: Option, // room_id - joined_at: chrono::DateTime, } // Typing state tracking #[derive(Debug, Clone)] struct TypingState { - username: String, started_at: Instant, } +#[derive(Debug, Clone)] +struct MessageRateLimitState { + window_start: Instant, + messages_sent: u32, +} + +fn persisted_cat_breed(breed: CatBreed) -> &'static str { + match breed { + CatBreed::Tabby => "tabby", + CatBreed::Siamese => "siamese", + CatBreed::Persian => "persian", + CatBreed::MaineCoon => "maine_coon", + CatBreed::BritishShorthair => "british_shorthair", + CatBreed::Ragdoll => "ragdoll", + CatBreed::Sphynx => "sphynx", + CatBreed::ScottishFold => "scottish_fold", + CatBreed::Calico => "calico", + CatBreed::Tuxedo => "tuxedo", + } +} + +fn persisted_cat_color(color: CatColor) -> &'static str { + match color { + CatColor::Orange => "orange", + CatColor::Black => "black", + CatColor::White => "white", + CatColor::Gray => "gray", + CatColor::Brown => "brown", + CatColor::Cream => "cream", + CatColor::Blue => "blue", + CatColor::Lilac => "lilac", + CatColor::Cinnamon => "cinnamon", + CatColor::Fawn => "fawn", + } +} + +fn persisted_cat_expression(expression: CatExpression) -> &'static str { + match expression { + CatExpression::Happy => "happy", + CatExpression::Sleepy => "sleepy", + CatExpression::Curious => "curious", + CatExpression::Playful => "playful", + CatExpression::Content => "content", + CatExpression::Alert => "alert", + CatExpression::Grumpy => "grumpy", + CatExpression::Loving => "loving", + } +} + +fn cat_breed_from_persisted(value: &str) -> CatBreed { + match value { + "tabby" => CatBreed::Tabby, + "siamese" => CatBreed::Siamese, + "persian" => CatBreed::Persian, + "maine_coon" => CatBreed::MaineCoon, + "british_shorthair" => CatBreed::BritishShorthair, + "ragdoll" => CatBreed::Ragdoll, + "sphynx" => CatBreed::Sphynx, + "scottish_fold" => CatBreed::ScottishFold, + "calico" => CatBreed::Calico, + "tuxedo" => CatBreed::Tuxedo, + _ => CatBreed::Tabby, + } +} + +fn cat_color_from_persisted(value: &str) -> CatColor { + match value { + "orange" => CatColor::Orange, + "black" => CatColor::Black, + "white" => CatColor::White, + "gray" => CatColor::Gray, + "brown" => CatColor::Brown, + "cream" => CatColor::Cream, + "blue" => CatColor::Blue, + "lilac" => CatColor::Lilac, + "cinnamon" => CatColor::Cinnamon, + "fawn" => CatColor::Fawn, + _ => CatColor::Orange, + } +} + +fn cat_expression_from_persisted(value: &str) -> CatExpression { + match value { + "happy" => CatExpression::Happy, + "sleepy" => CatExpression::Sleepy, + "curious" => CatExpression::Curious, + "playful" => CatExpression::Playful, + "content" => CatExpression::Content, + "alert" => CatExpression::Alert, + "grumpy" => CatExpression::Grumpy, + "loving" => CatExpression::Loving, + _ => CatExpression::Happy, + } +} + +fn user_profile_from_persisted(persisted: &PersistedUserProfile) -> UserProfile { + UserProfile { + username: persisted.username.clone(), + display_name: persisted.display_name.clone(), + avatar: CatAvatar { + breed: cat_breed_from_persisted(&persisted.avatar.breed), + color: cat_color_from_persisted(&persisted.avatar.color), + expression: cat_expression_from_persisted(&persisted.avatar.expression), + }, + created_at: persisted.created_at.to_rfc3339(), + last_seen: persisted.last_seen.to_rfc3339(), + } +} + // Chat server state #[derive(Clone)] struct ChatServer { @@ -81,12 +184,17 @@ struct ChatServer { message_counter: Arc>, persistence: Arc, config: config::ChatConfig, + rate_limit: config::RateLimitConfig, typing_users: Arc>>>, // room_id -> username -> typing state + message_rate_limits: Arc>>, } impl ChatServer { - #[instrument(skip_all, fields(data_dir = ?config.data_dir))] - async fn new(config: config::ChatConfig) -> Result { + #[instrument(skip_all, fields(data_dir = ?config.data_dir, rate_limit_enabled = rate_limit.enabled))] + async fn new_with_rate_limit( + config: config::ChatConfig, + rate_limit: config::RateLimitConfig, + ) -> Result { info!("Initializing chat server with data directory"); let persistence = Arc::new(PersistenceManager::new(&config.data_dir)); persistence.init().await.map_err(|e| { @@ -107,7 +215,9 @@ impl ChatServer { message_counter: Arc::new(RwLock::new(state.next_message_id)), persistence, config: config.clone(), + rate_limit, typing_users: Arc::new(Mutex::new(HashMap::new())), + message_rate_limits: Arc::new(Mutex::new(HashMap::new())), }; // Restore rooms @@ -178,6 +288,51 @@ impl ChatServer { }) } + async fn check_message_rate_limit( + &self, + username: &str, + ) -> Result<(), Box> { + if !self.rate_limit.enabled { + return Ok(()); + } + + if self.rate_limit.messages_per_minute == 0 { + return Err("Message rate limit is configured with zero messages per minute".into()); + } + + let now = Instant::now(); + let window = Duration::from_secs(60); + let mut limits = self.message_rate_limits.lock().await; + let state = limits + .entry(username.to_string()) + .or_insert_with(|| MessageRateLimitState { + window_start: now, + messages_sent: 0, + }); + + if now.duration_since(state.window_start) >= window { + state.window_start = now; + state.messages_sent = 0; + } + + if state.messages_sent >= self.rate_limit.messages_per_minute { + return Err(format!( + "Rate limit exceeded. Maximum {} messages per minute", + self.rate_limit.messages_per_minute + ) + .into()); + } + + state.messages_sent += 1; + Ok(()) + } + + async fn clear_message_rate_limit(&self, username: &str) { + if self.rate_limit.enabled { + self.message_rate_limits.lock().await.remove(username); + } + } + // Clean up expired typing states (older than 5 seconds) async fn cleanup_expired_typing_states(&self, connection_manager: &dyn ConnectionManager) { let mut typing_users = self.typing_users.lock().await; @@ -248,14 +403,13 @@ impl ChatServer { for target_username in room_users { if target_username != username { for entry in self.user_sessions.iter() { - if entry.username == target_username { - if let Err(e) = connection_manager - .send_to_connection(entry.connection_id, msg.clone()) + if entry.username == target_username + && let Err(e) = connection_manager + .send_to_connection(*entry.key(), msg.clone()) .await - { - warn!(target_user = %target_username, connection_id = %entry.connection_id, - "Failed to send typing notification: {:?}", e); - } + { + warn!(target_user = %target_username, connection_id = %entry.key(), + "Failed to send typing notification: {:?}", e); } } } @@ -301,6 +455,8 @@ impl ChatServiceService for ChatServer { let username = session.username.clone(); drop(session); + self.check_message_rate_limit(&username).await?; + // Clear typing state when sending a message let mut typing_users = self.typing_users.lock().await; let mut was_typing = false; @@ -379,10 +535,10 @@ impl ChatServiceService for ChatServer { notification_msg, ); if let Err(e) = connection_manager - .send_to_connection(entry.connection_id, msg) + .send_to_connection(*entry.key(), msg) .await { - warn!(target_user = %target_username, connection_id = %entry.connection_id, + warn!(target_user = %target_username, connection_id = %entry.key(), "Failed to send message notification: {:?}", e); } } @@ -468,10 +624,10 @@ impl ChatServiceService for ChatServer { notification_msg, ); if let Err(e) = connection_manager - .send_to_connection(entry.connection_id, msg) + .send_to_connection(*entry.key(), msg) .await { - warn!(connection_id = %entry.connection_id, + warn!(connection_id = %entry.key(), "Failed to send room_created notification: {:?}", e); } } @@ -488,35 +644,37 @@ impl ChatServiceService for ChatServer { let username = session.username.clone(); // Leave current room if in one - if let Some(current_room_id) = &session.current_room { - if let Some(mut room) = self.rooms.get_mut(current_room_id) { - room.users.remove(&username); - let user_count = room.users.len() as u32; - drop(room); - - // Notify users in old room - let notification = UserLeftNotification { - username: username.clone(), - room_id: current_room_id.clone(), - user_count, - }; + if let Some(current_room_id) = &session.current_room + && let Some(mut room) = self.rooms.get_mut(current_room_id) + { + room.users.remove(&username); + let user_count = room.users.len() as u32; + drop(room); - for entry in self.user_sessions.iter() { - if entry.current_room.as_ref() == Some(current_room_id) { - let notification_msg = - ras_jsonrpc_bidirectional_types::ServerNotification { - method: "user_left".to_string(), - params: serde_json::to_value(¬ification).unwrap(), - metadata: None, - }; - let msg = ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification(notification_msg); - if let Err(e) = connection_manager - .send_to_connection(entry.connection_id, msg) - .await - { - warn!(connection_id = %entry.connection_id, - "Failed to send user_left notification: {:?}", e); - } + // Notify users in old room + let notification = UserLeftNotification { + username: username.clone(), + room_id: current_room_id.clone(), + user_count, + }; + + for entry in self.user_sessions.iter() { + if entry.current_room.as_ref() == Some(current_room_id) { + let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { + method: "user_left".to_string(), + params: serde_json::to_value(¬ification).unwrap(), + metadata: None, + }; + let msg = + ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification( + notification_msg, + ); + if let Err(e) = connection_manager + .send_to_connection(*entry.key(), msg) + .await + { + warn!(connection_id = %entry.key(), + "Failed to send user_left notification: {:?}", e); } } } @@ -567,10 +725,10 @@ impl ChatServiceService for ChatServer { notification_msg, ); if let Err(e) = connection_manager - .send_to_connection(entry.connection_id, msg) + .send_to_connection(*entry.key(), msg) .await { - warn!(target_user = %target_username, connection_id = %entry.connection_id, + warn!(target_user = %target_username, connection_id = %entry.key(), "Failed to send message notification: {:?}", e); } } @@ -639,10 +797,10 @@ impl ChatServiceService for ChatServer { }; let msg = ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification(notification_msg); if let Err(e) = connection_manager - .send_to_connection(entry.connection_id, msg) + .send_to_connection(*entry.key(), msg) .await { - warn!(connection_id = %entry.connection_id, + warn!(connection_id = %entry.key(), "Failed to send user_left notification: {:?}", e); } } @@ -690,7 +848,7 @@ impl ChatServiceService for ChatServer { for entry in self.user_sessions.iter() { if entry.username == request.target_username { - target_connection_id = Some(entry.connection_id); + target_connection_id = Some(*entry.key()); target_room_id = entry.current_room.clone(); break; } @@ -699,10 +857,10 @@ impl ChatServiceService for ChatServer { let target_id = target_connection_id.ok_or("Target user not found")?; // Remove user from their room if they're in one - if let Some(ref room_id) = target_room_id { - if let Some(mut room) = self.rooms.get_mut(room_id) { - room.users.remove(&request.target_username); - } + if let Some(ref room_id) = target_room_id + && let Some(mut room) = self.rooms.get_mut(room_id) + { + room.users.remove(&request.target_username); } // Send kick notification to the target user @@ -726,6 +884,8 @@ impl ChatServiceService for ChatServer { // Remove the user's session self.user_sessions.remove(&target_id); + self.clear_message_rate_limit(&request.target_username) + .await; debug!("Removed user session for {}", request.target_username); // Disconnect the user @@ -760,10 +920,10 @@ impl ChatServiceService for ChatServer { notification_msg, ); if let Err(e) = connection_manager - .send_to_connection(entry.connection_id, msg) + .send_to_connection(*entry.key(), msg) .await { - warn!(connection_id = %entry.connection_id, + warn!(connection_id = %entry.key(), "Failed to send announcement: {:?}", e); } } @@ -785,51 +945,7 @@ impl ChatServiceService for ChatServer { // Get profile from persistence or create default let profile = if let Some(persisted) = state.user_profiles.get(&request.username) { - UserProfile { - username: persisted.username.clone(), - display_name: persisted.display_name.clone(), - avatar: CatAvatar { - breed: match persisted.avatar.breed.as_str() { - "tabby" => CatBreed::Tabby, - "siamese" => CatBreed::Siamese, - "persian" => CatBreed::Persian, - "maine_coon" => CatBreed::MaineCoon, - "british_shorthair" => CatBreed::BritishShorthair, - "ragdoll" => CatBreed::Ragdoll, - "sphynx" => CatBreed::Sphynx, - "scottish_fold" => CatBreed::ScottishFold, - "calico" => CatBreed::Calico, - "tuxedo" => CatBreed::Tuxedo, - _ => CatBreed::Tabby, - }, - color: match persisted.avatar.color.as_str() { - "orange" => CatColor::Orange, - "black" => CatColor::Black, - "white" => CatColor::White, - "gray" => CatColor::Gray, - "brown" => CatColor::Brown, - "cream" => CatColor::Cream, - "blue" => CatColor::Blue, - "lilac" => CatColor::Lilac, - "cinnamon" => CatColor::Cinnamon, - "fawn" => CatColor::Fawn, - _ => CatColor::Orange, - }, - expression: match persisted.avatar.expression.as_str() { - "happy" => CatExpression::Happy, - "sleepy" => CatExpression::Sleepy, - "curious" => CatExpression::Curious, - "playful" => CatExpression::Playful, - "content" => CatExpression::Content, - "alert" => CatExpression::Alert, - "grumpy" => CatExpression::Grumpy, - "loving" => CatExpression::Loving, - _ => CatExpression::Happy, - }, - }, - created_at: persisted.created_at.to_rfc3339(), - last_seen: persisted.last_seen.to_rfc3339(), - } + user_profile_from_persisted(persisted) } else { // Create default profile UserProfile { @@ -882,9 +998,9 @@ impl ChatServiceService for ChatServer { if let Some(avatar) = request.avatar { persisted_profile.avatar = PersistedCatAvatar { - breed: format!("{:?}", avatar.breed).to_lowercase(), - color: format!("{:?}", avatar.color).to_lowercase(), - expression: format!("{:?}", avatar.expression).to_lowercase(), + breed: persisted_cat_breed(avatar.breed).to_string(), + color: persisted_cat_color(avatar.color).to_string(), + expression: persisted_cat_expression(avatar.expression).to_string(), }; } @@ -898,51 +1014,7 @@ impl ChatServiceService for ChatServer { self.persistence.save_state(&state).await?; // Convert to response - let profile = UserProfile { - username: persisted_profile.username, - display_name: persisted_profile.display_name, - avatar: CatAvatar { - breed: match persisted_profile.avatar.breed.as_str() { - "tabby" => CatBreed::Tabby, - "siamese" => CatBreed::Siamese, - "persian" => CatBreed::Persian, - "maine_coon" => CatBreed::MaineCoon, - "british_shorthair" => CatBreed::BritishShorthair, - "ragdoll" => CatBreed::Ragdoll, - "sphynx" => CatBreed::Sphynx, - "scottish_fold" => CatBreed::ScottishFold, - "calico" => CatBreed::Calico, - "tuxedo" => CatBreed::Tuxedo, - _ => CatBreed::Tabby, - }, - color: match persisted_profile.avatar.color.as_str() { - "orange" => CatColor::Orange, - "black" => CatColor::Black, - "white" => CatColor::White, - "gray" => CatColor::Gray, - "brown" => CatColor::Brown, - "cream" => CatColor::Cream, - "blue" => CatColor::Blue, - "lilac" => CatColor::Lilac, - "cinnamon" => CatColor::Cinnamon, - "fawn" => CatColor::Fawn, - _ => CatColor::Orange, - }, - expression: match persisted_profile.avatar.expression.as_str() { - "happy" => CatExpression::Happy, - "sleepy" => CatExpression::Sleepy, - "curious" => CatExpression::Curious, - "playful" => CatExpression::Playful, - "content" => CatExpression::Content, - "alert" => CatExpression::Alert, - "grumpy" => CatExpression::Grumpy, - "loving" => CatExpression::Loving, - _ => CatExpression::Happy, - }, - }, - created_at: persisted_profile.created_at.to_rfc3339(), - last_seen: persisted_profile.last_seen.to_rfc3339(), - }; + let profile = user_profile_from_persisted(&persisted_profile); Ok(UpdateProfileResponse { profile }) } @@ -977,7 +1049,6 @@ impl ChatServiceService for ChatServer { room_typing_users.insert( username.clone(), TypingState { - username: username.clone(), started_at: Instant::now(), }, ); @@ -1040,7 +1111,8 @@ impl ChatServiceService for ChatServer { Ok(()) } - // Notification stub methods (required by the trait but not used by server) + // Server-side notification hooks required by the generated trait. The chat + // server broadcasts notifications directly through the connection manager. async fn notify_message_received( &self, _connection_id: ConnectionId, @@ -1156,6 +1228,7 @@ impl ChatServiceService for ChatServer { // Remove user session and notify room members if let Some((_, session)) = self.user_sessions.remove(&client_id) { let username = session.username.clone(); + self.clear_message_rate_limit(&username).await; if let Some(room_id) = session.current_room { // Clear typing state if user was typing @@ -1207,10 +1280,10 @@ impl ChatServiceService for ChatServer { }; let msg = ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification(notification_msg); if let Err(e) = connection_manager - .send_to_connection(entry.connection_id, msg) + .send_to_connection(*entry.key(), msg) .await { - warn!(connection_id = %entry.connection_id, + warn!(connection_id = %entry.key(), "Failed to send user_left notification on disconnect: {:?}", e); } } @@ -1237,9 +1310,7 @@ impl ChatServiceService for ChatServer { // Create user session let session = UserSession { username: user.user_id.clone(), - connection_id: client_id, current_room: None, - joined_at: Utc::now(), }; self.user_sessions.insert(client_id, session); @@ -1383,7 +1454,6 @@ impl AuthHandlers { })) } } - #[tokio::main] async fn main() -> Result<()> { // Load environment variables first (before config loading) @@ -1487,12 +1557,8 @@ async fn main() -> Result<()> { jwt_ttl: chrono::Duration::seconds(config.auth.jwt_ttl_seconds), refresh_enabled: config.auth.refresh_enabled, enforce_active_sessions: true, - algorithm: match config.auth.jwt_algorithm.as_str() { - "HS256" => jsonwebtoken::Algorithm::HS256, - "HS384" => jsonwebtoken::Algorithm::HS384, - "HS512" => jsonwebtoken::Algorithm::HS512, - _ => jsonwebtoken::Algorithm::HS256, // Default - }, + algorithm: JwtAlgorithm::from_name(&config.auth.jwt_algorithm) + .unwrap_or(JwtAlgorithm::HS256), }; info!( "Creating session service with JWT TTL: {} seconds", @@ -1517,10 +1583,14 @@ async fn main() -> Result<()> { let connection_manager = Arc::new(DefaultConnectionManager::new()); // Create chat server with configuration - let chat_server = Arc::new(ChatServer::new(config.chat.clone()).await.map_err(|e| { - error!("Failed to create chat server: {}", e); - e - })?); + let chat_server = Arc::new( + ChatServer::new_with_rate_limit(config.chat.clone(), config.rate_limit.clone()) + .await + .map_err(|e| { + error!("Failed to create chat server: {}", e); + e + })?, + ); // Create handler with the service and connection manager let handler = Arc::new(bidirectional_chat_api::ChatServiceHandler::new( @@ -1622,3 +1692,966 @@ async fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use ras_jsonrpc_bidirectional_server::MessageHandler; + use ras_jsonrpc_bidirectional_server::connection::{ChannelMessageSender, ConnectionContext}; + use ras_jsonrpc_bidirectional_server::handler::{ + WebSocketHandler, WebSocketIo, WebSocketIoMessage, + }; + use ras_jsonrpc_bidirectional_types::{BidirectionalMessage, ConnectionInfo}; + use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse}; + use std::collections::VecDeque; + use std::future; + use tempfile::TempDir; + use tokio::sync::mpsc; + + struct InMemorySocket { + incoming: VecDeque, + outgoing: Vec, + close_when_empty: bool, + close_after_outgoing: Option, + } + + impl InMemorySocket { + fn closing_after_outgoing( + incoming: impl IntoIterator, + outgoing_count: usize, + ) -> Self { + Self { + incoming: incoming.into_iter().collect(), + outgoing: Vec::new(), + close_when_empty: false, + close_after_outgoing: Some(outgoing_count), + } + } + } + + #[async_trait::async_trait] + impl WebSocketIo for InMemorySocket { + async fn send( + &mut self, + message: WebSocketIoMessage, + ) -> ras_jsonrpc_bidirectional_server::ServerResult<()> { + self.outgoing.push(message); + if self + .close_after_outgoing + .is_some_and(|count| self.outgoing.len() >= count) + { + self.close_when_empty = true; + } + Ok(()) + } + + async fn recv( + &mut self, + ) -> Option> { + if let Some(message) = self.incoming.pop_front() { + Some(Ok(message)) + } else if self.close_when_empty { + None + } else { + future::pending().await + } + } + } + + async fn test_chat_server(temp_dir: &TempDir) -> Result> { + test_chat_server_with_rate_limit(temp_dir, config::RateLimitConfig::default()).await + } + + async fn test_chat_server_with_rate_limit( + temp_dir: &TempDir, + rate_limit: config::RateLimitConfig, + ) -> Result> { + let chat_config = config::ChatConfig { + data_dir: temp_dir.path().join("chat_data"), + ..Default::default() + }; + + Ok(Arc::new( + ChatServer::new_with_rate_limit(chat_config, rate_limit).await?, + )) + } + + fn test_user(username: &str, permissions: &[&str]) -> AuthenticatedUser { + AuthenticatedUser { + user_id: username.to_string(), + permissions: permissions + .iter() + .map(|permission| (*permission).to_string()) + .collect(), + metadata: Default::default(), + } + } + + fn request(id: &str, method: &str, params: serde_json::Value) -> WebSocketIoMessage { + let request = JsonRpcRequest::new( + method.to_string(), + Some(params), + Some(serde_json::Value::String(id.to_string())), + ); + let message = BidirectionalMessage::Request(request); + WebSocketIoMessage::Text(serde_json::to_string(&message).unwrap()) + } + + struct TestConnection { + context: Arc, + messages: mpsc::Receiver, + user: AuthenticatedUser, + } + + async fn register_test_connection( + connection_manager: &Arc, + user: AuthenticatedUser, + ) -> Result { + let connection_id = ConnectionId::new(); + let (message_tx, messages) = mpsc::channel(16); + let sender = ChannelMessageSender::new(connection_id, message_tx); + + let mut info = ConnectionInfo::new(connection_id); + info.set_user(user.clone()); + + let context = Arc::new(ConnectionContext::new(connection_id, sender.clone())); + context.set_user(user.clone()).await; + + connection_manager + .add_connection_with_sender(info, Box::new(sender)) + .await?; + + Ok(TestConnection { + context, + messages, + user, + }) + } + + fn drain_messages( + receiver: &mut mpsc::Receiver, + ) -> Vec { + let mut messages = Vec::new(); + while let Ok(message) = receiver.try_recv() { + messages.push(message); + } + messages + } + + async fn call_handler( + handler: &ChatServiceHandler, + context: Arc, + id: &str, + method: &str, + params: serde_json::Value, + ) -> Result { + let request = JsonRpcRequest::new( + method.to_string(), + Some(params), + Some(serde_json::Value::String(id.to_string())), + ); + + let response = handler + .handle_request(request, context) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))? + .ok_or_else(|| anyhow::anyhow!("handler returned no response for {method}"))?; + + Ok(response) + } + + async fn run_socketless_chat_flow( + chat_server: Arc, + user: AuthenticatedUser, + incoming: Vec, + close_after_outgoing: usize, + ) -> Result> { + let connection_manager = Arc::new(DefaultConnectionManager::new()); + let handler = Arc::new(ChatServiceHandler::new( + Arc::clone(&chat_server), + Arc::clone(&connection_manager), + )); + + let connection_id = ConnectionId::new(); + let (message_tx, message_rx) = mpsc::channel(16); + let sender = ChannelMessageSender::new(connection_id, message_tx); + + let mut info = ConnectionInfo::new(connection_id); + info.set_user(user.clone()); + + let context = Arc::new(ConnectionContext::new(connection_id, sender.clone())); + context.set_user(user).await; + + connection_manager + .add_connection_with_sender(info, Box::new(sender)) + .await?; + + let mut socket = InMemorySocket::closing_after_outgoing(incoming, close_after_outgoing); + + tokio::time::timeout( + Duration::from_secs(2), + WebSocketHandler::new(handler, context, message_rx, 4096).run_with_io(&mut socket), + ) + .await + .expect("socketless chat flow should finish")?; + + Ok(socket + .outgoing + .into_iter() + .filter_map(|message| match message { + WebSocketIoMessage::Text(text) => serde_json::from_str(&text).ok(), + _ => None, + }) + .collect()) + } + + fn response_by_id<'a>( + messages: &'a [BidirectionalMessage], + id: &str, + ) -> Option<&'a JsonRpcResponse> { + messages.iter().find_map(|message| match message { + BidirectionalMessage::Response(response) + if response.id.as_ref() == Some(&serde_json::Value::String(id.to_string())) => + { + Some(response) + } + _ => None, + }) + } + + fn notification_by_method<'a>( + messages: &'a [BidirectionalMessage], + method: &str, + ) -> Option<&'a ras_jsonrpc_bidirectional_types::ServerNotification> { + messages.iter().find_map(|message| match message { + BidirectionalMessage::ServerNotification(notification) + if notification.method == method => + { + Some(notification) + } + _ => None, + }) + } + + fn notifications_by_method<'a>( + messages: &'a [BidirectionalMessage], + method: &str, + ) -> Vec<&'a ras_jsonrpc_bidirectional_types::ServerNotification> { + messages + .iter() + .filter_map(|message| match message { + BidirectionalMessage::ServerNotification(notification) + if notification.method == method => + { + Some(notification) + } + _ => None, + }) + .collect() + } + + fn room_info<'a>(response: &'a ListRoomsResponse, room_id: &str) -> Option<&'a RoomInfo> { + response.rooms.iter().find(|room| room.room_id == room_id) + } + + #[tokio::test] + async fn websocket_flow_joins_room_and_broadcasts_message_without_socket() -> Result<()> { + let temp_dir = TempDir::new()?; + let chat_server = test_chat_server(&temp_dir).await?; + + let messages = run_socketless_chat_flow( + chat_server, + test_user("alice", &["user"]), + vec![ + request("join", "join_room", json!({ "room_name": "general" })), + request("send", "send_message", json!({ "text": "hello from test" })), + ], + 7, + ) + .await?; + + let join_response = response_by_id(&messages, "join").expect("join_room response"); + assert!( + join_response.error.is_none(), + "join_room should succeed: {:?}", + join_response.error + ); + let join_result: JoinRoomResponse = + serde_json::from_value(join_response.result.clone().expect("join result"))?; + assert_eq!(join_result.room_id, "general"); + assert_eq!(join_result.user_count, 1); + assert!(join_result.existing_users.is_empty()); + + let send_response = response_by_id(&messages, "send").expect("send_message response"); + assert!( + send_response.error.is_none(), + "send_message should succeed: {:?}", + send_response.error + ); + let send_result: SendMessageResponse = + serde_json::from_value(send_response.result.clone().expect("send result"))?; + assert_eq!(send_result.message_id, 1); + + let joined = notification_by_method(&messages, "user_joined").expect("join notification"); + let joined: UserJoinedNotification = serde_json::from_value(joined.params.clone())?; + assert_eq!(joined.username, "alice"); + assert_eq!(joined.room_id, "general"); + + let received = + notification_by_method(&messages, "message_received").expect("message notification"); + let received: MessageReceivedNotification = + serde_json::from_value(received.params.clone())?; + assert_eq!(received.username, "alice"); + assert_eq!(received.text, "hello from test"); + assert_eq!(received.room_id, "general"); + + Ok(()) + } + + #[tokio::test] + async fn multi_user_broadcast_reaches_all_room_members_without_socket() -> Result<()> { + let temp_dir = TempDir::new()?; + let chat_server = test_chat_server(&temp_dir).await?; + let connection_manager = Arc::new(DefaultConnectionManager::new()); + let handler = + ChatServiceHandler::new(Arc::clone(&chat_server), Arc::clone(&connection_manager)); + + let mut alice = + register_test_connection(&connection_manager, test_user("alice", &["user"])).await?; + let mut bob = + register_test_connection(&connection_manager, test_user("bob", &["user"])).await?; + + handler + .on_client_authenticated(alice.context.id, &alice.user) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + handler + .on_client_authenticated(bob.context.id, &bob.user) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + + drain_messages(&mut alice.messages); + drain_messages(&mut bob.messages); + + let alice_join = call_handler( + &handler, + Arc::clone(&alice.context), + "alice-join", + "join_room", + json!({ "room_name": "general" }), + ) + .await?; + assert!(alice_join.error.is_none()); + + let bob_join = call_handler( + &handler, + Arc::clone(&bob.context), + "bob-join", + "join_room", + json!({ "room_name": "general" }), + ) + .await?; + assert!(bob_join.error.is_none()); + let bob_join: JoinRoomResponse = + serde_json::from_value(bob_join.result.expect("bob join result"))?; + assert_eq!(bob_join.existing_users, vec!["alice".to_string()]); + assert_eq!(bob_join.user_count, 2); + + drain_messages(&mut alice.messages); + drain_messages(&mut bob.messages); + + let send_response = call_handler( + &handler, + Arc::clone(&alice.context), + "alice-send", + "send_message", + json!({ "text": "hello bob" }), + ) + .await?; + assert!( + send_response.error.is_none(), + "send_message should succeed: {:?}", + send_response.error + ); + + let alice_messages = drain_messages(&mut alice.messages); + let bob_messages = drain_messages(&mut bob.messages); + + for (username, messages) in [ + ("alice", alice_messages.as_slice()), + ("bob", bob_messages.as_slice()), + ] { + let notifications = notifications_by_method(messages, "message_received"); + assert_eq!( + notifications.len(), + 1, + "{username} should receive one message notification" + ); + let notification: MessageReceivedNotification = + serde_json::from_value(notifications[0].params.clone())?; + assert_eq!(notification.username, "alice"); + assert_eq!(notification.text, "hello bob"); + assert_eq!(notification.room_id, "general"); + } + + Ok(()) + } + + #[tokio::test] + async fn multi_user_room_list_and_leave_update_presence_without_socket() -> Result<()> { + let temp_dir = TempDir::new()?; + let chat_server = test_chat_server(&temp_dir).await?; + let connection_manager = Arc::new(DefaultConnectionManager::new()); + let handler = + ChatServiceHandler::new(Arc::clone(&chat_server), Arc::clone(&connection_manager)); + + let mut alice = + register_test_connection(&connection_manager, test_user("alice", &["user"])).await?; + let mut bob = + register_test_connection(&connection_manager, test_user("bob", &["user"])).await?; + + handler + .on_client_authenticated(alice.context.id, &alice.user) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + handler + .on_client_authenticated(bob.context.id, &bob.user) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + + drain_messages(&mut alice.messages); + drain_messages(&mut bob.messages); + + let alice_join = call_handler( + &handler, + Arc::clone(&alice.context), + "alice-join", + "join_room", + json!({ "room_name": "general" }), + ) + .await?; + assert!(alice_join.error.is_none()); + + let bob_join = call_handler( + &handler, + Arc::clone(&bob.context), + "bob-join", + "join_room", + json!({ "room_name": "general" }), + ) + .await?; + assert!(bob_join.error.is_none()); + + drain_messages(&mut alice.messages); + drain_messages(&mut bob.messages); + + let before_leave = call_handler( + &handler, + Arc::clone(&alice.context), + "list-before-leave", + "list_rooms", + json!({}), + ) + .await?; + assert!(before_leave.error.is_none()); + let before_leave: ListRoomsResponse = + serde_json::from_value(before_leave.result.expect("list before leave result"))?; + let general = room_info(&before_leave, "general").expect("general room before leave"); + assert_eq!(general.user_count, 2); + + let bob_leave = call_handler( + &handler, + Arc::clone(&bob.context), + "bob-leave", + "leave_room", + json!({ "room_id": "general" }), + ) + .await?; + assert!( + bob_leave.error.is_none(), + "leave_room should succeed: {:?}", + bob_leave.error + ); + + let alice_messages = drain_messages(&mut alice.messages); + let left = + notification_by_method(&alice_messages, "user_left").expect("user_left notification"); + let left: UserLeftNotification = serde_json::from_value(left.params.clone())?; + assert_eq!(left.username, "bob"); + assert_eq!(left.room_id, "general"); + assert_eq!(left.user_count, 1); + + let after_leave = call_handler( + &handler, + Arc::clone(&alice.context), + "list-after-leave", + "list_rooms", + json!({}), + ) + .await?; + assert!(after_leave.error.is_none()); + let after_leave: ListRoomsResponse = + serde_json::from_value(after_leave.result.expect("list after leave result"))?; + let general = room_info(&after_leave, "general").expect("general room after leave"); + assert_eq!(general.user_count, 1); + + Ok(()) + } + + #[tokio::test] + async fn profile_update_round_trips_multi_word_avatar_without_socket() -> Result<()> { + let temp_dir = TempDir::new()?; + let chat_server = test_chat_server(&temp_dir).await?; + let connection_manager = Arc::new(DefaultConnectionManager::new()); + let handler = + ChatServiceHandler::new(Arc::clone(&chat_server), Arc::clone(&connection_manager)); + + let mut alice = + register_test_connection(&connection_manager, test_user("alice", &["user"])).await?; + + handler + .on_client_authenticated(alice.context.id, &alice.user) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + drain_messages(&mut alice.messages); + + let before_update = call_handler( + &handler, + Arc::clone(&alice.context), + "profile-before-update", + "get_profile", + json!({ "username": "alice" }), + ) + .await?; + assert!( + before_update.error.is_none(), + "get_profile should return the default profile: {:?}", + before_update.error + ); + let before_update: GetProfileResponse = + serde_json::from_value(before_update.result.expect("profile before update result"))?; + assert_eq!(before_update.profile.username, "alice"); + assert!(before_update.profile.display_name.is_none()); + assert!(matches!( + before_update.profile.avatar.breed, + CatBreed::Tabby + )); + assert!(matches!( + before_update.profile.avatar.color, + CatColor::Orange + )); + assert!(matches!( + before_update.profile.avatar.expression, + CatExpression::Happy + )); + + let update_response = call_handler( + &handler, + Arc::clone(&alice.context), + "profile-update", + "update_profile", + json!({ + "display_name": "Captain Alice", + "avatar": { + "breed": "maine_coon", + "color": "lilac", + "expression": "curious" + } + }), + ) + .await?; + assert!( + update_response.error.is_none(), + "update_profile should succeed: {:?}", + update_response.error + ); + let update_response: UpdateProfileResponse = + serde_json::from_value(update_response.result.expect("profile update result"))?; + assert_eq!( + update_response.profile.display_name.as_deref(), + Some("Captain Alice") + ); + assert!(matches!( + update_response.profile.avatar.breed, + CatBreed::MaineCoon + )); + assert!(matches!( + update_response.profile.avatar.color, + CatColor::Lilac + )); + assert!(matches!( + update_response.profile.avatar.expression, + CatExpression::Curious + )); + + let after_update = call_handler( + &handler, + Arc::clone(&alice.context), + "profile-after-update", + "get_profile", + json!({ "username": "alice" }), + ) + .await?; + assert!( + after_update.error.is_none(), + "get_profile should read the persisted profile: {:?}", + after_update.error + ); + let after_update: GetProfileResponse = + serde_json::from_value(after_update.result.expect("profile after update result"))?; + assert_eq!( + after_update.profile.display_name.as_deref(), + Some("Captain Alice") + ); + assert!(matches!( + after_update.profile.avatar.breed, + CatBreed::MaineCoon + )); + assert!(matches!(after_update.profile.avatar.color, CatColor::Lilac)); + assert!(matches!( + after_update.profile.avatar.expression, + CatExpression::Curious + )); + + Ok(()) + } + + #[tokio::test] + async fn websocket_request_error_allows_later_request_without_socket() -> Result<()> { + let temp_dir = TempDir::new()?; + let chat_server = test_chat_server(&temp_dir).await?; + + let messages = run_socketless_chat_flow( + chat_server, + test_user("alice", &["user"]), + vec![ + request( + "send-before-join", + "send_message", + json!({ "text": "too early" }), + ), + request( + "join-after-error", + "join_room", + json!({ "room_name": "general" }), + ), + ], + 4, + ) + .await?; + + let error_response = + response_by_id(&messages, "send-before-join").expect("send_message error response"); + let error = error_response.error.as_ref().expect("send_message error"); + assert_eq!(error.code, ras_jsonrpc_types::error_codes::INTERNAL_ERROR); + assert!(error.message.contains("User not in any room")); + + let join_response = + response_by_id(&messages, "join-after-error").expect("join_room response"); + assert!( + join_response.error.is_none(), + "join_room should succeed after a previous request error: {:?}", + join_response.error + ); + let join_result: JoinRoomResponse = + serde_json::from_value(join_response.result.clone().expect("join result"))?; + assert_eq!(join_result.room_id, "general"); + assert_eq!(join_result.user_count, 1); + + Ok(()) + } + + #[tokio::test] + async fn message_rate_limit_rejects_excess_messages_without_socket() -> Result<()> { + let temp_dir = TempDir::new()?; + let chat_server = test_chat_server_with_rate_limit( + &temp_dir, + config::RateLimitConfig { + enabled: true, + messages_per_minute: 1, + connections_per_ip: 10, + login_attempts_per_hour: 10, + }, + ) + .await?; + + let messages = run_socketless_chat_flow( + chat_server, + test_user("alice", &["user"]), + vec![ + request("join", "join_room", json!({ "room_name": "general" })), + request("send-1", "send_message", json!({ "text": "first" })), + request("send-2", "send_message", json!({ "text": "second" })), + request("list-after-limit", "list_rooms", json!({})), + ], + 9, + ) + .await?; + + let first_send = response_by_id(&messages, "send-1").expect("first send response"); + assert!( + first_send.error.is_none(), + "first message should pass the rate limit: {:?}", + first_send.error + ); + + let second_send = response_by_id(&messages, "send-2").expect("second send response"); + let error = second_send.error.as_ref().expect("rate limit error"); + assert_eq!(error.code, ras_jsonrpc_types::error_codes::INTERNAL_ERROR); + assert!(error.message.contains("Rate limit exceeded")); + assert!(error.message.contains("1 messages per minute")); + + let after_limit = + response_by_id(&messages, "list-after-limit").expect("list_rooms after rate limit"); + assert!( + after_limit.error.is_none(), + "later requests should continue after rate limit rejection: {:?}", + after_limit.error + ); + let rooms: ListRoomsResponse = + serde_json::from_value(after_limit.result.clone().expect("rooms result"))?; + let general = room_info(&rooms, "general").expect("general room"); + assert_eq!(general.user_count, 1); + + let delivered = notifications_by_method(&messages, "message_received"); + assert_eq!(delivered.len(), 1); + let delivered: MessageReceivedNotification = + serde_json::from_value(delivered[0].params.clone())?; + assert_eq!(delivered.text, "first"); + + Ok(()) + } + + #[tokio::test] + async fn disconnect_clears_room_and_typing_state_without_socket() -> Result<()> { + let temp_dir = TempDir::new()?; + let chat_server = test_chat_server(&temp_dir).await?; + let connection_manager = Arc::new(DefaultConnectionManager::new()); + let handler = + ChatServiceHandler::new(Arc::clone(&chat_server), Arc::clone(&connection_manager)); + + let mut alice = + register_test_connection(&connection_manager, test_user("alice", &["user"])).await?; + let mut bob = + register_test_connection(&connection_manager, test_user("bob", &["user"])).await?; + + handler + .on_client_authenticated(alice.context.id, &alice.user) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + handler + .on_client_authenticated(bob.context.id, &bob.user) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + + drain_messages(&mut alice.messages); + drain_messages(&mut bob.messages); + + for (id, context) in [ + ("alice-join", Arc::clone(&alice.context)), + ("bob-join", Arc::clone(&bob.context)), + ] { + let join = call_handler( + &handler, + context, + id, + "join_room", + json!({ "room_name": "general" }), + ) + .await?; + assert!(join.error.is_none(), "{id} should join: {:?}", join.error); + } + + drain_messages(&mut alice.messages); + drain_messages(&mut bob.messages); + + let start_typing = call_handler( + &handler, + Arc::clone(&bob.context), + "bob-start-typing", + "start_typing", + json!({}), + ) + .await?; + assert!( + start_typing.error.is_none(), + "start_typing should succeed: {:?}", + start_typing.error + ); + + let alice_messages = drain_messages(&mut alice.messages); + let started = notification_by_method(&alice_messages, "user_started_typing") + .expect("user_started_typing notification"); + let started: UserStartedTypingNotification = + serde_json::from_value(started.params.clone())?; + assert_eq!(started.username, "bob"); + assert_eq!(started.room_id, "general"); + + handler + .on_client_disconnected(bob.context.id) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + + let alice_messages = drain_messages(&mut alice.messages); + let stopped = notification_by_method(&alice_messages, "user_stopped_typing") + .expect("user_stopped_typing notification"); + let stopped: UserStoppedTypingNotification = + serde_json::from_value(stopped.params.clone())?; + assert_eq!(stopped.username, "bob"); + assert_eq!(stopped.room_id, "general"); + + let left = + notification_by_method(&alice_messages, "user_left").expect("user_left notification"); + let left: UserLeftNotification = serde_json::from_value(left.params.clone())?; + assert_eq!(left.username, "bob"); + assert_eq!(left.room_id, "general"); + assert_eq!(left.user_count, 1); + + let after_disconnect = call_handler( + &handler, + Arc::clone(&alice.context), + "list-after-disconnect", + "list_rooms", + json!({}), + ) + .await?; + assert!(after_disconnect.error.is_none()); + let after_disconnect: ListRoomsResponse = serde_json::from_value( + after_disconnect + .result + .expect("list after disconnect result"), + )?; + let general = + room_info(&after_disconnect, "general").expect("general room after disconnect"); + assert_eq!(general.user_count, 1); + + Ok(()) + } + + #[tokio::test] + async fn admin_operations_kick_and_broadcast_without_socket() -> Result<()> { + let temp_dir = TempDir::new()?; + let chat_server = test_chat_server(&temp_dir).await?; + let connection_manager = Arc::new(DefaultConnectionManager::new()); + let handler = + ChatServiceHandler::new(Arc::clone(&chat_server), Arc::clone(&connection_manager)); + + let mut admin = + register_test_connection(&connection_manager, test_user("admin", &["admin", "user"])) + .await?; + let mut moderator = register_test_connection( + &connection_manager, + test_user("moderator", &["moderator", "user"]), + ) + .await?; + let mut bob = + register_test_connection(&connection_manager, test_user("bob", &["user"])).await?; + + for connection in [&admin, &moderator, &bob] { + handler + .on_client_authenticated(connection.context.id, &connection.user) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + } + + drain_messages(&mut admin.messages); + drain_messages(&mut moderator.messages); + drain_messages(&mut bob.messages); + + let denied_broadcast = call_handler( + &handler, + Arc::clone(&bob.context), + "broadcast-denied", + "broadcast_announcement", + json!({ "message": "not allowed", "level": "warning" }), + ) + .await?; + let denied = denied_broadcast + .error + .as_ref() + .expect("regular user should not broadcast announcements"); + assert_eq!(denied.code, -32002); + + let bob_join = call_handler( + &handler, + Arc::clone(&bob.context), + "bob-join", + "join_room", + json!({ "room_name": "general" }), + ) + .await?; + assert!(bob_join.error.is_none()); + drain_messages(&mut bob.messages); + + let kick_response = call_handler( + &handler, + Arc::clone(&moderator.context), + "kick-bob", + "kick_user", + json!({ "target_username": "bob", "reason": "policy violation" }), + ) + .await?; + assert!( + kick_response.error.is_none(), + "kick_user should succeed for moderators: {:?}", + kick_response.error + ); + assert_eq!( + kick_response.result.expect("kick result"), + serde_json::Value::Bool(true) + ); + + let bob_messages = drain_messages(&mut bob.messages); + let kicked = + notification_by_method(&bob_messages, "user_kicked").expect("user_kicked notification"); + let kicked: UserKickedNotification = serde_json::from_value(kicked.params.clone())?; + assert_eq!(kicked.username, "bob"); + assert_eq!(kicked.reason, "policy violation"); + assert_eq!(kicked.room_id, "general"); + + let after_kick = call_handler( + &handler, + Arc::clone(&moderator.context), + "list-after-kick", + "list_rooms", + json!({}), + ) + .await?; + assert!(after_kick.error.is_none()); + let after_kick: ListRoomsResponse = + serde_json::from_value(after_kick.result.expect("list after kick result"))?; + let general = room_info(&after_kick, "general").expect("general room after kick"); + assert_eq!(general.user_count, 0); + + let announcement_response = call_handler( + &handler, + Arc::clone(&admin.context), + "broadcast-announcement", + "broadcast_announcement", + json!({ "message": "maintenance soon", "level": "warning" }), + ) + .await?; + assert!( + announcement_response.error.is_none(), + "broadcast_announcement should succeed for admins: {:?}", + announcement_response.error + ); + + for (username, messages) in [ + ("admin", drain_messages(&mut admin.messages)), + ("moderator", drain_messages(&mut moderator.messages)), + ] { + let announcement = notification_by_method(&messages, "system_announcement") + .unwrap_or_else(|| { + panic!("{username} should receive system_announcement notification") + }); + let announcement: SystemAnnouncementNotification = + serde_json::from_value(announcement.params.clone())?; + assert_eq!(announcement.message, "maintenance soon"); + assert!(matches!(announcement.level, AnnouncementLevel::Warning)); + } + assert!(drain_messages(&mut bob.messages).is_empty()); + + Ok(()) + } +} diff --git a/examples/bidirectional-chat/server/src/persistence.rs b/examples/bidirectional-chat/server/src/persistence.rs index 5014ce0..2761512 100644 --- a/examples/bidirectional-chat/server/src/persistence.rs +++ b/examples/bidirectional-chat/server/src/persistence.rs @@ -232,11 +232,12 @@ impl PersistenceManager { // Apply limit if specified (return most recent messages) if let Some(limit) = limit { + let total_messages = messages.len(); let start = messages.len().saturating_sub(limit); messages = messages[start..].to_vec(); debug!( - total_messages = messages.len(), - returned_messages = messages.len() - start, + total_messages, + returned_messages = messages.len(), "Applied message limit" ); } @@ -310,4 +311,34 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn load_room_messages_returns_recent_limit_without_underflow() -> Result<()> { + let temp_dir = TempDir::new()?; + let persistence = PersistenceManager::new(temp_dir.path()); + persistence.init().await?; + + for id in 1..=3 { + persistence + .append_message( + "general", + &PersistedMessage { + id, + room_id: "general".to_string(), + username: "alice".to_string(), + text: format!("message-{id}"), + timestamp: Utc::now(), + }, + ) + .await?; + } + + let messages = persistence.load_room_messages("general", Some(1)).await?; + + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].id, 3); + assert_eq!(messages[0].text, "message-3"); + + Ok(()) + } } diff --git a/examples/bidirectional-chat/server/tests/README.md b/examples/bidirectional-chat/server/tests/README.md index 9640769..0af3639 100644 --- a/examples/bidirectional-chat/server/tests/README.md +++ b/examples/bidirectional-chat/server/tests/README.md @@ -1,47 +1,60 @@ # Bidirectional Chat Server Integration Tests -This directory contains comprehensive integration tests for the bidirectional chat server. The tests are organized into two main test files: +This directory contains focused integration tests for the bidirectional chat server. The tests are organized into two files: ## Test Files ### `server_tests.rs` Basic server functionality and configuration tests: - **Configuration Tests**: Validates default values, configuration parsing, and validation logic +- **Example Config Test**: Loads `../config.example.toml` and verifies its JWT secret works with session creation - **Persistence Tests**: Tests the persistence layer for storing and loading chat state -- **Server Lifecycle**: Tests server startup, health checks, and basic endpoints -- **Authentication Endpoints**: Tests user registration and login endpoints +- **Server Lifecycle**: Tests in-memory server startup and health checks - **Rate Limiting Configuration**: Validates rate limiting settings - **CORS Configuration**: Tests CORS settings and validation - **Logging Configuration**: Validates logging settings -### `websocket_tests.rs` -WebSocket and chat-specific functionality tests: -- **Server Lifecycle**: Tests server startup and shutdown -- **User Authentication**: Tests login with valid/invalid credentials -- **User Registration**: Tests new user registration flow -- **Admin Permissions**: Tests admin vs regular user permissions -- **Concurrent Users**: Tests multiple users connecting simultaneously +### `auth_lifecycle_tests.rs` +Chat server auth and lifecycle tests: +- **Server Lifecycle**: Tests in-memory server startup and health checks +- **User Authentication**: Tests login with valid, invalid, and missing credentials +- **User Registration**: Tests new user registration, duplicate rejection, and login after registration +- **Admin Permissions**: Tests admin vs regular user permissions in JWT claims +- **Concurrent Users**: Tests multiple users logging in simultaneously + +### `../src/main.rs` Unit Tests +Socketless WebSocket flow tests for the real chat server implementation: +- **Generated WebSocket Dispatch**: Runs the generated `ChatServiceHandler` through the in-memory `WebSocketIo` adapter +- **Room Join Flow**: Verifies an authenticated client can join the default room +- **Message Broadcast Flow**: Verifies `send_message` emits both the JSON-RPC response and `message_received` notification without binding a socket +- **Multi-user Broadcast Flow**: Verifies a message from one authenticated user is delivered to multiple room members through the in-memory connection manager +- **Room Presence Flow**: Verifies `list_rooms` user counts and `leave_room` notifications across multiple authenticated connections +- **Profile Management Flow**: Verifies profile read/update behavior and multi-word cat avatar persistence through the generated handler +- **Admin Operation Flow**: Verifies generated permission enforcement, moderator kicks, and admin announcements without binding a socket +- **Disconnect Cleanup Flow**: Verifies typing state, room membership, and user-left notifications are cleaned up on disconnect +- **Request Error Recovery Flow**: Verifies the in-memory WebSocket handler sends a JSON-RPC error response and continues processing later requests +- **Message Rate Limit Flow**: Verifies `messages_per_minute` rejects excess `send_message` calls without closing the in-memory connection ## Running the Tests Run all tests: ```bash -cargo test +cargo test -p bidirectional-chat-server --all-targets --all-features --locked ``` Run only server configuration tests: ```bash -cargo test --test server_tests +cargo test -p bidirectional-chat-server --test server_tests --locked ``` -Run only WebSocket tests: +Run only auth lifecycle tests: ```bash -cargo test --test websocket_tests +cargo test -p bidirectional-chat-server --test auth_lifecycle_tests --locked ``` Run a specific test: ```bash -cargo test test_user_authentication +cargo test -p bidirectional-chat-server --locked test_user_authentication ``` ## Test Coverage @@ -50,26 +63,34 @@ The tests cover the following areas: 1. **Server Startup and Configuration** - Configuration file parsing and validation - - Environment variable overrides + - Example config loading - Default value handling - Invalid configuration detection 2. **User Registration and Login** - New user registration + - Duplicate username rejection + - Login after registration - Login with credentials - Invalid credential handling - Concurrent user sessions -3. **WebSocket Connections** - - Connection establishment - - Authentication over WebSocket - - Connection lifecycle management - -4. **Chat Operations** (partially tested) - - Room management - - Message sending/receiving - - User profiles - - Admin operations +3. **Authorization** + - Admin permission assignment + - Regular user permission assignment + - Permission-bearing JWT claims + +4. **WebSocket Message Flow** + - Socketless generated handler dispatch + - Authenticated room join + - Message response and notification emission + - Multi-user room broadcast through the in-memory connection manager + - Multi-user room list and leave presence updates + - Profile update/readback with multi-word avatar fields + - Moderator kick and admin announcement notifications + - Typing-state and room cleanup on disconnect + - Request error response followed by a successful request on the same in-memory connection + - Per-user chat message rate limiting 5. **Persistence** - Message persistence to disk @@ -83,20 +104,16 @@ The tests cover the following areas: ## Test Architecture -The tests use a simplified test server implementation that: -- Creates temporary directories for data storage -- Finds available ports automatically -- Provides minimal implementations of chat functionality -- Supports concurrent test execution +The tests use in-memory harnesses: +- `server_tests.rs` keeps configuration, health, and persistence checks isolated from auth setup. +- `auth_lifecycle_tests.rs` runs HTTP-style requests through `axum-test` with login and registration wired through the same in-memory identity provider. +- The `../src/main.rs` WebSocket unit tests exercise the real `ChatServer` through the generated handler, in-memory socket adapter, and in-memory connection manager. +- Both suites use `axum-test` mock transport instead of binding sockets for HTTP checks. +- Both suites create temporary directories for runtime data and support concurrent test execution. -## Future Improvements +## Known Coverage Gaps Areas for additional test coverage: -1. Full WebSocket message flow testing with real client connections -2. Room management operations (join, leave, list) -3. Message broadcasting to multiple users -4. User profile management -5. Admin operations (kick user, broadcast announcement) -6. Rate limiting behavior -7. Connection reconnection and error recovery -8. Performance and load testing \ No newline at end of file +1. Deployment-level connection and login-attempt rate limiting hooks; configuration validation is covered, but the chat service currently enforces only per-user message limits +2. Reconnection behavior beyond disconnect cleanup +3. Performance and load testing diff --git a/examples/bidirectional-chat/server/tests/websocket_tests.rs b/examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs similarity index 80% rename from examples/bidirectional-chat/server/tests/websocket_tests.rs rename to examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs index d983b4c..e9029c8 100644 --- a/examples/bidirectional-chat/server/tests/websocket_tests.rs +++ b/examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs @@ -1,16 +1,13 @@ -//! WebSocket integration tests for the bidirectional chat server +//! Chat server auth and lifecycle integration tests //! //! These tests cover: -//! - WebSocket connection establishment -//! - Authentication over WebSocket -//! - Message sending and receiving -//! - Room management -//! - User profiles -//! - Admin operations +//! - In-memory server startup and health checks +//! - Login and registration flows +//! - Permission-bearing session creation +//! - Concurrent login handling use anyhow::Result; -use axum::Router; -use axum::routing::get; +use axum::{Router, http::StatusCode, routing::get}; use bidirectional_chat_api::*; use bidirectional_chat_server::config::{ AdminConfig, AdminUser, AuthConfig, ChatConfig, Config, LoggingConfig, RateLimitConfig, @@ -20,23 +17,22 @@ use chrono::Utc; use ras_auth_core::AuthenticatedUser; use ras_identity_core::{UserPermissions, VerifiedIdentity}; use ras_identity_local::LocalUserProvider; -use ras_identity_session::{JwtAuthProvider, SessionConfig, SessionService}; +use ras_identity_session::{JwtAlgorithm, JwtAuthProvider, SessionConfig, SessionService}; use ras_jsonrpc_bidirectional_server::{ DefaultConnectionManager, WebSocketServiceBuilder, service::{BuiltWebSocketService, websocket_handler}, }; use ras_jsonrpc_bidirectional_types::{ConnectionId, ConnectionManager}; use serde_json::json; -use std::{collections::HashSet, net::SocketAddr, sync::Arc, time::Duration}; +use std::{collections::HashSet, sync::Arc}; use tempfile::TempDir; -use tokio::{net::TcpListener, sync::RwLock, time::timeout}; +use tokio::sync::RwLock; use tower_http::cors::CorsLayer; -/// Test server with full chat functionality +/// Test server with auth and WebSocket routers wired through in-memory transport. struct TestChatServer { - addr: SocketAddr, - shutdown_tx: tokio::sync::oneshot::Sender<()>, - handle: tokio::task::JoinHandle<()>, + server: Arc, + session_service: Arc, _temp_dir: TempDir, } @@ -46,15 +42,10 @@ impl TestChatServer { let temp_dir = TempDir::new()?; let data_dir = temp_dir.path().join("chat_data"); - // Find available port - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - drop(listener); - let config = Config { server: ServerConfig { - host: addr.ip(), - port: addr.port(), + host: "127.0.0.1".parse().unwrap(), + port: 3001, cors: Default::default(), }, auth: AuthConfig { @@ -142,7 +133,7 @@ impl TestChatServer { jwt_ttl: chrono::Duration::seconds(config.auth.jwt_ttl_seconds), refresh_enabled: config.auth.refresh_enabled, enforce_active_sessions: true, - algorithm: jsonwebtoken::Algorithm::HS256, + algorithm: JwtAlgorithm::HS256, }; let session_service = Arc::new( @@ -153,33 +144,8 @@ impl TestChatServer { ))), ); - // Register identity provider with session service - let session_identity_provider = LocalUserProvider::new(); - for admin_user in &config.admin.users { - let _ = session_identity_provider - .add_user( - admin_user.username.clone(), - admin_user.password.clone(), - admin_user.email.clone(), - admin_user.display_name.clone(), - ) - .await; - } - - // Add test users to session provider - for (username, password, email, display_name) in &test_users { - let _ = session_identity_provider - .add_user( - username.to_string(), - password.to_string(), - email.map(|s| s.to_string()), - display_name.map(|s| s.to_string()), - ) - .await; - } - session_service - .register_provider(Box::new(session_identity_provider)) + .register_provider(Box::new((*identity_provider).clone())) .await; // Create JWT auth provider @@ -209,7 +175,11 @@ impl TestChatServer { let auth_router = Router::new() .route("/auth/login", axum::routing::post(login_handler)) .route("/auth/register", axum::routing::post(register_handler)) - .with_state((session_service, identity_provider, chat_server)); + .with_state(( + Arc::clone(&session_service), + Arc::clone(&identity_provider), + chat_server, + )); type ChatServiceType = BuiltWebSocketService< ChatServiceHandler, @@ -229,59 +199,35 @@ impl TestChatServer { .merge(health_router) .layer(CorsLayer::permissive()); - // Start server - let listener = TcpListener::bind(addr).await?; - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - - let handle = tokio::spawn(async move { - let server = axum::serve(listener, app); - let graceful = server.with_graceful_shutdown(async move { - let _ = shutdown_rx.await; - }); - let _ = graceful.await; - }); - - // Wait for server to start - tokio::time::sleep(Duration::from_millis(100)).await; - Ok(Self { - addr, - shutdown_tx, - handle, + server: Arc::new( + axum_test::TestServer::builder() + .mock_transport() + .build(app)?, + ), + session_service, _temp_dir: temp_dir, }) } - fn url(&self) -> String { - format!("http://{}", self.addr) - } - - fn ws_url(&self) -> String { - format!("ws://{}/ws", self.addr) - } - - async fn shutdown(self) { - let _ = self.shutdown_tx.send(()); - let _ = timeout(Duration::from_secs(5), self.handle).await; - } + async fn shutdown(self) {} /// Helper to login and get a token async fn login(&self, username: &str, password: &str) -> Result { - let client = reqwest::Client::new(); - let response = client - .post(format!("{}/auth/login", self.url())) + let response = self + .server + .post("/auth/login") .json(&json!({ "username": username, "password": password, })) - .send() - .await?; + .await; - if response.status() != 200 { - anyhow::bail!("Login failed with status: {}", response.status()); + if response.status_code() != StatusCode::OK { + anyhow::bail!("Login failed with status: {}", response.status_code()); } - let body: serde_json::Value = response.json().await?; + let body: serde_json::Value = response.json(); Ok(body["token"].as_str().unwrap().to_string()) } } @@ -398,12 +344,7 @@ struct ChatRoom { } #[derive(Debug, Clone)] -struct UserSession { - username: String, - connection_id: ConnectionId, - current_room: Option, - joined_at: chrono::DateTime, -} +struct UserSession; #[derive(Clone)] struct ChatServer { @@ -411,7 +352,6 @@ struct ChatServer { user_sessions: Arc>, message_counter: Arc>, persistence: Arc, - config: ChatConfig, } impl ChatServer { @@ -426,7 +366,6 @@ impl ChatServer { user_sessions: Arc::new(DashMap::new()), message_counter: Arc::new(RwLock::new(state.next_message_id)), persistence, - config: config.clone(), }; // Create default rooms @@ -475,14 +414,6 @@ impl ChatServer { *counter += 1; id } - - fn get_room_info(&self, room_id: &str) -> Option { - self.rooms.get(room_id).map(|room| RoomInfo { - room_id: room.id.clone(), - room_name: room.name.clone(), - user_count: room.users.len() as u32, - }) - } } // Minimal implementation of ChatServiceService for testing @@ -735,16 +666,10 @@ impl ChatServiceService for ChatServer { &self, client_id: ConnectionId, _connection_manager: &dyn ConnectionManager, - user: &AuthenticatedUser, + _user: &AuthenticatedUser, ) -> Result<(), Box> { // Create user session - let session = UserSession { - username: user.user_id.clone(), - connection_id: client_id, - current_room: None, - joined_at: Utc::now(), - }; - self.user_sessions.insert(client_id, session); + self.user_sessions.insert(client_id, UserSession); Ok(()) } } @@ -756,12 +681,8 @@ async fn test_server_lifecycle() -> Result<()> { let server = TestChatServer::start().await?; // Check health endpoint - let client = reqwest::Client::new(); - let response = client - .get(format!("{}/health", server.url())) - .send() - .await?; - assert_eq!(response.status(), 200); + let response = server.server.get("/health").await; + response.assert_status_ok(); server.shutdown().await; Ok(()) @@ -783,6 +704,21 @@ async fn test_user_authentication() -> Result<()> { let result = server.login("nonexistent", "anypass").await; assert!(result.is_err()); + // Test malformed login payloads + let missing_password = server + .server + .post("/auth/login") + .json(&json!({ "username": "alice" })) + .await; + missing_password.assert_status(StatusCode::UNAUTHORIZED); + + let missing_username = server + .server + .post("/auth/login") + .json(&json!({ "password": "alice123" })) + .await; + missing_username.assert_status(StatusCode::UNAUTHORIZED); + server.shutdown().await; Ok(()) } @@ -790,39 +726,36 @@ async fn test_user_authentication() -> Result<()> { #[tokio::test] async fn test_user_registration() -> Result<()> { let server = TestChatServer::start().await?; - let client = reqwest::Client::new(); // Register a new user - let response = client - .post(format!("{}/auth/register", server.url())) + let response = server + .server + .post("/auth/register") .json(&json!({ "username": "newuser", "password": "newpass123", "email": "new@test.com", "display_name": "New User" })) - .send() - .await?; + .await; + + response.assert_status_ok(); - assert_eq!(response.status(), 200); + // The new user is added to the same identity provider that backs login. + let token = server.login("newuser", "newpass123").await?; + assert!(!token.is_empty()); - // Try to register the same user again (in this test setup, it will succeed) - let response = client - .post(format!("{}/auth/register", server.url())) + // Duplicate registration is rejected instead of overwriting credentials. + let response = server + .server + .post("/auth/register") .json(&json!({ "username": "newuser", "password": "newpass123" })) - .send() - .await?; + .await; - // Note: In a real implementation, this would return 409 (Conflict) - // But our test handler doesn't check for duplicates - assert_eq!(response.status(), 200); - - // Login with the new user - // Note: In a real implementation, this would work because the user was registered - // But our test handler doesn't actually store users, so we'll skip this check + response.assert_status(StatusCode::CONFLICT); server.shutdown().await; Ok(()) @@ -835,12 +768,16 @@ async fn test_admin_permissions() -> Result<()> { // Login as admin let admin_token = server.login("admin", "admin123456").await?; assert!(!admin_token.is_empty()); + let admin_claims = server.session_service.verify_session(&admin_token).await?; + assert!(admin_claims.permissions.contains("admin")); + assert!(admin_claims.permissions.contains("moderator")); // Login as regular user let user_token = server.login("alice", "alice123").await?; assert!(!user_token.is_empty()); - - // TODO: Test permission-based operations when WebSocket client is available + let user_claims = server.session_service.verify_session(&user_token).await?; + assert!(user_claims.permissions.contains("user")); + assert!(!user_claims.permissions.contains("admin")); server.shutdown().await; Ok(()) @@ -854,21 +791,18 @@ async fn test_multiple_concurrent_users() -> Result<()> { let handles: Vec<_> = vec!["alice", "bob", "charlie"] .into_iter() .map(|username| { - let url = server.url(); + let server = Arc::clone(&server.server); tokio::spawn(async move { - let client = reqwest::Client::new(); - let response = client - .post(format!("{}/auth/login", url)) + let response = server + .post("/auth/login") .json(&json!({ "username": username, "password": format!("{}123", username), })) - .send() - .await - .unwrap(); + .await; - assert_eq!(response.status(), 200); - let body: serde_json::Value = response.json().await.unwrap(); + response.assert_status_ok(); + let body: serde_json::Value = response.json(); assert!(body["token"].is_string()); }) }) diff --git a/examples/bidirectional-chat/server/tests/server_tests.rs b/examples/bidirectional-chat/server/tests/server_tests.rs index d784052..942138d 100644 --- a/examples/bidirectional-chat/server/tests/server_tests.rs +++ b/examples/bidirectional-chat/server/tests/server_tests.rs @@ -2,9 +2,8 @@ //! //! These tests cover: //! - Server startup and health checks -//! - User registration and authentication -//! - Basic API endpoint testing //! - Configuration validation +//! - Persistence behavior use anyhow::Result; use axum::Router; @@ -12,98 +11,33 @@ use bidirectional_chat_server::config::{ AdminConfig, AdminUser, AuthConfig, ChatConfig, Config, LoggingConfig, RateLimitConfig, RoomConfig, ServerConfig, }; -use serde_json::json; -use std::net::SocketAddr; -use std::time::Duration; +use config::{Config as FileConfig, File}; +use ras_identity_session::{JwtAlgorithm, SessionConfig}; use tempfile::TempDir; -use tokio::net::TcpListener; -use tokio::time::timeout; use tower_http::cors::CorsLayer; /// Test server instance struct TestServer { - addr: SocketAddr, - shutdown_tx: tokio::sync::oneshot::Sender<()>, - handle: tokio::task::JoinHandle<()>, + server: axum_test::TestServer, } impl TestServer { /// Start a test server with the given configuration - async fn start(config: Config) -> Result { - let addr = config.socket_addr(); - - // Create a minimal server setup for testing - let auth_router = Router::new() - .route("/auth/login", axum::routing::post(dummy_login_handler)) - .route( - "/auth/register", - axum::routing::post(dummy_register_handler), - ); - + async fn start(_config: Config) -> Result { let health_router = Router::new().route("/health", axum::routing::get(|| async { "OK" })); let app = Router::new() - .merge(auth_router) .merge(health_router) .layer(CorsLayer::permissive()); - let listener = TcpListener::bind(addr).await?; - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - - let handle = tokio::spawn(async move { - let server = axum::serve(listener, app); - let graceful = server.with_graceful_shutdown(async move { - let _ = shutdown_rx.await; - }); - let _ = graceful.await; - }); - - // Wait for server to start - tokio::time::sleep(Duration::from_millis(100)).await; - Ok(Self { - addr, - shutdown_tx, - handle, + server: axum_test::TestServer::builder() + .mock_transport() + .build(app)?, }) } - fn url(&self) -> String { - format!("http://{}", self.addr) - } - - async fn shutdown(self) { - let _ = self.shutdown_tx.send(()); - let _ = timeout(Duration::from_secs(5), self.handle).await; - } -} - -// Dummy handlers for basic testing -async fn dummy_login_handler( - axum::Json(payload): axum::Json, -) -> Result, axum::http::StatusCode> { - if payload.get("username").is_some() && payload.get("password").is_some() { - Ok(axum::Json(json!({ - "token": "test-token", - "expires_at": 1234567890, - "user_id": payload["username"] - }))) - } else { - Err(axum::http::StatusCode::BAD_REQUEST) - } -} - -async fn dummy_register_handler( - axum::Json(payload): axum::Json, -) -> Result, axum::http::StatusCode> { - if payload.get("username").is_some() && payload.get("password").is_some() { - Ok(axum::Json(json!({ - "message": "User registered successfully", - "username": payload["username"] - }))) - } else { - Err(axum::http::StatusCode::BAD_REQUEST) - } + async fn shutdown(self) {} } // Helper function to create test configuration @@ -111,15 +45,10 @@ async fn create_test_config() -> Result<(Config, TempDir)> { let temp_dir = TempDir::new()?; let data_dir = temp_dir.path().join("chat_data"); - // Find available port - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - drop(listener); - let config = Config { server: ServerConfig { - host: addr.ip(), - port: addr.port(), + host: "127.0.0.1".parse().unwrap(), + port: 3001, cors: Default::default(), }, auth: AuthConfig { @@ -193,58 +122,39 @@ async fn test_config_validation() { assert!(config.validate().is_err()); } -#[tokio::test] -async fn test_server_startup() -> Result<()> { - let (config, _temp_dir) = create_test_config().await?; - let server = TestServer::start(config).await?; - - // Test health endpoint - let client = reqwest::Client::new(); - let response = client - .get(format!("{}/health", server.url())) - .send() - .await?; +#[test] +fn config_example_loads_with_session_compatible_secret() -> Result<()> { + let config_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("config.example.toml"); + let config: Config = FileConfig::builder() + .add_source(File::from(config_path)) + .build()? + .try_deserialize()?; + + config.validate()?; + + let session_config = SessionConfig { + jwt_secret: config.auth.jwt_secret, + jwt_ttl: chrono::Duration::seconds(config.auth.jwt_ttl_seconds), + refresh_enabled: config.auth.refresh_enabled, + enforce_active_sessions: true, + algorithm: JwtAlgorithm::HS256, + }; - assert_eq!(response.status(), 200); - assert_eq!(response.text().await?, "OK"); + session_config.validate()?; - server.shutdown().await; Ok(()) } #[tokio::test] -async fn test_auth_endpoints() -> Result<()> { +async fn test_server_startup() -> Result<()> { let (config, _temp_dir) = create_test_config().await?; let server = TestServer::start(config).await?; - let client = reqwest::Client::new(); - - // Test registration endpoint - let response = client - .post(format!("{}/auth/register", server.url())) - .json(&json!({ - "username": "testuser", - "password": "testpass123" - })) - .send() - .await?; - - assert_eq!(response.status(), 200); - let body: serde_json::Value = response.json().await?; - assert_eq!(body["username"], "testuser"); - - // Test login endpoint - let response = client - .post(format!("{}/auth/login", server.url())) - .json(&json!({ - "username": "testuser", - "password": "testpass123" - })) - .send() - .await?; - - assert_eq!(response.status(), 200); - let body: serde_json::Value = response.json().await?; - assert!(body.get("token").is_some()); + + // Test health endpoint + let response = server.server.get("/health").await; + + response.assert_status_ok(); + assert_eq!(response.text(), "OK"); server.shutdown().await; Ok(()) diff --git a/examples/bidirectional-chat/tui/Cargo.toml b/examples/bidirectional-chat/tui/Cargo.toml index 627b210..4ddafdc 100644 --- a/examples/bidirectional-chat/tui/Cargo.toml +++ b/examples/bidirectional-chat/tui/Cargo.toml @@ -2,6 +2,13 @@ name = "bidirectional-chat-tui" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Terminal chat client for the Rust Agent Stack bidirectional example" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +publish = false +readme = "README.md" [dependencies] # TUI framework @@ -12,7 +19,7 @@ crossterm = { workspace = true } tokio = { workspace = true, features = ["full"] } # Chat API client -bidirectional-chat-api = { path = "../api" } +bidirectional-chat-api = { path = "../api", version = "0.1.0" } # Error handling anyhow = { workspace = true } @@ -33,4 +40,4 @@ tracing-subscriber = { workspace = true } chrono = { workspace = true } # Configuration -dotenvy = { workspace = true } \ No newline at end of file +dotenvy = { workspace = true } diff --git a/examples/bidirectional-chat/tui/README.md b/examples/bidirectional-chat/tui/README.md index b80bfe2..49b89f7 100644 --- a/examples/bidirectional-chat/tui/README.md +++ b/examples/bidirectional-chat/tui/README.md @@ -2,9 +2,18 @@ A terminal user interface (TUI) client for the bidirectional chat server, built with Ratatui and using the generated API client from the chat API crate. -## Default Test Credentials - -The server comes with default admin users configured: +## Test Credentials + +Debug server builds create these regular test users: +- **Alice**: Username: `alice`, Password: `alice123` +- **Bob**: Username: `bob`, Password: `bob123` + +If you copy `examples/bidirectional-chat/server/config.example.toml` to +`examples/bidirectional-chat/server/config.toml` and start the server with +`CHAT_CONFIG_FILE` pointing at that file, the server also creates these +configured admin users. For workspace-root server commands, also set +`CHAT_DATA_DIR=examples/bidirectional-chat/server/chat_data` if you want +runtime chat state under the example directory. - **Admin**: Username: `admin`, Password: `admin123456` - **Moderator**: Username: `moderator`, Password: `moderator123` @@ -22,22 +31,26 @@ The server comes with default admin users configured: ## Prerequisites - The bidirectional chat server must be running (see `examples/bidirectional-chat/server`) -- Rust 1.75+ (for edition 2024 support) +- Rust 1.88+ for Rust 2024 edition crates ## Running the Client 1. Start the chat server: ```bash - cd examples/bidirectional-chat/server - cargo run + cargo run -p bidirectional-chat-server --locked ``` 2. In a new terminal, run the TUI client: ```bash - cd examples/bidirectional-chat-tui - cargo run + cargo run -p bidirectional-chat-tui --locked ``` +## Checks + +```bash +cargo test -p bidirectional-chat-tui --locked +``` + ## Usage ### Login Screen @@ -93,4 +106,4 @@ The TUI client demonstrates proper usage of the generated bidirectional client A - Uses the `ChatServiceClient` generated by the `jsonrpc_bidirectional_service!` macro - Implements proper WebSocket lifecycle management (connect/disconnect) - Handles all server-to-client notifications (messages, user join/leave, etc.) -- Non-blocking UI with async event handling \ No newline at end of file +- Non-blocking UI with async event handling diff --git a/examples/bidirectional-chat/tui/src/app.rs b/examples/bidirectional-chat/tui/src/app.rs index 916bdf8..d545c5f 100644 --- a/examples/bidirectional-chat/tui/src/app.rs +++ b/examples/bidirectional-chat/tui/src/app.rs @@ -11,7 +11,6 @@ use tokio::sync::mpsc; #[derive(Debug, Clone)] pub struct Message { - pub id: u64, pub username: String, pub text: String, pub timestamp: DateTime, @@ -26,8 +25,6 @@ pub enum AppEvent { UserStartedTyping { username: String, room_id: String }, UserStoppedTyping { username: String, room_id: String }, SystemAnnouncement { message: String }, - RoomListUpdated(Vec), - Error(String), Connected, Disconnected, } @@ -88,6 +85,100 @@ impl Default for AppState { } } +impl AppState { + pub fn enter_room(&mut self, room_id: String, room_name: String, existing_users: Vec) { + self.current_room = Some((room_id.clone(), room_name.clone())); + self.screen = AppScreen::Chat { room_id, room_name }; + self.messages.clear(); + + let Some((room_id, _)) = &self.current_room else { + return; + }; + + let mut users = Vec::new(); + for user in existing_users { + if !users.contains(&user) { + users.push(user); + } + } + + if let Some(username) = &self.username + && !users.contains(username) + { + users.push(username.clone()); + } + + self.room_users.insert(room_id.clone(), users); + } + + pub fn leave_room(&mut self, room_id: &str) { + self.screen = AppScreen::RoomList; + self.current_room = None; + self.input_buffer.clear(); + self.room_users.remove(room_id); + self.typing_users.remove(room_id); + } + + pub fn apply_event(&mut self, event: AppEvent) { + match event { + AppEvent::MessageReceived(message) => { + self.messages.push(message); + } + AppEvent::UserJoined { username, room_id } => { + let users = self.room_users.entry(room_id.clone()).or_default(); + if !users.contains(&username) { + users.push(username.clone()); + } + + self.push_system_message(format!("{} joined the room", username), room_id); + } + AppEvent::UserLeft { username, room_id } => { + if let Some(users) = self.room_users.get_mut(&room_id) { + users.retain(|user| user != &username); + } + + self.push_system_message(format!("{} left the room", username), room_id); + } + AppEvent::SystemAnnouncement { message } => { + if let Some((room_id, _)) = &self.current_room { + self.push_system_message(message, room_id.clone()); + } + } + AppEvent::Connected => { + self.connected = true; + } + AppEvent::Disconnected => { + self.connected = false; + self.screen = AppScreen::Login; + self.error_message = Some("Disconnected from server".to_string()); + } + AppEvent::UserStartedTyping { username, room_id } => { + self.typing_users + .entry(room_id) + .or_default() + .insert(username); + } + AppEvent::UserStoppedTyping { username, room_id } => { + if let Some(typing_users) = self.typing_users.get_mut(&room_id) { + typing_users.remove(&username); + if typing_users.is_empty() { + self.typing_users.remove(&room_id); + } + } + } + } + } + + fn push_system_message(&mut self, text: String, room_id: String) { + self.messages.push(Message { + username: "System".to_string(), + text, + timestamp: Local::now(), + room_id, + }); + } +} + pub struct ChatClient { client: Option, event_tx: mpsc::UnboundedSender, @@ -128,7 +219,6 @@ impl ChatClient { let tx = self.event_tx.clone(); client.on_message_received(move |notification: MessageReceivedNotification| { let message = Message { - id: notification.message_id, username: notification.username, text: notification.text, timestamp: DateTime::parse_from_rfc3339(¬ification.timestamp) @@ -251,3 +341,178 @@ impl ChatClient { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn message(room_id: &str, text: &str) -> Message { + Message { + username: "alice".to_string(), + text: text.to_string(), + timestamp: Local::now(), + room_id: room_id.to_string(), + } + } + + #[test] + fn default_state_starts_on_login_screen() { + let app = AppState::default(); + + assert_eq!(app.screen, AppScreen::Login); + assert!(!app.connected); + assert!(app.messages.is_empty()); + assert_eq!(app.auth_field_focus, AuthField::Username); + } + + #[test] + fn enter_room_tracks_current_room_and_deduplicates_users() { + let mut app = AppState { + username: Some("alice".to_string()), + messages: vec![message("lobby", "stale")], + ..AppState::default() + }; + + app.enter_room( + "room-1".to_string(), + "General".to_string(), + vec!["bob".to_string(), "bob".to_string(), "alice".to_string()], + ); + + assert_eq!( + app.screen, + AppScreen::Chat { + room_id: "room-1".to_string(), + room_name: "General".to_string(), + } + ); + assert_eq!( + app.current_room, + Some(("room-1".to_string(), "General".to_string())) + ); + assert!(app.messages.is_empty()); + assert_eq!( + app.room_users.get("room-1").expect("room users"), + &vec!["bob".to_string(), "alice".to_string()] + ); + } + + #[test] + fn leave_room_clears_chat_state_for_that_room() { + let mut app = AppState { + current_room: Some(("room-1".to_string(), "General".to_string())), + screen: AppScreen::Chat { + room_id: "room-1".to_string(), + room_name: "General".to_string(), + }, + input_buffer: "draft".to_string(), + ..AppState::default() + }; + app.room_users + .insert("room-1".to_string(), vec!["alice".to_string()]); + + app.leave_room("room-1"); + + assert_eq!(app.screen, AppScreen::RoomList); + assert_eq!(app.current_room, None); + assert!(app.input_buffer.is_empty()); + assert!(!app.room_users.contains_key("room-1")); + } + + #[test] + fn apply_message_and_system_announcement_append_room_messages() { + let mut app = AppState { + current_room: Some(("room-1".to_string(), "General".to_string())), + ..AppState::default() + }; + + app.apply_event(AppEvent::MessageReceived(message("room-1", "hello"))); + app.apply_event(AppEvent::SystemAnnouncement { + message: "maintenance soon".to_string(), + }); + + assert_eq!(app.messages.len(), 2); + assert_eq!(app.messages[0].text, "hello"); + assert_eq!(app.messages[1].username, "System"); + assert_eq!(app.messages[1].text, "maintenance soon"); + assert_eq!(app.messages[1].room_id, "room-1"); + } + + #[test] + fn apply_user_joined_deduplicates_user_and_records_notice() { + let mut app = AppState::default(); + app.room_users + .insert("room-1".to_string(), vec!["alice".to_string()]); + + app.apply_event(AppEvent::UserJoined { + username: "alice".to_string(), + room_id: "room-1".to_string(), + }); + + assert_eq!( + app.room_users.get("room-1").expect("room users"), + &vec!["alice".to_string()] + ); + assert_eq!(app.messages[0].text, "alice joined the room"); + } + + #[test] + fn apply_user_left_removes_user_and_records_notice() { + let mut app = AppState::default(); + app.room_users.insert( + "room-1".to_string(), + vec!["alice".to_string(), "bob".to_string()], + ); + + app.apply_event(AppEvent::UserLeft { + username: "alice".to_string(), + room_id: "room-1".to_string(), + }); + + assert_eq!( + app.room_users.get("room-1").expect("room users"), + &vec!["bob".to_string()] + ); + assert_eq!(app.messages[0].text, "alice left the room"); + } + + #[test] + fn typing_events_remove_empty_room_sets() { + let mut app = AppState::default(); + + app.apply_event(AppEvent::UserStartedTyping { + username: "alice".to_string(), + room_id: "room-1".to_string(), + }); + assert!( + app.typing_users + .get("room-1") + .expect("typing users") + .contains("alice") + ); + + app.apply_event(AppEvent::UserStoppedTyping { + username: "alice".to_string(), + room_id: "room-1".to_string(), + }); + assert!(!app.typing_users.contains_key("room-1")); + } + + #[test] + fn disconnected_event_returns_to_login_with_error() { + let mut app = AppState { + connected: true, + screen: AppScreen::RoomList, + ..AppState::default() + }; + + app.apply_event(AppEvent::Disconnected); + + assert!(!app.connected); + assert_eq!(app.screen, AppScreen::Login); + assert_eq!( + app.error_message.as_deref(), + Some("Disconnected from server") + ); + } +} diff --git a/examples/bidirectional-chat/tui/src/auth.rs b/examples/bidirectional-chat/tui/src/auth.rs index dbdf353..b9ac573 100644 --- a/examples/bidirectional-chat/tui/src/auth.rs +++ b/examples/bidirectional-chat/tui/src/auth.rs @@ -15,19 +15,15 @@ impl AuthClient { pub fn new(base_url: String) -> Self { Self { client: Client::new(), - base_url, + base_url: base_url.trim_end_matches('/').to_string(), } } pub async fn login(&self, username: String, password: String) -> Result { let response = self .client - .post(&format!("{}/auth/login", self.base_url)) - .json(&LoginRequest { - username, - password, - provider: None, // Use default "local" provider - }) + .post(self.login_url()) + .json(&Self::login_request(username, password)) .send() .await?; @@ -42,13 +38,8 @@ impl AuthClient { pub async fn register(&self, username: String, password: String) -> Result { let response = self .client - .post(&format!("{}/auth/register", self.base_url)) - .json(&RegisterRequest { - username, - password, - email: None, - display_name: None, - }) + .post(self.register_url()) + .json(&Self::register_request(username, password)) .send() .await?; @@ -59,4 +50,61 @@ impl AuthClient { anyhow::bail!("Registration failed: {}", error_text) } } + + fn login_url(&self) -> String { + format!("{}/auth/login", self.base_url) + } + + fn register_url(&self) -> String { + format!("{}/auth/register", self.base_url) + } + + fn login_request(username: String, password: String) -> LoginRequest { + LoginRequest { + username, + password, + provider: None, // Use default "local" provider + } + } + + fn register_request(username: String, password: String) -> RegisterRequest { + RegisterRequest { + username, + password, + email: None, + display_name: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_client_normalizes_trailing_slash_in_base_url() { + let client = AuthClient::new("http://localhost:3000/".to_string()); + + assert_eq!(client.login_url(), "http://localhost:3000/auth/login"); + assert_eq!(client.register_url(), "http://localhost:3000/auth/register"); + } + + #[test] + fn login_request_uses_default_local_provider() { + let request = AuthClient::login_request("alice".to_string(), "secret".to_string()); + + assert_eq!(request.username, "alice"); + assert_eq!(request.password, "secret"); + assert_eq!(request.provider, None); + } + + #[test] + fn register_request_leaves_optional_profile_fields_empty() { + let request = AuthClient::register_request("bob".to_string(), "hunter2".to_string()); + + assert_eq!(request.username, "bob"); + assert_eq!(request.password, "hunter2"); + assert_eq!(request.email, None); + assert_eq!(request.display_name, None); + } } diff --git a/examples/bidirectional-chat/tui/src/avatar.rs b/examples/bidirectional-chat/tui/src/avatar.rs index 072b143..ba0d30f 100644 --- a/examples/bidirectional-chat/tui/src/avatar.rs +++ b/examples/bidirectional-chat/tui/src/avatar.rs @@ -84,33 +84,6 @@ impl AvatarManager { .collect() } - pub fn _get_compact_avatar_for_user(&mut self, username: &str) -> String { - // For inline display, just show the face part - let avatar_index = self.user_avatars.get(username).copied().unwrap_or_else(|| { - let mut hasher = DefaultHasher::new(); - username.hash(&mut hasher); - let hash = hasher.finish(); - let index = (hash % CAT_VARIATIONS.len() as u64) as usize; - self.user_avatars.insert(username.to_string(), index); - index - }); - - // Return just the face line for compact display - let face = if self.frame_counter < 30 { - CAT_VARIATIONS[avatar_index][1] - } else if self.frame_counter < 35 { - if avatar_index % 2 == 0 { - CAT_FRAMES[1][1] // Blink - } else { - CAT_FRAMES[2][1] // Wink - } - } else { - CAT_FRAMES[3][1] // Happy - }; - - face.to_string() - } - pub fn get_typing_avatar_for_user(&mut self, username: &str) -> Vec { // Get the base avatar for the user let avatar_index = self.user_avatars.get(username).copied().unwrap_or_else(|| { diff --git a/examples/bidirectional-chat/tui/src/main.rs b/examples/bidirectional-chat/tui/src/main.rs index 0536bc0..5c5036a 100644 --- a/examples/bidirectional-chat/tui/src/main.rs +++ b/examples/bidirectional-chat/tui/src/main.rs @@ -87,312 +87,267 @@ async fn run_app( } // Check for terminal events - if event::poll(Duration::from_millis(10))? { - if let Event::Key(key) = event::read()? { - let mut app = app_state.lock().await; - - match app.screen.clone() { - AppScreen::Login | AppScreen::Register => { - match key.code { - KeyCode::Tab => { - app.auth_field_focus = match app.auth_field_focus { - AuthField::Username => AuthField::Password, - AuthField::Password => AuthField::Username, - }; - } - KeyCode::Enter => { - let username = app.auth_username_input.clone(); - let password = app.auth_password_input.clone(); - drop(app); // Release lock before async operation + if event::poll(Duration::from_millis(10))? + && let Event::Key(key) = event::read()? + { + let mut app = app_state.lock().await; - // Skip if empty - if username.is_empty() || password.is_empty() { - app_state.lock().await.error_message = - Some("Username and password cannot be empty".to_string()); - continue; - } + match app.screen.clone() { + AppScreen::Login | AppScreen::Register => { + match key.code { + KeyCode::Tab => { + app.auth_field_focus = match app.auth_field_focus { + AuthField::Username => AuthField::Password, + AuthField::Password => AuthField::Username, + }; + } + KeyCode::Enter => { + let username = app.auth_username_input.clone(); + let password = app.auth_password_input.clone(); + drop(app); // Release lock before async operation + + // Skip if empty + if username.is_empty() || password.is_empty() { + app_state.lock().await.error_message = + Some("Username and password cannot be empty".to_string()); + continue; + } - let result = if app_state.lock().await.screen == AppScreen::Login { - // Login returns LoginResponse - match auth_client.login(username.clone(), password).await { - Ok(login_response) => { - Ok((login_response.token, login_response.user_id)) - } - Err(e) => Err(e), + let result = if app_state.lock().await.screen == AppScreen::Login { + // Login returns LoginResponse + match auth_client.login(username.clone(), password).await { + Ok(login_response) => { + Ok((login_response.token, login_response.user_id)) } - } else { - // Register returns RegisterResponse, but we need to login after registration - match auth_client - .register(username.clone(), password.clone()) - .await - { - Ok(_register_response) => { - // After successful registration, login to get the token - match auth_client - .login(username.clone(), password) - .await - { - Ok(login_response) => Ok(( - login_response.token, - login_response.user_id, - )), - Err(e) => Err(e), + Err(e) => Err(e), + } + } else { + // Register returns RegisterResponse, but we need to login after registration + match auth_client + .register(username.clone(), password.clone()) + .await + { + Ok(_register_response) => { + // After successful registration, login to get the token + match auth_client.login(username.clone(), password).await { + Ok(login_response) => { + Ok((login_response.token, login_response.user_id)) } + Err(e) => Err(e), } - Err(e) => Err(e), } - }; - - match result { - Ok((token, user_id)) => { - _jwt_token = Some(token.clone()); - let mut app = app_state.lock().await; - app.username = Some(user_id); - app.screen = AppScreen::RoomList; - app.error_message = None; - drop(app); - - // Connect to WebSocket - let mut client = chat_client.lock().await; - if let Err(e) = client.connect(server_url, token).await { - app_state.lock().await.error_message = - Some(format!("Failed to connect: {}", e)); - } else { - // Load room list - match client.list_rooms().await { - Ok(rooms) => { - app_state.lock().await.rooms = rooms; - } - Err(e) => { - app_state.lock().await.error_message = Some( - format!("Failed to load rooms: {}", e), - ); - } + Err(e) => Err(e), + } + }; + + match result { + Ok((token, user_id)) => { + _jwt_token = Some(token.clone()); + let mut app = app_state.lock().await; + app.username = Some(user_id); + app.screen = AppScreen::RoomList; + app.error_message = None; + drop(app); + + // Connect to WebSocket + let mut client = chat_client.lock().await; + if let Err(e) = client.connect(server_url, token).await { + app_state.lock().await.error_message = + Some(format!("Failed to connect: {}", e)); + } else { + // Load room list + match client.list_rooms().await { + Ok(rooms) => { + app_state.lock().await.rooms = rooms; + } + Err(e) => { + app_state.lock().await.error_message = + Some(format!("Failed to load rooms: {}", e)); } } } - Err(e) => { - app_state.lock().await.error_message = Some(e.to_string()); - } + } + Err(e) => { + app_state.lock().await.error_message = Some(e.to_string()); } } - KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.screen = AppScreen::Register; + } + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.screen = AppScreen::Register; + app.error_message = None; + } + KeyCode::Esc => { + if app.screen == AppScreen::Register { + app.screen = AppScreen::Login; app.error_message = None; + } else { + return Ok(()); } - KeyCode::Esc => { - if app.screen == AppScreen::Register { - app.screen = AppScreen::Login; - app.error_message = None; - } else { - return Ok(()); - } + } + KeyCode::Backspace => match app.auth_field_focus { + AuthField::Username => { + app.auth_username_input.pop(); } - KeyCode::Backspace => match app.auth_field_focus { - AuthField::Username => { - app.auth_username_input.pop(); - } - AuthField::Password => { - app.auth_password_input.pop(); - } - }, - KeyCode::Char(c) => { - match app.auth_field_focus { - AuthField::Username => app.auth_username_input.push(c), - AuthField::Password => app.auth_password_input.push(c), - } - tracing::debug!( - "Input char: {}, username: {}, password len: {}", - c, - app.auth_username_input, - app.auth_password_input.len() - ); + AuthField::Password => { + app.auth_password_input.pop(); } - _ => {} + }, + KeyCode::Char(c) => { + match app.auth_field_focus { + AuthField::Username => app.auth_username_input.push(c), + AuthField::Password => app.auth_password_input.push(c), + } + tracing::debug!( + "Input char: {}, username: {}, password len: {}", + c, + app.auth_username_input, + app.auth_password_input.len() + ); } + _ => {} } - AppScreen::RoomList => { - match key.code { - KeyCode::Char('q') | KeyCode::Char('Q') => { - let mut client = chat_client.lock().await; - let _ = client.disconnect().await; - return Ok(()); + } + AppScreen::RoomList => match key.code { + KeyCode::Char('q') | KeyCode::Char('Q') => { + let mut client = chat_client.lock().await; + let _ = client.disconnect().await; + return Ok(()); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + drop(app); + let client = chat_client.lock().await; + match client.list_rooms().await { + Ok(rooms) => { + app_state.lock().await.rooms = rooms; } - KeyCode::Char('r') | KeyCode::Char('R') => { - drop(app); - let client = chat_client.lock().await; - match client.list_rooms().await { - Ok(rooms) => { - app_state.lock().await.rooms = rooms; - } - Err(e) => { - app_state.lock().await.error_message = - Some(format!("Failed to refresh rooms: {}", e)); - } - } + Err(e) => { + app_state.lock().await.error_message = + Some(format!("Failed to refresh rooms: {}", e)); } - KeyCode::Char(c) if c.is_digit(10) => { - let index = c.to_digit(10).unwrap() as usize - 1; - if index < app.rooms.len() { - let room_name = app.rooms[index].room_name.clone(); - drop(app); - - let client = chat_client.lock().await; - match client.join_room(room_name.clone()).await { - Ok((room_id, existing_users)) => { - let mut app = app_state.lock().await; - app.current_room = - Some((room_id.clone(), room_name.clone())); - app.screen = AppScreen::Chat { - room_id: room_id.clone(), - room_name, - }; - app.messages.clear(); - - // Clear and populate room_users with existing users - app.room_users - .entry(room_id.clone()) - .or_insert_with(Vec::new) - .clear(); - - tracing::debug!( - "Existing users in room: {:?}", - existing_users - ); - - app.room_users - .entry(room_id.clone()) - .or_insert_with(Vec::new) - .extend(existing_users); - - // Add current user to room_users - if let Some(username) = app.username.clone() { - tracing::debug!( - "Adding current user to room: {}", - username - ); - app.room_users - .entry(room_id.clone()) - .or_insert_with(Vec::new) - .push(username); - } - - tracing::debug!( - "Room users after join: {:?}", - app.room_users.get(&room_id) - ); - } - Err(e) => { - app_state.lock().await.error_message = - Some(format!("Failed to join room: {}", e)); - } - } + } + } + KeyCode::Char(c) if c.is_ascii_digit() => { + let index = c.to_digit(10).unwrap() as usize - 1; + if index < app.rooms.len() { + let room_name = app.rooms[index].room_name.clone(); + drop(app); + + let client = chat_client.lock().await; + match client.join_room(room_name.clone()).await { + Ok((room_id, existing_users)) => { + let mut app = app_state.lock().await; + tracing::debug!("Existing users in room: {:?}", existing_users); + + app.enter_room(room_id.clone(), room_name, existing_users); + + tracing::debug!( + "Room users after join: {:?}", + app.room_users.get(&room_id) + ); + } + Err(e) => { + app_state.lock().await.error_message = + Some(format!("Failed to join room: {}", e)); } } - _ => {} } } - AppScreen::Chat { - room_id: chat_room_id, - .. - } => { - match key.code { - KeyCode::Esc => { - // Stop typing if leaving room - let was_typing = app.is_typing; - if was_typing { - app.is_typing = false; - app.last_typing_time = None; - } + _ => {} + }, + AppScreen::Chat { + room_id: chat_room_id, + .. + } => { + match key.code { + KeyCode::Esc => { + // Stop typing if leaving room + let was_typing = app.is_typing; + if was_typing { + app.is_typing = false; + app.last_typing_time = None; + } - let room_id = chat_room_id.clone(); - drop(app); + let room_id = chat_room_id.clone(); + drop(app); - let client = chat_client.lock().await; + let client = chat_client.lock().await; - // Send stop typing if needed - if was_typing { - let _ = client.stop_typing().await; - } + // Send stop typing if needed + if was_typing { + let _ = client.stop_typing().await; + } - if let Err(e) = client.leave_room(room_id.clone()).await { - app_state.lock().await.error_message = - Some(format!("Failed to leave room: {}", e)); - } + if let Err(e) = client.leave_room(room_id.clone()).await { + app_state.lock().await.error_message = + Some(format!("Failed to leave room: {}", e)); + } - let mut app = app_state.lock().await; - app.screen = AppScreen::RoomList; - app.current_room = None; + let mut app = app_state.lock().await; + app.leave_room(&room_id); + } + KeyCode::Enter => { + if !app.input_buffer.is_empty() { + let text = app.input_buffer.clone(); app.input_buffer.clear(); - // Clear room users when leaving - app.room_users.remove(&room_id); - } - KeyCode::Enter => { - if !app.input_buffer.is_empty() { - let text = app.input_buffer.clone(); - app.input_buffer.clear(); - - // Check for slash commands - if text.starts_with('/') { - let command = text.trim_start_matches('/').to_lowercase(); - match command.as_str() { - "quit" | "exit" => { - drop(app); - let mut client = chat_client.lock().await; - let _ = client.disconnect().await; - return Ok(()); - } - _ => { - app.error_message = - Some(format!("Unknown command: /{}", command)); - } + + // Check for slash commands + if text.starts_with('/') { + let command = text.trim_start_matches('/').to_lowercase(); + match command.as_str() { + "quit" | "exit" => { + drop(app); + let mut client = chat_client.lock().await; + let _ = client.disconnect().await; + return Ok(()); } - } else { - // Stop typing when sending message - app.is_typing = false; - app.last_typing_time = None; - drop(app); - - let client = chat_client.lock().await; - // Stop typing notification - let _ = client.stop_typing().await; - - if let Err(e) = client.send_message(text).await { - app_state.lock().await.error_message = - Some(format!("Failed to send message: {}", e)); + _ => { + app.error_message = + Some(format!("Unknown command: /{}", command)); } } - } - } - KeyCode::Backspace => { - app.input_buffer.pop(); - } - KeyCode::Char(c) => { - app.input_buffer.push(c); - - // Track typing state - let now = std::time::Instant::now(); - let should_send_typing = if let Some(last_time) = - app.last_typing_time - { - !app.is_typing || now.duration_since(last_time).as_secs() >= 4 } else { - true - }; - - if should_send_typing { - app.last_typing_time = Some(now); - app.is_typing = true; + // Stop typing when sending message + app.is_typing = false; + app.last_typing_time = None; drop(app); let client = chat_client.lock().await; - if let Err(e) = client.start_typing().await { - tracing::warn!("Failed to send start typing: {}", e); + // Stop typing notification + let _ = client.stop_typing().await; + + if let Err(e) = client.send_message(text).await { + app_state.lock().await.error_message = + Some(format!("Failed to send message: {}", e)); } } } - _ => {} } + KeyCode::Backspace => { + app.input_buffer.pop(); + } + KeyCode::Char(c) => { + app.input_buffer.push(c); + + // Track typing state + let now = std::time::Instant::now(); + let should_send_typing = if let Some(last_time) = app.last_typing_time { + !app.is_typing || now.duration_since(last_time).as_secs() >= 4 + } else { + true + }; + + if should_send_typing { + app.last_typing_time = Some(now); + app.is_typing = true; + drop(app); + + let client = chat_client.lock().await; + if let Err(e) = client.start_typing().await { + tracing::warn!("Failed to send start typing: {}", e); + } + } + } + _ => {} } } } @@ -401,96 +356,22 @@ async fn run_app( // Handle app events (non-blocking) if let Ok(event) = event_rx.try_recv() { let mut app = app_state.lock().await; - match event { - AppEvent::MessageReceived(message) => { - app.messages.push(message); - } - AppEvent::UserJoined { username, room_id } => { - // Add user to room_users - app.room_users - .entry(room_id.clone()) - .or_insert_with(Vec::new) - .push(username.clone()); - - let msg = app::Message { - id: 0, - username: "System".to_string(), - text: format!("{} joined the room", username), - timestamp: chrono::Local::now(), - room_id, - }; - app.messages.push(msg); - } - AppEvent::UserLeft { username, room_id } => { - // Remove user from room_users - if let Some(users) = app.room_users.get_mut(&room_id) { - users.retain(|u| u != &username); - } - - let msg = app::Message { - id: 0, - username: "System".to_string(), - text: format!("{} left the room", username), - timestamp: chrono::Local::now(), - room_id, - }; - app.messages.push(msg); - } - AppEvent::SystemAnnouncement { message } => { - if let Some((room_id, _)) = &app.current_room { - let msg = app::Message { - id: 0, - username: "System".to_string(), - text: message, - timestamp: chrono::Local::now(), - room_id: room_id.clone(), - }; - app.messages.push(msg); - } - } - AppEvent::Connected => { - app.connected = true; - } - AppEvent::Disconnected => { - app.connected = false; - app.screen = AppScreen::Login; - app.error_message = Some("Disconnected from server".to_string()); - } - AppEvent::Error(e) => { - app.error_message = Some(e); - } - AppEvent::UserStartedTyping { username, room_id } => { - app.typing_users - .entry(room_id) - .or_insert_with(std::collections::HashSet::new) - .insert(username); - } - AppEvent::UserStoppedTyping { username, room_id } => { - if let Some(typing_users) = app.typing_users.get_mut(&room_id) { - typing_users.remove(&username); - if typing_users.is_empty() { - app.typing_users.remove(&room_id); - } - } - } - _ => {} - } + app.apply_event(event); } // Check for typing timeout { let mut app = app_state.lock().await; - if app.is_typing { - if let Some(last_typing_time) = app.last_typing_time { - if last_typing_time.elapsed().as_secs() >= 5 { - app.is_typing = false; - app.last_typing_time = None; - drop(app); - - let client = chat_client.lock().await; - let _ = client.stop_typing().await; - } - } + if app.is_typing + && let Some(last_typing_time) = app.last_typing_time + && last_typing_time.elapsed().as_secs() >= 5 + { + app.is_typing = false; + app.last_typing_time = None; + drop(app); + + let client = chat_client.lock().await; + let _ = client.stop_typing().await; } } diff --git a/examples/bidirectional-chat/tui/src/ui.rs b/examples/bidirectional-chat/tui/src/ui.rs index 315b6ee..7d75023 100644 --- a/examples/bidirectional-chat/tui/src/ui.rs +++ b/examples/bidirectional-chat/tui/src/ui.rs @@ -425,12 +425,12 @@ fn draw_chat_screen(frame: &mut Frame, app: &mut AppState, room_name: &str) { let mut user_list: Vec = vec!["System".to_string()]; // Add users from room_users for current room - if let Some((room_id, _)) = &app.current_room { - if let Some(users) = app.room_users.get(room_id) { - for user in users { - if !user_list.contains(user) { - user_list.push(user.clone()); - } + if let Some((room_id, _)) = &app.current_room + && let Some(users) = app.room_users.get(room_id) + { + for user in users { + if !user_list.contains(user) { + user_list.push(user.clone()); } } } @@ -557,48 +557,48 @@ fn draw_chat_screen(frame: &mut Frame, app: &mut AppState, room_name: &str) { .split(chunks[2]); // Typing indicator - if let Some((room_id, _)) = &app.current_room { - if let Some(typing_users) = app.typing_users.get(room_id) { - let typing_users: Vec<&String> = typing_users - .iter() - .filter(|u| app.username.as_ref() != Some(u)) - .collect(); - - if !typing_users.is_empty() { - let typing_text = if typing_users.len() == 1 { - format!("{} is typing...", typing_users[0]) - } else if typing_users.len() == 2 { - format!("{} and {} are typing...", typing_users[0], typing_users[1]) - } else { - format!( - "{} and {} others are typing...", - typing_users[0], - typing_users.len() - 1 - ) - }; - - // Animated dots based on current time - let dots = match std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() - / 500 - % 4 - { - 0 => "", - 1 => ".", - 2 => "..", - _ => "...", - }; - - let typing_indicator = - Paragraph::new(format!("{}{}", typing_text.trim_end_matches('.'), dots)).style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::ITALIC), - ); - frame.render_widget(typing_indicator, input_chunks[0]); - } + if let Some((room_id, _)) = &app.current_room + && let Some(typing_users) = app.typing_users.get(room_id) + { + let typing_users: Vec<&String> = typing_users + .iter() + .filter(|u| app.username.as_ref() != Some(u)) + .collect(); + + if !typing_users.is_empty() { + let typing_text = if typing_users.len() == 1 { + format!("{} is typing...", typing_users[0]) + } else if typing_users.len() == 2 { + format!("{} and {} are typing...", typing_users[0], typing_users[1]) + } else { + format!( + "{} and {} others are typing...", + typing_users[0], + typing_users.len() - 1 + ) + }; + + // Animated dots based on current time + let dots = match std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + / 500 + % 4 + { + 0 => "", + 1 => ".", + 2 => "..", + _ => "...", + }; + + let typing_indicator = + Paragraph::new(format!("{}{}", typing_text.trim_end_matches('.'), dots)).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::ITALIC), + ); + frame.render_widget(typing_indicator, input_chunks[0]); } } diff --git a/examples/file-service-example/Cargo.toml b/examples/file-service-example/Cargo.toml index c14b1b5..0ac02ce 100644 --- a/examples/file-service-example/Cargo.toml +++ b/examples/file-service-example/Cargo.toml @@ -2,14 +2,21 @@ name = "file-service-example" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Runnable file upload and download service example for Rust Agent Stack" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +publish = false +readme = "README.md" [dependencies] axum = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -ras-file-macro = { path = "../../crates/rest/ras-file-macro" } -ras-auth-core = { path = "../../crates/core/ras-auth-core" } +ras-file-macro = { path = "../../crates/rest/ras-file-macro", version = "0.1.0" } +ras-auth-core = { path = "../../crates/core/ras-auth-core", version = "0.1.0" } thiserror = { workspace = true } async-trait = { workspace = true } tower-http = { workspace = true, features = ["fs", "trace"] } @@ -17,4 +24,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } uuid = { workspace = true, features = ["v4"] } reqwest = { workspace = true } -tokio-util = { workspace = true } \ No newline at end of file +tokio-util = { workspace = true } + +[dev-dependencies] +axum-test = { workspace = true } diff --git a/examples/file-service-example/README.md b/examples/file-service-example/README.md new file mode 100644 index 0000000..9b77909 --- /dev/null +++ b/examples/file-service-example/README.md @@ -0,0 +1,62 @@ +# File Service Example + +Small Axum server demonstrating the `file_service!` macro for upload and download routes. + +## What It Shows + +- Public file download endpoint. +- Authenticated multipart upload endpoint. +- Admin-only file metadata endpoint. +- Simple bearer-token auth provider for local testing. +- Request and duration hooks emitted from the generated service builder. + +## Run It + +```bash +cargo run -p file-service-example --locked +``` + +The server listens on `http://localhost:3000` and serves routes under `/api/files`. + +## Try It + +Public download: + +```bash +curl http://localhost:3000/api/files/download/test123 +``` + +Authenticated upload: + +```bash +printf 'hello from rust-agent-stack\n' > /tmp/ras-upload.txt +curl -X POST \ + -H 'Authorization: Bearer user-token' \ + -F 'file=@/tmp/ras-upload.txt' \ + http://localhost:3000/api/files/upload +``` + +Admin file info: + +```bash +curl -H 'Authorization: Bearer admin-token' \ + http://localhost:3000/api/files/info/test123 +``` + +## Tokens + +- `user-token`: has the `upload` permission. +- `admin-token`: has both `upload` and `admin` permissions. + +Any other bearer token is rejected. + +## Checks + +```bash +cargo test -p file-service-example --locked +cargo clippy -p file-service-example --all-targets --all-features --locked -- -D warnings +``` + +## Notes + +This example returns generated demo data instead of persisting files. Use `examples/file-service-wasm/file-service-backend` for a fuller backend with filesystem storage and CORS configuration. diff --git a/examples/file-service-example/src/main.rs b/examples/file-service-example/src/main.rs index 3b8ae92..88c2b73 100644 --- a/examples/file-service-example/src/main.rs +++ b/examples/file-service-example/src/main.rs @@ -92,7 +92,7 @@ impl DocumentServiceTrait for DocumentServiceImpl { format!("attachment; filename=\"{}.txt\"", file_id), ) .body(body) - .unwrap()) + .map_err(|e| DocumentServiceFileError::DownloadFailed(e.to_string()))?) } async fn upload( @@ -158,7 +158,7 @@ impl DocumentServiceTrait for DocumentServiceImpl { } #[tokio::main] -async fn main() { +async fn main() -> Result<(), Box> { // Initialize tracing tracing_subscriber::fmt::init(); @@ -189,7 +189,173 @@ async fn main() { ); // Start server - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; - axum::serve(listener, app).await.unwrap(); + axum::serve(listener, app).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::to_bytes, response::IntoResponse}; + use axum_test::{ + TestServer, + multipart::{MultipartForm, Part}, + }; + + fn test_user(user_id: &str, permissions: &[&str]) -> AuthenticatedUser { + AuthenticatedUser { + user_id: user_id.to_string(), + permissions: permissions + .iter() + .map(|permission| (*permission).to_string()) + .collect(), + metadata: None, + } + } + + fn test_server() -> TestServer { + let app = DocumentServiceBuilder::new(DocumentServiceImpl) + .auth_provider(DemoAuthProvider) + .build(); + + TestServer::builder() + .mock_transport() + .build(app) + .expect("in-memory axum-test server") + } + + #[tokio::test] + async fn demo_auth_provider_maps_user_and_admin_permissions() { + let auth = DemoAuthProvider; + + let user = auth.authenticate("user-token".to_string()).await.unwrap(); + assert_eq!(user.user_id, "user-123"); + assert!(user.permissions.contains("upload")); + assert!(!user.permissions.contains("admin")); + + let admin = auth.authenticate("admin-token".to_string()).await.unwrap(); + assert_eq!(admin.user_id, "admin-456"); + assert!(admin.permissions.contains("upload")); + assert!(admin.permissions.contains("admin")); + } + + #[tokio::test] + async fn demo_auth_provider_rejects_unknown_tokens() { + let auth = DemoAuthProvider; + + let error = auth + .authenticate("not-a-token".to_string()) + .await + .expect_err("unknown token should be rejected"); + + assert!(matches!(error, AuthError::InvalidToken)); + } + + #[tokio::test] + async fn download_returns_text_attachment() { + let service = DocumentServiceImpl; + + let response = service + .download("test123".to_string()) + .await + .expect("download response") + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "text/plain"); + assert_eq!( + response.headers()["content-disposition"], + "attachment; filename=\"test123.txt\"" + ); + + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body bytes"); + assert_eq!(&body[..], b"File content for test123"); + } + + #[tokio::test] + async fn info_returns_demo_metadata_for_admin_user() { + let service = DocumentServiceImpl; + let admin = test_user("admin-456", &["admin", "upload"]); + + let response = service + .info(&admin, "report".to_string()) + .await + .expect("info response") + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body bytes"); + let info: FileInfo = serde_json::from_slice(&body).expect("file info json"); + + assert_eq!(info.id, "report"); + assert_eq!(info.name, "report.pdf"); + assert_eq!(info.size, 1024 * 1024); + assert_eq!(info.content_type, "application/pdf"); + } + + #[tokio::test] + async fn generated_public_download_route_works_without_token() { + let server = test_server(); + + let response = server.get("/api/files/download/test123").await; + + response.assert_status_ok(); + assert_eq!(response.headers()["content-type"], "text/plain"); + assert_eq!( + response.headers()["content-disposition"], + "attachment; filename=\"test123.txt\"" + ); + assert_eq!(response.text(), "File content for test123"); + } + + #[tokio::test] + async fn generated_upload_route_accepts_user_token_and_multipart_file() { + let server = test_server(); + let form = MultipartForm::new().add_part( + "file", + Part::bytes("example bytes") + .file_name("example.txt") + .mime_type("text/plain"), + ); + + let response = server + .post("/api/files/upload") + .authorization_bearer("user-token") + .multipart(form) + .await; + + response.assert_status_ok(); + let upload: UploadResponse = response.json(); + assert!(upload.file_id.starts_with("file_")); + assert_eq!(upload.size, "example bytes".len() as u64); + assert_eq!(upload.filename, "example.txt"); + } + + #[tokio::test] + async fn generated_admin_info_route_enforces_admin_permission() { + let server = test_server(); + + let user_response = server + .get("/api/files/info/report") + .authorization_bearer("user-token") + .await; + user_response.assert_status(StatusCode::FORBIDDEN); + + let admin_response = server + .get("/api/files/info/report") + .authorization_bearer("admin-token") + .await; + admin_response.assert_status_ok(); + + let info: FileInfo = admin_response.json(); + assert_eq!(info.id, "report"); + assert_eq!(info.name, "report.pdf"); + } } diff --git a/examples/file-service-wasm/README.md b/examples/file-service-wasm/README.md index db7dace..8d33add 100644 --- a/examples/file-service-wasm/README.md +++ b/examples/file-service-wasm/README.md @@ -1,49 +1,38 @@ -# File Service WASM Example +# File Service OpenAPI Usage Sample -This example demonstrates how to use the `file_service!` macro with TypeScript through WebAssembly. +This example demonstrates the `file_service!` macro with an Axum backend, +generated OpenAPI, and a minimal TypeScript usage sample that calls a generated +fetch client. ## Structure - `file-service-api/` - The API library crate that contains the service definition -- `typescript-example/` - TypeScript/HTML example showing client usage +- `file-service-backend/` - Axum server implementation and OpenAPI generation +- `typescript-example/` - Minimal TypeScript usage sample for a generated client ## How it Works -1. The `file_service!` macro generates both server and client code -2. The client code is compiled to WASM using wasm-pack with the `wasmpack-client` feature -3. TypeScript bindings are automatically generated by wasm-bindgen -4. The browser can use these bindings to interact with the file service - -## Building the WASM Package - -```bash -cd file-service-api -./build-wasm.sh -# or -npm run build -``` - -This generates TypeScript bindings in `file-service-api/pkg/`. +1. The `file_service!` macro defines upload, download, and secured file endpoints. +2. The backend build script writes the generated OpenAPI document. +3. A TypeScript OpenAPI generator can create a fetch client from the generated document. +4. `typescript-example/src/example.ts` shows the generated client call shape. ## Running the Example -1. First, build the WASM package as shown above - -2. Start a local web server in the typescript-example directory: - ```bash - cd typescript-example - python3 -m http.server 8000 - ``` +Run these commands from the repository root. -3. Open http://localhost:8000 in your browser +```bash +cargo check -p file-service-backend --locked +``` -4. Make sure your file service server is running on http://localhost:3000 +To exercise the calls manually, run the backend at `http://localhost:3000` and +use the functions shown in `typescript-example/src/example.ts`. -## Key Differences for WASM +## Native And Browser Client Shape -When targeting WASM, the upload methods have different signatures: +The Rust client and TypeScript usage sample both come from the same API +definition. Native Rust uploads stream files from disk: -**Native (non-WASM):** ```rust pub async fn upload( &self, @@ -53,64 +42,19 @@ pub async fn upload( ) -> Result> ``` -**WASM:** -```rust -pub async fn upload( - &self, - file_bytes: Vec, - file_name: &str, - content_type: Option<&str> -) -> Result> -``` - -The WASM wrapper handles the conversion from browser File API to bytes automatically. +The TypeScript sample assumes a generated fetch client at +`typescript-example/src/generated`, which is intentionally ignored. That client +uses browser `Blob | File` values for multipart uploads; see +`typescript-example/src/example.ts`. ## TypeScript Usage -```typescript -import init, { WasmDocumentServiceClient } from './pkg/file_service_api.js'; - -// Initialize WASM -await init(); - -// Create client -const client = new WasmDocumentServiceClient('http://localhost:3000'); - -// Set authentication token (optional) -client.set_bearer_token('your-token-here'); - -// Upload a file -const file = document.getElementById('fileInput').files[0]; -const uploadResponse = await client.upload(file); - -// Download a file -const fileData = await client.download('file-id'); -``` +See `typescript-example/src/example.ts` for public upload/download calls and +the bearer-token variants for protected endpoints. ## Server Implementation -The server can use the same API crate: - -```rust -// In your server Cargo.toml -[dependencies] -file-service-api = { path = "../file-service-api", features = ["server"] } - -// In your server code -use file_service_api::{DocumentServiceServer, UploadResponse}; - -struct MyDocumentService; - -#[async_trait] -impl DocumentServiceServer for MyDocumentService { - async fn upload( - &self, - auth: Option, - multipart: Multipart, - ) -> Result { - // Implementation - } - - // ... other methods -} -``` \ No newline at end of file +The backend depends on the shared API crate with the `server` feature enabled +and implements the generated `DocumentServiceTrait`. The checked-in +implementation is in [file_service.rs](file-service-backend/src/file_service.rs) +and stores uploaded files through [storage.rs](file-service-backend/src/storage.rs). diff --git a/examples/file-service-wasm/file-service-api/Cargo.toml b/examples/file-service-wasm/file-service-api/Cargo.toml index 37d9a6a..64fa05a 100644 --- a/examples/file-service-wasm/file-service-api/Cargo.toml +++ b/examples/file-service-wasm/file-service-api/Cargo.toml @@ -2,15 +2,20 @@ name = "file-service-api" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Shared API definition for the Rust Agent Stack file-service OpenAPI usage example" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +publish = false +readme = "README.md" [lib] -# Default to rlib only to avoid conflicts with build dependencies -# cdylib will be added when building for wasm32 target via wasm-pack crate-type = ["rlib"] [dependencies] -ras-file-macro = { path = "../../../crates/rest/ras-file-macro" } -ras-auth-core = { path = "../../../crates/core/ras-auth-core" } +ras-file-macro = { path = "../../../crates/rest/ras-file-macro", version = "0.1.0" } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } serde = { workspace = true, features = ["derive"] } async-trait = { workspace = true } thiserror = { workspace = true } @@ -22,7 +27,7 @@ serde-wasm-bindgen = { version = "0.6", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] axum = { workspace = true } -axum-extra.workspace = true +axum-extra = { workspace = true } tower = { workspace = true } http = { workspace = true } reqwest = { workspace = true } @@ -37,4 +42,4 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul [features] default = [] wasm-client = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys", "serde-wasm-bindgen"] -server = [] \ No newline at end of file +server = [] diff --git a/examples/file-service-wasm/file-service-api/README.md b/examples/file-service-wasm/file-service-api/README.md new file mode 100644 index 0000000..ca411e5 --- /dev/null +++ b/examples/file-service-wasm/file-service-api/README.md @@ -0,0 +1,28 @@ +# File Service API + +Shared file-service contract for the [file service WASM/OpenAPI example](../README.md). This crate uses `ras-file-macro` to generate upload/download server traits, clients, and OpenAPI output for the backend and generated TypeScript usage sample. + +## Generated Service + +The contract in [src/lib.rs](src/lib.rs) defines `DocumentService` at base path `/api/documents` with a 100 MB body limit: + +- `POST /api/documents/upload` +- `POST /api/documents/upload_profile_picture` +- `GET /api/documents/download/{file_id}` +- `GET /api/documents/download_secure/{file_id}` + +The backend implementation is documented in [../file-service-backend/README.md](../file-service-backend/README.md). The plain TypeScript generated-client usage sample is documented in [../typescript-example/README.md](../typescript-example/README.md). + +## Features + +- `server` - marker feature used by the backend package when depending on this shared API crate. +- `wasm-client` - re-exports the macro-generated WASM client on `wasm32`. + +## Checks + +```bash +cargo check -p file-service-api --locked +cargo check -p file-service-api --features server --locked +cargo test -p file-service-api --locked +cargo clippy -p file-service-api --all-targets --all-features --locked -- -D warnings +``` diff --git a/examples/file-service-wasm/file-service-api/package.json b/examples/file-service-wasm/file-service-api/package.json deleted file mode 100644 index 275938e..0000000 --- a/examples/file-service-wasm/file-service-api/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "file-service-api", - "version": "0.1.0", - "description": "TypeScript bindings for file service API", - "main": "pkg/file_service_api.js", - "types": "pkg/file_service_api.d.ts", - "scripts": { - "build": "wasm-pack build --target web --out-dir pkg --features wasm-client", - "build:node": "wasm-pack build --target nodejs --out-dir pkg-node --features wasm-client", - "build:bundler": "wasm-pack build --target bundler --out-dir pkg-bundler --features wasm-client" - }, - "devDependencies": { - "wasm-pack": "^0.12.1" - }, - "files": [ - "pkg/**/*", - "!pkg/.gitignore" - ] -} \ No newline at end of file diff --git a/examples/file-service-wasm/file-service-api/src/lib.rs b/examples/file-service-wasm/file-service-api/src/lib.rs index 9034b7e..683e7e2 100644 --- a/examples/file-service-wasm/file-service-api/src/lib.rs +++ b/examples/file-service-wasm/file-service-api/src/lib.rs @@ -25,7 +25,7 @@ file_service!({ service_name: DocumentService, base_path: "/api/documents", openapi: true, - body_limit: 204857600, // 100 MB + body_limit: 104857600, // 100 MB endpoints: [ UPLOAD UNAUTHORIZED upload() -> UploadResponse, UPLOAD WITH_PERMISSIONS(["user"]) upload_profile_picture() -> UploadResponse, @@ -41,19 +41,158 @@ pub use wasm_client::*; #[cfg(test)] mod tests { use super::*; + use serde_json::{Value, json}; #[test] - fn test_openapi_generation() { + fn upload_response_serializes_file_identity_and_size() { + let response = UploadResponse { + file_id: "file-123".to_string(), + file_name: "report.pdf".to_string(), + size: 4096, + }; + + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "file_id": "file-123", + "file_name": "report.pdf", + "size": 4096 + }) + ); + } + + #[test] + fn file_metadata_serializes_optional_content_type() { + let metadata = FileMetadata { + id: "file-123".to_string(), + name: "report.pdf".to_string(), + size: 4096, + content_type: Some("application/pdf".to_string()), + uploaded_at: "2026-05-23T12:00:00Z".to_string(), + }; + + assert_eq!( + serde_json::to_value(metadata).unwrap(), + json!({ + "id": "file-123", + "name": "report.pdf", + "size": 4096, + "content_type": "application/pdf", + "uploaded_at": "2026-05-23T12:00:00Z" + }) + ); + } + + #[test] + fn file_metadata_preserves_absent_content_type_as_null() { + let metadata = FileMetadata { + id: "file-123".to_string(), + name: "archive.bin".to_string(), + size: 8192, + content_type: None, + uploaded_at: "2026-05-23T12:00:00Z".to_string(), + }; + + assert_eq!( + serde_json::to_value(metadata).unwrap(), + json!({ + "id": "file-123", + "name": "archive.bin", + "size": 8192, + "content_type": null, + "uploaded_at": "2026-05-23T12:00:00Z" + }) + ); + } + + #[test] + fn upload_response_deserializes_generated_client_payload() { + let response: UploadResponse = serde_json::from_value(json!({ + "file_id": "file-123", + "file_name": "report.pdf", + "size": 4096 + })) + .unwrap(); + + assert_eq!(response.file_id, "file-123"); + assert_eq!(response.file_name, "report.pdf"); + assert_eq!(response.size, 4096); + } + + fn parameter<'a>(operation: &'a Value, name: &str) -> &'a Value { + operation["parameters"] + .as_array() + .expect("parameters array") + .iter() + .find(|parameter| parameter["name"] == name) + .unwrap_or_else(|| panic!("missing parameter {name}")) + } + + #[test] + fn generated_openapi_documents_upload_routes_and_multipart_body() { let doc = generate_documentservice_openapi(); - println!( - "Generated OpenAPI doc: {}", - serde_json::to_string_pretty(&doc).unwrap() + + assert_eq!(doc["openapi"], "3.0.3"); + assert_eq!(doc["info"]["title"], "DocumentService File Service API"); + assert_eq!(doc["servers"][0]["url"], "/api/documents"); + + let public_upload = &doc["paths"]["/upload"]["post"]; + assert_eq!( + public_upload["requestBody"]["content"]["multipart/form-data"]["schema"]["$ref"], + "#/components/schemas/FileUploadRequest" ); + assert!(public_upload.get("security").is_none()); - // Test that we can write to file - generate_documentservice_openapi_to_file().expect("Failed to write OpenAPI to file"); + let profile_upload = &doc["paths"]["/upload_profile_picture"]["post"]; + assert_eq!(profile_upload["security"][0]["bearerAuth"], json!([])); + assert_eq!(profile_upload["x-permissions"], json!(["user"])); } -} -#[cfg(test)] -mod test_openapi; + #[test] + fn generated_openapi_documents_download_path_parameters_and_auth() { + let doc = generate_documentservice_openapi(); + + let public_download = &doc["paths"]["/download/{file_id}"]["get"]; + assert_eq!(parameter(public_download, "file_id")["in"], json!("path")); + assert_eq!( + parameter(public_download, "file_id")["required"], + json!(true) + ); + assert_eq!( + public_download["responses"]["200"]["content"]["application/octet-stream"]["schema"]["$ref"], + "#/components/schemas/BinaryFileResponse" + ); + assert!(public_download.get("security").is_none()); + + let secure_download = &doc["paths"]["/download_secure/{file_id}"]["get"]; + assert_eq!(secure_download["security"][0]["bearerAuth"], json!([])); + assert_eq!(secure_download["x-permissions"], json!(["user"])); + } + + #[test] + fn generated_openapi_includes_file_operation_component_schemas() { + let doc = generate_documentservice_openapi(); + + let upload_schema = &doc["components"]["schemas"]["FileUploadRequest"]; + assert_eq!(upload_schema["required"], json!(["file"])); + assert_eq!( + upload_schema["properties"]["file"]["format"], + json!("binary") + ); + + let download_schema = &doc["components"]["schemas"]["BinaryFileResponse"]; + assert_eq!(download_schema["type"], json!("string")); + assert_eq!(download_schema["format"], json!("binary")); + + let upload_response_schema = &doc["components"]["schemas"]["UploadResponse"]; + assert_eq!( + upload_response_schema["properties"]["file_id"]["type"], + json!("string") + ); + assert_eq!( + upload_response_schema["properties"]["file_name"]["type"], + json!("string") + ); + assert!(upload_response_schema["properties"]["size"].is_object()); + } +} diff --git a/examples/file-service-wasm/file-service-api/src/test_openapi.rs b/examples/file-service-wasm/file-service-api/src/test_openapi.rs deleted file mode 100644 index 9d70bb1..0000000 --- a/examples/file-service-wasm/file-service-api/src/test_openapi.rs +++ /dev/null @@ -1,11 +0,0 @@ -#[cfg(test)] -mod tests { - use crate::{generate_documentservice_openapi, generate_documentservice_openapi_to_file}; - - #[test] - fn test_openapi_function_exists() { - // This test verifies that the OpenAPI generation function exists - let _ = generate_documentservice_openapi(); - let _ = generate_documentservice_openapi_to_file(); - } -} diff --git a/examples/file-service-wasm/file-service-backend/Cargo.toml b/examples/file-service-wasm/file-service-backend/Cargo.toml index 5cb88b5..095c1fe 100644 --- a/examples/file-service-wasm/file-service-backend/Cargo.toml +++ b/examples/file-service-wasm/file-service-backend/Cargo.toml @@ -2,36 +2,32 @@ name = "file-service-backend" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Backend for the Rust Agent Stack file-service OpenAPI usage example" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +publish = false +readme = "README.md" [dependencies] # API crate with server feature -file-service-api = { path = "../file-service-api", features = ["server"] } +file-service-api = { path = "../file-service-api", version = "0.1.0", features = ["server"] } # Core dependencies axum = { workspace = true } tokio = { workspace = true, features = ["full"] } -tower = { workspace = true } tower-http = { workspace = true, features = ["cors", "fs"] } # Authentication -ras-auth-core = { path = "../../../crates/core/ras-auth-core" } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } async-trait = { workspace = true } -# Serialization -serde = { workspace = true } -serde_json = { workspace = true } - -# JWT dependencies (required by SessionConfig) -chrono = { workspace = true } -jsonwebtoken = { workspace = true } - # Error handling -thiserror = { workspace = true } anyhow = { workspace = true } # File handling uuid = { workspace = true, features = ["v4", "serde"] } -tokio-util = { workspace = true } mime_guess = { workspace = true } # Logging @@ -42,4 +38,7 @@ tracing-subscriber = { workspace = true } dotenvy = { workspace = true } [build-dependencies] -file-service-api = { path = "../file-service-api", features = ["server"] } \ No newline at end of file +file-service-api = { path = "../file-service-api", version = "0.1.0", features = ["server"] } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/examples/file-service-wasm/file-service-backend/README.md b/examples/file-service-wasm/file-service-backend/README.md index 8893e13..1460718 100644 --- a/examples/file-service-wasm/file-service-backend/README.md +++ b/examples/file-service-wasm/file-service-backend/README.md @@ -1,31 +1,39 @@ # File Service Backend -A production-ready file service backend built with Axum and the `file_service!` macro. +An example file service backend built with Axum and the `file_service!` macro. ## Features -- 📁 **File Upload/Download** - Multipart file uploads with streaming downloads -- 🔐 **JWT Authentication** - Secure endpoints with role-based access -- 💾 **File Storage** - UUID-based file storage with metadata tracking -- 🌐 **CORS Support** - Full CORS configuration for web clients -- 📊 **Logging** - Structured logging with tracing +- **File upload/download** - Multipart file uploads with streaming downloads +- **Mock bearer authentication** - Secure endpoints with role-based access +- **File storage** - UUID-based file storage with metadata tracking +- **CORS support** - Permissive demo CORS layer for browser clients +- **Logging** - Structured logging with tracing ## Setup +From the workspace root: + +```bash +cargo run -p file-service-backend --locked +``` + +The server will start on `http://localhost:3000`. + +The example also includes an optional `.env.example` for directory-local runs: + 1. Copy the environment file: ```bash cp .env.example .env ``` -2. Edit `.env` and set your JWT secret +2. Edit `.env` if you want a different storage path or log filter -3. Run the server: +3. Run the server from this directory: ```bash - cargo run + cargo run --locked ``` -The server will start on `http://localhost:3000`. - ## API Endpoints Generated by the `file_service!` macro: @@ -63,7 +71,8 @@ uploads/ └── {uuid}.{ext} ``` -Each file is assigned a UUID and retains its original extension. +Each file is assigned a UUID and retains its original extension. The default +`uploads/` directory is local runtime state and is ignored by git. ## Configuration @@ -74,22 +83,49 @@ Environment variables: ## Security Considerations - Files are stored with UUID names to prevent path traversal -- JWT tokens include user permissions for role-based access -- CORS is configured to allow all origins (restrict in production) -- File size limits should be configured in production +- The mock bearer token includes user permissions for role-based access +- CORS is configured to allow all origins for the demo; restrict it for shared environments. +- The generated service applies the API crate's 100 MB `body_limit`; choose a limit that matches your deployment. ## Development The service implementation follows the trait generated by the `file_service!` macro: ```rust +use axum::{body::Body, extract::Multipart, response::Response}; +use ras_auth_core::AuthenticatedUser; + #[async_trait] -impl DocumentServiceServer for FileServiceImpl { - async fn upload(...) -> Result { } - async fn upload_profile_picture(...) -> Result { } - async fn download(...) -> Result, FileServiceError> { } - async fn download_secure(...) -> Result, FileServiceError> { } +pub trait DocumentServiceTrait: Send + Sync { + async fn upload( + &self, + multipart: Multipart, + ) -> Result; + + async fn upload_profile_picture( + &self, + user: &AuthenticatedUser, + multipart: Multipart, + ) -> Result; + + async fn download( + &self, + file_id: String, + ) -> Result, DocumentServiceFileError>; + + async fn download_secure( + &self, + user: &AuthenticatedUser, + file_id: String, + ) -> Result, DocumentServiceFileError>; } ``` -The macro handles routing, authentication, and error handling automatically. \ No newline at end of file +The macro handles routing, authentication, and error handling automatically. + +## Checks + +```bash +cargo test -p file-service-backend --locked +cargo clippy -p file-service-backend --all-targets --all-features --locked -- -D warnings +``` diff --git a/examples/file-service-wasm/file-service-backend/src/file_service.rs b/examples/file-service-wasm/file-service-backend/src/file_service.rs index 76f7237..ca483a3 100644 --- a/examples/file-service-wasm/file-service-backend/src/file_service.rs +++ b/examples/file-service-wasm/file-service-backend/src/file_service.rs @@ -58,6 +58,12 @@ impl FileServiceImpl { DocumentServiceFileError::Internal(e.to_string()) })?; + debug!( + file_id = %metadata.id, + stored_path = %metadata.stored_path.display(), + "Saved uploaded file" + ); + return Ok(UploadResponse { file_id: metadata.id, file_name: metadata.original_name, @@ -139,3 +145,87 @@ impl DocumentServiceTrait for FileServiceImpl { self.download(file_id).await } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + use std::collections::HashSet; + use tempfile::TempDir; + + fn test_user() -> AuthenticatedUser { + AuthenticatedUser { + user_id: "testuser".to_string(), + permissions: HashSet::from(["user".to_string()]), + metadata: None, + } + } + + fn test_service(temp_dir: &TempDir) -> FileServiceImpl { + FileServiceImpl::new(Arc::new(FileStorage::new(temp_dir.path()))) + } + + #[tokio::test] + async fn download_returns_saved_file_with_headers() { + let temp_dir = TempDir::new().expect("temp dir"); + let storage = Arc::new(FileStorage::new(temp_dir.path())); + let saved = storage + .save_file( + b"download body".to_vec(), + "report.txt", + Some("text/plain".to_string()), + ) + .await + .expect("save file"); + let service = FileServiceImpl::new(storage); + + let response = service.download(saved.id).await.expect("download response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "13"); + assert_eq!(response.headers()[header::CONTENT_TYPE], "text/plain"); + assert_eq!( + response.headers()[header::CONTENT_DISPOSITION], + "attachment; filename=\"file.txt\"" + ); + + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"); + assert_eq!(&body[..], b"download body"); + } + + #[tokio::test] + async fn download_missing_file_maps_to_not_found() { + let temp_dir = TempDir::new().expect("temp dir"); + let service = test_service(&temp_dir); + + let error = service + .download("missing".to_string()) + .await + .expect_err("missing file should be not found"); + + assert!(matches!(error, DocumentServiceFileError::NotFound)); + } + + #[tokio::test] + async fn secure_download_uses_same_download_path() { + let temp_dir = TempDir::new().expect("temp dir"); + let storage = Arc::new(FileStorage::new(temp_dir.path())); + let saved = storage + .save_file(b"secure body".to_vec(), "secure.bin", None) + .await + .expect("save file"); + let service = FileServiceImpl::new(storage); + + let response = service + .download_secure(&test_user(), saved.id) + .await + .expect("secure download response"); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"); + + assert_eq!(&body[..], b"secure body"); + } +} diff --git a/examples/file-service-wasm/file-service-backend/src/simple_auth.rs b/examples/file-service-wasm/file-service-backend/src/simple_auth.rs index 3269de4..b77cb9a 100644 --- a/examples/file-service-wasm/file-service-backend/src/simple_auth.rs +++ b/examples/file-service-wasm/file-service-backend/src/simple_auth.rs @@ -1,7 +1,7 @@ use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; use std::collections::HashSet; -/// Simple mock auth provider that accepts "validtoken" as a valid JWT +/// Simple mock auth provider that accepts "validtoken" as a bearer token. #[derive(Clone)] pub struct SimpleAuthProvider; @@ -23,3 +23,34 @@ impl AuthProvider for SimpleAuthProvider { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn validtoken_authenticates_as_user() { + let provider = SimpleAuthProvider; + + let user = provider + .authenticate("validtoken".to_string()) + .await + .expect("valid bearer token"); + + assert_eq!(user.user_id, "testuser"); + assert!(user.permissions.contains("user")); + assert!(!user.permissions.contains("admin")); + } + + #[tokio::test] + async fn unknown_token_is_rejected() { + let provider = SimpleAuthProvider; + + let error = provider + .authenticate("wrong-token".to_string()) + .await + .expect_err("unknown token should be rejected"); + + assert!(matches!(error, AuthError::InvalidToken)); + } +} diff --git a/examples/file-service-wasm/file-service-backend/src/storage.rs b/examples/file-service-wasm/file-service-backend/src/storage.rs index 3ade169..b8c490c 100644 --- a/examples/file-service-wasm/file-service-backend/src/storage.rs +++ b/examples/file-service-wasm/file-service-backend/src/storage.rs @@ -84,20 +84,93 @@ impl FileStorage { anyhow::bail!("File not found: {}", file_id) } +} - pub async fn delete_file(&self, file_id: &str) -> Result<()> { - let mut entries = tokio::fs::read_dir(&self.base_path).await?; +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn save_file_writes_bytes_and_returns_metadata() { + let temp_dir = TempDir::new().expect("temp dir"); + let storage = FileStorage::new(temp_dir.path()); + + let metadata = storage + .save_file( + b"hello world".to_vec(), + "greeting.txt", + Some("text/plain".to_string()), + ) + .await + .expect("save file"); + + assert_eq!(metadata.original_name, "greeting.txt"); + assert_eq!(metadata.size, 11); + assert_eq!(metadata.content_type.as_deref(), Some("text/plain")); + assert!(metadata.stored_path.starts_with(temp_dir.path())); + assert_eq!( + tokio::fs::read(&metadata.stored_path) + .await + .expect("stored bytes"), + b"hello world" + ); + } - while let Some(entry) = entries.next_entry().await? { - let file_name = entry.file_name(); - let file_name_str = file_name.to_string_lossy(); + #[tokio::test] + async fn save_file_uses_generated_filename_inside_storage_dir() { + let temp_dir = TempDir::new().expect("temp dir"); + let storage = FileStorage::new(temp_dir.path()); + + let metadata = storage + .save_file(b"secret".to_vec(), "../../secret.txt", None) + .await + .expect("save file"); + + assert_eq!(metadata.original_name, "../../secret.txt"); + assert!(metadata.stored_path.starts_with(temp_dir.path())); + assert_ne!( + metadata.stored_path, + temp_dir.path().join("../../secret.txt") + ); + assert_eq!( + metadata + .stored_path + .extension() + .and_then(|extension| extension.to_str()), + Some("txt") + ); + } - if file_name_str.starts_with(file_id) { - tokio::fs::remove_file(entry.path()).await?; - return Ok(()); - } - } + #[tokio::test] + async fn get_file_reads_saved_file_by_id() { + let temp_dir = TempDir::new().expect("temp dir"); + let storage = FileStorage::new(temp_dir.path()); + let saved = storage + .save_file(b"download me".to_vec(), "download.txt", None) + .await + .expect("save file"); + + let (data, metadata) = storage.get_file(&saved.id).await.expect("get file"); + + assert_eq!(data, b"download me"); + let metadata = metadata.expect("file metadata"); + assert_eq!(metadata.id, saved.id); + assert_eq!(metadata.original_name, "file.txt"); + assert_eq!(metadata.size, 11); + assert_eq!(metadata.content_type.as_deref(), Some("text/plain")); + } - anyhow::bail!("File not found: {}", file_id) + #[tokio::test] + async fn get_file_returns_error_when_id_is_missing() { + let temp_dir = TempDir::new().expect("temp dir"); + let storage = FileStorage::new(temp_dir.path()); + + let error = storage + .get_file("missing") + .await + .expect_err("missing file should be rejected"); + + assert!(error.to_string().contains("File not found: missing")); } } diff --git a/examples/file-service-wasm/typescript-example/.gitignore b/examples/file-service-wasm/typescript-example/.gitignore index 0d26fb5..36ea541 100644 --- a/examples/file-service-wasm/typescript-example/.gitignore +++ b/examples/file-service-wasm/typescript-example/.gitignore @@ -1,19 +1,7 @@ -# Dependencies +src/generated/ node_modules/ dist/ - -# Build outputs -public/pkg/ -pkg-node/ -pkg-bundler/ - -# IDE -.vscode/ -.idea/ - -# Logs +public/ *.log - -# OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db diff --git a/examples/file-service-wasm/typescript-example/README.md b/examples/file-service-wasm/typescript-example/README.md index 1316d19..1bdbbdf 100644 --- a/examples/file-service-wasm/typescript-example/README.md +++ b/examples/file-service-wasm/typescript-example/README.md @@ -1,85 +1,29 @@ -# File Service SolidJS App +# File-Service Generated Client Usage -A modern file upload/download service built with SolidJS, TypeScript, and WebAssembly (Rust). - -## Features - -- 🚀 **SolidJS** - Reactive UI framework with fine-grained reactivity -- 🦀 **Rust + WebAssembly** - High-performance client using the `file_service!` macro -- 🔐 **Authentication** - JWT token support with secure/public endpoints -- 📁 **File Operations** - Drag-and-drop upload, download with progress -- 💅 **Modern UI** - Tailwind CSS with responsive design -- ⚡ **Vite** - Fast development and optimized production builds +Minimal TypeScript usage sample for the OpenAPI client generated from the Rust +file-service definition. This is not an npm application; it is only the client +call shape a frontend would use after generating a client. ## Prerequisites -Build the WASM package first: -```bash -cd ../file-service-api -./build-wasm.sh -``` - -## Development - -1. Install dependencies: - ```bash - npm install - ``` +- The file-service OpenAPI document generated by `cargo check --locked` or `cargo build --locked` -2. Start the development server: - ```bash - npm run dev - ``` - -3. Open http://localhost:3001 in your browser - -## Building for Production +From the repository root: ```bash -npm run build -npm run preview +cargo check -p file-service-backend --locked ``` -## Architecture - -### Components - -- **FileUpload** - Drag-and-drop file upload with progress -- **FileList** - Display uploaded files with download functionality -- **AuthForm** - JWT token management - -### State Management +This writes the OpenAPI document to: -- **auth.ts** - Authentication state with localStorage persistence -- **client.ts** - WASM client initialization and management - -### API Integration - -The app uses the generated WASM client from the `file_service!` macro: - -```typescript -import init, { WasmDocumentServiceClient } from '/pkg/file_service_api.js'; - -// Initialize once -await init(); - -// Create client -const client = new WasmDocumentServiceClient(window.location.origin); - -// Use the client -const response = await client.upload(file); +``` +examples/file-service-wasm/file-service-backend/target/openapi/documentservice.json ``` -## Configuration - -The Vite dev server proxies API requests to `http://localhost:3000`. Update `vite.config.ts` if your backend runs on a different port. - -## Security - -- Tokens are stored in localStorage -- WASM client handles authentication headers automatically -- Secure endpoints require valid JWT tokens - -## Browser Support +## What To Look At -Requires browsers with WebAssembly support (all modern browsers). \ No newline at end of file +- `src/example.ts` assumes a generated fetch client exists at `src/generated`. +- `src/generated` is intentionally ignored; generate it locally from the OpenAPI document with the client generator your frontend uses. +- It shows public upload/download calls, bearer-token headers for + protected endpoints, typed multipart request bodies, and snake_case path + parameters from the OpenAPI document. diff --git a/examples/file-service-wasm/typescript-example/index.html b/examples/file-service-wasm/typescript-example/index.html deleted file mode 100644 index f2a9d4b..0000000 --- a/examples/file-service-wasm/typescript-example/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - File Service - SolidJS - - -
- - - \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/openapi-ts.config.ts b/examples/file-service-wasm/typescript-example/openapi-ts.config.ts deleted file mode 100644 index 8c4c5a8..0000000 --- a/examples/file-service-wasm/typescript-example/openapi-ts.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from '@hey-api/openapi-ts'; - -export default defineConfig({ - client: 'fetch', - input: '../file-service-api/target/openapi/documentservice.json', - output: './src/generated', - schemas: { - export: true, - }, -}); \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/package-lock.json b/examples/file-service-wasm/typescript-example/package-lock.json deleted file mode 100644 index 222c892..0000000 --- a/examples/file-service-wasm/typescript-example/package-lock.json +++ /dev/null @@ -1,3608 +0,0 @@ -{ - "name": "file-service-solidjs-app", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "file-service-solidjs-app", - "version": "0.1.0", - "dependencies": { - "@solidjs/router": "^0.10.5", - "solid-js": "^1.8.11" - }, - "devDependencies": { - "@hey-api/openapi-ts": "^0.73.0", - "@types/node": "^20.10.5", - "autoprefixer": "^10.4.16", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "typescript": "^5.3.3", - "vite": "^5.0.10", - "vite-plugin-solid": "^2.8.0" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", - "dev": true, - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.28.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@hey-api/json-schema-ref-parser": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz", - "integrity": "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w==", - "dev": true, - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.15", - "js-yaml": "^4.1.0", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - } - }, - "node_modules/@hey-api/openapi-ts": { - "version": "0.73.0", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.73.0.tgz", - "integrity": "sha512-sUscR3OIGW0k9U//28Cu6BTp3XaogWMDORj9H+5Du9E5AvTT7LZbCEDvkLhebFOPkp2cZAQfd66HiZsiwssBcQ==", - "dev": true, - "dependencies": { - "@hey-api/json-schema-ref-parser": "1.0.6", - "ansi-colors": "4.1.3", - "c12": "2.0.1", - "color-support": "1.1.3", - "commander": "13.0.0", - "handlebars": "4.7.8", - "open": "10.1.2" - }, - "bin": { - "openapi-ts": "bin/index.cjs" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=22.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - }, - "peerDependencies": { - "typescript": "^5.5.3" - } - }, - "node_modules/@hey-api/openapi-ts/node_modules/commander": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", - "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "dev": true - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", - "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", - "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", - "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", - "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", - "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", - "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", - "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", - "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", - "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", - "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", - "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", - "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", - "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", - "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", - "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", - "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", - "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", - "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", - "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", - "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@solidjs/router": { - "version": "0.10.10", - "resolved": "https://registry.npmjs.org/@solidjs/router/-/router-0.10.10.tgz", - "integrity": "sha512-nGl7gMgsojuaupI5MAK2cFtkndmWWSAPhill/8La3IjujY3vMBamcQFymBsA2ejzxEYJjkOlEQHYgp2jNFkwuQ==", - "peerDependencies": { - "solid-js": "^1.8.6" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", - "dev": true, - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "node_modules/@types/node": { - "version": "20.19.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.4.tgz", - "integrity": "sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==", - "dev": true, - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/babel-plugin-jsx-dom-expressions": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.39.8.tgz", - "integrity": "sha512-/MVOIIjonylDXnrWmG23ZX82m9mtKATsVHB7zYlPfDR9Vdd/NBE48if+wv27bSkBtyO7EPMUlcUc4J63QwuACQ==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "7.18.6", - "@babel/plugin-syntax-jsx": "^7.18.6", - "@babel/types": "^7.20.7", - "html-entities": "2.3.3", - "parse5": "^7.1.2", - "validate-html-nesting": "^1.2.1" - }, - "peerDependencies": { - "@babel/core": "^7.20.12" - } - }, - "node_modules/babel-plugin-jsx-dom-expressions/node_modules/@babel/helper-module-imports": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", - "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/babel-preset-solid": { - "version": "1.9.6", - "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.6.tgz", - "integrity": "sha512-HXTK9f93QxoH8dYn1M2mJdOlWgMsR88Lg/ul6QCZGkNTktjTE5HAf93YxQumHoCudLEtZrU1cFCMFOVho6GqFg==", - "dev": true, - "dependencies": { - "babel-plugin-jsx-dom-expressions": "^0.39.8" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/c12": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz", - "integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==", - "dev": true, - "dependencies": { - "chokidar": "^4.0.1", - "confbox": "^0.1.7", - "defu": "^6.1.4", - "dotenv": "^16.4.5", - "giget": "^1.2.3", - "jiti": "^2.3.0", - "mlly": "^1.7.1", - "ohash": "^1.1.4", - "pathe": "^1.1.2", - "perfect-debounce": "^1.0.0", - "pkg-types": "^1.2.0", - "rc9": "^2.1.2" - }, - "peerDependencies": { - "magicast": "^0.3.5" - }, - "peerDependenciesMeta": { - "magicast": { - "optional": true - } - } - }, - "node_modules/c12/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/c12/node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "dev": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/c12/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "dev": true, - "dependencies": { - "consola": "^3.2.3" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", - "dev": true, - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "dev": true - }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "dev": true - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/electron-to-chromium": { - "version": "1.5.180", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz", - "integrity": "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==", - "dev": true - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/giget": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz", - "integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==", - "dev": true, - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "defu": "^6.1.4", - "node-fetch-native": "^1.6.6", - "nypm": "^0.5.4", - "pathe": "^2.0.3", - "tar": "^6.2.1" - }, - "bin": { - "giget": "dist/cli.mjs" - } - }, - "node_modules/giget/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-entities": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", - "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", - "dev": true - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-what": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", - "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", - "dev": true, - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "node_modules/merge-anything": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.7.tgz", - "integrity": "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==", - "dev": true, - "dependencies": { - "is-what": "^4.1.8" - }, - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mlly": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", - "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", - "dev": true, - "dependencies": { - "acorn": "^8.14.0", - "pathe": "^2.0.1", - "pkg-types": "^1.3.0", - "ufo": "^1.5.4" - } - }, - "node_modules/mlly/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, - "node_modules/node-fetch-native": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz", - "integrity": "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==", - "dev": true - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nypm": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz", - "integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==", - "dev": true, - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "tinyexec": "^0.3.2", - "ufo": "^1.5.4" - }, - "bin": { - "nypm": "dist/cli.mjs" - }, - "engines": { - "node": "^14.16.0 || >=16.10.0" - } - }, - "node_modules/nypm/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/ohash": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", - "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", - "dev": true - }, - "node_modules/open": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", - "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", - "dev": true, - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/pkg-types/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/rc9": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "dev": true, - "dependencies": { - "defu": "^6.1.4", - "destr": "^2.0.3" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", - "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.44.2", - "@rollup/rollup-android-arm64": "4.44.2", - "@rollup/rollup-darwin-arm64": "4.44.2", - "@rollup/rollup-darwin-x64": "4.44.2", - "@rollup/rollup-freebsd-arm64": "4.44.2", - "@rollup/rollup-freebsd-x64": "4.44.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", - "@rollup/rollup-linux-arm-musleabihf": "4.44.2", - "@rollup/rollup-linux-arm64-gnu": "4.44.2", - "@rollup/rollup-linux-arm64-musl": "4.44.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", - "@rollup/rollup-linux-riscv64-gnu": "4.44.2", - "@rollup/rollup-linux-riscv64-musl": "4.44.2", - "@rollup/rollup-linux-s390x-gnu": "4.44.2", - "@rollup/rollup-linux-x64-gnu": "4.44.2", - "@rollup/rollup-linux-x64-musl": "4.44.2", - "@rollup/rollup-win32-arm64-msvc": "4.44.2", - "@rollup/rollup-win32-ia32-msvc": "4.44.2", - "@rollup/rollup-win32-x64-msvc": "4.44.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-applescript": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/seroval": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", - "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", - "engines": { - "node": ">=10" - } - }, - "node_modules/seroval-plugins": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.2.tgz", - "integrity": "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "seroval": "^1.0" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/solid-js": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.7.tgz", - "integrity": "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==", - "dependencies": { - "csstype": "^3.1.0", - "seroval": "~1.3.0", - "seroval-plugins": "~1.3.0" - } - }, - "node_modules/solid-refresh": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/solid-refresh/-/solid-refresh-0.6.3.tgz", - "integrity": "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==", - "dev": true, - "dependencies": { - "@babel/generator": "^7.23.6", - "@babel/helper-module-imports": "^7.22.15", - "@babel/types": "^7.23.6" - }, - "peerDependencies": { - "solid-js": "^1.3" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "dev": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.6", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/validate-html-nesting": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/validate-html-nesting/-/validate-html-nesting-1.2.3.tgz", - "integrity": "sha512-kdkWdCl6eCeLlRShJKbjVOU2kFKxMF8Ghu50n+crEoyx+VKm3FxAxF9z4DCy6+bbTOqNW0+jcIYRnjoIRzigRw==", - "dev": true - }, - "node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", - "dev": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-plugin-solid": { - "version": "2.11.7", - "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.7.tgz", - "integrity": "sha512-5TgK1RnE449g0Ryxb9BXqem89RSy7fE8XGVCo+Gw84IHgPuPVP7nYNP6WBVAaY/0xw+OqfdQee+kusL0y3XYNg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.23.3", - "@types/babel__core": "^7.20.4", - "babel-preset-solid": "^1.8.4", - "merge-anything": "^5.1.7", - "solid-refresh": "^0.6.3", - "vitefu": "^1.0.4" - }, - "peerDependencies": { - "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", - "solid-js": "^1.7.2", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "@testing-library/jest-dom": { - "optional": true - } - } - }, - "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "dev": true, - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, - "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "dev": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - } - } -} diff --git a/examples/file-service-wasm/typescript-example/package.json b/examples/file-service-wasm/typescript-example/package.json deleted file mode 100644 index 10d1696..0000000 --- a/examples/file-service-wasm/typescript-example/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "file-service-solidjs-app", - "version": "0.1.0", - "description": "SolidJS TypeScript app for file service", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "generate": "@hey-api/openapi-ts" - }, - "dependencies": { - "solid-js": "^1.8.11", - "@solidjs/router": "^0.10.5" - }, - "devDependencies": { - "@types/node": "^20.10.5", - "typescript": "^5.3.3", - "vite": "^5.0.10", - "vite-plugin-solid": "^2.8.0", - "autoprefixer": "^10.4.16", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "@hey-api/openapi-ts": "^0.73.0" - } -} \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/postcss.config.js b/examples/file-service-wasm/typescript-example/postcss.config.js deleted file mode 100644 index e99ebc2..0000000 --- a/examples/file-service-wasm/typescript-example/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/src/App.tsx b/examples/file-service-wasm/typescript-example/src/App.tsx deleted file mode 100644 index fee2571..0000000 --- a/examples/file-service-wasm/typescript-example/src/App.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { createSignal } from 'solid-js'; -import FileUpload from './components/FileUpload'; -import FileList from './components/FileList'; -import AuthForm from './components/AuthForm'; -import { useAuth } from './stores/auth'; -import type { UploadResponse } from './lib/client'; - -function App() { - const { isAuthenticated } = useAuth(); - const [files, setFiles] = createSignal([]); - const [activeTab, setActiveTab] = createSignal<'public' | 'secure'>('public'); - - const handleUploadSuccess = (response: UploadResponse) => { - setFiles((prev) => [...prev, response]); - }; - - const tabClass = (tab: 'public' | 'secure') => - `px-4 py-2 font-medium rounded-t-lg transition-colors ${ - activeTab() === tab - ? 'bg-white text-blue-600 border-b-2 border-blue-600' - : 'bg-gray-100 text-gray-600 hover:bg-gray-200' - }`; - - return ( -
- {/* Header */} -
-
-
-

- File Service Demo -

-
- SolidJS + OpenAPI + Rust -
-
-
-
- - {/* Main Content */} -
-
- {/* Authentication Section */} -
-

- Authentication -

- -
- - {/* Upload Section */} -
-

- File Upload -

- -
-
- - -
-
- -
- -
-
- - {/* Files Section */} -
-

- Uploaded Files -

- -
-
-
- - {/* Footer */} - -
- ); -} - -export default App; \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/src/components/AuthForm.tsx b/examples/file-service-wasm/typescript-example/src/components/AuthForm.tsx deleted file mode 100644 index 73153c2..0000000 --- a/examples/file-service-wasm/typescript-example/src/components/AuthForm.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { createSignal } from 'solid-js'; -import { useAuth } from '../stores/auth'; - -export default function AuthForm() { - const { token, setToken, isAuthenticated } = useAuth(); - const [inputToken, setInputToken] = createSignal(''); - - const handleSubmit = (e: Event) => { - e.preventDefault(); - const tokenValue = inputToken().trim(); - setToken(tokenValue || null); - if (!tokenValue) { - setInputToken(''); - } - }; - - const handleLogout = () => { - setToken(null); - setInputToken(''); - }; - - if (isAuthenticated()) { - return ( -
-
-
- - - - Authenticated -
- -
-

- Token: {token()?.substring(0, 20)}... -

-
- ); - } - - return ( -
- -
- setInputToken(e.currentTarget.value)} - placeholder="Bearer token" - class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - -
-

- Leave empty for public access or use "validtoken" for authenticated access -

-
- ); -} \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/src/components/FileList.tsx b/examples/file-service-wasm/typescript-example/src/components/FileList.tsx deleted file mode 100644 index 35246ee..0000000 --- a/examples/file-service-wasm/typescript-example/src/components/FileList.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { createSignal, For, Show } from 'solid-js'; -import { getClient } from '../lib/client'; - -interface FileItem { - file_id: string; - file_name: string; - size: number; - uploaded_at?: string; -} - -interface FileListProps { - files: FileItem[]; - requireAuth?: boolean; -} - -export default function FileList(props: FileListProps) { - const [downloading, setDownloading] = createSignal(null); - const [error, setError] = createSignal(null); - - const handleDownload = async (fileId: string, fileName: string) => { - setDownloading(fileId); - setError(null); - - try { - const client = await getClient(); - // For now, all downloads use the public endpoint - // In a real app, you'd need different endpoints for secure downloads - const blob = await client.download(fileId); - - // Create a download link - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - a.click(); - URL.revokeObjectURL(url); - } catch (err) { - setError(err instanceof Error ? err.message : 'Download failed'); - } finally { - setDownloading(null); - } - }; - - const formatSize = (bytes: number) => { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / 1024 / 1024).toFixed(1) + ' MB'; - }; - - return ( -
- -
- {error()} -
-
- - -
- - - -

No files uploaded yet

-
-
- - 0}> -
- - - - - - - - - - - - {(file) => ( - - - - - - - )} - - -
- File Name - - Size - - File ID - - Actions -
- {file.file_name} - - {formatSize(file.size)} - - {file.file_id} - - -
-
-
-
- ); -} \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/src/components/FileUpload.tsx b/examples/file-service-wasm/typescript-example/src/components/FileUpload.tsx deleted file mode 100644 index 43f73c2..0000000 --- a/examples/file-service-wasm/typescript-example/src/components/FileUpload.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { createSignal, Show } from 'solid-js'; -import { getClient, type UploadResponse } from '../lib/client'; - -interface FileUploadProps { - onUploadSuccess?: (response: UploadResponse) => void; - requireAuth?: boolean; -} - -export default function FileUpload(props: FileUploadProps) { - const [file, setFile] = createSignal(null); - const [uploading, setUploading] = createSignal(false); - const [error, setError] = createSignal(null); - const [dragActive, setDragActive] = createSignal(false); - - const handleDrag = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.type === "dragenter" || e.type === "dragover") { - setDragActive(true); - } else if (e.type === "dragleave") { - setDragActive(false); - } - }; - - const handleDrop = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setDragActive(false); - - if (e.dataTransfer?.files && e.dataTransfer.files[0]) { - setFile(e.dataTransfer.files[0]); - setError(null); - } - }; - - const handleFileSelect = (e: Event) => { - const target = e.target as HTMLInputElement; - if (target.files && target.files[0]) { - setFile(target.files[0]); - setError(null); - } - }; - - const handleUpload = async () => { - const selectedFile = file(); - if (!selectedFile) return; - - setUploading(true); - setError(null); - - try { - const client = await getClient(); - const response = props.requireAuth - ? await client.upload_profile_picture(selectedFile) - : await client.upload(selectedFile); - - props.onUploadSuccess?.(response as UploadResponse); - setFile(null); - } catch (err) { - setError(err instanceof Error ? err.message : 'Upload failed'); - } finally { - setUploading(false); - } - }; - - return ( -
-
- - - - - -
-

- {file()!.name} -

-

- {(file()!.size / 1024 / 1024).toFixed(2)} MB -

-
-
-
- - -
- {error()} -
-
- - -
- ); -} \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/src/example.ts b/examples/file-service-wasm/typescript-example/src/example.ts index 4cb5d41..29a6f8f 100644 --- a/examples/file-service-wasm/typescript-example/src/example.ts +++ b/examples/file-service-wasm/typescript-example/src/example.ts @@ -1,55 +1,82 @@ -import { fileService } from './fileClient'; - -/** - * Example usage of the generated TypeScript file service client - */ -export async function exampleUsage() { - try { - // Example: Upload a file - const fileInput = document.createElement('input'); - fileInput.type = 'file'; - - fileInput.onchange = async (event) => { - const target = event.target as HTMLInputElement; - const file = target.files?.[0]; - - if (!file) return; - - console.log('Uploading file:', file.name); - - // Upload file (no auth required) - const uploadResult = await fileService.uploadFile(file); - console.log('Upload successful:', uploadResult); - - // Download the same file - console.log('Downloading file:', uploadResult.file_id); - await fileService.downloadAndSave(uploadResult.file_id, uploadResult.file_name); - - // Example with authentication - fileService.setBearerToken('your-jwt-token-here'); - - // Upload profile picture (requires auth) - try { - const profileResult = await fileService.uploadProfilePicture(file); - console.log('Profile picture upload successful:', profileResult); - } catch (error) { - console.error('Profile upload failed (expected without valid token):', error); - } - }; - - // Trigger file picker - fileInput.click(); - - } catch (error) { - console.error('File operation failed:', error); +import { + downloadDownloadFileId, + downloadDownloadSecureFileId, + uploadUpload, + uploadUploadProfilePicture, +} from './generated'; +import type { BinaryFileResponse, UploadResponse } from './generated'; + +const BASE_URL = 'http://localhost:3000/api/documents'; + +type ApiResponse = { + data?: T; + error?: unknown; +}; + +function requireData(operation: string, response: ApiResponse): T { + if (response.error) { + throw new Error(`${operation} failed: ${JSON.stringify(response.error)}`); + } + + if (response.data === undefined) { + throw new Error(`${operation} returned no data`); } + + return response.data; +} + +export async function uploadFile( + file: Blob | File, + baseUrl = BASE_URL, +): Promise { + const response = await uploadUpload({ + baseUrl, + body: { file }, + }); + + return requireData('POST /upload', response); } -// Auto-run example if in browser -if (typeof window !== 'undefined') { - window.addEventListener('load', () => { - console.log('File service client ready! Call exampleUsage() to test.'); - // Uncomment to auto-run: - // exampleUsage(); +export async function uploadProfilePicture( + file: Blob | File, + bearerToken: string, + baseUrl = BASE_URL, +): Promise { + const response = await uploadUploadProfilePicture({ + baseUrl, + headers: { + Authorization: `Bearer ${bearerToken}`, + }, + body: { file }, }); -} \ No newline at end of file + + return requireData('POST /upload_profile_picture', response); +} + +export async function downloadFile( + fileId: string, + baseUrl = BASE_URL, +): Promise { + const response = await downloadDownloadFileId({ + baseUrl, + path: { file_id: fileId }, + }); + + return requireData('GET /download/{file_id}', response); +} + +export async function downloadSecureFile( + fileId: string, + bearerToken: string, + baseUrl = BASE_URL, +): Promise { + const response = await downloadDownloadSecureFileId({ + baseUrl, + headers: { + Authorization: `Bearer ${bearerToken}`, + }, + path: { file_id: fileId }, + }); + + return requireData('GET /download_secure/{file_id}', response); +} diff --git a/examples/file-service-wasm/typescript-example/src/fileClient.ts b/examples/file-service-wasm/typescript-example/src/fileClient.ts deleted file mode 100644 index 5ff3e6a..0000000 --- a/examples/file-service-wasm/typescript-example/src/fileClient.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { createClient } from './generated/client'; -import { uploadUpload, downloadDownloadFileId, uploadUploadProfilePicture } from './generated/sdk.gen'; -import type { UploadResponse } from './generated/types.gen'; - -// Create a client instance -const client = createClient({ - baseUrl: 'http://localhost:8080/api/documents', -}); - -export class FileServiceClient { - private bearerToken?: string; - - setBearerToken(token: string) { - this.bearerToken = token; - } - - /** - * Upload a file without authentication - */ - async uploadFile(file: File): Promise { - const { data, error } = await uploadUpload({ - client, - body: { file }, - }); - - if (error) { - throw new Error(`Upload failed: ${error}`); - } - - return data!; - } - - /** - * Upload a profile picture (requires authentication) - */ - async uploadProfilePicture(file: File): Promise { - if (!this.bearerToken) { - throw new Error('Authentication required for profile picture upload'); - } - - const { data, error } = await uploadUploadProfilePicture({ - client, - body: { file }, - headers: { - Authorization: `Bearer ${this.bearerToken}`, - }, - }); - - if (error) { - throw new Error(`Profile picture upload failed: ${error}`); - } - - return data!; - } - - /** - * Download a file by ID - */ - async downloadFile(fileId: string): Promise { - const { data, error } = await downloadDownloadFileId({ - client, - path: { file_id: fileId }, - }); - - if (error) { - throw new Error(`Download failed: ${error}`); - } - - return data! as Blob; - } - - /** - * Download a file and trigger browser download - */ - async downloadAndSave(fileId: string, filename?: string): Promise { - const blob = await this.downloadFile(fileId); - - // Create download link - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename || `file-${fileId}`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - } -} - -// Export a default instance -export const fileService = new FileServiceClient(); \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/src/generated/.gitignore b/examples/file-service-wasm/typescript-example/src/generated/.gitignore deleted file mode 100644 index 72e8ffc..0000000 --- a/examples/file-service-wasm/typescript-example/src/generated/.gitignore +++ /dev/null @@ -1 +0,0 @@ -* diff --git a/examples/file-service-wasm/typescript-example/src/index.css b/examples/file-service-wasm/typescript-example/src/index.css deleted file mode 100644 index bd6213e..0000000 --- a/examples/file-service-wasm/typescript-example/src/index.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/src/index.tsx b/examples/file-service-wasm/typescript-example/src/index.tsx deleted file mode 100644 index c4a0f92..0000000 --- a/examples/file-service-wasm/typescript-example/src/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/* @refresh reload */ -import { render } from 'solid-js/web'; -import './index.css'; -import App from './App'; - -const root = document.getElementById('root'); - -if (import.meta.env.DEV && !(root instanceof HTMLElement)) { - throw new Error( - 'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?', - ); -} - -render(() => , root!); \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/src/lib/client.ts b/examples/file-service-wasm/typescript-example/src/lib/client.ts deleted file mode 100644 index 7ede062..0000000 --- a/examples/file-service-wasm/typescript-example/src/lib/client.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createClient } from '../generated/client'; -import { uploadUpload, downloadDownloadFileId, uploadUploadProfilePicture } from '../generated/sdk.gen'; -import type { UploadResponse as GeneratedUploadResponse } from '../generated/types.gen'; - -// Re-export the generated type -export type UploadResponse = GeneratedUploadResponse; - -// Create the client instance -const client = createClient({ - baseUrl: `${window.location.origin}/api/documents`, -}); - -// Simple client wrapper that matches the WASM client interface -export class DocumentServiceClient { - private bearerToken?: string; - - setBearerToken(token: string) { - this.bearerToken = token; - } - - async upload(file: File): Promise { - const { data, error } = await uploadUpload({ - client, - body: { file }, - }); - - if (error) { - throw new Error(`Upload failed: ${JSON.stringify(error)}`); - } - - return data!; - } - - async upload_profile_picture(file: File): Promise { - if (!this.bearerToken) { - throw new Error('Authentication required for profile picture upload'); - } - - const { data, error } = await uploadUploadProfilePicture({ - client, - body: { file }, - headers: { - Authorization: `Bearer ${this.bearerToken}`, - }, - }); - - if (error) { - throw new Error(`Profile picture upload failed: ${JSON.stringify(error)}`); - } - - return data!; - } - - async download(fileId: string): Promise { - const { data, error } = await downloadDownloadFileId({ - client, - path: { file_id: fileId }, - }); - - if (error) { - throw new Error(`Download failed: ${JSON.stringify(error)}`); - } - - return data! as Blob; - } -} - -// Global client instance -let clientInstance: DocumentServiceClient | null = null; - -export async function getClient(): Promise { - if (!clientInstance) { - clientInstance = new DocumentServiceClient(); - } - - return clientInstance; -} \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/src/stores/auth.ts b/examples/file-service-wasm/typescript-example/src/stores/auth.ts deleted file mode 100644 index e0f80a4..0000000 --- a/examples/file-service-wasm/typescript-example/src/stores/auth.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createSignal } from 'solid-js'; -import { getClient } from '../lib/client'; - -const [token, setToken] = createSignal( - localStorage.getItem('auth_token') -); - -export function useAuth() { - const setAuthToken = async (newToken: string | null) => { - setToken(newToken); - - if (newToken) { - localStorage.setItem('auth_token', newToken); - } else { - localStorage.removeItem('auth_token'); - } - - const client = await getClient(); - if (newToken) { - client.setBearerToken(newToken); - } - }; - - const isAuthenticated = () => token() !== null; - - return { - token, - setToken: setAuthToken, - isAuthenticated, - }; -} \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/tailwind.config.js b/examples/file-service-wasm/typescript-example/tailwind.config.js deleted file mode 100644 index 89a305e..0000000 --- a/examples/file-service-wasm/typescript-example/tailwind.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [], -} \ No newline at end of file diff --git a/examples/file-service-wasm/typescript-example/vite.config.ts b/examples/file-service-wasm/typescript-example/vite.config.ts deleted file mode 100644 index f844f16..0000000 --- a/examples/file-service-wasm/typescript-example/vite.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig } from 'vite'; -import solid from 'vite-plugin-solid'; - -export default defineConfig({ - plugins: [ - solid(), - ], - server: { - port: 3001, - proxy: { - '/api': { - target: 'http://localhost:3000', - changeOrigin: true, - } - }, - }, - build: { - target: 'esnext', - }, -}); \ No newline at end of file diff --git a/examples/oauth2-demo/README.md b/examples/oauth2-demo/README.md new file mode 100644 index 0000000..2aa82f6 --- /dev/null +++ b/examples/oauth2-demo/README.md @@ -0,0 +1,34 @@ +# OAuth2 Demo + +This example demonstrates a Google OAuth2 authorization-code flow with PKCE, +JWT session creation, and permission-protected JSON-RPC methods. + +## Crates + +- `api/` defines the JSON-RPC contract shared by the server. +- `server/` hosts the OAuth2 callback, static pages, session service, and API. + +## Configure + +Create the server-local environment file: + +```bash +cp examples/oauth2-demo/server/.env.example examples/oauth2-demo/server/.env +``` + +Edit `examples/oauth2-demo/server/.env` with your Google OAuth2 client ID, +client secret, redirect URI, and JWT secret. + +## Run + +From the workspace root: + +```bash +cargo run -p oauth2-demo-server --locked +``` + +The server loads `examples/oauth2-demo/server/.env` when run by package name and +starts at `http://localhost:3000`. + +See [server/README.md](server/README.md) for Google Cloud setup and API +usage examples. diff --git a/examples/oauth2-demo/api/Cargo.toml b/examples/oauth2-demo/api/Cargo.toml index e3d31f3..0ccc694 100644 --- a/examples/oauth2-demo/api/Cargo.toml +++ b/examples/oauth2-demo/api/Cargo.toml @@ -2,7 +2,13 @@ name = "oauth2-demo-api" version = "0.1.0" edition = "2024" -publish = ["kellnr"] +rust-version = "1.88" +description = "Shared JSON-RPC API contract for the Rust Agent Stack OAuth2 demo" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +publish = false +readme = "README.md" [features] default = ["server"] @@ -11,13 +17,13 @@ client = [] [dependencies] # JSON-RPC infrastructure -ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", features = ["server"] } -ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types" } +ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", features = ["server"] } +ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } # Web framework and utilities axum = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -schemars.workspace = true -reqwest.workspace = true \ No newline at end of file +schemars = { workspace = true } +reqwest = { workspace = true } diff --git a/examples/oauth2-demo/api/README.md b/examples/oauth2-demo/api/README.md new file mode 100644 index 0000000..ce213cd --- /dev/null +++ b/examples/oauth2-demo/api/README.md @@ -0,0 +1,36 @@ +# OAuth2 Demo API + +Shared JSON-RPC API contract for the [OAuth2 demo](../README.md). This crate defines the service payloads and uses `ras-jsonrpc-macro` to generate the `GoogleOAuth2Service` server trait and OpenRPC document consumed by the demo server. + +## Generated Service + +The contract in [src/lib.rs](src/lib.rs) includes permission-gated methods for: + +- current user information +- document listing +- document creation +- document deletion +- system status +- beta feature access + +The runnable OAuth2 server is documented in [../server/README.md](../server/README.md). + +## Permissions + +The generated service metadata declares these permission checks: + +- `user:read` +- `content:create` +- `admin:write` +- `system:admin` +- `beta:access` + +The server decides how OAuth2 identities map to those permissions. + +## Checks + +```bash +cargo check -p oauth2-demo-api --locked +cargo test -p oauth2-demo-api --locked +cargo clippy -p oauth2-demo-api --all-targets --all-features --locked -- -D warnings +``` diff --git a/examples/oauth2-demo/api/src/lib.rs b/examples/oauth2-demo/api/src/lib.rs index 8ae5f45..3c61120 100644 --- a/examples/oauth2-demo/api/src/lib.rs +++ b/examples/oauth2-demo/api/src/lib.rs @@ -147,3 +147,212 @@ jsonrpc_service!({ WITH_PERMISSIONS(["beta:access"]) get_beta_features(GetBetaFeaturesRequest) -> GetBetaFeaturesResponse, ] }); + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::BTreeMap; + + #[test] + fn list_documents_request_serializes_flat_optional_pagination() { + let request = ListDocumentsRequest { + limit: Some(25), + offset: None, + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ "limit": 25, "offset": null }) + ); + } + + #[test] + fn default_system_status_response_uses_demo_version() { + let response = GetSystemStatusResponse::default(); + + assert_eq!(response.status.uptime_seconds, 0); + assert_eq!(response.status.memory_usage_mb, 0); + assert_eq!(response.status.active_sessions, 0); + assert_eq!(response.status.version, "1.0.0"); + } + + #[test] + fn create_document_request_serializes_content_and_tags() { + let request = CreateDocumentRequest { + title: "Roadmap".to_string(), + content: "Launch the documented demo".to_string(), + tags: vec!["docs".to_string(), "demo".to_string()], + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "title": "Roadmap", + "content": "Launch the documented demo", + "tags": ["docs", "demo"] + }) + ); + } + + #[test] + fn beta_features_response_serializes_enabled_flags() { + let response = GetBetaFeaturesResponse { + features: vec![ + BetaFeature { + name: "workspace-search".to_string(), + description: "Search across workspace documents".to_string(), + enabled: true, + }, + BetaFeature { + name: "admin-dashboard".to_string(), + description: "Preview admin dashboard".to_string(), + enabled: false, + }, + ], + }; + + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "features": [ + { + "name": "workspace-search", + "description": "Search across workspace documents", + "enabled": true + }, + { + "name": "admin-dashboard", + "description": "Preview admin dashboard", + "enabled": false + } + ] + }) + ); + } + + #[test] + fn user_info_response_serializes_permissions_and_metadata() { + let response = GetUserInfoResponse { + user_id: "user-1".to_string(), + permissions: vec!["user:read".to_string(), "beta:access".to_string()], + metadata: Some(json!({ + "email": "alice@example.test", + "verified": true + })), + }; + + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "user_id": "user-1", + "permissions": ["user:read", "beta:access"], + "metadata": { + "email": "alice@example.test", + "verified": true + } + }) + ); + } + + #[test] + fn list_documents_response_serializes_documents_and_total() { + let response = ListDocumentsResponse { + documents: vec![DocumentInfo { + id: "doc-1".to_string(), + title: "Roadmap".to_string(), + created_at: "2026-05-23T12:00:00Z".to_string(), + tags: vec!["docs".to_string(), "demo".to_string()], + }], + total: 1, + }; + + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "documents": [{ + "id": "doc-1", + "title": "Roadmap", + "created_at": "2026-05-23T12:00:00Z", + "tags": ["docs", "demo"] + }], + "total": 1 + }) + ); + } + + #[test] + fn delete_document_response_default_is_unsuccessful_empty_message() { + let response = DeleteDocumentResponse::default(); + + assert!(!response.success); + assert!(response.message.is_empty()); + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "success": false, + "message": "" + }) + ); + } + + #[test] + fn generated_openrpc_documents_permissions_for_all_methods() { + let doc = generate_googleoauth2service_openrpc(); + let methods = doc["methods"].as_array().expect("methods array"); + + assert_eq!(doc["openrpc"], "1.3.2"); + assert_eq!(doc["info"]["title"], "GoogleOAuth2Service JSON-RPC API"); + + let permissions_by_method = methods + .iter() + .map(|method| { + let name = method["name"].as_str().expect("method name").to_string(); + let permissions = method + .get("x-permissions") + .and_then(|permissions| permissions.as_array()) + .map(|permissions| { + permissions + .iter() + .map(|permission| permission.as_str().expect("permission").to_string()) + .collect::>() + }) + .unwrap_or_default(); + (name, permissions) + }) + .collect::>(); + + assert_eq!( + permissions_by_method, + BTreeMap::from([ + ( + "create_document".to_string(), + vec!["content:create".to_string()] + ), + ( + "delete_document".to_string(), + vec!["admin:write".to_string()] + ), + ( + "get_beta_features".to_string(), + vec!["beta:access".to_string()] + ), + ( + "get_system_status".to_string(), + vec!["system:admin".to_string()] + ), + ("get_user_info".to_string(), vec![]), + ("list_documents".to_string(), vec!["user:read".to_string()]), + ]) + ); + } + + #[test] + fn generated_openrpc_uses_object_schema_for_user_metadata() { + let doc = generate_googleoauth2service_openrpc(); + let metadata = + &doc["components"]["schemas"]["GetUserInfoResponse"]["properties"]["metadata"]; + + assert_eq!(metadata["type"], json!("object")); + } +} diff --git a/examples/oauth2-demo/server/.env.example b/examples/oauth2-demo/server/.env.example index 21fedf8..58c30ec 100644 --- a/examples/oauth2-demo/server/.env.example +++ b/examples/oauth2-demo/server/.env.example @@ -1,13 +1,13 @@ # Google OAuth2 Configuration # Get these from https://console.cloud.google.com/ -GOOGLE_CLIENT_ID=your_google_client_id_here -GOOGLE_CLIENT_SECRET=your_google_client_secret_here +GOOGLE_CLIENT_ID=000000000000-example.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=GOCSPX-local-demo-secret # OAuth2 Configuration (optional) REDIRECT_URI=http://localhost:3000/auth/callback # JWT Configuration (optional) -JWT_SECRET=change-me-in-production-please +JWT_SECRET=oauth2-demo-local-secret-at-least-32-bytes # Server Configuration (optional) SERVER_HOST=0.0.0.0 diff --git a/examples/oauth2-demo/server/Cargo.toml b/examples/oauth2-demo/server/Cargo.toml index ff7ff2e..4f1c1c3 100644 --- a/examples/oauth2-demo/server/Cargo.toml +++ b/examples/oauth2-demo/server/Cargo.toml @@ -2,19 +2,25 @@ name = "oauth2-demo-server" version = "0.1.1" edition = "2024" +rust-version = "1.88" +description = "OAuth2 and JWT session demo server for Rust Agent Stack" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" publish = false +readme = "README.md" [dependencies] -oauth2-demo-api = { path = "../api"} +oauth2-demo-api = { path = "../api", version = "0.1.0" } # JSON-RPC infrastructure -ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro" } -ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types" } +ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0" } +ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } # Identity management -ras-identity-core = { path = "../../../crates/core/ras-identity-core" } -ras-identity-oauth2 = { path = "../../../crates/identity/ras-identity-oauth2" } -ras-identity-session = { path = "../../../crates/identity/ras-identity-session" } +ras-identity-core = { path = "../../../crates/core/ras-identity-core", version = "0.1.1" } +ras-identity-oauth2 = { path = "../../../crates/identity/ras-identity-oauth2", version = "0.1.2" } +ras-identity-session = { path = "../../../crates/identity/ras-identity-session", version = "0.1.1" } # Web framework and utilities axum = { workspace = true } @@ -26,8 +32,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } -jsonwebtoken = { workspace = true } -schemars.workspace = true +schemars = { workspace = true } # Additional dependencies for this example tracing-subscriber = { workspace = true } @@ -37,5 +42,5 @@ dotenvy = { workspace = true } mime_guess = { workspace = true } [build-dependencies] -oauth2-demo-api = { path = "../api"} -serde_json.workspace = true \ No newline at end of file +oauth2-demo-api = { path = "../api", version = "0.1.0" } +serde_json = { workspace = true } diff --git a/examples/oauth2-demo/server/README.md b/examples/oauth2-demo/server/README.md index aed60b7..3b3d1a2 100644 --- a/examples/oauth2-demo/server/README.md +++ b/examples/oauth2-demo/server/README.md @@ -1,19 +1,19 @@ # Google OAuth2 Example -A comprehensive example demonstrating Google OAuth2 integration with the Rust Agent Stack identity management system. This example showcases the complete OAuth2 Authorization Code flow with PKCE, JWT session management, and role-based access control through a JSON-RPC API. +An example demonstrating Google OAuth2 integration with the Rust Agent Stack identity management system. It covers the OAuth2 Authorization Code flow with PKCE, JWT session management, and role-based access control through a JSON-RPC API. ## Features -- 🔐 **Secure OAuth2 Flow**: Authorization Code with PKCE for enhanced security -- 🎯 **Role-Based Permissions**: Dynamic permission assignment based on user attributes -- 🚀 **JSON-RPC API**: Type-safe API endpoints with compile-time validation -- ⚡ **JWT Session Management**: Stateless authentication with embedded permissions -- 🛡️ **CSRF Protection**: State parameter validation and secure token handling -- 📚 **Interactive Documentation**: Built-in API documentation and testing interface +- **Secure OAuth2 flow**: Authorization Code with PKCE for enhanced security +- **Role-based permissions**: Dynamic permission assignment based on user attributes +- **JSON-RPC API**: Type-safe API endpoints with compile-time validation +- **JWT session management**: Stateless authentication with embedded permissions +- **CSRF protection**: State parameter validation and secure token handling +- **Interactive documentation**: Built-in API documentation and testing interface ## Architecture -This example demonstrates the complete integration of several Rust Agent Stack components: +This example demonstrates how several Rust Agent Stack components fit together: ``` ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ @@ -65,10 +65,10 @@ This example demonstrates the complete integration of several Rust Agent Stack c 2. Edit `.env` with your Google OAuth2 credentials: ```bash - GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com - GOOGLE_CLIENT_SECRET=your_client_secret + GOOGLE_CLIENT_ID=000000000000-example.apps.googleusercontent.com + GOOGLE_CLIENT_SECRET=GOCSPX-local-demo-secret REDIRECT_URI=http://localhost:3000/auth/callback - JWT_SECRET=your-super-secret-jwt-key-change-in-production + JWT_SECRET=oauth2-demo-local-secret-at-least-32-bytes ``` ### 3. Run the Application @@ -77,10 +77,10 @@ From the workspace root: ```bash # Build the example -cargo build -p google-oauth-example +cargo build -p oauth2-demo-server --locked # Run the server -cargo run -p google-oauth-example +cargo run -p oauth2-demo-server --locked ``` The server will start on `http://localhost:3000`. @@ -99,12 +99,14 @@ The server will start on `http://localhost:3000`. The application provides several test endpoints demonstrating different permission levels: +Set `JWT_TOKEN` to the token shown after completing the browser login flow. + #### Basic User Operations ```bash # Get user information curl -X POST http://localhost:3000/api/rpc \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Authorization: Bearer $JWT_TOKEN" \ -d '{ "jsonrpc": "2.0", "method": "get_user_info", @@ -115,7 +117,7 @@ curl -X POST http://localhost:3000/api/rpc \ # List documents curl -X POST http://localhost:3000/api/rpc \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Authorization: Bearer $JWT_TOKEN" \ -d '{ "jsonrpc": "2.0", "method": "list_documents", @@ -129,13 +131,13 @@ curl -X POST http://localhost:3000/api/rpc \ # Create document (requires content:create permission) curl -X POST http://localhost:3000/api/rpc \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Authorization: Bearer $JWT_TOKEN" \ -d '{ "jsonrpc": "2.0", "method": "create_document", "params": { "title": "My New Document", - "content": "Document content here...", + "content": "This document was created through the JSON-RPC demo API.", "tags": ["example", "api"] }, "id": 3 @@ -147,7 +149,7 @@ curl -X POST http://localhost:3000/api/rpc \ # Delete document (requires admin:write permission) curl -X POST http://localhost:3000/api/rpc \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Authorization: Bearer $JWT_TOKEN" \ -d '{ "jsonrpc": "2.0", "method": "delete_document", @@ -158,7 +160,7 @@ curl -X POST http://localhost:3000/api/rpc \ # System status (requires system:admin permission) curl -X POST http://localhost:3000/api/rpc \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Authorization: Bearer $JWT_TOKEN" \ -d '{ "jsonrpc": "2.0", "method": "get_system_status", @@ -201,7 +203,7 @@ To test different permission levels, you can: ### JWT Security - **Configurable JWT secrets** (change in production!) - **Token expiration** with configurable TTL -- **Session tracking** for token revocation +- **JWT session creation** with permissions embedded in claims - **Embedded permissions** for stateless authorization ### API Security @@ -215,23 +217,22 @@ To test different permission levels, you can: ### Project Structure ``` -examples/google-oauth-example/ -├── src/ -│ ├── main.rs # Main application and server setup -│ ├── permissions.rs # Custom permission provider -│ └── service.rs # JSON-RPC service definitions -├── static/ -│ ├── index.html # Frontend interface -│ └── api-docs.html # API documentation -├── .env.example # Environment configuration template -├── Cargo.toml # Dependencies and metadata -└── README.md # This file +examples/oauth2-demo/ +├── api/ # Shared JSON-RPC API definitions +└── server/ + ├── src/ + │ ├── main.rs # Main application and server setup + │ ├── permissions.rs # Custom permission provider + │ └── service.rs # JSON-RPC service implementation + ├── static/ # Frontend and API documentation pages + ├── Cargo.toml # Dependencies and metadata + └── README.md # This file ``` ### Key Dependencies -- **rust-identity-oauth2**: OAuth2 provider implementation -- **rust-identity-session**: JWT session management +- **ras-identity-oauth2**: OAuth2 provider implementation +- **ras-identity-session**: JWT session management - **ras-jsonrpc-macro**: Type-safe JSON-RPC service generation - **axum**: Web framework for HTTP handling - **tower-http**: Middleware for CORS and static files @@ -240,20 +241,20 @@ examples/google-oauth-example/ ```bash # Run all tests -cargo test -p google-oauth-example +cargo test -p oauth2-demo-server --locked # Run with output -cargo test -p google-oauth-example -- --nocapture +cargo test -p oauth2-demo-server --locked -- --nocapture # Run specific test -cargo test -p google-oauth-example test_permissions +cargo test -p oauth2-demo-server --locked test_basic_user_permissions ``` ### Development Tips 1. **Enable debug logging**: ```bash - RUST_LOG=debug cargo run -p google-oauth-example + RUST_LOG=debug cargo run -p oauth2-demo-server --locked ``` 2. **Use ngrok for HTTPS testing**: @@ -310,13 +311,20 @@ cargo test -p google-oauth-example test_permissions ## Related Examples -- **basic-jsonrpc-service**: Simpler JSON-RPC service example -- Check other examples in the `/examples` directory for additional patterns +- [`examples/basic-jsonrpc`](../../basic-jsonrpc/) - Simpler JSON-RPC service with authentication and generated OpenRPC docs +- [`examples/bidirectional-chat`](../../bidirectional-chat/) - WebSocket authentication and session-backed login ## Contributing This example is part of the Rust Agent Stack project. See the main project README for contribution guidelines. +## Checks + +```bash +cargo test -p oauth2-demo-server --locked +cargo clippy -p oauth2-demo-server --all-targets --all-features --locked -- -D warnings +``` + ## License This example follows the same license as the Rust Agent Stack project. diff --git a/examples/oauth2-demo/server/build.rs b/examples/oauth2-demo/server/build.rs index 22c3b56..d337383 100644 --- a/examples/oauth2-demo/server/build.rs +++ b/examples/oauth2-demo/server/build.rs @@ -1,21 +1,5 @@ -use oauth2_demo_api::*; -use std::fs; - fn main() { - // Create target/openrpc directory if it doesn't exist - fs::create_dir_all("openrpc").expect("Failed to create directory"); - - // Generate OpenRPC document - let openrpc_doc = generate_googleoauth2service_openrpc(); - - println!("✅ OpenRPC document generated successfully!"); - println!("\n📋 Generated OpenRPC content:"); - println!("{}", serde_json::to_string_pretty(&openrpc_doc).unwrap()); - - let file = generate_googleoauth2service_openrpc(); - fs::write( - "openrpc/google-oauth2.openrpc.json", - serde_json::to_string_pretty(&file).unwrap(), - ) - .unwrap(); + println!("cargo:rerun-if-changed=../api/src/lib.rs"); + oauth2_demo_api::generate_googleoauth2service_openrpc_to_file() + .expect("failed to generate OAuth2 demo OpenRPC document"); } diff --git a/examples/oauth2-demo/server/examples/test_openrpc.rs b/examples/oauth2-demo/server/examples/test_openrpc.rs deleted file mode 100644 index c2155c7..0000000 --- a/examples/oauth2-demo/server/examples/test_openrpc.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::fs; - -// Simple test to verify OpenRPC generation works -fn main() { - use oauth2_demo_api::*; - - // Create target/openrpc directory if it doesn't exist - fs::create_dir_all("openrpc").expect("Failed to create directory"); - - // Generate OpenRPC document - let openrpc_doc = generate_googleoauth2service_openrpc(); - - println!("✅ OpenRPC document generated successfully!"); - println!("\n📋 Generated OpenRPC content:"); - println!("{}", serde_json::to_string_pretty(&openrpc_doc).unwrap()); - - let file = generate_googleoauth2service_openrpc(); - fs::write("openrpc.json", serde_json::to_string_pretty(&file).unwrap()).unwrap(); -} diff --git a/examples/oauth2-demo/server/src/main.rs b/examples/oauth2-demo/server/src/main.rs index 038be11..1e2bcd1 100644 --- a/examples/oauth2-demo/server/src/main.rs +++ b/examples/oauth2-demo/server/src/main.rs @@ -11,7 +11,7 @@ use ras_identity_oauth2::{ InMemoryStateStore, OAuth2AuthPayload, OAuth2Config, OAuth2Provider, OAuth2ProviderConfig, OAuth2Response, }; -use ras_identity_session::{JwtAuthProvider, SessionConfig, SessionService}; +use ras_identity_session::{JwtAlgorithm, JwtAuthProvider, SessionConfig, SessionService}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; @@ -160,7 +160,7 @@ fn create_session_service(config: &AppConfig) -> Result { jwt_ttl: chrono::Duration::hours(24), refresh_enabled: true, enforce_active_sessions: false, - algorithm: jsonwebtoken::Algorithm::HS256, + algorithm: JwtAlgorithm::HS256, }; let permissions_provider = Arc::new(GoogleOAuth2Permissions::new()); @@ -256,39 +256,15 @@ async fn oauth2_callback_handler( info!("OAuth2 callback successful, redirecting with token"); - // Redirect to success page with token (in a real app, you'd handle this more securely) + // The success page immediately moves the token into sessionStorage and + // clears it from the URL. A production app should use its own token + // delivery policy. Ok(Redirect::to(&format!("/success?token={}", token))) } -/// Handler for success page -async fn success_handler(Query(params): Query>) -> Html { - let token = params.get("token").cloned().unwrap_or_default(); - let html = format!( - r#" - - - - OAuth2 Success - - - -

OAuth2 Authentication Successful!

-

You have successfully authenticated with Google OAuth2.

-

JWT Token:

-
{}
-

You can now use this token to make authenticated requests to the JSON-RPC API.

- Back to Home - View API Documentation - - - "#, - token - ); - Html(html) +/// Handler for success page. +async fn success_handler() -> Html<&'static str> { + Html(include_str!("../static/success.html")) } /// Handler for error page @@ -325,8 +301,12 @@ async fn main() -> Result<()> { // Initialize tracing tracing_subscriber::fmt::init(); - // Load environment variables from .env file if present - let _ = dotenvy::dotenv(); + // Load the example-local .env when run from the workspace root with + // `cargo run --locked -p oauth2-demo-server`; fall back to the current directory. + let manifest_env = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env"); + if dotenvy::from_path(&manifest_env).is_err() { + let _ = dotenvy::dotenv(); + } // Load configuration let config = AppConfig::from_env()?; @@ -371,12 +351,9 @@ async fn main() -> Result<()> { ) .with_state(app_state); - // Add the JSON-RPC API routes to a separate router that will run on a different port - // or integrate them directly into the main app + // Build the JSON-RPC API router and mount it under the same Axum app. let api_router = service::create_api_router(auth_provider); - // For this example, let's run both on the same server by merging them manually - // We'll create a new combined router let combined_app = Router::new().merge(app).nest("/api", api_router); // Start the server @@ -397,3 +374,32 @@ async fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod static_page_tests { + const INDEX_HTML: &str = include_str!("../static/index.html"); + const API_DOCS_HTML: &str = include_str!("../static/api-docs.html"); + const SUCCESS_HTML: &str = include_str!("../static/success.html"); + + #[test] + fn oauth_static_pages_do_not_persist_jwt_in_local_storage() { + for (name, html) in [ + ("index", INDEX_HTML), + ("api-docs", API_DOCS_HTML), + ("success", SUCCESS_HTML), + ] { + assert!( + !html.contains("localStorage.getItem('jwt_token')") + && !html.contains("localStorage.setItem('jwt_token'") + && !html.contains("localStorage.removeItem('jwt_token'"), + "{name} must not persist JWTs in localStorage" + ); + } + } + + #[test] + fn success_page_api_docs_action_preserves_session_token() { + assert!(SUCCESS_HTML.contains("sessionStorage.setItem('jwt_token', jwtToken)")); + assert!(SUCCESS_HTML.contains("onclick=\"storeAndRedirect()\">Interactive API Docs")); + } +} diff --git a/examples/oauth2-demo/server/src/permissions.rs b/examples/oauth2-demo/server/src/permissions.rs index 547e251..dcf2416 100644 --- a/examples/oauth2-demo/server/src/permissions.rs +++ b/examples/oauth2-demo/server/src/permissions.rs @@ -50,19 +50,18 @@ impl GoogleOAuth2Permissions { // Check metadata for additional context if let Some(metadata) = &identity.metadata { // If user has verified email, grant additional permissions - if let Some(email_verified) = metadata.get("email_verified") { - if email_verified.as_bool().unwrap_or(false) { - permissions.push("email:verified".to_string()); - } + if let Some(email_verified) = metadata.get("email_verified") + && email_verified.as_bool().unwrap_or(false) + { + permissions.push("email:verified".to_string()); } // Example: Grant permissions based on other OAuth2 claims - if let Some(locale) = metadata.get("locale") { - if let Some(locale_str) = locale.as_str() { - if locale_str.starts_with("en") { - permissions.push("content:english".to_string()); - } - } + if let Some(locale) = metadata.get("locale") + && let Some(locale_str) = locale.as_str() + && locale_str.starts_with("en") + { + permissions.push("content:english".to_string()); } } diff --git a/examples/oauth2-demo/server/static/api-docs.html b/examples/oauth2-demo/server/static/api-docs.html index 95e1f7f..459566d 100644 --- a/examples/oauth2-demo/server/static/api-docs.html +++ b/examples/oauth2-demo/server/static/api-docs.html @@ -4,7 +4,7 @@ API Documentation - Google OAuth2 Example - + - - -
- - - \ No newline at end of file diff --git a/examples/rest-wasm-example/typescript-example/openapi-ts.config.ts b/examples/rest-wasm-example/typescript-example/openapi-ts.config.ts deleted file mode 100644 index 25d8066..0000000 --- a/examples/rest-wasm-example/typescript-example/openapi-ts.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from '@hey-api/openapi-ts'; - -export default defineConfig({ - client: '@hey-api/client-fetch', - input: '../rest-backend/target/openapi/userservice.json', - output: './src/generated', - schemas: { - export: true, - }, -}); \ No newline at end of file diff --git a/examples/rest-wasm-example/typescript-example/package-lock.json b/examples/rest-wasm-example/typescript-example/package-lock.json deleted file mode 100644 index 20f2602..0000000 --- a/examples/rest-wasm-example/typescript-example/package-lock.json +++ /dev/null @@ -1,2285 +0,0 @@ -{ - "name": "rest-wasm-example", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "rest-wasm-example", - "version": "1.0.0", - "dependencies": { - "@hey-api/client-fetch": "^0.1.0", - "solid-js": "^1.8.11" - }, - "devDependencies": { - "@hey-api/openapi-ts": "^0.45.1", - "@hey-api/vite-plugin": "^0.2.0", - "@types/node": "^20.10.5", - "typescript": "^5.3.3", - "vite": "^5.0.10", - "vite-plugin-solid": "^2.8.0" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "11.6.1", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.6.1.tgz", - "integrity": "sha512-DxjgKBCoyReu4p5HMvpmgSOfRhhBcuf5V5soDDRgOTZMwsA4KSFzol1abFZgiCTE11L2kKGca5Md9GwDdXVBwQ==", - "dev": true, - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.15", - "js-yaml": "^4.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/philsturgeon" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", - "dev": true, - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.28.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@hey-api/client-fetch": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.1.14.tgz", - "integrity": "sha512-6RoO2prOVrQfx2wClJ7DkeIVWQokiRLIdqiuxvFTfWEQVfOc+hQhAw6/IijYrSM9cA7A7DzmHpzTLc/gbQ7/2Q==", - "deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts." - }, - "node_modules/@hey-api/openapi-ts": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.45.1.tgz", - "integrity": "sha512-TT4YC9SshgruHnr/z47LD945hFhefuD6xSfdt9+fv/sU+shP0nPJhNdyt71oMGTAB9h6nsrjC8z84ZnoAGKHrg==", - "dev": true, - "dependencies": { - "@apidevtools/json-schema-ref-parser": "11.6.1", - "c12": "1.10.0", - "camelcase": "8.0.0", - "commander": "12.0.0", - "handlebars": "4.7.8" - }, - "bin": { - "openapi-ts": "bin/index.cjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "typescript": "^5.x" - } - }, - "node_modules/@hey-api/vite-plugin": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@hey-api/vite-plugin/-/vite-plugin-0.2.0.tgz", - "integrity": "sha512-3QsCX2jOfuCrMyBgDTJ4rAjoIejo/Lp3FKceDJqlZnoswsezRzuEPzIHfLjLxz2T6/8ZQOysCAZ/qiI0926czg==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/hey-api" - }, - "peerDependencies": { - "@hey-api/openapi-ts": "0.64.13" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "dev": true - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.0.tgz", - "integrity": "sha512-2o/FgACbji4tW1dzXOqAV15Eu7DdgbKsF2QKcxfG4xbh5iwU7yr5RRP5/U+0asQliSYv5M4o7BevlGIoSL0LXg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.0.tgz", - "integrity": "sha512-PSZ0SvMOjEAxwZeTx32eI/j5xSYtDCRxGu5k9zvzoY77xUNssZM+WV6HYBLROpY5CkXsbQjvz40fBb7WPwDqtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.0.tgz", - "integrity": "sha512-BA4yPIPssPB2aRAWzmqzQ3y2/KotkLyZukVB7j3psK/U3nVJdceo6qr9pLM2xN6iRP/wKfxEbOb1yrlZH6sYZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.0.tgz", - "integrity": "sha512-Pr2o0lvTwsiG4HCr43Zy9xXrHspyMvsvEw4FwKYqhli4FuLE5FjcZzuQ4cfPe0iUFCvSQG6lACI0xj74FDZKRA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.0.tgz", - "integrity": "sha512-lYE8LkE5h4a/+6VnnLiL14zWMPnx6wNbDG23GcYFpRW1V9hYWHAw9lBZ6ZUIrOaoK7NliF1sdwYGiVmziUF4vA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.0.tgz", - "integrity": "sha512-PVQWZK9sbzpvqC9Q0GlehNNSVHR+4m7+wET+7FgSnKG3ci5nAMgGmr9mGBXzAuE5SvguCKJ6mHL6vq1JaJ/gvw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.0.tgz", - "integrity": "sha512-hLrmRl53prCcD+YXTfNvXd776HTxNh8wPAMllusQ+amcQmtgo3V5i/nkhPN6FakW+QVLoUUr2AsbtIRPFU3xIA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.0.tgz", - "integrity": "sha512-XBKGSYcrkdiRRjl+8XvrUR3AosXU0NvF7VuqMsm7s5nRy+nt58ZMB19Jdp1RdqewLcaYnpk8zeVs/4MlLZEJxw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.0.tgz", - "integrity": "sha512-fRvZZPUiBz7NztBE/2QnCS5AtqLVhXmUOPj9IHlfGEXkapgImf4W9+FSkL8cWqoAjozyUzqFmSc4zh2ooaeF6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.0.tgz", - "integrity": "sha512-Btv2WRZOcUGi8XU80XwIvzTg4U6+l6D0V6sZTrZx214nrwxw5nAi8hysaXj/mctyClWgesyuxbeLylCBNauimg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.0.tgz", - "integrity": "sha512-Li0emNnwtUZdLwHjQPBxn4VWztcrw/h7mgLyHiEI5Z0MhpeFGlzaiBHpSNVOMB/xucjXTTcO+dhv469Djr16KA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.0.tgz", - "integrity": "sha512-sB8+pfkYx2kvpDCfd63d5ScYT0Fz1LO6jIb2zLZvmK9ob2D8DeVqrmBDE0iDK8KlBVmsTNzrjr3G1xV4eUZhSw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.0.tgz", - "integrity": "sha512-5GQ6PFhh7E6jQm70p1aW05G2cap5zMOvO0se5JMecHeAdj5ZhWEHbJ4hiKpfi1nnnEdTauDXxPgXae/mqjow9w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.0.tgz", - "integrity": "sha512-N/euLsBd1rekWcuduakTo/dJw6U6sBP3eUq+RXM9RNfPuWTvG2w/WObDkIvJ2KChy6oxZmOSC08Ak2OJA0UiAA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.0.tgz", - "integrity": "sha512-2l9sA7d7QdikL0xQwNMO3xURBUNEWyHVHfAsHsUdq+E/pgLTUcCE+gih5PCdmyHmfTDeXUWVhqL0WZzg0nua3g==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.0.tgz", - "integrity": "sha512-XZdD3fEEQcwG2KrJDdEQu7NrHonPxxaV0/w2HpvINBdcqebz1aL+0vM2WFJq4DeiAVT6F5SUQas65HY5JDqoPw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.0.tgz", - "integrity": "sha512-7ayfgvtmmWgKWBkCGg5+xTQ0r5V1owVm67zTrsEY1008L5ro7mCyGYORomARt/OquB9KY7LpxVBZes+oSniAAQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.0.tgz", - "integrity": "sha512-B+IJgcBnE2bm93jEW5kHisqvPITs4ddLOROAcOc/diBgrEiQJJ6Qcjby75rFSmH5eMGrqJryUgJDhrfj942apQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.0.tgz", - "integrity": "sha512-+CXwwG66g0/FpWOnP/v1HnrGVSOygK/osUbu3wPRy8ECXjoYKjRAyfxYpDQOfghC5qPJYLPH0oN4MCOjwgdMug==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.0.tgz", - "integrity": "sha512-SRf1cytG7wqcHVLrBc9VtPK4pU5wxiB/lNIkNmW2ApKXIg+RpqwHfsaEK+e7eH4A1BpI6BX/aBWXxZCIrJg3uA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", - "dev": true, - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "node_modules/@types/node": { - "version": "20.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.7.tgz", - "integrity": "sha512-1GM9z6BJOv86qkPvzh2i6VW5+VVrXxCLknfmTkWEqz+6DqosiY28XUWCTmBcJ0ACzKqx/iwdIREfo1fwExIlkA==", - "dev": true, - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/babel-plugin-jsx-dom-expressions": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.39.8.tgz", - "integrity": "sha512-/MVOIIjonylDXnrWmG23ZX82m9mtKATsVHB7zYlPfDR9Vdd/NBE48if+wv27bSkBtyO7EPMUlcUc4J63QwuACQ==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "7.18.6", - "@babel/plugin-syntax-jsx": "^7.18.6", - "@babel/types": "^7.20.7", - "html-entities": "2.3.3", - "parse5": "^7.1.2", - "validate-html-nesting": "^1.2.1" - }, - "peerDependencies": { - "@babel/core": "^7.20.12" - } - }, - "node_modules/babel-plugin-jsx-dom-expressions/node_modules/@babel/helper-module-imports": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", - "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/babel-preset-solid": { - "version": "1.9.6", - "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.6.tgz", - "integrity": "sha512-HXTK9f93QxoH8dYn1M2mJdOlWgMsR88Lg/ul6QCZGkNTktjTE5HAf93YxQumHoCudLEtZrU1cFCMFOVho6GqFg==", - "dev": true, - "dependencies": { - "babel-plugin-jsx-dom-expressions": "^0.39.8" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/c12": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-1.10.0.tgz", - "integrity": "sha512-0SsG7UDhoRWcuSvKWHaXmu5uNjDCDN3nkQLRL4Q42IlFy+ze58FcCoI3uPwINXinkz7ZinbhEgyzYFw9u9ZV8g==", - "dev": true, - "dependencies": { - "chokidar": "^3.6.0", - "confbox": "^0.1.3", - "defu": "^6.1.4", - "dotenv": "^16.4.5", - "giget": "^1.2.1", - "jiti": "^1.21.0", - "mlly": "^1.6.1", - "ohash": "^1.1.3", - "pathe": "^1.1.2", - "perfect-debounce": "^1.0.0", - "pkg-types": "^1.0.3", - "rc9": "^2.1.1" - } - }, - "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "dev": true, - "dependencies": { - "consola": "^3.2.3" - } - }, - "node_modules/commander": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz", - "integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "dev": true - }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "dev": true - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.182", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz", - "integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==", - "dev": true - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/giget": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz", - "integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==", - "dev": true, - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "defu": "^6.1.4", - "node-fetch-native": "^1.6.6", - "nypm": "^0.5.4", - "pathe": "^2.0.3", - "tar": "^6.2.1" - }, - "bin": { - "giget": "dist/cli.mjs" - } - }, - "node_modules/giget/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/html-entities": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", - "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", - "dev": true - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-what": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", - "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", - "dev": true, - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lru-cache/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, - "node_modules/merge-anything": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.7.tgz", - "integrity": "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==", - "dev": true, - "dependencies": { - "is-what": "^4.1.8" - }, - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mlly": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", - "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", - "dev": true, - "dependencies": { - "acorn": "^8.14.0", - "pathe": "^2.0.1", - "pkg-types": "^1.3.0", - "ufo": "^1.5.4" - } - }, - "node_modules/mlly/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, - "node_modules/node-fetch-native": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz", - "integrity": "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==", - "dev": true - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nypm": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz", - "integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==", - "dev": true, - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "tinyexec": "^0.3.2", - "ufo": "^1.5.4" - }, - "bin": { - "nypm": "dist/cli.mjs" - }, - "engines": { - "node": "^14.16.0 || >=16.10.0" - } - }, - "node_modules/nypm/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/ohash": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", - "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", - "dev": true - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/pkg-types/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rc9": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "dev": true, - "dependencies": { - "defu": "^6.1.4", - "destr": "^2.0.3" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/rollup": { - "version": "4.45.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.0.tgz", - "integrity": "sha512-WLjEcJRIo7i3WDDgOIJqVI2d+lAC3EwvOGy+Xfq6hs+GQuAA4Di/H72xmXkOhrIWFg2PFYSKZYfH0f4vfKXN4A==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.45.0", - "@rollup/rollup-android-arm64": "4.45.0", - "@rollup/rollup-darwin-arm64": "4.45.0", - "@rollup/rollup-darwin-x64": "4.45.0", - "@rollup/rollup-freebsd-arm64": "4.45.0", - "@rollup/rollup-freebsd-x64": "4.45.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.45.0", - "@rollup/rollup-linux-arm-musleabihf": "4.45.0", - "@rollup/rollup-linux-arm64-gnu": "4.45.0", - "@rollup/rollup-linux-arm64-musl": "4.45.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.45.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.45.0", - "@rollup/rollup-linux-riscv64-gnu": "4.45.0", - "@rollup/rollup-linux-riscv64-musl": "4.45.0", - "@rollup/rollup-linux-s390x-gnu": "4.45.0", - "@rollup/rollup-linux-x64-gnu": "4.45.0", - "@rollup/rollup-linux-x64-musl": "4.45.0", - "@rollup/rollup-win32-arm64-msvc": "4.45.0", - "@rollup/rollup-win32-ia32-msvc": "4.45.0", - "@rollup/rollup-win32-x64-msvc": "4.45.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/seroval": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", - "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", - "engines": { - "node": ">=10" - } - }, - "node_modules/seroval-plugins": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.2.tgz", - "integrity": "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "seroval": "^1.0" - } - }, - "node_modules/solid-js": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.7.tgz", - "integrity": "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==", - "dependencies": { - "csstype": "^3.1.0", - "seroval": "~1.3.0", - "seroval-plugins": "~1.3.0" - } - }, - "node_modules/solid-refresh": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/solid-refresh/-/solid-refresh-0.6.3.tgz", - "integrity": "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==", - "dev": true, - "dependencies": { - "@babel/generator": "^7.23.6", - "@babel/helper-module-imports": "^7.22.15", - "@babel/types": "^7.23.6" - }, - "peerDependencies": { - "solid-js": "^1.3" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/validate-html-nesting": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/validate-html-nesting/-/validate-html-nesting-1.2.3.tgz", - "integrity": "sha512-kdkWdCl6eCeLlRShJKbjVOU2kFKxMF8Ghu50n+crEoyx+VKm3FxAxF9z4DCy6+bbTOqNW0+jcIYRnjoIRzigRw==", - "dev": true - }, - "node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", - "dev": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-plugin-solid": { - "version": "2.11.7", - "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.7.tgz", - "integrity": "sha512-5TgK1RnE449g0Ryxb9BXqem89RSy7fE8XGVCo+Gw84IHgPuPVP7nYNP6WBVAaY/0xw+OqfdQee+kusL0y3XYNg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.23.3", - "@types/babel__core": "^7.20.4", - "babel-preset-solid": "^1.8.4", - "merge-anything": "^5.1.7", - "solid-refresh": "^0.6.3", - "vitefu": "^1.0.4" - }, - "peerDependencies": { - "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", - "solid-js": "^1.7.2", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "@testing-library/jest-dom": { - "optional": true - } - } - }, - "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "dev": true, - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } -} diff --git a/examples/rest-wasm-example/typescript-example/package.json b/examples/rest-wasm-example/typescript-example/package.json deleted file mode 100644 index 8b95c96..0000000 --- a/examples/rest-wasm-example/typescript-example/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "rest-wasm-example", - "version": "1.0.0", - "description": "TypeScript example using WASM REST client", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "solid-js": "^1.8.11", - "@hey-api/client-fetch": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^20.10.5", - "typescript": "^5.3.3", - "vite": "^5.0.10", - "vite-plugin-solid": "^2.8.0", - "@hey-api/openapi-ts": "^0.45.1", - "@hey-api/vite-plugin": "^0.2.0" - } -} diff --git a/examples/rest-wasm-example/typescript-example/src/example.ts b/examples/rest-wasm-example/typescript-example/src/example.ts new file mode 100644 index 0000000..ce405bf --- /dev/null +++ b/examples/rest-wasm-example/typescript-example/src/example.ts @@ -0,0 +1,72 @@ +import { + getUsers, + getUsersId, + getUsersUserIdTasks, + postUsers, +} from './generated'; +import type { CreateUserRequest, Task, User } from './generated'; + +const BASE_URL = 'http://localhost:3000/api/v1'; + +type ApiResponse = { + data?: T; + error?: unknown; +}; + +function requireData(operation: string, response: ApiResponse): T { + if (response.error) { + throw new Error(`${operation} failed: ${JSON.stringify(response.error)}`); + } + + if (response.data === undefined) { + throw new Error(`${operation} returned no data`); + } + + return response.data; +} + +export async function listUsers(baseUrl = BASE_URL): Promise { + const response = await getUsers({ baseUrl }); + return requireData('GET /users', response).users; +} + +export async function getUser(userId: string, baseUrl = BASE_URL): Promise { + const response = await getUsersId({ + baseUrl, + path: { id: userId }, + }); + + return requireData('GET /users/{id}', response); +} + +export async function createUser( + request: CreateUserRequest, + adminToken: string, + baseUrl = BASE_URL, +): Promise { + const response = await postUsers({ + baseUrl, + headers: { + Authorization: `Bearer ${adminToken}`, + }, + body: request, + }); + + return requireData('POST /users', response); +} + +export async function listUserTasks( + userId: string, + bearerToken: string, + baseUrl = BASE_URL, +): Promise { + const response = await getUsersUserIdTasks({ + baseUrl, + headers: { + Authorization: `Bearer ${bearerToken}`, + }, + path: { user_id: userId }, + }); + + return requireData('GET /users/{user_id}/tasks', response).tasks; +} diff --git a/examples/rest-wasm-example/typescript-example/src/generated/.gitignore b/examples/rest-wasm-example/typescript-example/src/generated/.gitignore deleted file mode 100644 index 72e8ffc..0000000 --- a/examples/rest-wasm-example/typescript-example/src/generated/.gitignore +++ /dev/null @@ -1 +0,0 @@ -* diff --git a/examples/rest-wasm-example/typescript-example/src/index.tsx b/examples/rest-wasm-example/typescript-example/src/index.tsx deleted file mode 100644 index 9f6632a..0000000 --- a/examples/rest-wasm-example/typescript-example/src/index.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { render } from 'solid-js/web'; -import { createSignal, Show, For } from 'solid-js'; -import * as api from './generated'; -import type { User, UsersResponse, CreateUserRequest, Task, TasksResponse, CreateTaskRequest } from './generated'; - -// API configuration -const API_BASE_URL = window.location.origin + '/api/v1'; - -function App() { - const [loading, setLoading] = createSignal(false); - const [error, setError] = createSignal(null); - const [users, setUsers] = createSignal([]); - const [selectedUser, setSelectedUser] = createSignal(null); - const [tasks, setTasks] = createSignal([]); - const [token, setToken] = createSignal(''); - - // Get auth headers - const getAuthHeaders = () => { - const tokenValue = token(); - if (tokenValue) { - return { Authorization: `Bearer ${tokenValue}` }; - } - return {}; - }; - - // Get all users (public endpoint) - const handleGetUsers = async () => { - try { - setLoading(true); - setError(null); - - const response = await api.getUsers({ - baseUrl: API_BASE_URL, - }); - - if (response.data) { - setUsers(response.data.users); - } else if (response.error) { - setError(`Error: ${response.error.error || 'Unknown error'}`); - } - } catch (err) { - setError(`Error: ${err}`); - } finally { - setLoading(false); - } - }; - - // Get specific user (public endpoint) - const handleGetUser = async (userId: string) => { - try { - setLoading(true); - setError(null); - - const response = await api.getUsersId({ - baseUrl: API_BASE_URL, - path: { id: userId }, - }); - - if (response.data) { - setSelectedUser(response.data); - } else if (response.error) { - setError(`Error: ${response.error.error || 'Unknown error'}`); - } - } catch (err) { - setError(`Error: ${err}`); - } finally { - setLoading(false); - } - }; - - // Create user (admin endpoint) - const handleCreateUser = async () => { - try { - setLoading(true); - setError(null); - - const newUser: CreateUserRequest = { - name: 'New User', - email: 'newuser@example.com', - }; - - const response = await api.postUsers({ - baseUrl: API_BASE_URL, - headers: getAuthHeaders(), - body: newUser, - }); - - if (response.data) { - // Refresh users list - await handleGetUsers(); - } else if (response.error) { - setError(`Error: ${response.error.error || 'Unauthorized'}`); - } - } catch (err) { - setError(`Error: ${err}`); - } finally { - setLoading(false); - } - }; - - // Get user tasks (user endpoint) - const handleGetUserTasks = async (userId: string) => { - try { - setLoading(true); - setError(null); - - const response = await api.getUsersUserIdTasks({ - baseUrl: API_BASE_URL, - headers: getAuthHeaders(), - path: { user_id: userId }, - }); - - if (response.data) { - setTasks(response.data.tasks); - } else if (response.error) { - setError(`Error: ${response.error.error || 'Unauthorized'}`); - } - } catch (err) { - setError(`Error: ${err}`); - } finally { - setLoading(false); - } - }; - - return ( -
-

REST API TypeScript Client Demo

-

This demo uses a TypeScript client generated from the OpenAPI specification.

- -
-

Authentication

-
- - - -
-
- Current token: {token() ? `"${token()}"` : 'None'} -
-
- -
-

Public Endpoints

- -
- -
-

Admin Endpoints

- -
- - -
- {error()} -
-
- - -
Loading...
-
- - 0}> -
-

Users

- - - - - - - - - - - - - {(user) => ( - - - - - - - - )} - - -
IDNameEmailRoleActions
{user.id}{user.name}{user.email}{user.role} - - -
-
-
- - -
-

Selected User

-
{JSON.stringify(selectedUser(), null, 2)}
-
-
- - 0}> -
-

Tasks

- - - - - - - - - - - - {(task) => ( - - - - - - - )} - - -
IDTitleDescriptionCompleted
{task.id}{task.title}{task.description} - {task.completed ? '✓' : '✗'} -
-
-
- -
-

Features:

-
    -
  • ✅ Fully type-safe client generated from OpenAPI spec
  • -
  • ✅ Automatic type inference for requests and responses
  • -
  • ✅ Built-in error handling with typed error responses
  • -
  • ✅ Auto-completion and IntelliSense support
  • -
  • ✅ No manual type definitions needed
  • -
  • ✅ Automatic client regeneration via Vite plugin
  • -
-
-
- ); -} - -render(() => , document.getElementById('app')!); \ No newline at end of file diff --git a/examples/rest-wasm-example/typescript-example/vite.config.ts b/examples/rest-wasm-example/typescript-example/vite.config.ts deleted file mode 100644 index 13a749f..0000000 --- a/examples/rest-wasm-example/typescript-example/vite.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { defineConfig } from "vite"; -import solid from "vite-plugin-solid"; -import { resolve } from "path"; -import { heyApiPlugin } from "@hey-api/vite-plugin"; - -export default defineConfig({ - plugins: [heyApiPlugin({}), solid()], - resolve: { - alias: {}, - }, - server: { - port: 3001, - proxy: { - "/api": { - target: "http://localhost:3000", - changeOrigin: true, - }, - }, - fs: { - // Allow serving files from public directory - allow: [".."], - }, - }, - build: { - target: "esnext", - }, - optimizeDeps: { - exclude: [], - }, -}); diff --git a/examples/wasm-ui-demo/.gitignore b/examples/wasm-ui-demo/.gitignore index 5519ea1..21958e3 100644 --- a/examples/wasm-ui-demo/.gitignore +++ b/examples/wasm-ui-demo/.gitignore @@ -1,7 +1,4 @@ node_modules/ -public/bundle.js -public/bundle.js.map +dist/ target/ pkg/ -Cargo.lock -package-lock.json \ No newline at end of file diff --git a/examples/wasm-ui-demo/Cargo.toml b/examples/wasm-ui-demo/Cargo.toml index 7d4fc73..3378474 100644 --- a/examples/wasm-ui-demo/Cargo.toml +++ b/examples/wasm-ui-demo/Cargo.toml @@ -2,12 +2,19 @@ name = "wasm-ui-demo" version = "0.1.0" edition = "2024" +rust-version = "1.88" +description = "Dominator WASM UI demo using the generated Rust Agent Stack JSON-RPC client" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" +publish = false +readme = "README.md" [lib] crate-type = ["cdylib"] [dependencies] -basic-jsonrpc-api = { path = "../basic-jsonrpc/api", default-features = false, features = ["client"]} +basic-jsonrpc-api = { path = "../basic-jsonrpc/api", version = "0.1.0", default-features = false, features = ["client"] } dominator = { workspace = true } dwind = { workspace = true } @@ -15,17 +22,6 @@ dwind-macros = { workspace = true } wasm-bindgen = { workspace = true } wasm-bindgen-futures = { workspace = true } futures-signals = { workspace = true } -gloo-events = { workspace = true } -gloo-timers = { workspace = true } -gloo-utils = { workspace = true } -gloo-net = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } web-sys = { workspace = true } console_error_panic_hook = { workspace = true } -wee_alloc = { workspace = true } -reqwest = { workspace = true } once_cell = { workspace = true } - -[dev-dependencies] -wasm-bindgen-test = { workspace = true } diff --git a/examples/wasm-ui-demo/README.md b/examples/wasm-ui-demo/README.md new file mode 100644 index 0000000..a40e4ae --- /dev/null +++ b/examples/wasm-ui-demo/README.md @@ -0,0 +1,57 @@ +# WASM UI Demo + +Browser-based task management UI built with Dominator, dwind styling, and the generated JSON-RPC client from `basic-jsonrpc-api`. + +## What It Shows + +- Rust UI compiled to WebAssembly with Rollup. +- Generated JSON-RPC client calls from the browser. +- Login, task list, task creation, profile, and dashboard flows. +- Reactive state with `futures-signals`. +- Local serving with `/rpc` proxied to `basic-jsonrpc-service`. + +## Run It + +Start the JSON-RPC backend: + +```bash +cargo run -p basic-jsonrpc-service --locked +``` + +In another terminal, build and serve the UI: + +```bash +npm --prefix examples/wasm-ui-demo ci +npm --prefix examples/wasm-ui-demo start +``` + +Requires Node.js 22.13 or newer. + +Open `http://localhost:8080`. The local Vite server proxies `/rpc` to `http://localhost:3000/rpc`. + +## Credentials + +- User: `user` / `password` +- Admin: `admin` / `secret` + +## Build + +```bash +npm --prefix examples/wasm-ui-demo run build +``` + +The browser bundle is written to `dist/`. + +The Rollup Rust plugin uses `wasm-opt` when it is available. If `wasm-opt` is not installed, the build still succeeds and skips that optimization step. + +## Checks + +```bash +cargo test -p wasm-ui-demo --locked +cargo clippy -p wasm-ui-demo --all-targets --all-features --locked -- -D warnings +cargo check -p wasm-ui-demo --target wasm32-unknown-unknown --locked +``` + +## Notes + +This demo expects the backend JSON-RPC route at `/rpc`. If you serve the built `dist/` directory behind another host, proxy `/rpc` to the basic JSON-RPC service or serve both from the same origin. diff --git a/examples/wasm-ui-demo/package-lock.json b/examples/wasm-ui-demo/package-lock.json index df8ac01..0fc6deb 100644 --- a/examples/wasm-ui-demo/package-lock.json +++ b/examples/wasm-ui-demo/package-lock.json @@ -1,360 +1,372 @@ { - "name": "dominator-example", + "name": "wasm-ui-demo", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dominator-example", + "name": "wasm-ui-demo", "version": "0.1.0", "devDependencies": { - "@rollup/plugin-terser": "^0.4.4", - "@wasm-tool/rollup-plugin-rust": "^3.0.5", - "rimraf": "^6.0.1", - "rollup": "^4.44.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dev": "^2.0.3", - "rollup-plugin-livereload": "^2.0.5", - "rollup-plugin-terser": "^7.0.2" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@wasm-tool/rollup-plugin-rust": "^3.1.5", + "rollup": "^4.60.4", + "vite": "^8.0.14" }, "engines": { - "node": ">=6.9.0" + "node": ">=22.13" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.9.0" + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" } }, - "node_modules/@fastify/ajv-compiler": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-1.1.0.tgz", - "integrity": "sha512-gvCOUNpXsWrIQ3A4aXCLIdblL0tDq42BG/2Xw7oxbil9h11uow10ztS2GuFazNBfjbrsZ5nl+nPl5jDSjj5TSg==", + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "ajv": "^6.12.6" + "tslib": "^2.4.0" } }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, - "engines": { - "node": ">=14" + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@fastify/error": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-2.0.0.tgz", - "integrity": "sha512-wI3fpfDT0t7p8E6dA2eTECzzOd+bZsZCJ2Hcv+Onn2b7ZwK3RwD27uW2QDaMtQhAfWQQP+WNK7nKf0twLsBf9w==", + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", "dev": true }, - "node_modules/@fastify/http-proxy": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@fastify/http-proxy/-/http-proxy-7.2.0.tgz", - "integrity": "sha512-Ld5E9NWqeMM1wkXdpqaJnXxSddfyDVphfh9sK2GGxcflzS1HBL1+bgIXVpYicOFeFR73qxMeefJfazYAvqV12A==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dev": true, "dependencies": { - "@fastify/reply-from": "^7.0.0", - "ws": "^8.4.2" - } - }, - "node_modules/@fastify/http-proxy/node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "minipass": "^7.0.4" }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@fastify/reply-from": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-7.0.1.tgz", - "integrity": "sha512-ikp6GpmEJ7AVxcDdSVE9MhpUtC9KnImQDegc5ePZ+H7QZcraIjotP7YndwT/fP8lYj2Qr1h4RtuFNU8Wdwleuw==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "end-of-stream": "^1.4.4", - "fastify-plugin": "^3.0.0", - "http-errors": "^2.0.0", - "pump": "^3.0.0", - "semver": "^7.3.5", - "tiny-lru": "^8.0.1", - "undici": "^5.0.0" + "@tybys/wasm-util": "^0.10.1" }, - "engines": { - "node": ">=12.18" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@fastify/static": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-5.0.2.tgz", - "integrity": "sha512-HvyXZ5a7hUHoSBRq9jKUuKIUCkHMkCDcmiAeEmixXlGOx8pEWx3NYOIaiivcjWa6/NLvfdUT+t/jzfVQ2PA7Gw==", + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, - "dependencies": { - "content-disposition": "^0.5.3", - "encoding-negotiator": "^2.0.1", - "fastify-plugin": "^3.0.0", - "glob": "^7.1.4", - "p-limit": "^3.1.0", - "readable-stream": "^3.4.0", - "send": "^0.17.1" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@fastify/static/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@fastify/static/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "*" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "dev": true - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@iarna/toml": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", - "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", - "dev": true - }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "20 || >=22" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], "dev": true, - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "20 || >=22" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "minipass": "^7.0.4" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">= 8" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 8" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 8" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rollup/plugin-terser": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "dependencies": { - "serialize-javascript": "^6.0.1", - "smob": "^1.0.0", - "terser": "^5.17.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } + "license": "MIT" }, "node_modules/@rollup/pluginutils": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", - "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -372,2379 +384,1227 @@ } } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", - "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ - "x64" + "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ] }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", - "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ - "x64" + "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ] }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "dev": true - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@types/fs-extra": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", - "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", - "dev": true, - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", - "dev": true - }, - "node_modules/@types/node": { - "version": "24.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz", - "integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==", - "dev": true, - "dependencies": { - "undici-types": "~7.8.0" - } - }, - "node_modules/@wasm-tool/rollup-plugin-rust": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@wasm-tool/rollup-plugin-rust/-/rollup-plugin-rust-3.0.5.tgz", - "integrity": "sha512-L3TyDtmrmAY4XdlZAZKMomDManfrKHLQHjDeFNJKi4LjPQRwWUnPoS0yV44NWy+pKXVKlv7wbdkaTmhiFJRmLg==", - "dev": true, - "dependencies": { - "@iarna/toml": "^2.2.5", - "@rollup/pluginutils": "^5.1.4", - "chalk": "^5.4.1", - "glob": "^11.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^6.0.1", - "tar": "^7.4.3" - }, - "peerDependencies": { - "binaryen": "^121.0.0" - } - }, - "node_modules/abstract-logging": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", - "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", - "dev": true - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/archy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", - "dev": true - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "dev": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/avvio": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-7.2.5.tgz", - "integrity": "sha512-AOhBxyLVdpOad3TujtC9kL/9r3HnTkxwQ5ggOsYrvvZP1cCFvzHWJd5XxZDFuTn+IN8vkKSG5SEJrd27vCSbeA==", - "dev": true, - "dependencies": { - "archy": "^1.0.0", - "debug": "^4.0.0", - "fastq": "^1.6.1", - "queue-microtask": "^1.1.2" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "dev": true - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/date-time": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/date-time/-/date-time-4.0.0.tgz", - "integrity": "sha512-I53Xcn8FBobcKFcNUfZUSE6O3HeRdp1IOLWyHkipi5S07sEZbTwP+xTrPp5Ch6q6iyFkC06B14+bm96FrdfIEA==", - "dev": true, - "dependencies": { - "time-zone": "^2.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==", - "dev": true - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/encoding-negotiator": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/encoding-negotiator/-/encoding-negotiator-2.0.1.tgz", - "integrity": "sha512-GSK7qphNR4iPcejfAlZxKDoz3xMhnspwImK+Af5WhePS9jUpK/Oh7rUdyENWu+9rgDflOCTmAojBsgsvM8neAQ==", - "dev": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fast-content-type-parse": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", - "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", - "dev": true - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "dev": true - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-json-stringify": { - "version": "2.7.13", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-2.7.13.tgz", - "integrity": "sha512-ar+hQ4+OIurUGjSJD1anvYSDcUflywhKjfxnsW4TBTD7+u0tJufv6DKRWoQk3vI6YBOWMoz0TQtfbe7dxbQmvA==", - "dev": true, - "dependencies": { - "ajv": "^6.11.0", - "deepmerge": "^4.2.2", - "rfdc": "^1.2.0", - "string-similarity": "^4.0.1" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/fast-redact": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", - "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true - }, - "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ] - }, - "node_modules/fastify": { - "version": "3.29.5", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-3.29.5.tgz", - "integrity": "sha512-FBDgb1gkenZxxh4sTD6AdI6mFnZnsgckpjIXzIvfLSYCa4isfQeD8QWGPib63dxq6btnY0l1j8I0xYhMvUb+sw==", - "dev": true, - "dependencies": { - "@fastify/ajv-compiler": "^1.0.0", - "@fastify/error": "^2.0.0", - "abstract-logging": "^2.0.0", - "avvio": "^7.1.2", - "fast-content-type-parse": "^1.0.0", - "fast-json-stringify": "^2.5.2", - "find-my-way": "^4.5.0", - "flatstr": "^1.0.12", - "light-my-request": "^4.2.0", - "pino": "^6.13.0", - "process-warning": "^1.0.0", - "proxy-addr": "^2.0.7", - "rfdc": "^1.1.4", - "secure-json-parse": "^2.0.0", - "semver": "^7.3.2", - "tiny-lru": "^8.0.1" - } - }, - "node_modules/fastify-plugin": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-3.0.1.tgz", - "integrity": "sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA==", - "dev": true - }, - "node_modules/fastify-request-timing": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fastify-request-timing/-/fastify-request-timing-2.0.2.tgz", - "integrity": "sha512-Bb/PerFokhIJCE6YOH5hCOsZAnSVj5CI+W0kxEClii8nHhk0ZqVlivpNwrUiOzgdaIjPe9S2yIHNVPpZNFj7AQ==", - "dev": true, - "dependencies": { - "fastify-plugin": "^3.0.0" - } - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/femtocolor": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/femtocolor/-/femtocolor-2.0.3.tgz", - "integrity": "sha512-mOG24a824C+h3fN/ojN+waWDGGuuObMvDbVuYS0ocWGAOFqCXEugOCjiO7JBNKOy3MJ5cQ3il0ExcrSlSW+N8w==", - "dev": true - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-my-way": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-4.5.1.tgz", - "integrity": "sha512-kE0u7sGoUFbMXcOG/xpkmz4sRLCklERnBcg7Ftuu1iAxsfEt2S46RLJ3Sq7vshsEy2wJT2hZxE58XZK27qa8kg==", - "dev": true, - "dependencies": { - "fast-decode-uri-component": "^1.0.1", - "fast-deep-equal": "^3.1.3", - "safe-regex2": "^2.0.0", - "semver-store": "^0.3.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/flatstr": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/flatstr/-/flatstr-1.0.12.tgz", - "integrity": "sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==", - "dev": true - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", - "dev": true, - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globby": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.1.tgz", - "integrity": "sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A==", - "dev": true, - "dependencies": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/globby/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globby/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-object": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz", - "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-worker": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", - "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", - "dev": true, - "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/light-my-request": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-4.12.0.tgz", - "integrity": "sha512-0y+9VIfJEsPVzK5ArSIJ8Dkxp8QMP7/aCuxCUtG/tr9a2NoOf/snATE/OUc05XUplJCEnRh6gTkH7xh9POt1DQ==", - "dev": true, - "dependencies": { - "ajv": "^8.1.0", - "cookie": "^0.5.0", - "process-warning": "^1.0.0", - "set-cookie-parser": "^2.4.1" - } - }, - "node_modules/light-my-request/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/light-my-request/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/livereload": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz", - "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==", - "dev": true, - "dependencies": { - "chokidar": "^3.5.0", - "livereload-js": "^3.3.1", - "opts": ">= 1.2.0", - "ws": "^7.4.3" - }, - "bin": { - "livereload": "bin/livereload.js" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/livereload-js": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.4.1.tgz", - "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==", - "dev": true - }, - "node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", - "dev": true, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "dev": true, - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "dev": true, - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/opts": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz", - "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==", - "dev": true + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], "dev": true, - "engines": { - "node": ">=0.10.0" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], "dev": true, - "engines": { - "node": ">=8" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], "dev": true, - "engines": { - "node": ">=8" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "ISC" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/pino": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-6.14.0.tgz", - "integrity": "sha512-iuhEDel3Z3hF9Jfe44DPXR8l07bhjuFY3GMHIXbjnY9XcafbyDDwl2sN2vw2GjMPf5Nkoe+OFao7ffn9SXaKDg==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], "dev": true, - "dependencies": { - "fast-redact": "^3.0.0", - "fast-safe-stringify": "^2.0.8", - "flatstr": "^1.0.12", - "pino-std-serializers": "^3.1.0", - "process-warning": "^1.0.0", - "quick-format-unescaped": "^4.0.3", - "sonic-boom": "^1.0.2" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-std-serializers": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz", - "integrity": "sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg==", - "dev": true - }, - "node_modules/process-warning": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz", - "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==", - "dev": true + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], "dev": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], "dev": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], "dev": true, - "engines": { - "node": ">=6" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" ] }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "dev": true - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], "dev": true, - "engines": { - "node": ">= 0.6" - } + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], "dev": true, - "engines": { - "node": ">=0.10.0" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/ret": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", - "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], "dev": true, - "engines": { - "node": ">=4" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, - "node_modules/rimraf": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", - "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "node_modules/@wasm-tool/rollup-plugin-rust": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@wasm-tool/rollup-plugin-rust/-/rollup-plugin-rust-3.1.5.tgz", + "integrity": "sha512-9JmWBqn6lZAo+TYLObppMffPhFZJsBX5Oyh9IKDWScCvxDiRfqoM5WnLMf9twgboVO8n0f1a1SwtjqHpmMM9fQ==", "dev": true, + "license": "MIT", "dependencies": { - "glob": "^11.0.0", - "package-json-from-dist": "^1.0.0" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" + "@iarna/toml": "^2.2.5", + "@rollup/pluginutils": "^5.3.0", + "chalk": "^5.6.2", + "glob": "^13.0.0", + "node-fetch": "^3.3.2", + "rimraf": "^6.1.2", + "tar": "^7.5.2" }, + "peerDependencies": { + "binaryen": "*" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/rollup": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", - "integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==", + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.44.0", - "@rollup/rollup-android-arm64": "4.44.0", - "@rollup/rollup-darwin-arm64": "4.44.0", - "@rollup/rollup-darwin-x64": "4.44.0", - "@rollup/rollup-freebsd-arm64": "4.44.0", - "@rollup/rollup-freebsd-x64": "4.44.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.44.0", - "@rollup/rollup-linux-arm-musleabihf": "4.44.0", - "@rollup/rollup-linux-arm64-gnu": "4.44.0", - "@rollup/rollup-linux-arm64-musl": "4.44.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.44.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.44.0", - "@rollup/rollup-linux-riscv64-gnu": "4.44.0", - "@rollup/rollup-linux-riscv64-musl": "4.44.0", - "@rollup/rollup-linux-s390x-gnu": "4.44.0", - "@rollup/rollup-linux-x64-gnu": "4.44.0", - "@rollup/rollup-linux-x64-musl": "4.44.0", - "@rollup/rollup-win32-arm64-msvc": "4.44.0", - "@rollup/rollup-win32-ia32-msvc": "4.44.0", - "@rollup/rollup-win32-x64-msvc": "4.44.0", - "fsevents": "~2.3.2" + "node": ">=18" } }, - "node_modules/rollup-plugin-copy": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-copy/-/rollup-plugin-copy-3.5.0.tgz", - "integrity": "sha512-wI8D5dvYovRMx/YYKtUNt3Yxaw4ORC9xo6Gt9t22kveWz1enG9QrhVlagzwrxSC455xD1dHMKhIJkbsQ7d48BA==", + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "dev": true, - "dependencies": { - "@types/fs-extra": "^8.0.1", - "colorette": "^1.1.0", - "fs-extra": "^8.1.0", - "globby": "10.0.1", - "is-plain-object": "^3.0.0" - }, "engines": { - "node": ">=8.3" + "node": ">= 12" } }, - "node_modules/rollup-plugin-dev": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/rollup-plugin-dev/-/rollup-plugin-dev-2.0.5.tgz", - "integrity": "sha512-DV6slBM7wH9tWzlvvT4c+4/klrk7cpdyfxhbYfmdk3/IJjQpcq/uoSZK15VsHl/l4uWdv4rpvrIB8iWx/WY7NQ==", - "dev": true, - "dependencies": { - "@fastify/http-proxy": "^7.0.0", - "@fastify/static": "^5.0.0", - "date-time": "^4.0.0", - "fastify": "^3.28.0", - "fastify-plugin": "^3.0.1", - "fastify-request-timing": "^2.0.1", - "femtocolor": "^2.0.2", - "get-port": "^5.1.1", - "joi": "^17.4.2", - "ms": "^2.1.3" - } - }, - "node_modules/rollup-plugin-dev/node_modules/get-port": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rollup-plugin-livereload": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/rollup-plugin-livereload/-/rollup-plugin-livereload-2.0.5.tgz", - "integrity": "sha512-vqQZ/UQowTW7VoiKEM5ouNW90wE5/GZLfdWuR0ELxyKOJUIaj+uismPZZaICU4DnWPVjnpCDDxEqwU7pcKY/PA==", + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, - "dependencies": { - "livereload": "^0.9.1" - }, - "engines": { - "node": ">=8.3" - } + "license": "MIT" }, - "node_modules/rollup-plugin-terser": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", - "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", - "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "jest-worker": "^26.2.1", - "serialize-javascript": "^4.0.0", - "terser": "^5.0.0" + "engines": { + "node": ">=12.0.0" }, "peerDependencies": { - "rollup": "^2.0.0" - } - }, - "node_modules/rollup-plugin-terser/node_modules/serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true } - ], - "dependencies": { - "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "url": "https://github.com/sponsors/jimmywarting" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "paypal", + "url": "https://paypal.me/jimmywarting" } - ] - }, - "node_modules/safe-regex2": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-2.0.0.tgz", - "integrity": "sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ==", - "dev": true, + ], "dependencies": { - "ret": "~0.2.0" - } - }, - "node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", - "dev": true - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" }, "engines": { - "node": ">=10" + "node": "^12.20 || >= 14.13" } }, - "node_modules/semver-store": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/semver-store/-/semver-store-0.3.0.tgz", - "integrity": "sha512-TcZvGMMy9vodEFSse30lWinkj+JgOBvPn8wRItpQRSayhc+4ssDs335uklkfvQQJgL/WvmHLVj4Ycv2s7QCQMg==", - "dev": true - }, - "node_modules/send": { - "version": "0.17.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.17.2.tgz", - "integrity": "sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==", + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "dev": true, "dependencies": { - "debug": "2.6.9", - "depd": "~1.1.2", - "destroy": "~1.0.4", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "1.8.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.3.0", - "range-parser": "~1.2.1", - "statuses": "~1.5.0" + "fetch-blob": "^3.1.2" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" + "node": ">=12.20.0" } }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/send/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.6" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/send/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "bin": { - "mime": "cli.js" + "node": "18 || 20 || >=22" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/send/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "dev": true - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, + "license": "MPL-2.0", "dependencies": { - "shebang-regex": "^3.0.0" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/smob": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", - "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", - "dev": true - }, - "node_modules/sonic-boom": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-1.4.1.tgz", - "integrity": "sha512-LRHh/A8tpW7ru89lrlkU4AszXt1dbwSjVWguGrmlxE7tawVmDBlI1PILMkXAxJTwqhgsEeTHzj36D5CmHgQmNg==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "atomic-sleep": "^1.0.0", - "flatstr": "^1.0.12" + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "safe-buffer": "~5.2.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/string-similarity": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz", - "integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": "20 || >=22" } }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "ansi-regex": "^6.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=12" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, - "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=18" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/terser": { - "version": "5.43.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", - "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" + "minipass": "^7.1.2" }, "engines": { - "node": ">=10" + "node": ">= 18" } }, - "node_modules/time-zone": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/time-zone/-/time-zone-2.0.0.tgz", - "integrity": "sha512-2cp/YLRm7ly33CzvySyXqo/QEOu4KMn6fCof0gpqosWY3PEJUJJhXP/Cb2wXFUuCzWWJYEmPvdHNzjLlfXC49A==", + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, - "engines": { - "node": ">=12" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/tiny-lru": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-8.0.2.tgz", - "integrity": "sha512-ApGvZ6vVvTNdsmt676grvCkUCGwzG9IqXma5Z07xJgiC5L7akUMof5U8G2JTI9Rz/ovtVhJBlY6mNhEvtjzOIg==", + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], "engines": { - "node": ">=6" + "node": ">=10.5.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "dev": true, "dependencies": { - "is-number": "^7.0.0" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, "engines": { - "node": ">=8.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, - "node_modules/toidentifier": { + "node_modules/package-json-from-dist": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, - "engines": { - "node": ">=0.6" - } + "license": "BlueOak-1.0.0" }, - "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "@fastify/busboy": "^2.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=14.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "dev": true + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "engines": { - "node": ">= 8" + "node": "^10 || ^12 || >=14" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "isexe": "^2.0.0" + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" }, "bin": { - "node-which": "bin/node-which" + "rimraf": "dist/esm/bin.mjs" }, "engines": { - "node": ">= 8" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=10" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "color-convert": "^2.0.1" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=18" } }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, + "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" + "node": ">=12.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=8.3.0" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "bufferutil": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { "optional": true }, - "utf-8-validate": { + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -2753,18 +1613,6 @@ "engines": { "node": ">=18" } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/examples/wasm-ui-demo/package.json b/examples/wasm-ui-demo/package.json index 25591f9..9b40522 100644 --- a/examples/wasm-ui-demo/package.json +++ b/examples/wasm-ui-demo/package.json @@ -1,20 +1,18 @@ { - "name": "dominator-example", + "name": "wasm-ui-demo", "type": "module", "version": "0.1.0", "private": true, + "engines": { + "node": ">=22.13" + }, "scripts": { - "build": "rimraf rollup-dist/js && rollup --config", - "start": "rimraf rollup-dist/js && rollup --config --watch" + "build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && rollup --config", + "start": "npm run build && vite --host 0.0.0.0 --port 8080 dist" }, "devDependencies": { - "rollup-plugin-copy": "^3.5.0", - "@rollup/plugin-terser": "^0.4.4", - "@wasm-tool/rollup-plugin-rust": "^3.0.5", - "rimraf": "^6.0.1", - "rollup": "^4.44.0", - "rollup-plugin-livereload": "^2.0.5", - "rollup-plugin-dev": "^2.0.3", - "rollup-plugin-terser": "^7.0.2" + "@wasm-tool/rollup-plugin-rust": "^3.1.5", + "rollup": "^4.60.4", + "vite": "^8.0.14" } -} \ No newline at end of file +} diff --git a/examples/wasm-ui-demo/resources/.gitkeep b/examples/wasm-ui-demo/resources/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/examples/wasm-ui-demo/rollup.config.js b/examples/wasm-ui-demo/rollup.config.js index cc34983..b6f67e5 100644 --- a/examples/wasm-ui-demo/rollup.config.js +++ b/examples/wasm-ui-demo/rollup.config.js @@ -1,10 +1,15 @@ +import { copyFileSync, mkdirSync } from "node:fs"; import rust from "@wasm-tool/rollup-plugin-rust"; -import dev from "rollup-plugin-dev"; -import livereload from "rollup-plugin-livereload"; -import {terser} from "rollup-plugin-terser"; -import copy from 'rollup-plugin-copy' -const is_watch = !!process.env.ROLLUP_WATCH; +function copyIndexHtml() { + return { + name: "copy-index-html", + writeBundle() { + mkdirSync("dist", { recursive: true }); + copyFileSync("index.html", "dist/index.html"); + }, + }; +} export default { input: { @@ -23,23 +28,6 @@ export default { wasmBindgen: ["--debug", "--keep-debug"] }, }), - copy({ - targets: [ - {rename: "index.html", src: 'index.html', dest: 'dist/'} - ] - }), - is_watch && dev({ - dirs: ["dist"], - port: 8080, - host: "0.0.0.0", - proxy: [ - { from: "/api", to: "http://localhost:3000/api" } - ], - spa: true, - }), - - is_watch && livereload("dist"), - - !is_watch && terser(), + copyIndexHtml(), ], -}; \ No newline at end of file +}; diff --git a/examples/wasm-ui-demo/src/lib.rs b/examples/wasm-ui-demo/src/lib.rs index 11d6379..836f832 100644 --- a/examples/wasm-ui-demo/src/lib.rs +++ b/examples/wasm-ui-demo/src/lib.rs @@ -19,10 +19,6 @@ use basic_jsonrpc_api::{ SignInResponse, Task, TaskListResponse, TaskPriority, UpdateTaskRequest, }; -// Global allocator for smaller WASM size -#[global_allocator] -static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; - // Define styles using dominator's class! macro static STYLES: Lazy = Lazy::new(|| { class! { @@ -133,7 +129,7 @@ impl App { let location = window.location(); let protocol = location.protocol().unwrap(); let host = location.host().unwrap(); - let api_url = format!("{}//{}/api/rpc", protocol, host); + let api_url = rpc_endpoint_url(&protocol, &host); // Initialize the RPC client let client = MyServiceClientBuilder::new() @@ -246,21 +242,15 @@ impl App { let description = app.new_task_description.get_cloned(); let priority = app.new_task_priority.get_cloned(); - if title.is_empty() { + let Some(request) = create_task_request(title, description, priority) else { return; - } + }; spawn_local(clone!(app => async move { if let Some(token) = app.auth_token.get_cloned() { let mut client = app.client.clone(); client.set_bearer_token(Some(token)); - let request = CreateTaskRequest { - title, - description, - priority, - }; - if let Ok(task) = client.create_task(request).await { app.tasks.lock_mut().push_cloned(task); app.new_task_title.set(String::new()); @@ -285,15 +275,7 @@ impl App { .position(|t| t.id == task_id); if let Some(index) = task_index { - let completed = !app.tasks.lock_ref()[index].completed; - - let request = UpdateTaskRequest { - id: task_id, - title: None, - description: None, - completed: Some(completed), - priority: None, - }; + let request = task_completion_update(&app.tasks.lock_ref()[index]); if let Ok(updated_task) = client.update_task(request).await { app.tasks.lock_mut().set_cloned(index, updated_task); @@ -316,10 +298,10 @@ impl App { app.tasks.lock_mut().retain(|t| t.id != task_id); // Clear selection if the deleted task was selected - if let Some(selected) = app.selected_task.get_cloned() { - if selected.id == task_id { - app.selected_task.set(None); - } + if let Some(selected) = app.selected_task.get_cloned() + && selected.id == task_id + { + app.selected_task.set(None); } // Reload stats @@ -330,6 +312,48 @@ impl App { } } +fn rpc_endpoint_url(protocol: &str, host: &str) -> String { + format!("{}//{}/rpc", protocol, host) +} + +fn create_task_request( + title: String, + description: String, + priority: TaskPriority, +) -> Option { + if title.is_empty() { + return None; + } + + Some(CreateTaskRequest { + title, + description, + priority, + }) +} + +fn task_completion_update(task: &Task) -> UpdateTaskRequest { + UpdateTaskRequest { + id: task.id.clone(), + title: None, + description: None, + completed: Some(!task.completed), + priority: None, + } +} + +fn task_id_preview(id: &str) -> &str { + safe_prefix(id, 8) +} + +fn timestamp_date(timestamp: &str) -> &str { + safe_prefix(timestamp, 10) +} + +fn safe_prefix(value: &str, max_bytes: usize) -> &str { + value.get(..max_bytes).unwrap_or(value) +} + fn render_login_form(app: Arc) -> Dom { html!("div", { .class(&*STYLES) @@ -534,7 +558,7 @@ fn render_stats_card(stats: &DashboardStats) -> Dom { }), html!("div", { .apply(|b| dwclass!(b, "text-picton-blue-300")) - .text("📋") + .text("All") .style("font-size", "1.5rem") }), ]) @@ -568,7 +592,7 @@ fn render_stats_card(stats: &DashboardStats) -> Dom { }), html!("div", { .apply(|b| dwclass!(b, "text-apple-300")) - .text("✅") + .text("Done") .style("font-size", "1.5rem") }), ]) @@ -636,7 +660,7 @@ fn render_stats_card(stats: &DashboardStats) -> Dom { }), html!("div", { .apply(|b| dwclass!(b, "text-red-300")) - .text("🔥") + .text("High") .style("font-size", "1.5rem") }), ]) @@ -825,14 +849,14 @@ fn render_task_form(app: Arc) -> Dom { fn render_task_item(app: Arc, task: Task) -> Dom { let task_id = task.id.clone(); - let (_priority_color, _priority_bg, priority_icon) = match task.priority { - TaskPriority::High => ("text-red-400", "bg-red-900 bg-opacity-20", "🔥"), + let (_priority_color, _priority_bg, priority_mark) = match task.priority { + TaskPriority::High => ("text-red-400", "bg-red-900 bg-opacity-20", "H"), TaskPriority::Medium => ( "text-candlelight-400", "bg-candlelight-900 bg-opacity-20", - "⚡", + "M", ), - TaskPriority::Low => ("text-apple-400", "bg-apple-900 bg-opacity-20", "💚"), + TaskPriority::Low => ("text-apple-400", "bg-apple-900 bg-opacity-20", "L"), }; html!("div", { @@ -896,7 +920,7 @@ fn render_task_item(app: Arc, task: Task) -> Dom { .style("align-items", "center") .children(&mut [ html!("span", { - .text(priority_icon) + .text(priority_mark) }), html!("span", { .class(match task.priority { @@ -929,10 +953,10 @@ fn render_task_item(app: Arc, task: Task) -> Dom { .style("align-items", "center") .children(&mut [ html!("span", { - .text("📅") + .text("Created") }), html!("span", { - .text(&format!("{}", &task.created_at[..10])) + .text(timestamp_date(&task.created_at)) }), ]) }), @@ -995,9 +1019,9 @@ fn render_task_list(app: Arc) -> Dom { .visible_signal(app.tasks.signal_vec_cloned().len().map(|len| len == 0)) .children(&mut [ html!("div", { - .apply(|b| dwclass!(b, "text-6xl")) + .apply(|b| dwclass!(b, "text-2xl font-semibold text-bunker-300")) .style("margin-bottom", "1rem") - .text("📝") + .text("No Tasks") }), html!("p", { .apply(|b| dwclass!(b, "text-bunker-400 text-lg")) @@ -1167,7 +1191,7 @@ fn render_dashboard(app: Arc) -> Dom { html!("div", { .apply(|b| dwclass!(b, "text-sm text-bunker-300 font-mono")) .style("margin-top", "0.25rem") - .text(&t.id[..8]) + .text(task_id_preview(&t.id)) .attr("title", &t.id) }), ]) @@ -1209,7 +1233,7 @@ fn render_dashboard(app: Arc) -> Dom { html!("div", { .apply(|b| dwclass!(b, "text-sm text-bunker-300")) .style("margin-top", "0.25rem") - .text(&t.created_at[..10]) + .text(timestamp_date(&t.created_at)) }), ]) }), @@ -1227,7 +1251,7 @@ fn render_dashboard(app: Arc) -> Dom { html!("div", { .apply(|b| dwclass!(b, "text-sm text-bunker-300")) .style("margin-top", "0.25rem") - .text(&t.updated_at[..10]) + .text(timestamp_date(&t.updated_at)) }), ]) }), @@ -1261,6 +1285,86 @@ fn render(app: Arc) -> Dom { }) } +#[cfg(test)] +mod tests { + use super::*; + + fn task(completed: bool) -> Task { + Task { + id: "task-1".to_string(), + title: "Review generated client".to_string(), + description: "Keep the browser example using typed requests".to_string(), + completed, + priority: TaskPriority::High, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } + } + + #[test] + fn rpc_endpoint_url_uses_same_origin_rpc_path() { + assert_eq!( + rpc_endpoint_url("https:", "app.example.test"), + "https://app.example.test/rpc" + ); + assert_eq!( + rpc_endpoint_url("http:", "localhost:8080"), + "http://localhost:8080/rpc" + ); + } + + #[test] + fn create_task_request_preserves_typed_form_values() { + let request = create_task_request( + "Ship docs".to_string(), + "Update the example README".to_string(), + TaskPriority::High, + ) + .expect("non-empty title should build request"); + + assert_eq!(request.title, "Ship docs"); + assert_eq!(request.description, "Update the example README"); + assert!(matches!(request.priority, TaskPriority::High)); + } + + #[test] + fn create_task_request_rejects_empty_title() { + assert!( + create_task_request(String::new(), "ignored".to_string(), TaskPriority::Low).is_none() + ); + } + + #[test] + fn task_completion_update_only_toggles_completion() { + let update = task_completion_update(&task(false)); + + assert_eq!(update.id, "task-1"); + assert_eq!(update.title, None); + assert_eq!(update.description, None); + assert_eq!(update.completed, Some(true)); + assert!(update.priority.is_none()); + + assert_eq!(task_completion_update(&task(true)).completed, Some(false)); + } + + #[test] + fn task_id_preview_uses_short_safe_display_id() { + assert_eq!(task_id_preview("1234567890"), "12345678"); + assert_eq!(task_id_preview("short"), "short"); + } + + #[test] + fn timestamp_date_uses_date_prefix_when_timestamp_is_long_enough() { + assert_eq!(timestamp_date("2026-01-01T00:00:00Z"), "2026-01-01"); + assert_eq!(timestamp_date("bad"), "bad"); + } + + #[test] + fn safe_prefix_returns_original_when_byte_boundary_would_split_character() { + assert_eq!(safe_prefix("abcé", 4), "abcé"); + } +} + #[wasm_bindgen(start)] pub fn main() { // Initialize panic hook for better error messages diff --git a/examples/wasm-ui-demo/vite.config.js b/examples/wasm-ui-demo/vite.config.js new file mode 100644 index 0000000..952d13d --- /dev/null +++ b/examples/wasm-ui-demo/vite.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + server: { + proxy: { + "/rpc": "http://localhost:3000", + }, + }, +}); diff --git a/sketchpad/.gitignore b/sketchpad/.gitignore deleted file mode 100644 index f59ec20..0000000 --- a/sketchpad/.gitignore +++ /dev/null @@ -1 +0,0 @@ -* \ No newline at end of file diff --git a/tests/playwright/README.md b/tests/playwright/README.md index e4d1759..61621cd 100644 --- a/tests/playwright/README.md +++ b/tests/playwright/README.md @@ -8,7 +8,7 @@ current and compatibility routes. ## Local setup ```bash -npm --prefix tests/playwright install +npm --prefix tests/playwright ci npm --prefix tests/playwright run install:browsers npm --prefix tests/playwright test ``` diff --git a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml index 84ce56e..e86dcf0 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml +++ b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml @@ -2,7 +2,13 @@ name = "playwright-jsonrpc-fixture" version = "0.0.0" edition = "2024" +rust-version = "1.88" +description = "JSON-RPC API explorer fixture server for Playwright tests" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" publish = false +readme = "README.md" [features] default = ["server"] @@ -12,12 +18,15 @@ client = ["ras-jsonrpc-macro/client"] [dependencies] anyhow = { workspace = true } axum = { workspace = true } -ras-auth-core = { path = "../../../../crates/core/ras-auth-core" } -ras-jsonrpc-core = { path = "../../../../crates/rpc/ras-jsonrpc-core" } -ras-jsonrpc-macro = { path = "../../../../crates/rpc/ras-jsonrpc-macro" } -ras-jsonrpc-types = { path = "../../../../crates/rpc/ras-jsonrpc-types" } +ras-auth-core = { path = "../../../../crates/core/ras-auth-core", version = "0.1.0" } +ras-jsonrpc-core = { path = "../../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } +ras-jsonrpc-macro = { path = "../../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0" } +ras-jsonrpc-types = { path = "../../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } reqwest = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } + +[dev-dependencies] +axum-test = { workspace = true } diff --git a/tests/playwright/fixtures/jsonrpc-fixture/README.md b/tests/playwright/fixtures/jsonrpc-fixture/README.md new file mode 100644 index 0000000..70415e4 --- /dev/null +++ b/tests/playwright/fixtures/jsonrpc-fixture/README.md @@ -0,0 +1,38 @@ +# Playwright JSON-RPC Fixture + +Socket-bound fixture server for the JSON-RPC API explorer browser tests. It is not a reusable example server; it exists so Playwright can load a real explorer page and exercise browser behavior. + +## Routes + +The service is mounted at `/rpc` and exposes: + +- JSON-RPC endpoint: `/rpc` +- Explorer page: `/rpc/explorer` +- OpenRPC document: `/rpc/explorer/openrpc.json` + +The contract in [src/main.rs](src/main.rs) includes public methods, permission-gated methods, Markdown doc comments, schema field docs, and versioned compatibility methods. The browser tests use those cases to verify explorer rendering and request behavior. + +## Run + +From the workspace root: + +```bash +PLAYWRIGHT_JSONRPC_ADDR=127.0.0.1:3102 cargo run --locked -p playwright-jsonrpc-fixture +``` + +The Playwright config starts this server automatically. Use `PLAYWRIGHT_JSONRPC_PORT` when running the full browser suite to avoid local port collisions. + +## Test Tokens + +- `user-token` +- `admin-token` + +## Checks + +```bash +cargo check -p playwright-jsonrpc-fixture --locked +cargo test -p playwright-jsonrpc-fixture --locked +cargo clippy -p playwright-jsonrpc-fixture --all-targets --all-features --locked -- -D warnings +``` + +See [../../README.md](../../README.md) for the full Playwright suite. diff --git a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs index 1bfa8cf..2c0c5b1 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs +++ b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs @@ -83,7 +83,7 @@ jsonrpc_service!({ /// {"message":"hello"} /// ``` /// - /// See [Rust API Stack](https://example.com/docs). + /// See [Rust API Stack](https://github.com/JedimEmO/rust-agent-stack/blob/main/crates/rpc/ras-jsonrpc-macro/README.md). UNAUTHORIZED ping(PingRequest) -> PingResponse, UNAUTHORIZED no_params(()) -> String, UNAUTHORIZED rename_widget(RenameWidgetV2) -> RenameWidgetResponseV2 { @@ -220,3 +220,212 @@ async fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use axum_test::TestServer; + use serde_json::json; + use std::collections::BTreeSet; + + fn test_server() -> TestServer { + let rpc_router = ExplorerRpcFixtureBuilder::new(ExplorerRpcFixtureImpl) + .base_url("/rpc") + .auth_provider(FixtureAuthProvider) + .build() + .expect("fixture JSON-RPC service should build"); + + TestServer::builder() + .mock_transport() + .build(Router::new().merge(rpc_router)) + .expect("in-memory axum-test server") + } + + async fn jsonrpc_request( + server: &TestServer, + method: &str, + params: serde_json::Value, + token: Option<&str>, + ) -> serde_json::Value { + let body = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + }); + let mut request = server.post("/rpc").json(&body); + + if let Some(token) = token { + request = request.authorization_bearer(token); + } + + request.await.json() + } + + fn method<'a>(doc: &'a serde_json::Value, name: &str) -> &'a serde_json::Value { + doc["methods"] + .as_array() + .expect("methods array") + .iter() + .find(|method| method["name"] == name) + .unwrap_or_else(|| panic!("missing method {name}")) + } + + #[test] + fn generated_openrpc_documents_explorer_fixture_methods() { + let doc = generate_explorerrpcfixture_openrpc(); + + assert_eq!(doc["openrpc"], "1.3.2"); + assert_eq!(doc["info"]["title"], "ExplorerRpcFixture JSON-RPC API"); + + let method_names = doc["methods"] + .as_array() + .expect("methods array") + .iter() + .map(|method| method["name"].as_str().expect("method name")) + .collect::>(); + + assert_eq!( + method_names, + BTreeSet::from([ + "create_widget", + "current_profile", + "no_params", + "ping", + "rename_widget.v1", + "rename_widget.v2", + ]) + ); + } + + #[test] + fn generated_openrpc_keeps_docs_permissions_and_version_metadata() { + let doc = generate_explorerrpcfixture_openrpc(); + + let ping = method(&doc, "ping"); + assert_eq!(ping["summary"], json!("Echo a `PingRequest` message.")); + assert!( + ping["description"] + .as_str() + .expect("ping description") + .contains("Preserves list items") + ); + assert!(ping.get("x-authentication").is_none()); + + let create_widget = method(&doc, "create_widget"); + assert_eq!( + create_widget["x-authentication"]["required"].as_bool(), + Some(true) + ); + assert_eq!(create_widget["x-permissions"], json!(["admin"])); + + let rename_v1 = method(&doc, "rename_widget.v1"); + assert_eq!(rename_v1["x-ras-version"], json!("v1")); + assert_eq!(rename_v1["x-ras-canonical-version"], json!("v2")); + assert_eq!( + rename_v1["x-ras-canonical-method"], + json!("rename_widget.v2") + ); + + let rename_v2 = method(&doc, "rename_widget.v2"); + assert_eq!(rename_v2["x-ras-version"], json!("v2")); + } + + #[test] + fn rename_widget_compat_upgrades_request_and_downgrades_response() { + let upgraded = >::migrate(RenameWidgetV1 { + name: "legacy name".to_string(), + }) + .expect("legacy request migrates"); + + assert_eq!(upgraded.display_name, "legacy name"); + assert!(!upgraded.notify); + + let downgraded = >::migrate(RenameWidgetResponseV2 { + display_name: "canonical name".to_string(), + notified: true, + }) + .expect("canonical response migrates"); + + assert_eq!(downgraded.name, "canonical name"); + } + + #[tokio::test] + async fn fixture_auth_provider_maps_tokens_to_permission_sets() { + let user = FixtureAuthProvider + .authenticate("user-token".to_string()) + .await + .expect("user token authenticates"); + assert_eq!(user.user_id, "user-1"); + assert!(user.permissions.contains("user")); + assert!(!user.permissions.contains("admin")); + + let admin = FixtureAuthProvider + .authenticate("admin-token".to_string()) + .await + .expect("admin token authenticates"); + assert_eq!(admin.user_id, "admin-1"); + assert!(admin.permissions.contains("user")); + assert!(admin.permissions.contains("admin")); + + assert!( + FixtureAuthProvider + .authenticate("invalid-token".to_string()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn generated_jsonrpc_routes_round_trip_without_socket() { + let server = test_server(); + + let response = jsonrpc_request(&server, "ping", json!({ "message": "hello" }), None).await; + + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], 1); + assert_eq!(response["result"]["message"], "pong: hello"); + assert!(response.get("error").is_none()); + + let response = jsonrpc_request(&server, "no_params", json!(null), None).await; + assert_eq!(response["result"], "no params ok"); + } + + #[tokio::test] + async fn generated_jsonrpc_routes_enforce_permissions_without_socket() { + let server = test_server(); + let params = json!({ + "name": "Fixture Widget", + "owner": "docs", + }); + + let user_response = + jsonrpc_request(&server, "create_widget", params.clone(), Some("user-token")).await; + assert!(user_response.get("result").is_none()); + assert!(user_response.get("error").is_some()); + + let admin_response = + jsonrpc_request(&server, "create_widget", params, Some("admin-token")).await; + assert_eq!(admin_response["result"]["id"], "rpc-created-widget"); + assert_eq!(admin_response["result"]["owner"], "docs"); + assert!(admin_response.get("error").is_none()); + } + + #[tokio::test] + async fn generated_openrpc_route_serves_document_without_socket() { + let server = test_server(); + + let response = server.get("/rpc/explorer/openrpc.json").await; + + response.assert_status_ok(); + let doc: serde_json::Value = response.json(); + assert_eq!(doc["openrpc"], "1.3.2"); + assert_eq!(doc["info"]["title"], "ExplorerRpcFixture JSON-RPC API"); + } +} diff --git a/tests/playwright/fixtures/rest-fixture/Cargo.toml b/tests/playwright/fixtures/rest-fixture/Cargo.toml index 7254c85..6c55c8f 100644 --- a/tests/playwright/fixtures/rest-fixture/Cargo.toml +++ b/tests/playwright/fixtures/rest-fixture/Cargo.toml @@ -2,7 +2,13 @@ name = "playwright-rest-fixture" version = "0.0.0" edition = "2024" +rust-version = "1.88" +description = "REST API explorer fixture server for Playwright tests" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-agent-stack" +homepage = "https://github.com/JedimEmO/rust-agent-stack" publish = false +readme = "README.md" [features] default = ["server"] @@ -14,12 +20,15 @@ anyhow = { workspace = true } async-trait = { workspace = true } axum = { workspace = true } axum-extra = { workspace = true } -ras-auth-core = { path = "../../../../crates/core/ras-auth-core" } -ras-rest-core = { path = "../../../../crates/rest/ras-rest-core" } -ras-rest-macro = { path = "../../../../crates/rest/ras-rest-macro" } +ras-auth-core = { path = "../../../../crates/core/ras-auth-core", version = "0.1.0" } +ras-rest-core = { path = "../../../../crates/rest/ras-rest-core", version = "0.1.1" } +ras-rest-macro = { path = "../../../../crates/rest/ras-rest-macro", version = "0.2.1" } reqwest = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } + +[dev-dependencies] +axum-test = { workspace = true } diff --git a/tests/playwright/fixtures/rest-fixture/README.md b/tests/playwright/fixtures/rest-fixture/README.md new file mode 100644 index 0000000..877d06d --- /dev/null +++ b/tests/playwright/fixtures/rest-fixture/README.md @@ -0,0 +1,41 @@ +# Playwright REST Fixture + +Socket-bound fixture server for the REST API explorer browser tests. It is intentionally small and deterministic so Playwright can exercise the generated explorer with a real browser-visible HTTP server. + +## Routes + +The service is mounted at `/api/v1` and exposes: + +- Explorer page: `/api/v1/docs` +- OpenAPI document: `/api/v1/docs/openapi.json` +- Health route: `/api/v1/health` +- Public widget routes +- Permission-gated widget/profile routes +- Versioned rename routes used by compatibility tests + +The contract in [src/main.rs](src/main.rs) includes Markdown operation docs, schema field docs, auth-protected operations, query parameters, path parameters, and versioned route metadata. + +## Run + +From the workspace root: + +```bash +PLAYWRIGHT_REST_ADDR=127.0.0.1:3101 cargo run --locked -p playwright-rest-fixture +``` + +The Playwright config starts this server automatically. Use `PLAYWRIGHT_REST_PORT` when running the full browser suite to avoid local port collisions. + +## Test Tokens + +- `user-token` +- `admin-token` + +## Checks + +```bash +cargo check -p playwright-rest-fixture --locked +cargo test -p playwright-rest-fixture --locked +cargo clippy -p playwright-rest-fixture --all-targets --all-features --locked -- -D warnings +``` + +See [../../README.md](../../README.md) for the full Playwright suite. diff --git a/tests/playwright/fixtures/rest-fixture/src/main.rs b/tests/playwright/fixtures/rest-fixture/src/main.rs index 0d1966a..7a131c9 100644 --- a/tests/playwright/fixtures/rest-fixture/src/main.rs +++ b/tests/playwright/fixtures/rest-fixture/src/main.rs @@ -78,7 +78,7 @@ rest_service!({ /// {"status":"ok"} /// ``` /// - /// See [REST docs](https://example.com/rest). + /// See [REST docs](https://github.com/JedimEmO/rust-agent-stack/blob/main/documentation/ras-rest-macro.md). GET UNAUTHORIZED health() -> HealthResponse, GET UNAUTHORIZED widgets/{id: String}() -> Widget, GET UNAUTHORIZED search/widgets ? q: String & limit: Option () -> WidgetsResponse, @@ -236,3 +236,197 @@ async fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::StatusCode; + use axum_test::TestServer; + use serde_json::{Value, json}; + + fn test_server() -> TestServer { + let app = ExplorerRestFixtureBuilder::new(FixtureService) + .auth_provider(FixtureAuthProvider) + .build(); + + TestServer::builder() + .mock_transport() + .build(app) + .expect("in-memory axum-test server") + } + + fn parameter<'a>(operation: &'a Value, name: &str) -> &'a Value { + operation["parameters"] + .as_array() + .expect("parameters array") + .iter() + .find(|parameter| parameter["name"] == name) + .unwrap_or_else(|| panic!("missing parameter {name}")) + } + + #[test] + fn generated_openapi_documents_explorer_fixture_routes() { + let doc = generate_explorerrestfixture_openapi(); + + assert_eq!(doc["openapi"], "3.0.3"); + assert_eq!(doc["info"]["title"], "ExplorerRestFixture REST API"); + + let health = &doc["paths"]["/health"]["get"]; + assert_eq!(health["summary"], json!("Check fixture `health`.")); + assert!( + health["description"] + .as_str() + .expect("health description") + .contains("Preserves line breaks") + ); + + assert!(doc["paths"]["/widgets/{id}"]["get"].is_object()); + assert!(doc["paths"]["/search/widgets"]["get"].is_object()); + assert!(doc["paths"]["/v1/widgets/{id}/rename"]["post"].is_object()); + assert!(doc["paths"]["/v2/widgets/{id}/rename"]["post"].is_object()); + } + + #[test] + fn generated_openapi_keeps_auth_query_and_version_metadata() { + let doc = generate_explorerrestfixture_openapi(); + + let search_widgets = &doc["paths"]["/search/widgets"]["get"]; + assert_eq!(parameter(search_widgets, "q")["required"], json!(true)); + assert_eq!(parameter(search_widgets, "limit")["required"], json!(false)); + + let create_widget = &doc["paths"]["/widgets"]["post"]; + assert_eq!(create_widget["security"][0]["bearerAuth"], json!([])); + assert_eq!(create_widget["x-permissions"], json!(["admin"])); + + let rename_v1 = &doc["paths"]["/v1/widgets/{id}/rename"]["post"]; + assert_eq!(rename_v1["x-ras-version"], json!("v1")); + assert_eq!(rename_v1["x-ras-canonical-version"], json!("v2")); + assert_eq!( + rename_v1["x-ras-canonical-path"], + json!("/v2/widgets/{id}/rename") + ); + + let rename_v2 = &doc["paths"]["/v2/widgets/{id}/rename"]["post"]; + assert_eq!(rename_v2["x-ras-version"], json!("v2")); + assert_eq!(parameter(rename_v2, "id")["required"], json!(true)); + } + + #[test] + fn rename_widget_compat_upgrades_request_and_downgrades_response() { + let upgraded = >::migrate(ExplorerRestFixturePostV2WidgetsByIdRenameV1Request { + path: ExplorerRestFixturePostV2WidgetsByIdRenameV1Path { + id: "widget-1".to_string(), + }, + query: ExplorerRestFixturePostV2WidgetsByIdRenameV1Query {}, + body: RenameWidgetV1 { + name: "legacy name".to_string(), + }, + }) + .expect("legacy request migrates"); + + assert_eq!(upgraded.path.id, "widget-1"); + assert_eq!(upgraded.body.display_name, "legacy name"); + assert!(!upgraded.body.notify); + + let downgraded = >::migrate(Widget { + id: "widget-1".to_string(), + name: "canonical name".to_string(), + owner: "fixture".to_string(), + }) + .expect("canonical response migrates"); + + assert_eq!(downgraded.name, "canonical name"); + } + + #[tokio::test] + async fn fixture_auth_provider_maps_tokens_to_permission_sets() { + let user = FixtureAuthProvider + .authenticate("user-token".to_string()) + .await + .expect("user token authenticates"); + assert_eq!(user.user_id, "user-1"); + assert!(user.permissions.contains("user")); + assert!(!user.permissions.contains("admin")); + + let admin = FixtureAuthProvider + .authenticate("admin-token".to_string()) + .await + .expect("admin token authenticates"); + assert_eq!(admin.user_id, "admin-1"); + assert!(admin.permissions.contains("user")); + assert!(admin.permissions.contains("admin")); + + assert!( + FixtureAuthProvider + .authenticate("invalid-token".to_string()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn generated_rest_routes_round_trip_without_socket() { + let server = test_server(); + + let health = server.get("/api/v1/health").await; + health.assert_status_ok(); + let health: HealthResponse = health.json(); + assert_eq!(health.status, "ok"); + + let search = server.get("/api/v1/search/widgets?q=docs&limit=3").await; + search.assert_status_ok(); + let search: WidgetsResponse = search.json(); + assert_eq!(search.total, 3); + assert_eq!(search.widgets[0].name, "docs-0"); + assert_eq!(search.widgets[2].id, "widget-2"); + } + + #[tokio::test] + async fn generated_rest_routes_enforce_permissions_without_socket() { + let server = test_server(); + + let user_response = server + .post("/api/v1/widgets") + .authorization_bearer("user-token") + .json(&json!({ + "name": "Fixture Widget", + "owner": "docs", + })) + .await; + user_response.assert_status(StatusCode::FORBIDDEN); + + let admin_response = server + .post("/api/v1/widgets") + .authorization_bearer("admin-token") + .json(&json!({ + "name": "Fixture Widget", + "owner": "docs", + })) + .await; + admin_response.assert_status(StatusCode::CREATED); + let widget: Widget = admin_response.json(); + assert_eq!(widget.id, "created-widget"); + assert_eq!(widget.owner, "docs"); + } + + #[tokio::test] + async fn generated_docs_routes_serve_explorer_and_openapi_without_socket() { + let server = test_server(); + + let docs = server.get("/api/v1/docs").await; + docs.assert_status_ok(); + assert!(docs.text().contains("ExplorerRestFixture")); + + let spec = server.get("/api/v1/docs/openapi.json").await; + spec.assert_status_ok(); + let doc: Value = spec.json(); + assert_eq!(doc["openapi"], "3.0.3"); + assert_eq!(doc["info"]["title"], "ExplorerRestFixture REST API"); + } +} diff --git a/tests/playwright/package-lock.json b/tests/playwright/package-lock.json index 5794c72..5a8bfc2 100644 --- a/tests/playwright/package-lock.json +++ b/tests/playwright/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.0", "devDependencies": { "@playwright/test": "1.58.2" + }, + "engines": { + "node": ">=22.13" } }, "node_modules/@playwright/test": { diff --git a/tests/playwright/package.json b/tests/playwright/package.json index f933c92..63fb74e 100644 --- a/tests/playwright/package.json +++ b/tests/playwright/package.json @@ -2,6 +2,9 @@ "name": "ras-api-explorer-e2e", "version": "0.0.0", "private": true, + "engines": { + "node": ">=22.13" + }, "scripts": { "test": "playwright test", "test:headed": "playwright test --headed", diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 0126e22..9596c52 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -25,13 +25,13 @@ export default defineConfig({ ], webServer: [ { - command: `PLAYWRIGHT_REST_ADDR=127.0.0.1:${restPort} cargo run -p playwright-rest-fixture`, + command: `PLAYWRIGHT_REST_ADDR=127.0.0.1:${restPort} cargo run --locked -p playwright-rest-fixture`, url: `http://127.0.0.1:${restPort}/api/v1/docs/openapi.json`, reuseExistingServer: !process.env.CI, timeout: 240_000 }, { - command: `PLAYWRIGHT_JSONRPC_ADDR=127.0.0.1:${jsonrpcPort} cargo run -p playwright-jsonrpc-fixture`, + command: `PLAYWRIGHT_JSONRPC_ADDR=127.0.0.1:${jsonrpcPort} cargo run --locked -p playwright-jsonrpc-fixture`, url: `http://127.0.0.1:${jsonrpcPort}/rpc/explorer/openrpc.json`, reuseExistingServer: !process.env.CI, timeout: 240_000 diff --git a/tests/playwright/tests/jsonrpc-explorer.spec.ts b/tests/playwright/tests/jsonrpc-explorer.spec.ts index eb8bc75..88ca05d 100644 --- a/tests/playwright/tests/jsonrpc-explorer.spec.ts +++ b/tests/playwright/tests/jsonrpc-explorer.spec.ts @@ -35,7 +35,7 @@ test.describe('JSON-RPC API explorer', () => { await expect(page.locator('#operation-description pre code')).toContainText('{"message":"hello"}'); await expect(page.locator('#operation-description a').filter({ hasText: 'Rust API Stack' })).toHaveAttribute( 'href', - 'https://example.com/docs' + 'https://github.com/JedimEmO/rust-agent-stack/blob/main/crates/rpc/ras-jsonrpc-macro/README.md' ); const descriptionText = await page.locator('#operation-description').evaluate((el) => el.textContent ?? ''); expect(descriptionText).toContain('Line one\nLine two'); @@ -102,7 +102,7 @@ test.describe('JSON-RPC API explorer', () => { await expect(page.locator('#response-status')).toContainText('RPC error'); await expect(page.locator('#response-output')).toContainText('Authentication'); - await page.locator('#jwt-token').fill('admin-token'); + await page.locator('#bearer-token').fill('admin-token'); await page.locator('#save-token').click(); await expect(page.locator('#auth-state')).toContainText('Token set'); await send(page); @@ -117,7 +117,7 @@ test.describe('JSON-RPC API explorer', () => { test('saves JSON-RPC requests, restores history, and keeps tokens out of localStorage', async ({ page }) => { await selectMethod(page, 'create_widget'); - await page.locator('#jwt-token').fill('admin-token'); + await page.locator('#bearer-token').fill('admin-token'); await page.locator('#save-token').click(); await page.locator('#params-editor').fill(JSON.stringify({ name: 'Saved RPC', owner: 'saved-owner' }, null, 2)); diff --git a/tests/playwright/tests/rest-explorer.spec.ts b/tests/playwright/tests/rest-explorer.spec.ts index c5a3d45..d8fa7ac 100644 --- a/tests/playwright/tests/rest-explorer.spec.ts +++ b/tests/playwright/tests/rest-explorer.spec.ts @@ -42,7 +42,7 @@ test.describe('REST API explorer', () => { await expect(page.locator('#operation-description pre code')).toContainText('{"status":"ok"}'); await expect(page.locator('#operation-description a').filter({ hasText: 'REST docs' })).toHaveAttribute( 'href', - 'https://example.com/rest' + 'https://github.com/JedimEmO/rust-agent-stack/blob/main/documentation/ras-rest-macro.md' ); const descriptionText = await page.locator('#operation-description').evaluate((el) => el.textContent ?? ''); expect(descriptionText).toContain('Alpha line\nBeta line'); @@ -103,7 +103,7 @@ test.describe('REST API explorer', () => { await send(page); await expect(page.locator('#response-status')).toContainText('401'); - await page.locator('#jwt-token').fill('admin-token'); + await page.locator('#bearer-token').fill('admin-token'); await page.locator('#save-token').click(); await expect(page.locator('#auth-state')).toContainText('Token set'); await send(page); @@ -120,7 +120,7 @@ test.describe('REST API explorer', () => { test('shows permission denied responses for insufficient token permissions', async ({ page }) => { await selectOperation(page, 'POST', '/widgets'); await page.locator('#body-editor').fill(JSON.stringify({ name: 'Denied Widget', owner: 'playwright' }, null, 2)); - await page.locator('#jwt-token').fill('user-token'); + await page.locator('#bearer-token').fill('user-token'); await page.locator('#save-token').click(); await expect(page.locator('#auth-state')).toContainText('Token set'); @@ -131,7 +131,7 @@ test.describe('REST API explorer', () => { test('saves requests, restores history, and keeps tokens out of localStorage', async ({ page }) => { await selectOperation(page, 'POST', '/widgets'); - await page.locator('#jwt-token').fill('admin-token'); + await page.locator('#bearer-token').fill('admin-token'); await page.locator('#save-token').click(); await page.locator('#body-editor').fill(JSON.stringify({ name: 'Saved Body', owner: 'saved-owner' }, null, 2)); From 7d275334bf86af127bba0b836803025a36d1f443 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sun, 24 May 2026 10:11:33 +0200 Subject: [PATCH 02/14] Fix publish-readiness compatibility issues --- CHANGELOG.md | 3 + Cargo.lock | 2 +- .../identity/ras-identity-oauth2/Cargo.toml | 2 +- .../identity/ras-identity-session/Cargo.toml | 2 +- crates/rest/ras-rest-macro/Cargo.toml | 2 +- crates/rpc/ras-jsonrpc-macro/Cargo.toml | 2 +- crates/rpc/ras-jsonrpc-macro/README.md | 2 +- crates/rpc/ras-jsonrpc-macro/src/client.rs | 47 +++++++- crates/rpc/ras-jsonrpc-macro/tests/e2e.rs | 11 +- crates/specs/openrpc-types/src/components.rs | 4 +- .../openrpc-types/src/content_descriptor.rs | 4 +- .../specs/openrpc-types/src/error_object.rs | 4 +- crates/specs/openrpc-types/src/example.rs | 8 +- crates/specs/openrpc-types/src/extensions.rs | 103 ++++++++++++++---- .../specs/openrpc-types/src/external_docs.rs | 4 +- crates/specs/openrpc-types/src/info.rs | 12 +- crates/specs/openrpc-types/src/link.rs | 4 +- crates/specs/openrpc-types/src/method.rs | 4 +- crates/specs/openrpc-types/src/openrpc.rs | 4 +- crates/specs/openrpc-types/src/schema.rs | 4 +- crates/specs/openrpc-types/src/server.rs | 8 +- crates/specs/openrpc-types/src/tag.rs | 4 +- documentation/ras-identity.md | 2 +- examples/bidirectional-chat/server/Cargo.toml | 2 +- examples/oauth2-demo/server/Cargo.toml | 2 +- 25 files changed, 164 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd8b1d..1e1b21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,12 @@ All notable changes to this project will be documented in this file. - `ras-identity-local`: Duplicate local user creation now fails with `LocalUserError::UserAlreadyExists` instead of silently overwriting credentials. - Bumped `ras-identity-local` from `0.1.1` to `0.2.0` because `LocalUserProvider::add_user` now returns the crate-specific `LocalUserError`. - Bumped `ras-identity-oauth2` from `0.1.1` to `0.1.2` for the additive `UserInfoMapping` root re-export and updated OAuth2 docs. +- Bumped `ras-identity-session` from `0.1.1` to `0.2.0` because replacing `jsonwebtoken` exposes the crate-local `JwtAlgorithm` and string-backed JWT errors in the public API. - `documentation/ras-identity.md`: Identity examples now use the current `UserPermissions`, `SessionService`, JWT claims, session revocation, and Axum 0.8 server APIs. - `ras-identity-local`: README testing/security notes now distinguish default tests from optional timing-sensitive checks. - `ras-identity-session`: JWT signing now uses local HMAC-SHA implementations for HS256/HS384/HS512 instead of pulling in the broader `jsonwebtoken` RustCrypto/RSA dependency path. +- `openrpc-types`: Restored the original `Extensions::insert`, `Extensions::with`, and `Extensions::from_map` signatures for compatibility; checked variants are now available as `try_insert`, `try_with`, and `try_from_map`. +- `ras-jsonrpc-macro`: Version labels such as `"1.0.0"` and `"v1-beta"` now generate sanitized client method suffixes instead of invalid Rust identifiers. - Supply-chain policy now passes on current `cargo-deny`; vulnerable `rand`, `time`, `tracing-subscriber`, `protobuf`, and related OpenTelemetry/Prometheus dependencies were updated, and unmaintained `wee_alloc` was removed from the WASM UI example. - `examples/bidirectional-chat`: Auth lifecycle tests now verify login after registration, duplicate registration rejection, and permission-bearing JWT claims. - `examples/bidirectional-chat`: Removed fake auth endpoint checks from `server_tests.rs`; auth endpoint coverage now lives in the in-memory lifecycle suite that wires the real identity/session stack. diff --git a/Cargo.lock b/Cargo.lock index d04534c..23dfac6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2731,7 +2731,7 @@ dependencies = [ [[package]] name = "ras-identity-session" -version = "0.1.1" +version = "0.2.0" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/crates/identity/ras-identity-oauth2/Cargo.toml b/crates/identity/ras-identity-oauth2/Cargo.toml index d8b4981..960ed9d 100644 --- a/crates/identity/ras-identity-oauth2/Cargo.toml +++ b/crates/identity/ras-identity-oauth2/Cargo.toml @@ -34,4 +34,4 @@ axum-test = { workspace = true } tracing-subscriber = { workspace = true } # For the example -ras-identity-session = { path = "../ras-identity-session", version = "0.1.1" } +ras-identity-session = { path = "../ras-identity-session", version = "0.2.0" } diff --git a/crates/identity/ras-identity-session/Cargo.toml b/crates/identity/ras-identity-session/Cargo.toml index 012ca4d..7b4320e 100644 --- a/crates/identity/ras-identity-session/Cargo.toml +++ b/crates/identity/ras-identity-session/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-identity-session" -version = "0.1.1" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "JWT session management and authentication provider implementation" diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index 747dc38..05f4bbc 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -38,7 +38,7 @@ tokio = { workspace = true } reqwest = { workspace = true } tower = { workspace = true } rand = { workspace = true } -ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.1.1" } +ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.2.0" } ras-jsonrpc-core = { path = "../../rpc/ras-jsonrpc-core", version = "0.1.2" } futures = { workspace = true } chrono = { workspace = true } diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index 0e4dcf1..aa85db5 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -41,7 +41,7 @@ tokio = { workspace = true } reqwest = { workspace = true } tower = { workspace = true } rand = { workspace = true } -ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.1.1" } +ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.2.0" } futures = { workspace = true } # Server dependencies for tests axum = { workspace = true } diff --git a/crates/rpc/ras-jsonrpc-macro/README.md b/crates/rpc/ras-jsonrpc-macro/README.md index 2303832..5cc6336 100644 --- a/crates/rpc/ras-jsonrpc-macro/README.md +++ b/crates/rpc/ras-jsonrpc-macro/README.md @@ -323,7 +323,7 @@ jsonrpc_service!({ }); ``` -The generated server accepts both `rename_user.v2` and `rename_user.v1`. The generated Rust client exposes `rename_user(...)` for the canonical method and `rename_user_v1(...)` for the legacy method. +The generated server accepts both `rename_user.v2` and `rename_user.v1`. The generated Rust client exposes `rename_user(...)` for the canonical method and `rename_user_v1(...)` for the legacy method. Version labels can be identifiers such as `v1` or string labels such as `"1.0.0"`; string labels are sanitized for Rust method suffixes, for example `rename_user_v1_0_0(...)`. ## Authentication Flow diff --git a/crates/rpc/ras-jsonrpc-macro/src/client.rs b/crates/rpc/ras-jsonrpc-macro/src/client.rs index 8ee8327..356317c 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/client.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/client.rs @@ -153,6 +153,36 @@ fn method_wire_name(method: &MethodDefinition) -> String { .unwrap_or_else(|| method.name.to_string()) } +fn snake_ident_segment(value: &str) -> String { + let mut out = String::new(); + let mut pending_separator = false; + + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + if pending_separator && !out.is_empty() { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + pending_separator = false; + } else { + pending_separator = !out.is_empty(); + } + } + + if out.is_empty() { + "version".to_string() + } else if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { + format!("v{out}") + } else { + out + } +} + +fn versioned_method_ident(method_name: &syn::Ident, version: &str) -> syn::Ident { + let version = snake_ident_segment(version); + quote::format_ident!("{}_{}", method_name, version) +} + /// Generate client methods for the JSON-RPC service. fn generate_client_methods_for_method(method: &MethodDefinition) -> Vec { let mut methods = vec![generate_client_method( @@ -163,7 +193,7 @@ fn generate_client_methods_for_method(method: &MethodDefinition) -> Vec EchoResponse, UNAUTHORIZED rename_user(RenameUserV2) -> RenameUserResponseV2 { - version: v2, + version: "2.0.0", wire: "rename_user.v2", versions: [ - v1 { + "1.0.0" { wire: "rename_user.v1", request: RenameUserV1, response: RenameUserResponseV1, @@ -157,6 +157,13 @@ fn server() -> axum_test::TestServer { mock_http_server(router()) } +#[cfg(feature = "client")] +#[test] +fn versioned_client_method_names_sanitize_semver_labels() { + let _method = DemoClient::rename_user_v1_0_0; + let _method_with_timeout = DemoClient::rename_user_v1_0_0_with_timeout; +} + async fn call_rpc( server: &axum_test::TestServer, method: &str, diff --git a/crates/specs/openrpc-types/src/components.rs b/crates/specs/openrpc-types/src/components.rs index f379f7e..bfe6120 100644 --- a/crates/specs/openrpc-types/src/components.rs +++ b/crates/specs/openrpc-types/src/components.rs @@ -197,9 +197,7 @@ impl Components { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } diff --git a/crates/specs/openrpc-types/src/content_descriptor.rs b/crates/specs/openrpc-types/src/content_descriptor.rs index c2d1a05..3eca851 100644 --- a/crates/specs/openrpc-types/src/content_descriptor.rs +++ b/crates/specs/openrpc-types/src/content_descriptor.rs @@ -126,9 +126,7 @@ impl ContentDescriptor { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } diff --git a/crates/specs/openrpc-types/src/error_object.rs b/crates/specs/openrpc-types/src/error_object.rs index 473c7f4..b4c7327 100644 --- a/crates/specs/openrpc-types/src/error_object.rs +++ b/crates/specs/openrpc-types/src/error_object.rs @@ -50,9 +50,7 @@ impl ErrorObject { /// Add an extension field pub fn with_extension(mut self, key: impl Into, value: impl Into) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } diff --git a/crates/specs/openrpc-types/src/example.rs b/crates/specs/openrpc-types/src/example.rs index 6ab9a55..ef3435d 100644 --- a/crates/specs/openrpc-types/src/example.rs +++ b/crates/specs/openrpc-types/src/example.rs @@ -111,9 +111,7 @@ impl Example { /// Add an extension field pub fn with_extension(mut self, key: impl Into, value: impl Into) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } } @@ -216,9 +214,7 @@ impl ExamplePairing { /// Add an extension field pub fn with_extension(mut self, key: impl Into, value: impl Into) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } diff --git a/crates/specs/openrpc-types/src/extensions.rs b/crates/specs/openrpc-types/src/extensions.rs index 5693e63..a2a93b0 100644 --- a/crates/specs/openrpc-types/src/extensions.rs +++ b/crates/specs/openrpc-types/src/extensions.rs @@ -26,7 +26,16 @@ impl Extensions { /// Insert an extension field. /// /// Extension keys must start with `x-`. - pub fn insert( + /// + /// Panics if the key is invalid. Use [`Extensions::try_insert`] when the + /// key comes from user input or another fallible source. + pub fn insert(&mut self, key: impl Into, value: impl Into) -> &mut Self { + self.try_insert(key, value) + .expect("extension keys must start with 'x-'") + } + + /// Insert an extension field, returning a validation error for invalid keys. + pub fn try_insert( &mut self, key: impl Into, value: impl Into, @@ -82,8 +91,23 @@ impl Extensions { self.0.extend(other.0); } - /// Create an Extensions map from a HashMap - pub fn from_map(map: HashMap) -> OpenRpcResult { + /// Create an Extensions map from a HashMap. + /// + /// Extension keys must start with `x-`. + pub fn from_map(map: HashMap) -> Result { + for key in map.keys() { + if let Err(err) = validate_extension_key(key) { + return Err(match err { + OpenRpcError::ValidationError { message, .. } => message, + other => other.to_string(), + }); + } + } + Ok(Self(map)) + } + + /// Create an Extensions map from a HashMap, preserving typed errors. + pub fn try_from_map(map: HashMap) -> OpenRpcResult { for key in map.keys() { validate_extension_key(key)?; } @@ -95,9 +119,22 @@ impl Extensions { self.0 } - /// Builder pattern for adding extensions - pub fn with(mut self, key: impl Into, value: impl Into) -> OpenRpcResult { - self.insert(key, value)?; + /// Builder pattern for adding extensions. + /// + /// Panics if the key is invalid. Use [`Extensions::try_with`] when the key + /// comes from user input or another fallible source. + pub fn with(mut self, key: impl Into, value: impl Into) -> Self { + self.insert(key, value); + self + } + + /// Builder pattern for adding extensions, returning validation errors. + pub fn try_with( + mut self, + key: impl Into, + value: impl Into, + ) -> OpenRpcResult { + self.try_insert(key, value)?; Ok(self) } } @@ -172,7 +209,7 @@ macro_rules! extensions { ($($key:expr => $value:expr),+ $(,)?) => {{ let mut ext = $crate::Extensions::new(); $( - ext.insert($key, $value).expect("extension keys must start with 'x-'"); + ext.insert($key, $value); )+ ext }}; @@ -186,8 +223,8 @@ mod tests { #[test] fn test_extensions_creation() { let mut ext = Extensions::new(); - ext.insert("x-custom", "value").unwrap(); - ext.insert("x-number", 42).unwrap(); + ext.insert("x-custom", "value"); + ext.insert("x-number", 42); assert_eq!( ext.get("x-custom"), @@ -198,9 +235,16 @@ mod tests { } #[test] + #[should_panic(expected = "extension keys must start with 'x-'")] fn test_invalid_extension_key() { let mut ext = Extensions::new(); - let error = ext.insert("invalid-key", "value").unwrap_err(); + ext.insert("invalid-key", "value"); + } + + #[test] + fn try_insert_returns_validation_error_for_invalid_keys() { + let mut ext = Extensions::new(); + let error = ext.try_insert("invalid-key", "value").unwrap_err(); assert!(matches!(error, OpenRpcError::ValidationError { .. })); assert!(ext.is_empty()); } @@ -208,7 +252,7 @@ mod tests { #[test] fn test_extensions_validation() { let mut ext = Extensions::new(); - ext.insert("x-valid", "value").unwrap(); + ext.insert("x-valid", "value"); assert!(ext.validate().is_ok()); // Manually create invalid extension (bypassing insert validation) @@ -225,10 +269,10 @@ mod tests { #[test] fn test_extensions_merge() { let mut ext1 = Extensions::new(); - ext1.insert("x-first", "value1").unwrap(); + ext1.insert("x-first", "value1"); let mut ext2 = Extensions::new(); - ext2.insert("x-second", "value2").unwrap(); + ext2.insert("x-second", "value2"); ext1.merge(ext2); @@ -241,9 +285,7 @@ mod tests { fn test_extensions_with_builder() { let ext = Extensions::new() .with("x-first", "value1") - .unwrap() - .with("x-second", 42) - .unwrap(); + .with("x-second", 42); assert_eq!(ext.len(), 2); assert_eq!( @@ -253,6 +295,18 @@ mod tests { assert_eq!(ext.get("x-second"), Some(&Value::Number(42.into()))); } + #[test] + fn test_extensions_try_with_builder() { + let ext = Extensions::new() + .try_with("x-first", "value1") + .unwrap() + .try_with("x-second", 42) + .unwrap(); + + assert_eq!(ext.len(), 2); + assert!(Extensions::new().try_with("invalid", "value").is_err()); + } + #[test] fn test_extensions_from_map() { let map = HashMap::from([ @@ -261,18 +315,25 @@ mod tests { ]); let ext = Extensions::from_map(map.clone()).unwrap(); + let checked_ext = Extensions::try_from_map(map.clone()).unwrap(); assert_eq!(ext.len(), 2); + assert_eq!(checked_ext.len(), 2); // Test invalid map let invalid_map = HashMap::from([("invalid".to_string(), json!("value"))]); - assert!(Extensions::from_map(invalid_map).is_err()); + assert_eq!( + Extensions::from_map(invalid_map).unwrap_err(), + "Extension key must start with 'x-': invalid" + ); + let invalid_map = HashMap::from([("invalid".to_string(), json!("value"))]); + assert!(Extensions::try_from_map(invalid_map).is_err()); } #[test] fn test_extensions_serialization() { let mut ext = Extensions::new(); - ext.insert("x-custom", "value").unwrap(); - ext.insert("x-number", 42).unwrap(); + ext.insert("x-custom", "value"); + ext.insert("x-number", 42); let json = serde_json::to_value(&ext).unwrap(); let expected = json!({ @@ -304,8 +365,8 @@ mod tests { #[test] fn test_extensions_iterator() { let mut ext = Extensions::new(); - ext.insert("x-first", "value1").unwrap(); - ext.insert("x-second", "value2").unwrap(); + ext.insert("x-first", "value1"); + ext.insert("x-second", "value2"); let mut count = 0; for (key, _value) in &ext { diff --git a/crates/specs/openrpc-types/src/external_docs.rs b/crates/specs/openrpc-types/src/external_docs.rs index 7bbd5a3..8b6f8df 100644 --- a/crates/specs/openrpc-types/src/external_docs.rs +++ b/crates/specs/openrpc-types/src/external_docs.rs @@ -45,9 +45,7 @@ impl ExternalDocumentation { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } } diff --git a/crates/specs/openrpc-types/src/info.rs b/crates/specs/openrpc-types/src/info.rs index 092e7b1..535970c 100644 --- a/crates/specs/openrpc-types/src/info.rs +++ b/crates/specs/openrpc-types/src/info.rs @@ -87,9 +87,7 @@ impl Info { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } } @@ -188,9 +186,7 @@ impl Contact { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } } @@ -260,9 +256,7 @@ impl License { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } } diff --git a/crates/specs/openrpc-types/src/link.rs b/crates/specs/openrpc-types/src/link.rs index 677fa30..5e555ad 100644 --- a/crates/specs/openrpc-types/src/link.rs +++ b/crates/specs/openrpc-types/src/link.rs @@ -104,9 +104,7 @@ impl Link { /// Add an extension field pub fn with_extension(mut self, key: impl Into, value: impl Into) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } } diff --git a/crates/specs/openrpc-types/src/method.rs b/crates/specs/openrpc-types/src/method.rs index 603cc71..c43748d 100644 --- a/crates/specs/openrpc-types/src/method.rs +++ b/crates/specs/openrpc-types/src/method.rs @@ -293,9 +293,7 @@ impl Method { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } diff --git a/crates/specs/openrpc-types/src/openrpc.rs b/crates/specs/openrpc-types/src/openrpc.rs index abd9066..9a7a639 100644 --- a/crates/specs/openrpc-types/src/openrpc.rs +++ b/crates/specs/openrpc-types/src/openrpc.rs @@ -111,9 +111,7 @@ impl OpenRpc { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } diff --git a/crates/specs/openrpc-types/src/schema.rs b/crates/specs/openrpc-types/src/schema.rs index cd2762a..bab1f4c 100644 --- a/crates/specs/openrpc-types/src/schema.rs +++ b/crates/specs/openrpc-types/src/schema.rs @@ -422,9 +422,7 @@ impl Schema { /// Add an extension field pub fn with_extension(mut self, key: impl Into, value: impl Into) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } } diff --git a/crates/specs/openrpc-types/src/server.rs b/crates/specs/openrpc-types/src/server.rs index 534def0..57515d3 100644 --- a/crates/specs/openrpc-types/src/server.rs +++ b/crates/specs/openrpc-types/src/server.rs @@ -87,9 +87,7 @@ impl Server { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } @@ -197,9 +195,7 @@ impl ServerVariable { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } } diff --git a/crates/specs/openrpc-types/src/tag.rs b/crates/specs/openrpc-types/src/tag.rs index 7e2d5df..22701e9 100644 --- a/crates/specs/openrpc-types/src/tag.rs +++ b/crates/specs/openrpc-types/src/tag.rs @@ -67,9 +67,7 @@ impl Tag { key: impl Into, value: impl Into, ) -> Self { - self.extensions - .insert(key, value) - .expect("extension keys must start with 'x-'"); + self.extensions.insert(key, value); self } } diff --git a/documentation/ras-identity.md b/documentation/ras-identity.md index f0e165c..425eb35 100644 --- a/documentation/ras-identity.md +++ b/documentation/ras-identity.md @@ -35,7 +35,7 @@ ras-auth-core = "0.1.0" ras-identity-core = "0.1.1" # Session management (required) -ras-identity-session = "0.1.1" +ras-identity-session = "0.2.0" # Identity providers (choose what you need) ras-identity-local = "0.2.0" diff --git a/examples/bidirectional-chat/server/Cargo.toml b/examples/bidirectional-chat/server/Cargo.toml index 6c9e405..38529ba 100644 --- a/examples/bidirectional-chat/server/Cargo.toml +++ b/examples/bidirectional-chat/server/Cargo.toml @@ -22,7 +22,7 @@ ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2. ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1" } ras-identity-core = { path = "../../../crates/core/ras-identity-core", version = "0.1.1" } ras-identity-local = { path = "../../../crates/identity/ras-identity-local", version = "0.2.0" } -ras-identity-session = { path = "../../../crates/identity/ras-identity-session", version = "0.1.1" } +ras-identity-session = { path = "../../../crates/identity/ras-identity-session", version = "0.2.0" } # Workspace dependencies axum = { workspace = true } diff --git a/examples/oauth2-demo/server/Cargo.toml b/examples/oauth2-demo/server/Cargo.toml index 4f1c1c3..7bc7d7e 100644 --- a/examples/oauth2-demo/server/Cargo.toml +++ b/examples/oauth2-demo/server/Cargo.toml @@ -20,7 +20,7 @@ ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = # Identity management ras-identity-core = { path = "../../../crates/core/ras-identity-core", version = "0.1.1" } ras-identity-oauth2 = { path = "../../../crates/identity/ras-identity-oauth2", version = "0.1.2" } -ras-identity-session = { path = "../../../crates/identity/ras-identity-session", version = "0.1.1" } +ras-identity-session = { path = "../../../crates/identity/ras-identity-session", version = "0.2.0" } # Web framework and utilities axum = { workspace = true } From 7fd478ca4c88a9c2996d5babad9e8e82e46a6342 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sun, 24 May 2026 10:30:05 +0200 Subject: [PATCH 03/14] Rename OpenRPC types crate with RAS prefix --- CHANGELOG.md | 11 ++++++---- Cargo.lock | 20 +++++++++---------- README.md | 2 +- .../Cargo.toml | 2 +- .../README.md | 18 ++++++++--------- .../examples/basic_usage.rs | 4 ++-- .../src/components.rs | 0 .../src/content_descriptor.rs | 0 .../src/error.rs | 0 .../src/error_object.rs | 0 .../src/example.rs | 0 .../src/extensions.rs | 0 .../src/external_docs.rs | 0 .../src/info.rs | 0 .../src/lib.rs | 2 +- .../src/link.rs | 0 .../src/method.rs | 0 .../src/openrpc.rs | 0 .../src/reference.rs | 0 .../src/schema.rs | 0 .../src/server.rs | 0 .../src/tag.rs | 0 .../src/validation.rs | 0 23 files changed, 31 insertions(+), 28 deletions(-) rename crates/specs/{openrpc-types => ras-openrpc-types}/Cargo.toml (96%) rename crates/specs/{openrpc-types => ras-openrpc-types}/README.md (91%) rename crates/specs/{openrpc-types => ras-openrpc-types}/examples/basic_usage.rs (97%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/components.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/content_descriptor.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/error.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/error_object.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/example.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/extensions.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/external_docs.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/info.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/lib.rs (94%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/link.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/method.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/openrpc.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/reference.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/schema.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/server.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/tag.rs (100%) rename crates/specs/{openrpc-types => ras-openrpc-types}/src/validation.rs (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e1b21e..e86421e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed - 2026-05-24 +- Specification types crate now uses the `ras-openrpc-types` package name and `ras_openrpc_types` import path. + ### Fixed - 2026-05-23 - `ras-identity-local`: Duplicate local user creation now fails with `LocalUserError::UserAlreadyExists` instead of silently overwriting credentials. - Bumped `ras-identity-local` from `0.1.1` to `0.2.0` because `LocalUserProvider::add_user` now returns the crate-specific `LocalUserError`. @@ -12,7 +15,7 @@ All notable changes to this project will be documented in this file. - `documentation/ras-identity.md`: Identity examples now use the current `UserPermissions`, `SessionService`, JWT claims, session revocation, and Axum 0.8 server APIs. - `ras-identity-local`: README testing/security notes now distinguish default tests from optional timing-sensitive checks. - `ras-identity-session`: JWT signing now uses local HMAC-SHA implementations for HS256/HS384/HS512 instead of pulling in the broader `jsonwebtoken` RustCrypto/RSA dependency path. -- `openrpc-types`: Restored the original `Extensions::insert`, `Extensions::with`, and `Extensions::from_map` signatures for compatibility; checked variants are now available as `try_insert`, `try_with`, and `try_from_map`. +- `ras-openrpc-types`: Restored the original `Extensions::insert`, `Extensions::with`, and `Extensions::from_map` signatures for compatibility; checked variants are now available as `try_insert`, `try_with`, and `try_from_map`. - `ras-jsonrpc-macro`: Version labels such as `"1.0.0"` and `"v1-beta"` now generate sanitized client method suffixes instead of invalid Rust identifiers. - Supply-chain policy now passes on current `cargo-deny`; vulnerable `rand`, `time`, `tracing-subscriber`, `protobuf`, and related OpenTelemetry/Prometheus dependencies were updated, and unmaintained `wee_alloc` was removed from the WASM UI example. - `examples/bidirectional-chat`: Auth lifecycle tests now verify login after registration, duplicate registration rejection, and permission-bearing JWT claims. @@ -21,7 +24,7 @@ All notable changes to this project will be documented in this file. - `examples/bidirectional-chat`: README commands now use the actual `bidirectional-chat-tui` package and current example credentials. - `examples/bidirectional-chat`: TUI README now states the correct Rust 1.88+ requirement for Rust 2024 edition crates. - `examples/file-service-wasm`: README now names the real `wasm-client` feature. -- `openrpc-types` and `ras-jsonrpc-types`: README dependency snippets now match the current crate versions. +- `ras-openrpc-types` and `ras-jsonrpc-types`: README dependency snippets now match the current crate versions. - REST and JSON-RPC macro documentation dependency snippets now match the workspace Axum, Tokio, and schemars versions. - `ras-rest-macro` and `ras-jsonrpc-macro`: HTTP integration tests now use in-memory `axum-test` mock transport instead of binding local TCP sockets. - `ras-jsonrpc-macro`: Generated-client compile/config coverage no longer attempts requests against an unused localhost port. @@ -280,7 +283,7 @@ All notable changes to this project will be documented in this file. ### Fixed - 2025-01-09 - Fixed OpenRPC specification parsing to support extension fields and JSON Schema compatibility - - Removed deny_unknown_fields restrictions from Method and Schema structs in openrpc-types crate + - Removed deny_unknown_fields restrictions from Method and Schema structs in ras-openrpc-types crate - Added $schema field support to Schema struct for proper JSON Schema Draft 7 compatibility - Enables proper parsing of OpenRPC documents with x-authentication and x-permissions extensions @@ -434,7 +437,7 @@ All notable changes to this project will be documented in this file. - All internal dependencies already properly configured with path + version ### Added - 2025-01-08 -- Complete OpenRPC 1.3.2 specification types crate (openrpc-types) with full type safety and validation +- Complete OpenRPC 1.3.2 specification types crate (ras-openrpc-types) with full type safety and validation - Comprehensive implementation of all OpenRPC specification types with serde serialization support - Ergonomic builder patterns using bon crate for fluent API construction - Extensive validation system for OpenRPC documents, method names, error codes, and component references diff --git a/Cargo.lock b/Cargo.lock index 23dfac6..9e9a8a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2124,16 +2124,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "openrpc-types" -version = "0.1.1" -dependencies = [ - "bon", - "schemars", - "serde", - "serde_json", -] - [[package]] name = "openssl" version = "0.10.73" @@ -2922,6 +2912,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "ras-openrpc-types" +version = "0.1.1" +dependencies = [ + "bon", + "schemars", + "serde", + "serde_json", +] + [[package]] name = "ras-rest-core" version = "0.1.1" diff --git a/README.md b/README.md index 36cb8f8..d230d63 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ crates/ ├── observability/ # Monitoring and metrics │ └── ras-observability-otel # OpenTelemetry implementation ├── specs/ # Specification types -│ └── openrpc-types # OpenRPC 1.3.2 spec types +│ └── ras-openrpc-types # OpenRPC 1.3.2 spec types examples/ # Example applications ├── basic-jsonrpc/ # JSON-RPC service demo ├── bidirectional-chat/ # Real-time chat system diff --git a/crates/specs/openrpc-types/Cargo.toml b/crates/specs/ras-openrpc-types/Cargo.toml similarity index 96% rename from crates/specs/openrpc-types/Cargo.toml rename to crates/specs/ras-openrpc-types/Cargo.toml index 4525f4a..ca3f48f 100644 --- a/crates/specs/openrpc-types/Cargo.toml +++ b/crates/specs/ras-openrpc-types/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "openrpc-types" +name = "ras-openrpc-types" version = "0.1.1" edition = "2024" rust-version = "1.88" diff --git a/crates/specs/openrpc-types/README.md b/crates/specs/ras-openrpc-types/README.md similarity index 91% rename from crates/specs/openrpc-types/README.md rename to crates/specs/ras-openrpc-types/README.md index 5c50f70..1d71c2a 100644 --- a/crates/specs/openrpc-types/README.md +++ b/crates/specs/ras-openrpc-types/README.md @@ -19,13 +19,13 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -openrpc-types = "0.1.1" +ras-openrpc-types = "0.1.1" ``` ## Example Usage ```rust -use openrpc_types::{OpenRpc, Info, Method, ContentDescriptor, Schema}; +use ras_openrpc_types::{OpenRpc, Info, Method, ContentDescriptor, Schema}; // Create an OpenRPC document let openrpc = OpenRpc::builder() @@ -107,7 +107,7 @@ The crate provides validation helpers for: - **Reference validity** - Internal references point to valid components ```rust -use openrpc_types::{Info, OpenRpc, validation::Validate}; +use ras_openrpc_types::{Info, OpenRpc, validation::Validate}; let openrpc = OpenRpc::v1_3_2( Info::new("Validation Example API", "1.0.0"), @@ -126,7 +126,7 @@ match openrpc.validate() { Schema objects model the JSON Schema Draft 7 shapes used by OpenRPC: ```rust -use openrpc_types::Schema; +use ras_openrpc_types::Schema; let user_schema = Schema::object() .with_property("id", Schema::string().with_format("uuid")) @@ -143,7 +143,7 @@ let user_schema = Schema::object() All major types support ergonomic builder patterns using the `bon` crate: ```rust -use openrpc_types::{Method, ContentDescriptor, Schema, ParameterStructure}; +use ras_openrpc_types::{Method, ContentDescriptor, Schema, ParameterStructure}; let method = Method::builder() .name("createUser") @@ -169,7 +169,7 @@ let method = Method::builder() Error types provide detailed information about validation failures: ```rust -use openrpc_types::{OpenRpcError, OpenRpcResult}; +use ras_openrpc_types::{OpenRpcError, OpenRpcResult}; fn validate_document(openrpc: &OpenRpc) -> OpenRpcResult<()> { openrpc.validate() @@ -191,14 +191,14 @@ fn validate_document(openrpc: &OpenRpc) -> OpenRpcResult<()> { ```toml [dependencies] -openrpc-types = { version = "0.1.1", features = ["json-schema"] } +ras-openrpc-types = { version = "0.1.1", features = ["json-schema"] } ``` ## Checks ```bash -cargo test -p openrpc-types --locked -cargo clippy -p openrpc-types --all-targets --all-features --locked -- -D warnings +cargo test -p ras-openrpc-types --locked +cargo clippy -p ras-openrpc-types --all-targets --all-features --locked -- -D warnings ``` ## License diff --git a/crates/specs/openrpc-types/examples/basic_usage.rs b/crates/specs/ras-openrpc-types/examples/basic_usage.rs similarity index 97% rename from crates/specs/openrpc-types/examples/basic_usage.rs rename to crates/specs/ras-openrpc-types/examples/basic_usage.rs index 887659b..f61cfb1 100644 --- a/crates/specs/openrpc-types/examples/basic_usage.rs +++ b/crates/specs/ras-openrpc-types/examples/basic_usage.rs @@ -1,6 +1,6 @@ -//! Basic usage example for openrpc-types +//! Basic usage example for ras-openrpc-types -use openrpc_types::{ +use ras_openrpc_types::{ Components, ContentDescriptor, ContentDescriptorOrReference, Info, Method, OpenRpc, ParameterStructure, Schema, Validate, }; diff --git a/crates/specs/openrpc-types/src/components.rs b/crates/specs/ras-openrpc-types/src/components.rs similarity index 100% rename from crates/specs/openrpc-types/src/components.rs rename to crates/specs/ras-openrpc-types/src/components.rs diff --git a/crates/specs/openrpc-types/src/content_descriptor.rs b/crates/specs/ras-openrpc-types/src/content_descriptor.rs similarity index 100% rename from crates/specs/openrpc-types/src/content_descriptor.rs rename to crates/specs/ras-openrpc-types/src/content_descriptor.rs diff --git a/crates/specs/openrpc-types/src/error.rs b/crates/specs/ras-openrpc-types/src/error.rs similarity index 100% rename from crates/specs/openrpc-types/src/error.rs rename to crates/specs/ras-openrpc-types/src/error.rs diff --git a/crates/specs/openrpc-types/src/error_object.rs b/crates/specs/ras-openrpc-types/src/error_object.rs similarity index 100% rename from crates/specs/openrpc-types/src/error_object.rs rename to crates/specs/ras-openrpc-types/src/error_object.rs diff --git a/crates/specs/openrpc-types/src/example.rs b/crates/specs/ras-openrpc-types/src/example.rs similarity index 100% rename from crates/specs/openrpc-types/src/example.rs rename to crates/specs/ras-openrpc-types/src/example.rs diff --git a/crates/specs/openrpc-types/src/extensions.rs b/crates/specs/ras-openrpc-types/src/extensions.rs similarity index 100% rename from crates/specs/openrpc-types/src/extensions.rs rename to crates/specs/ras-openrpc-types/src/extensions.rs diff --git a/crates/specs/openrpc-types/src/external_docs.rs b/crates/specs/ras-openrpc-types/src/external_docs.rs similarity index 100% rename from crates/specs/openrpc-types/src/external_docs.rs rename to crates/specs/ras-openrpc-types/src/external_docs.rs diff --git a/crates/specs/openrpc-types/src/info.rs b/crates/specs/ras-openrpc-types/src/info.rs similarity index 100% rename from crates/specs/openrpc-types/src/info.rs rename to crates/specs/ras-openrpc-types/src/info.rs diff --git a/crates/specs/openrpc-types/src/lib.rs b/crates/specs/ras-openrpc-types/src/lib.rs similarity index 94% rename from crates/specs/openrpc-types/src/lib.rs rename to crates/specs/ras-openrpc-types/src/lib.rs index 02dbedf..c5b0572 100644 --- a/crates/specs/openrpc-types/src/lib.rs +++ b/crates/specs/ras-openrpc-types/src/lib.rs @@ -15,7 +15,7 @@ //! # Example //! //! ```rust -//! use openrpc_types::{OpenRpc, Info, Method, ContentDescriptor, Schema, MethodOrReference, ContentDescriptorOrReference, ContentDescriptorSchema}; +//! use ras_openrpc_types::{OpenRpc, Info, Method, ContentDescriptor, Schema, MethodOrReference, ContentDescriptorOrReference, ContentDescriptorSchema}; //! //! let openrpc = OpenRpc::builder() //! .openrpc("1.3.2".to_string()) diff --git a/crates/specs/openrpc-types/src/link.rs b/crates/specs/ras-openrpc-types/src/link.rs similarity index 100% rename from crates/specs/openrpc-types/src/link.rs rename to crates/specs/ras-openrpc-types/src/link.rs diff --git a/crates/specs/openrpc-types/src/method.rs b/crates/specs/ras-openrpc-types/src/method.rs similarity index 100% rename from crates/specs/openrpc-types/src/method.rs rename to crates/specs/ras-openrpc-types/src/method.rs diff --git a/crates/specs/openrpc-types/src/openrpc.rs b/crates/specs/ras-openrpc-types/src/openrpc.rs similarity index 100% rename from crates/specs/openrpc-types/src/openrpc.rs rename to crates/specs/ras-openrpc-types/src/openrpc.rs diff --git a/crates/specs/openrpc-types/src/reference.rs b/crates/specs/ras-openrpc-types/src/reference.rs similarity index 100% rename from crates/specs/openrpc-types/src/reference.rs rename to crates/specs/ras-openrpc-types/src/reference.rs diff --git a/crates/specs/openrpc-types/src/schema.rs b/crates/specs/ras-openrpc-types/src/schema.rs similarity index 100% rename from crates/specs/openrpc-types/src/schema.rs rename to crates/specs/ras-openrpc-types/src/schema.rs diff --git a/crates/specs/openrpc-types/src/server.rs b/crates/specs/ras-openrpc-types/src/server.rs similarity index 100% rename from crates/specs/openrpc-types/src/server.rs rename to crates/specs/ras-openrpc-types/src/server.rs diff --git a/crates/specs/openrpc-types/src/tag.rs b/crates/specs/ras-openrpc-types/src/tag.rs similarity index 100% rename from crates/specs/openrpc-types/src/tag.rs rename to crates/specs/ras-openrpc-types/src/tag.rs diff --git a/crates/specs/openrpc-types/src/validation.rs b/crates/specs/ras-openrpc-types/src/validation.rs similarity index 100% rename from crates/specs/openrpc-types/src/validation.rs rename to crates/specs/ras-openrpc-types/src/validation.rs From ed26b99f5f8c1896d9ff71ec5cbd72f9504309e7 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sun, 24 May 2026 10:45:59 +0200 Subject: [PATCH 04/14] Update repository URLs to rust-api-stack --- CHANGELOG.md | 1 + README.md | 4 ++-- crates/core/ras-auth-core/Cargo.toml | 4 ++-- crates/core/ras-identity-core/Cargo.toml | 4 ++-- crates/core/ras-observability-core/Cargo.toml | 4 ++-- crates/core/ras-version-core/Cargo.toml | 4 ++-- crates/identity/ras-identity-local/Cargo.toml | 4 ++-- crates/identity/ras-identity-oauth2/Cargo.toml | 4 ++-- crates/identity/ras-identity-session/Cargo.toml | 4 ++-- crates/observability/ras-observability-otel/Cargo.toml | 4 ++-- crates/rest/ras-file-macro/Cargo.toml | 4 ++-- crates/rest/ras-rest-core/Cargo.toml | 4 ++-- crates/rest/ras-rest-macro/Cargo.toml | 4 ++-- .../bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml | 4 ++-- .../bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml | 4 ++-- .../bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml | 4 ++-- .../bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml | 4 ++-- crates/rpc/ras-jsonrpc-core/Cargo.toml | 4 ++-- crates/rpc/ras-jsonrpc-macro/Cargo.toml | 4 ++-- crates/rpc/ras-jsonrpc-types/Cargo.toml | 4 ++-- crates/specs/ras-openrpc-types/Cargo.toml | 4 ++-- examples/basic-jsonrpc/api/Cargo.toml | 4 ++-- examples/basic-jsonrpc/service/Cargo.toml | 4 ++-- examples/bidirectional-chat/api/Cargo.toml | 4 ++-- examples/bidirectional-chat/server/Cargo.toml | 4 ++-- examples/bidirectional-chat/tui/Cargo.toml | 4 ++-- examples/file-service-example/Cargo.toml | 4 ++-- examples/file-service-example/README.md | 2 +- examples/file-service-wasm/file-service-api/Cargo.toml | 4 ++-- examples/file-service-wasm/file-service-backend/Cargo.toml | 4 ++-- examples/oauth2-demo/api/Cargo.toml | 4 ++-- examples/oauth2-demo/server/Cargo.toml | 4 ++-- examples/rest-wasm-example/rest-api/Cargo.toml | 4 ++-- examples/rest-wasm-example/rest-backend/Cargo.toml | 4 ++-- examples/wasm-ui-demo/Cargo.toml | 4 ++-- tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml | 4 ++-- tests/playwright/fixtures/jsonrpc-fixture/src/main.rs | 2 +- tests/playwright/fixtures/rest-fixture/Cargo.toml | 4 ++-- tests/playwright/fixtures/rest-fixture/src/main.rs | 2 +- tests/playwright/tests/jsonrpc-explorer.spec.ts | 2 +- tests/playwright/tests/rest-explorer.spec.ts | 2 +- 41 files changed, 76 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e86421e..ea3827e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Changed - 2026-05-24 - Specification types crate now uses the `ras-openrpc-types` package name and `ras_openrpc_types` import path. +- Package metadata, clone instructions, and documentation links now point to the moved `rust-api-stack` repository. ### Fixed - 2026-05-23 - `ras-identity-local`: Duplicate local user creation now fails with `LocalUserError::UserAlreadyExists` instead of silently overwriting credentials. diff --git a/README.md b/README.md index d230d63..a3699af 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ Prerequisites: ```bash # Clone the repository -git clone https://github.com/JedimEmO/rust-agent-stack.git -cd rust-agent-stack +git clone https://github.com/JedimEmO/rust-api-stack.git +cd rust-api-stack # Build the entire workspace with the checked-in lockfile cargo build --locked diff --git a/crates/core/ras-auth-core/Cargo.toml b/crates/core/ras-auth-core/Cargo.toml index 0eee9a8..0614baf 100644 --- a/crates/core/ras-auth-core/Cargo.toml +++ b/crates/core/ras-auth-core/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Core authentication and authorization traits for Rust Agent Stack services" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/core/ras-identity-core/Cargo.toml b/crates/core/ras-identity-core/Cargo.toml index b22d73d..79d51fa 100644 --- a/crates/core/ras-identity-core/Cargo.toml +++ b/crates/core/ras-identity-core/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Core traits and types for identity management and authentication" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/core/ras-observability-core/Cargo.toml b/crates/core/ras-observability-core/Cargo.toml index e588e74..4708872 100644 --- a/crates/core/ras-observability-core/Cargo.toml +++ b/crates/core/ras-observability-core/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Core traits and types for observability in Rust Agent Stack" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/core/ras-version-core/Cargo.toml b/crates/core/ras-version-core/Cargo.toml index 5b846f2..d67a76c 100644 --- a/crates/core/ras-version-core/Cargo.toml +++ b/crates/core/ras-version-core/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Core traits for versioned API migrations in Rust Agent Stack" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/identity/ras-identity-local/Cargo.toml b/crates/identity/ras-identity-local/Cargo.toml index 98635c9..b85c0bd 100644 --- a/crates/identity/ras-identity-local/Cargo.toml +++ b/crates/identity/ras-identity-local/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Local username/password authentication provider with Argon2 hashing" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [features] diff --git a/crates/identity/ras-identity-oauth2/Cargo.toml b/crates/identity/ras-identity-oauth2/Cargo.toml index 960ed9d..e36cbad 100644 --- a/crates/identity/ras-identity-oauth2/Cargo.toml +++ b/crates/identity/ras-identity-oauth2/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "OAuth2 authentication provider with Google support, PKCE, and state management" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/identity/ras-identity-session/Cargo.toml b/crates/identity/ras-identity-session/Cargo.toml index 7b4320e..82bf5db 100644 --- a/crates/identity/ras-identity-session/Cargo.toml +++ b/crates/identity/ras-identity-session/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "JWT session management and authentication provider implementation" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/observability/ras-observability-otel/Cargo.toml b/crates/observability/ras-observability-otel/Cargo.toml index 3bfaa3e..c886cc2 100644 --- a/crates/observability/ras-observability-otel/Cargo.toml +++ b/crates/observability/ras-observability-otel/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "OpenTelemetry implementation for Rust Agent Stack observability" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/rest/ras-file-macro/Cargo.toml b/crates/rest/ras-file-macro/Cargo.toml index 8ad60bd..070172d 100644 --- a/crates/rest/ras-file-macro/Cargo.toml +++ b/crates/rest/ras-file-macro/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Procedural macro for type-safe file upload and download APIs" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [lib] diff --git a/crates/rest/ras-rest-core/Cargo.toml b/crates/rest/ras-rest-core/Cargo.toml index 794aec2..35d5211 100644 --- a/crates/rest/ras-rest-core/Cargo.toml +++ b/crates/rest/ras-rest-core/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Core types and traits for REST services in Rust Agent Stack" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index 05f4bbc..3158105 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Procedural macro for type-safe REST APIs with auth integration and OpenAPI document generation" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [lib] diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml index faa43cb..62e1a68 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Cross-platform WebSocket client for bidirectional JSON-RPC communication" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml index 92ba694..ae4d4f7 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Procedural macro for bidirectional JSON-RPC services" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [lib] diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml index 92449db..92b568a 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "WebSocket server implementation for bidirectional JSON-RPC communication" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" keywords = ["jsonrpc", "websocket", "axum", "bidirectional", "server"] categories = ["web-programming", "network-programming"] diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml index 27d3ede..cc5dae3 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Shared types for bidirectional JSON-RPC clients and servers" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/rpc/ras-jsonrpc-core/Cargo.toml b/crates/rpc/ras-jsonrpc-core/Cargo.toml index 8af1df1..253d011 100644 --- a/crates/rpc/ras-jsonrpc-core/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-core/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Core types and traits for the ras-jsonrpc crate family" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index aa85db5..76e5b41 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Procedural macro for type-safe JSON-RPC interfaces with auth integration and OpenRPC document generation" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [lib] diff --git a/crates/rpc/ras-jsonrpc-types/Cargo.toml b/crates/rpc/ras-jsonrpc-types/Cargo.toml index db009bb..fbfb36c 100644 --- a/crates/rpc/ras-jsonrpc-types/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-types/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "JSON-RPC 2.0 protocol types and utilities" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/crates/specs/ras-openrpc-types/Cargo.toml b/crates/specs/ras-openrpc-types/Cargo.toml index ca3f48f..ec6a321 100644 --- a/crates/specs/ras-openrpc-types/Cargo.toml +++ b/crates/specs/ras-openrpc-types/Cargo.toml @@ -7,8 +7,8 @@ description = "Rust types for the OpenRPC 1.3.2 specification with serde support keywords = ["openrpc", "json-rpc", "api", "specification", "types"] categories = ["api-bindings", "data-structures", "web-programming"] license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] diff --git a/examples/basic-jsonrpc/api/Cargo.toml b/examples/basic-jsonrpc/api/Cargo.toml index 363be3d..5af3c99 100644 --- a/examples/basic-jsonrpc/api/Cargo.toml +++ b/examples/basic-jsonrpc/api/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Shared JSON-RPC API contract for the Rust Agent Stack basic example" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/basic-jsonrpc/service/Cargo.toml b/examples/basic-jsonrpc/service/Cargo.toml index 20efba5..0d32173 100644 --- a/examples/basic-jsonrpc/service/Cargo.toml +++ b/examples/basic-jsonrpc/service/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Runnable JSON-RPC task service example for Rust Agent Stack" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/bidirectional-chat/api/Cargo.toml b/examples/bidirectional-chat/api/Cargo.toml index 2dde064..5410aca 100644 --- a/examples/bidirectional-chat/api/Cargo.toml +++ b/examples/bidirectional-chat/api/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Shared bidirectional JSON-RPC API contract for the chat example" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/bidirectional-chat/server/Cargo.toml b/examples/bidirectional-chat/server/Cargo.toml index 38529ba..5db40f8 100644 --- a/examples/bidirectional-chat/server/Cargo.toml +++ b/examples/bidirectional-chat/server/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Authenticated WebSocket chat server example for Rust Agent Stack" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/bidirectional-chat/tui/Cargo.toml b/examples/bidirectional-chat/tui/Cargo.toml index 4ddafdc..f5d75ae 100644 --- a/examples/bidirectional-chat/tui/Cargo.toml +++ b/examples/bidirectional-chat/tui/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Terminal chat client for the Rust Agent Stack bidirectional example" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/file-service-example/Cargo.toml b/examples/file-service-example/Cargo.toml index 0ac02ce..3b72dc8 100644 --- a/examples/file-service-example/Cargo.toml +++ b/examples/file-service-example/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Runnable file upload and download service example for Rust Agent Stack" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/file-service-example/README.md b/examples/file-service-example/README.md index 9b77909..f9b71ce 100644 --- a/examples/file-service-example/README.md +++ b/examples/file-service-example/README.md @@ -29,7 +29,7 @@ curl http://localhost:3000/api/files/download/test123 Authenticated upload: ```bash -printf 'hello from rust-agent-stack\n' > /tmp/ras-upload.txt +printf 'hello from rust-api-stack\n' > /tmp/ras-upload.txt curl -X POST \ -H 'Authorization: Bearer user-token' \ -F 'file=@/tmp/ras-upload.txt' \ diff --git a/examples/file-service-wasm/file-service-api/Cargo.toml b/examples/file-service-wasm/file-service-api/Cargo.toml index 64fa05a..90aa94f 100644 --- a/examples/file-service-wasm/file-service-api/Cargo.toml +++ b/examples/file-service-wasm/file-service-api/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Shared API definition for the Rust Agent Stack file-service OpenAPI usage example" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/file-service-wasm/file-service-backend/Cargo.toml b/examples/file-service-wasm/file-service-backend/Cargo.toml index 095c1fe..83ef640 100644 --- a/examples/file-service-wasm/file-service-backend/Cargo.toml +++ b/examples/file-service-wasm/file-service-backend/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Backend for the Rust Agent Stack file-service OpenAPI usage example" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/oauth2-demo/api/Cargo.toml b/examples/oauth2-demo/api/Cargo.toml index 0ccc694..6984bfd 100644 --- a/examples/oauth2-demo/api/Cargo.toml +++ b/examples/oauth2-demo/api/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Shared JSON-RPC API contract for the Rust Agent Stack OAuth2 demo" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/oauth2-demo/server/Cargo.toml b/examples/oauth2-demo/server/Cargo.toml index 7bc7d7e..fb93a1f 100644 --- a/examples/oauth2-demo/server/Cargo.toml +++ b/examples/oauth2-demo/server/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "OAuth2 and JWT session demo server for Rust Agent Stack" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/rest-wasm-example/rest-api/Cargo.toml b/examples/rest-wasm-example/rest-api/Cargo.toml index bc7b7b8..3fdf98c 100644 --- a/examples/rest-wasm-example/rest-api/Cargo.toml +++ b/examples/rest-wasm-example/rest-api/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Shared REST API contract for the Rust Agent Stack OpenAPI usage example" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/rest-wasm-example/rest-backend/Cargo.toml b/examples/rest-wasm-example/rest-backend/Cargo.toml index 8774956..6174b4a 100644 --- a/examples/rest-wasm-example/rest-backend/Cargo.toml +++ b/examples/rest-wasm-example/rest-backend/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Backend for the Rust Agent Stack REST OpenAPI usage example" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/examples/wasm-ui-demo/Cargo.toml b/examples/wasm-ui-demo/Cargo.toml index 3378474..5c95918 100644 --- a/examples/wasm-ui-demo/Cargo.toml +++ b/examples/wasm-ui-demo/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "Dominator WASM UI demo using the generated Rust Agent Stack JSON-RPC client" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml index e86dcf0..4875e12 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml +++ b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "JSON-RPC API explorer fixture server for Playwright tests" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs index 2c0c5b1..81a59a6 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs +++ b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs @@ -83,7 +83,7 @@ jsonrpc_service!({ /// {"message":"hello"} /// ``` /// - /// See [Rust API Stack](https://github.com/JedimEmO/rust-agent-stack/blob/main/crates/rpc/ras-jsonrpc-macro/README.md). + /// See [Rust API Stack](https://github.com/JedimEmO/rust-api-stack/blob/main/crates/rpc/ras-jsonrpc-macro/README.md). UNAUTHORIZED ping(PingRequest) -> PingResponse, UNAUTHORIZED no_params(()) -> String, UNAUTHORIZED rename_widget(RenameWidgetV2) -> RenameWidgetResponseV2 { diff --git a/tests/playwright/fixtures/rest-fixture/Cargo.toml b/tests/playwright/fixtures/rest-fixture/Cargo.toml index 6c55c8f..f33c269 100644 --- a/tests/playwright/fixtures/rest-fixture/Cargo.toml +++ b/tests/playwright/fixtures/rest-fixture/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" rust-version = "1.88" description = "REST API explorer fixture server for Playwright tests" license = "MIT OR Apache-2.0" -repository = "https://github.com/JedimEmO/rust-agent-stack" -homepage = "https://github.com/JedimEmO/rust-agent-stack" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" diff --git a/tests/playwright/fixtures/rest-fixture/src/main.rs b/tests/playwright/fixtures/rest-fixture/src/main.rs index 7a131c9..f3136b6 100644 --- a/tests/playwright/fixtures/rest-fixture/src/main.rs +++ b/tests/playwright/fixtures/rest-fixture/src/main.rs @@ -78,7 +78,7 @@ rest_service!({ /// {"status":"ok"} /// ``` /// - /// See [REST docs](https://github.com/JedimEmO/rust-agent-stack/blob/main/documentation/ras-rest-macro.md). + /// See [REST docs](https://github.com/JedimEmO/rust-api-stack/blob/main/documentation/ras-rest-macro.md). GET UNAUTHORIZED health() -> HealthResponse, GET UNAUTHORIZED widgets/{id: String}() -> Widget, GET UNAUTHORIZED search/widgets ? q: String & limit: Option () -> WidgetsResponse, diff --git a/tests/playwright/tests/jsonrpc-explorer.spec.ts b/tests/playwright/tests/jsonrpc-explorer.spec.ts index 88ca05d..2be504d 100644 --- a/tests/playwright/tests/jsonrpc-explorer.spec.ts +++ b/tests/playwright/tests/jsonrpc-explorer.spec.ts @@ -35,7 +35,7 @@ test.describe('JSON-RPC API explorer', () => { await expect(page.locator('#operation-description pre code')).toContainText('{"message":"hello"}'); await expect(page.locator('#operation-description a').filter({ hasText: 'Rust API Stack' })).toHaveAttribute( 'href', - 'https://github.com/JedimEmO/rust-agent-stack/blob/main/crates/rpc/ras-jsonrpc-macro/README.md' + 'https://github.com/JedimEmO/rust-api-stack/blob/main/crates/rpc/ras-jsonrpc-macro/README.md' ); const descriptionText = await page.locator('#operation-description').evaluate((el) => el.textContent ?? ''); expect(descriptionText).toContain('Line one\nLine two'); diff --git a/tests/playwright/tests/rest-explorer.spec.ts b/tests/playwright/tests/rest-explorer.spec.ts index d8fa7ac..7c7994d 100644 --- a/tests/playwright/tests/rest-explorer.spec.ts +++ b/tests/playwright/tests/rest-explorer.spec.ts @@ -42,7 +42,7 @@ test.describe('REST API explorer', () => { await expect(page.locator('#operation-description pre code')).toContainText('{"status":"ok"}'); await expect(page.locator('#operation-description a').filter({ hasText: 'REST docs' })).toHaveAttribute( 'href', - 'https://github.com/JedimEmO/rust-agent-stack/blob/main/documentation/ras-rest-macro.md' + 'https://github.com/JedimEmO/rust-api-stack/blob/main/documentation/ras-rest-macro.md' ); const descriptionText = await page.locator('#operation-description').evaluate((el) => el.textContent ?? ''); expect(descriptionText).toContain('Alpha line\nBeta line'); From c8c0b4482c3f04322ad4e3741a6566496faa2836 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sun, 24 May 2026 18:51:31 +0200 Subject: [PATCH 05/14] Harden auth cookie transport --- Cargo.lock | 2 + Cargo.toml | 1 + README.md | 4 +- crates/core/ras-auth-core/Cargo.toml | 2 + crates/core/ras-auth-core/README.md | 40 + crates/core/ras-auth-core/src/lib.rs | 4 + crates/core/ras-auth-core/src/transport.rs | 944 ++++++++++++++++++ .../identity/ras-identity-session/src/lib.rs | 45 +- crates/rest/ras-file-macro/src/server.rs | 54 +- crates/rest/ras-file-macro/tests/e2e.rs | 148 ++- crates/rest/ras-rest-macro/README.md | 19 + crates/rest/ras-rest-macro/src/lib.rs | 88 +- .../ras-rest-macro/tests/http_integration.rs | 87 +- crates/rpc/ras-jsonrpc-core/README.md | 20 + crates/rpc/ras-jsonrpc-macro/README.md | 3 +- crates/rpc/ras-jsonrpc-macro/src/lib.rs | 57 +- .../tests/http_integration.rs | 120 ++- crates/rpc/ras-jsonrpc-types/src/lib.rs | 16 + documentation/ras-file-macro.md | 12 +- documentation/ras-identity.md | 44 + examples/file-service-example/src/main.rs | 4 +- 21 files changed, 1653 insertions(+), 61 deletions(-) create mode 100644 crates/core/ras-auth-core/src/transport.rs diff --git a/Cargo.lock b/Cargo.lock index b9a8adc..4352d69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2628,6 +2628,8 @@ dependencies = [ name = "ras-auth-core" version = "0.1.0" dependencies = [ + "cookie", + "http", "serde", "serde_json", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index b655a6d..a6bd596 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ base64 = "0.22" bon = "3.2" console_error_panic_hook = "0.1" criterion = "0.5" +cookie = "0.18" crossterm = "0.28" dashmap = "6.1" dominator = "0.5" diff --git a/README.md b/README.md index a3699af..6d8bf49 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Rust framework for building type-safe, authenticated agent systems with JSON-R ## Overview The Rust Agent Stack provides reusable building blocks for distributed agent systems: -- **Pluggable Authentication** - JWT sessions, OAuth2, local username/password auth, and reusable authorization traits +- **Pluggable Authentication** - JWT sessions, OAuth2, local username/password auth, secure cookie transport, and reusable authorization traits - **Type-Safe APIs** - Procedural macros for JSON-RPC, REST, and file services - **WebSocket Support** - Bidirectional real-time communication - **File Services** - Type-safe file upload/download with streaming support @@ -272,6 +272,7 @@ Package-level guides: - **JWT Configuration** - Configurable algorithms, secrets, TTLs, and active-session enforcement - **PKCE OAuth2** - Proof Key for Code Exchange by default - **Session Management** - JWT-based sessions with revocation support +- **Secure Cookie Transport** - Optional `HttpOnly`, `Secure`, `SameSite` cookies alongside bearer headers for browser sessions ### Observability @@ -305,6 +306,7 @@ Browser examples use generated contracts without hand-written DTOs: - REST and file-service TypeScript usage samples assume a fetch client generated from OpenAPI specs. - JSON-RPC WASM UI uses generated Rust/WASM client code from the shared API crate. - Bearer tokens are passed as ordinary per-request headers. +- Browser-facing services can opt into secure `HttpOnly` session cookies on the same generated builders, with double-submit CSRF protection for cookie-authenticated unsafe requests. ## Development diff --git a/crates/core/ras-auth-core/Cargo.toml b/crates/core/ras-auth-core/Cargo.toml index 0614baf..3e382f5 100644 --- a/crates/core/ras-auth-core/Cargo.toml +++ b/crates/core/ras-auth-core/Cargo.toml @@ -10,6 +10,8 @@ homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] +cookie = { workspace = true } +http = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/core/ras-auth-core/README.md b/crates/core/ras-auth-core/README.md index 611241b..41de2ee 100644 --- a/crates/core/ras-auth-core/README.md +++ b/crates/core/ras-auth-core/README.md @@ -11,6 +11,9 @@ JSON-RPC, bidirectional JSON-RPC, and identity crates: - `AuthenticatedUser` - Represents an authenticated user with permissions - `AuthError` - Common error types for authentication failures - `AuthFuture` - Boxed future type returned by authentication providers +- `AuthTransportConfig` - HTTP credential transport configuration for bearer and cookie auth +- `AuthCookieConfig` - Secure session cookie settings and `Set-Cookie` helpers +- `CsrfConfig` - CSRF guard for cookie-authenticated unsafe requests ## Key Types @@ -60,6 +63,43 @@ pub enum AuthError { } ``` +### HTTP Credential Transport + +`AuthProvider` still validates a token string. HTTP services can choose how the +token reaches the server: + +```rust +use ras_auth_core::{AuthCookieConfig, AuthTransportConfig, CsrfConfig}; + +let transport = AuthTransportConfig::default() + .with_cookie(AuthCookieConfig::default()) + .with_csrf(CsrfConfig::default()); +``` + +Bearer tokens remain enabled by default. If both `Authorization: Bearer ...` and +the configured cookie are present, bearer wins. If the bearer header is present +but malformed, the request fails instead of falling back to the cookie. + +Cookie helpers emit secure defaults: + +```rust +use ras_auth_core::AuthCookieConfig; + +let cookie = AuthCookieConfig::default(); +let set_cookie = cookie.session_cookie_header_value("jwt-token")?; +let clear_cookie = cookie.clear_cookie_header_value()?; +# Ok::<(), Box>(()) +``` + +The default cookie is `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/`, and uses a +`__Host-` name. Use `insecure_for_local_development()` only for local HTTP. + +`CsrfConfig::default()` uses a double-submit token: issue a CSRF cookie with +`csrf_cookie_header_value(...)`, then have browser clients echo the same token in +the `x-ras-csrf` header on cookie-authenticated `POST`, `PUT`, `PATCH`, and +`DELETE` requests. Use `header_presence_only(...)` only behind restrictive +credentialed CORS where a presence-only custom header is an intentional tradeoff. + ## Usage This crate is typically used as a dependency by: diff --git a/crates/core/ras-auth-core/src/lib.rs b/crates/core/ras-auth-core/src/lib.rs index f502615..cbd7edf 100644 --- a/crates/core/ras-auth-core/src/lib.rs +++ b/crates/core/ras-auth-core/src/lib.rs @@ -1,5 +1,7 @@ //! Authentication and authorization traits for JSON-RPC services. +mod transport; + use std::collections::HashSet; use std::future::Future; use std::pin::Pin; @@ -7,6 +9,8 @@ use std::pin::Pin; use serde::{Deserialize, Serialize}; use thiserror::Error; +pub use transport::*; + /// Errors that can occur during authentication or authorization. #[derive(Debug, Error, Clone, Serialize, Deserialize)] pub enum AuthError { diff --git a/crates/core/ras-auth-core/src/transport.rs b/crates/core/ras-auth-core/src/transport.rs new file mode 100644 index 0000000..5d797e0 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport.rs @@ -0,0 +1,944 @@ +//! HTTP credential transport helpers for bearer and cookie-based sessions. + +use cookie::{ + Cookie, SameSite, + time::{Duration, OffsetDateTime}, +}; +use http::header::{AUTHORIZATION, COOKIE, HeaderName, SET_COOKIE}; +use http::{HeaderMap, HeaderValue}; +use thiserror::Error; + +const DEFAULT_COOKIE_NAME: &str = "__Host-ras-session"; +const DEFAULT_CSRF_COOKIE_NAME: &str = "__Host-ras-csrf"; +const DEFAULT_CSRF_HEADER: &str = "x-ras-csrf"; + +/// Source from which an authentication token was extracted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthTokenSource { + /// `Authorization: Bearer ...` + Bearer, + /// Configured HTTP cookie. + Cookie, +} + +/// Authentication token extracted from an HTTP request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthCredential { + token: String, + source: AuthTokenSource, +} + +impl AuthCredential { + /// Create a credential for tests or custom extractors. + pub fn new(token: impl Into, source: AuthTokenSource) -> Self { + Self { + token: token.into(), + source, + } + } + + /// The token value to pass to `AuthProvider::authenticate`. + pub fn token(&self) -> &str { + &self.token + } + + /// The transport that supplied the token. + pub fn source(&self) -> AuthTokenSource { + self.source + } +} + +/// Errors that can occur while extracting or validating HTTP auth credentials. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum AuthTransportError { + /// No configured credential transport found a token. + #[error("missing authentication credentials")] + MissingCredentials, + + /// The `Authorization` header was present but was not a valid bearer token. + #[error("invalid authorization header")] + InvalidAuthorizationHeader, + + /// Cookie-authenticated request failed CSRF validation. + #[error("CSRF validation failed")] + CsrfValidationFailed, + + /// Cookie configuration is internally inconsistent. + #[error("invalid cookie configuration: {0}")] + InvalidCookieConfig(String), + + /// The request contained ambiguous or invalid cookie credentials. + #[error("invalid cookie header: {0}")] + InvalidCookieHeader(String), + + /// CSRF configuration is internally inconsistent. + #[error("invalid CSRF configuration: {0}")] + InvalidCsrfConfig(String), + + /// Auth transport configuration is internally inconsistent. + #[error("invalid auth transport configuration: {0}")] + InvalidAuthTransportConfig(String), + + /// Generated cookie header could not be represented as an HTTP header. + #[error("invalid set-cookie header: {0}")] + InvalidSetCookieHeader(String), +} + +/// SameSite setting for generated session cookies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CookieSameSite { + /// Send cookies for same-site requests and top-level cross-site navigations. + Lax, + /// Send cookies only for same-site requests. + Strict, + /// Send cookies cross-site. Requires `Secure`. + None, +} + +impl CookieSameSite { + fn as_cookie_same_site(self) -> SameSite { + match self { + Self::Lax => SameSite::Lax, + Self::Strict => SameSite::Strict, + Self::None => SameSite::None, + } + } +} + +/// Configuration for accepting and emitting a session cookie. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthCookieConfig { + /// Cookie name. Defaults to a host-only secure-cookie prefix. + pub name: String, + /// Cookie path. Defaults to `/`. + pub path: String, + /// Optional cookie domain. Must remain `None` for `__Host-` cookies. + pub domain: Option, + /// Whether to emit `Secure`. + pub secure: bool, + /// Whether to emit `HttpOnly`. + pub http_only: bool, + /// SameSite policy. + pub same_site: CookieSameSite, + /// Optional `Max-Age` in seconds for the set-cookie helper. + pub max_age_seconds: Option, +} + +impl Default for AuthCookieConfig { + fn default() -> Self { + Self { + name: DEFAULT_COOKIE_NAME.to_string(), + path: "/".to_string(), + domain: None, + secure: true, + http_only: true, + same_site: CookieSameSite::Lax, + max_age_seconds: None, + } + } +} + +impl AuthCookieConfig { + /// Create a secure cookie configuration with a custom name. + /// + /// Prefer [`Self::default`] or [`Self::host_prefixed`] for production browser sessions. + /// Plain shared-domain names are easier to confuse with cookies set by subdomains. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + ..Self::default() + } + } + + /// Create a secure `__Host-` prefixed cookie configuration with a custom suffix. + pub fn host_prefixed(name: impl Into) -> Self { + let name = name.into(); + let suffix = name.strip_prefix("__Host-").unwrap_or(&name); + Self { + name: format!("__Host-{suffix}"), + ..Self::default() + } + } + + /// Relax `Secure` for local HTTP development. + /// + /// Do not use this in production. + pub fn insecure_for_local_development(mut self) -> Self { + self.secure = false; + if let Some(name) = self.name.strip_prefix("__Host-") { + self.name = name.to_string(); + } + self + } + + /// Validate cookie prefix and browser-enforced security invariants. + pub fn validate(&self) -> Result<(), AuthTransportError> { + validate_cookie_name(&self.name)?; + + if self.path.trim().is_empty() { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie path must not be empty".to_string(), + )); + } + + if !self.path.starts_with('/') { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie path must start with '/'".to_string(), + )); + } + + if self.name.starts_with("__Secure-") && !self.secure { + return Err(AuthTransportError::InvalidCookieConfig( + "__Secure- cookies must be Secure".to_string(), + )); + } + + if self.name.starts_with("__Host-") { + if !self.secure { + return Err(AuthTransportError::InvalidCookieConfig( + "__Host- cookies must be Secure".to_string(), + )); + } + if self.domain.is_some() { + return Err(AuthTransportError::InvalidCookieConfig( + "__Host- cookies must not set Domain".to_string(), + )); + } + if self.path != "/" { + return Err(AuthTransportError::InvalidCookieConfig( + "__Host- cookies must use Path=/".to_string(), + )); + } + } + + if self.same_site == CookieSameSite::None && !self.secure { + return Err(AuthTransportError::InvalidCookieConfig( + "SameSite=None cookies must be Secure".to_string(), + )); + } + + if let Some(domain) = &self.domain + && domain.trim().is_empty() + { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie domain must not be empty".to_string(), + )); + } + + Ok(()) + } + + /// Build a `Set-Cookie` header value for a newly issued session token. + pub fn session_cookie_header_value( + &self, + token: &str, + ) -> Result { + self.validate()?; + + let mut builder = Cookie::build((self.name.clone(), token.to_string())) + .path(self.path.clone()) + .secure(self.secure) + .http_only(self.http_only) + .same_site(self.same_site.as_cookie_same_site()); + + if let Some(domain) = &self.domain { + builder = builder.domain(domain.clone()); + } + + if let Some(max_age) = self.max_age_seconds { + builder = builder.max_age(Duration::seconds(max_age)); + } + + set_cookie_value(builder.build().to_string()) + } + + /// Build a `Set-Cookie` header value that clears this session cookie. + pub fn clear_cookie_header_value(&self) -> Result { + self.validate()?; + + let mut builder = Cookie::build((self.name.clone(), "")) + .path(self.path.clone()) + .secure(self.secure) + .http_only(self.http_only) + .same_site(self.same_site.as_cookie_same_site()) + .max_age(Duration::seconds(0)) + .expires(OffsetDateTime::UNIX_EPOCH); + + if let Some(domain) = &self.domain { + builder = builder.domain(domain.clone()); + } + + set_cookie_value(builder.build().to_string()) + } +} + +fn validate_cookie_name(name: &str) -> Result<(), AuthTransportError> { + if name.trim().is_empty() { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie name must not be empty".to_string(), + )); + } + + if name.trim() != name { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie name must not contain leading or trailing whitespace".to_string(), + )); + } + + for byte in name.bytes() { + if byte <= 0x20 + || byte >= 0x7f + || matches!( + byte, + b'(' | b')' + | b'<' + | b'>' + | b'@' + | b',' + | b';' + | b':' + | b'\\' + | b'"' + | b'/' + | b'[' + | b']' + | b'?' + | b'=' + | b'{' + | b'}' + ) + { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie name must be a valid RFC6265 token".to_string(), + )); + } + } + + Ok(()) +} + +fn set_cookie_value(value: String) -> Result { + HeaderValue::from_str(&value) + .map_err(|err| AuthTransportError::InvalidSetCookieHeader(err.to_string())) +} + +/// CSRF guard configuration for cookie-authenticated unsafe requests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CsrfConfig { + /// Header that must be present on unsafe cookie-authenticated requests. + pub header_name: HeaderName, + /// Optional exact value the header must carry. If set, this value is used + /// instead of double-submit cookie validation. + pub expected_value: Option, + /// Cookie whose value must match the CSRF header. Enabled by default. + pub cookie_name: Option, +} + +impl Default for CsrfConfig { + fn default() -> Self { + Self { + header_name: HeaderName::from_static(DEFAULT_CSRF_HEADER), + expected_value: None, + cookie_name: Some(DEFAULT_CSRF_COOKIE_NAME.to_string()), + } + } +} + +impl CsrfConfig { + /// Require a custom header and the default double-submit CSRF cookie. + pub fn new(header_name: HeaderName) -> Self { + Self { + header_name, + ..Self::default() + } + } + + /// Require the custom header to carry an exact value. + /// + /// This is intended for callers that validate a session-specific CSRF token + /// outside of the default double-submit cookie flow. + pub fn with_expected_value(mut self, expected_value: impl Into) -> Self { + self.expected_value = Some(expected_value.into()); + self.cookie_name = None; + self + } + + /// Require the custom header to match this CSRF cookie. + pub fn with_cookie_name(mut self, cookie_name: impl Into) -> Self { + self.cookie_name = Some(cookie_name.into()); + self.expected_value = None; + self + } + + /// Require only a non-empty custom header. + /// + /// This mode depends on restrictive credentialed CORS and is not a complete + /// CSRF defense by itself. Prefer [`Self::default`] for browser sessions. + pub fn header_presence_only(header_name: HeaderName) -> Self { + Self { + header_name, + expected_value: None, + cookie_name: None, + } + } + + /// Build a `Set-Cookie` header value for the double-submit CSRF token. + /// + /// The CSRF cookie is intentionally not `HttpOnly` so browser clients can + /// copy its value into the configured CSRF header. + pub fn csrf_cookie_header_value(&self, token: &str) -> Result { + self.csrf_cookie_config()? + .session_cookie_header_value(token) + } + + /// Build a `Set-Cookie` header value that clears the CSRF cookie. + pub fn clear_csrf_cookie_header_value(&self) -> Result { + self.csrf_cookie_config()?.clear_cookie_header_value() + } + + /// Validate CSRF configuration. + pub fn validate(&self) -> Result<(), AuthTransportError> { + if let Some(expected) = &self.expected_value + && expected.trim().is_empty() + { + return Err(AuthTransportError::InvalidCsrfConfig( + "expected CSRF value must not be empty".to_string(), + )); + } + + if let Some(cookie_name) = &self.cookie_name { + let cookie = AuthCookieConfig { + name: cookie_name.clone(), + http_only: false, + ..AuthCookieConfig::default() + }; + cookie.validate()?; + } + + Ok(()) + } + + fn validate_headers(&self, headers: &HeaderMap) -> Result<(), AuthTransportError> { + self.validate()?; + + let value = headers + .get(&self.header_name) + .ok_or(AuthTransportError::CsrfValidationFailed)?; + let value = value + .to_str() + .map_err(|_| AuthTransportError::CsrfValidationFailed)?; + + if value.trim().is_empty() { + return Err(AuthTransportError::CsrfValidationFailed); + } + + if let Some(expected) = &self.expected_value + && value != expected + { + return Err(AuthTransportError::CsrfValidationFailed); + } + + if self.expected_value.is_some() { + return Ok(()); + } + + if let Some(cookie_name) = &self.cookie_name { + let Some(cookie_value) = extract_cookie(headers, cookie_name)? else { + return Err(AuthTransportError::CsrfValidationFailed); + }; + + if cookie_value.trim().is_empty() || cookie_value != value { + return Err(AuthTransportError::CsrfValidationFailed); + } + } + + Ok(()) + } + + fn csrf_cookie_config(&self) -> Result { + let cookie_name = self.cookie_name.as_ref().ok_or_else(|| { + AuthTransportError::InvalidCsrfConfig( + "CSRF cookie helper requires cookie validation mode".to_string(), + ) + })?; + + let cookie = AuthCookieConfig { + name: cookie_name.clone(), + http_only: false, + ..AuthCookieConfig::default() + }; + cookie.validate()?; + Ok(cookie) + } +} + +/// Configures which HTTP transports a generated service accepts for auth. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthTransportConfig { + /// Accept `Authorization: Bearer ...`. + pub bearer: bool, + /// Optional secure cookie credential transport. + pub cookie: Option, + /// Optional CSRF guard for cookie-authenticated unsafe requests. + pub csrf: Option, +} + +impl Default for AuthTransportConfig { + fn default() -> Self { + Self { + bearer: true, + cookie: None, + csrf: None, + } + } +} + +impl AuthTransportConfig { + /// Enable cookie auth alongside the default bearer transport. + pub fn with_cookie(mut self, cookie: AuthCookieConfig) -> Self { + self.cookie = Some(cookie); + self + } + + /// Enable CSRF protection for cookie-authenticated unsafe requests. + pub fn with_csrf(mut self, csrf: CsrfConfig) -> Self { + self.csrf = Some(csrf); + self + } + + /// Disable bearer-token extraction. + pub fn without_bearer(mut self) -> Self { + self.bearer = false; + self + } + + /// Validate all configured auth transports. + pub fn validate(&self) -> Result<(), AuthTransportError> { + if !self.bearer && self.cookie.is_none() { + return Err(AuthTransportError::InvalidAuthTransportConfig( + "at least one auth transport must be enabled".to_string(), + )); + } + + if let Some(cookie) = &self.cookie { + cookie.validate()?; + } + + if let Some(csrf) = &self.csrf { + csrf.validate()?; + } + + Ok(()) + } +} + +/// Extract an auth credential from configured HTTP transports. +pub fn extract_auth_credential( + headers: &HeaderMap, + config: &AuthTransportConfig, +) -> Result { + config.validate()?; + + if config.bearer + && let Some(header) = headers.get(AUTHORIZATION) + { + let header = header + .to_str() + .map_err(|_| AuthTransportError::InvalidAuthorizationHeader)?; + let (scheme, token) = header + .split_once(' ') + .ok_or(AuthTransportError::InvalidAuthorizationHeader)?; + if !scheme.eq_ignore_ascii_case("Bearer") || token.trim().is_empty() { + return Err(AuthTransportError::InvalidAuthorizationHeader); + } + let token = token.trim(); + + return Ok(AuthCredential::new(token, AuthTokenSource::Bearer)); + } + + if let Some(cookie_config) = &config.cookie + && let Some(token) = extract_cookie(headers, &cookie_config.name)? + { + return Ok(AuthCredential::new(token, AuthTokenSource::Cookie)); + } + + Err(AuthTransportError::MissingCredentials) +} + +/// Validate CSRF policy for a previously extracted credential. +pub fn validate_csrf_for_credential( + method: &str, + headers: &HeaderMap, + credential: &AuthCredential, + config: &AuthTransportConfig, +) -> Result<(), AuthTransportError> { + config.validate()?; + + if credential.source != AuthTokenSource::Cookie || !is_unsafe_method(method) { + return Ok(()); + } + + match &config.csrf { + Some(csrf) => csrf.validate_headers(headers), + None => Ok(()), + } +} + +/// Header name used by cookie helper return values. +pub fn set_cookie_header_name() -> HeaderName { + SET_COOKIE +} + +/// Clone headers with known credential-bearing values replaced by `[REDACTED]`. +pub fn redact_sensitive_headers(headers: &HeaderMap) -> HeaderMap { + let mut redacted = headers.clone(); + + redact_header(&mut redacted, AUTHORIZATION); + redact_header(&mut redacted, COOKIE); + redact_header(&mut redacted, SET_COOKIE); + redact_header( + &mut redacted, + HeaderName::from_static("proxy-authorization"), + ); + redact_header(&mut redacted, HeaderName::from_static("x-auth-token")); + redact_header(&mut redacted, HeaderName::from_static("x-api-key")); + redact_header(&mut redacted, HeaderName::from_static("x-csrf-token")); + redact_header(&mut redacted, HeaderName::from_static("x-xsrf-token")); + redact_header(&mut redacted, HeaderName::from_static(DEFAULT_CSRF_HEADER)); + redact_header( + &mut redacted, + HeaderName::from_static("sec-websocket-protocol"), + ); + + redacted +} + +/// Clone headers with default sensitive values and configured auth transport +/// header secrets replaced by `[REDACTED]`. +pub fn redact_sensitive_headers_for_auth_transport( + headers: &HeaderMap, + config: &AuthTransportConfig, +) -> HeaderMap { + let mut redacted = redact_sensitive_headers(headers); + + if let Some(csrf) = &config.csrf { + redact_header(&mut redacted, csrf.header_name.clone()); + } + + redacted +} + +fn redact_header(headers: &mut HeaderMap, name: HeaderName) { + if headers.contains_key(&name) { + headers.remove(&name); + headers.insert(name, HeaderValue::from_static("[REDACTED]")); + } +} + +fn is_unsafe_method(method: &str) -> bool { + matches!( + method.to_ascii_uppercase().as_str(), + "POST" | "PUT" | "PATCH" | "DELETE" + ) +} + +fn extract_cookie( + headers: &HeaderMap, + cookie_name: &str, +) -> Result, AuthTransportError> { + let mut found = None; + + for value in headers.get_all(COOKIE) { + let Ok(raw) = value.to_str() else { + continue; + }; + + for cookie in Cookie::split_parse(raw).filter_map(Result::ok) { + if cookie.name() == cookie_name { + if found.is_some() { + return Err(AuthTransportError::InvalidCookieHeader(format!( + "multiple {cookie_name} cookies were present" + ))); + } + found = Some(cookie.value().to_string()); + } + } + } + + Ok(found) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers(pairs: &[(&str, &str)]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for (name, value) in pairs { + headers.append( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + headers + } + + #[test] + fn extract_auth_credential_returns_bearer_token() { + let headers = headers(&[("authorization", "Bearer abc123")]); + + let credential = extract_auth_credential(&headers, &AuthTransportConfig::default()) + .expect("bearer extracts"); + + assert_eq!(credential.token(), "abc123"); + assert_eq!(credential.source(), AuthTokenSource::Bearer); + } + + #[test] + fn extract_auth_credential_accepts_case_insensitive_bearer_scheme() { + let headers = headers(&[("authorization", "bearer abc123")]); + + let credential = extract_auth_credential(&headers, &AuthTransportConfig::default()) + .expect("bearer extracts"); + + assert_eq!(credential.token(), "abc123"); + assert_eq!(credential.source(), AuthTokenSource::Bearer); + } + + #[test] + fn extract_auth_credential_returns_cookie_when_bearer_absent() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let headers = headers(&[("cookie", "theme=dark; __Host-ras-session=cookie-token")]); + + let credential = extract_auth_credential(&headers, &config).expect("cookie extracts"); + + assert_eq!(credential.token(), "cookie-token"); + assert_eq!(credential.source(), AuthTokenSource::Cookie); + } + + #[test] + fn extract_auth_credential_rejects_malformed_bearer_without_cookie_fallback() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let headers = headers(&[ + ("authorization", "Basic abc123"), + ("cookie", "__Host-ras-session=cookie-token"), + ]); + + let error = extract_auth_credential(&headers, &config).unwrap_err(); + + assert_eq!(error, AuthTransportError::InvalidAuthorizationHeader); + } + + #[test] + fn extract_auth_credential_prefers_bearer_when_both_are_present() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let headers = headers(&[ + ("authorization", "Bearer bearer-token"), + ("cookie", "__Host-ras-session=cookie-token"), + ]); + + let credential = extract_auth_credential(&headers, &config).expect("credential extracts"); + + assert_eq!(credential.token(), "bearer-token"); + assert_eq!(credential.source(), AuthTokenSource::Bearer); + } + + #[test] + fn extract_auth_credential_rejects_duplicate_session_cookies() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let headers = headers(&[( + "cookie", + "__Host-ras-session=first; __Host-ras-session=second", + )]); + + let error = extract_auth_credential(&headers, &config).unwrap_err(); + + assert!(matches!(error, AuthTransportError::InvalidCookieHeader(_))); + } + + #[test] + fn auth_cookie_config_validates_host_prefix_constraints() { + assert!(AuthCookieConfig::default().validate().is_ok()); + + let error = AuthCookieConfig { + secure: false, + ..AuthCookieConfig::default() + } + .validate() + .unwrap_err(); + assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); + + let error = AuthCookieConfig { + domain: Some("example.com".to_string()), + ..AuthCookieConfig::default() + } + .validate() + .unwrap_err(); + assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); + } + + #[test] + fn auth_cookie_config_validates_secure_prefix_and_cookie_name() { + let error = AuthCookieConfig { + name: "__Secure-ras-session".to_string(), + secure: false, + ..AuthCookieConfig::default() + } + .validate() + .unwrap_err(); + assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); + + let error = AuthCookieConfig::new("bad;name").validate().unwrap_err(); + assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); + } + + #[test] + fn auth_transport_config_validates_cookie_config_before_extraction() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig { + secure: false, + ..AuthCookieConfig::default() + }); + + let error = extract_auth_credential(&HeaderMap::new(), &config).unwrap_err(); + + assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); + } + + #[test] + fn local_development_cookie_helper_removes_host_prefix() { + let cookie = AuthCookieConfig::default().insecure_for_local_development(); + + assert_eq!(cookie.name, "ras-session"); + assert!(!cookie.secure); + assert!(cookie.validate().is_ok()); + } + + #[test] + fn auth_cookie_config_builds_secure_set_cookie_header() { + let value = AuthCookieConfig::default() + .session_cookie_header_value("jwt-token") + .expect("set-cookie header"); + let value = value.to_str().unwrap(); + + assert!(value.starts_with("__Host-ras-session=jwt-token")); + assert!(value.contains("HttpOnly")); + assert!(value.contains("SameSite=Lax")); + assert!(value.contains("Secure")); + assert!(value.contains("Path=/")); + } + + #[test] + fn auth_cookie_config_builds_clear_cookie_header() { + let value = AuthCookieConfig::default() + .clear_cookie_header_value() + .expect("clear-cookie header"); + let value = value.to_str().unwrap(); + + assert!(value.starts_with("__Host-ras-session=")); + assert!(value.contains("Max-Age=0")); + assert!(value.contains("Expires=")); + assert!(value.contains("HttpOnly")); + assert!(value.contains("Path=/")); + } + + #[test] + fn csrf_validation_only_applies_to_cookie_auth_on_unsafe_methods() { + let config = AuthTransportConfig::default() + .with_cookie(AuthCookieConfig::default()) + .with_csrf(CsrfConfig::default()); + let bearer = AuthCredential::new("bearer-token", AuthTokenSource::Bearer); + let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); + let headers_without_csrf = HeaderMap::new(); + let headers_with_csrf = headers(&[ + (DEFAULT_CSRF_HEADER, "csrf-token"), + ("cookie", "__Host-ras-csrf=csrf-token"), + ]); + let headers_with_mismatched_csrf = headers(&[ + (DEFAULT_CSRF_HEADER, "csrf-token"), + ("cookie", "__Host-ras-csrf=other-token"), + ]); + + assert!( + validate_csrf_for_credential("POST", &headers_without_csrf, &bearer, &config).is_ok() + ); + assert!( + validate_csrf_for_credential("GET", &headers_without_csrf, &cookie, &config).is_ok() + ); + assert_eq!( + validate_csrf_for_credential("POST", &headers_without_csrf, &cookie, &config) + .unwrap_err(), + AuthTransportError::CsrfValidationFailed + ); + assert!(validate_csrf_for_credential("POST", &headers_with_csrf, &cookie, &config).is_ok()); + assert_eq!( + validate_csrf_for_credential("POST", &headers_with_mismatched_csrf, &cookie, &config) + .unwrap_err(), + AuthTransportError::CsrfValidationFailed + ); + } + + #[test] + fn csrf_expected_value_mode_does_not_require_csrf_cookie() { + let config = AuthTransportConfig::default() + .with_cookie(AuthCookieConfig::default()) + .with_csrf(CsrfConfig::default().with_expected_value("csrf-token")); + let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); + let headers = headers(&[(DEFAULT_CSRF_HEADER, "csrf-token")]); + + assert!(validate_csrf_for_credential("POST", &headers, &cookie, &config).is_ok()); + } + + #[test] + fn csrf_config_builds_readable_double_submit_cookie() { + let value = CsrfConfig::default() + .csrf_cookie_header_value("csrf-token") + .expect("set-cookie header"); + let value = value.to_str().unwrap(); + + assert!(value.starts_with("__Host-ras-csrf=csrf-token")); + assert!(!value.contains("HttpOnly")); + assert!(value.contains("SameSite=Lax")); + assert!(value.contains("Secure")); + assert!(value.contains("Path=/")); + } + + #[test] + fn redact_sensitive_headers_removes_credential_values() { + let headers = headers(&[ + ("authorization", "Bearer secret"), + ("cookie", "__Host-ras-session=secret"), + (DEFAULT_CSRF_HEADER, "csrf-secret"), + ("user-agent", "test-agent"), + ]); + + let redacted = redact_sensitive_headers(&headers); + + assert_eq!( + redacted.get("authorization").unwrap(), + HeaderValue::from_static("[REDACTED]") + ); + assert_eq!( + redacted.get("cookie").unwrap(), + HeaderValue::from_static("[REDACTED]") + ); + assert_eq!( + redacted.get(DEFAULT_CSRF_HEADER).unwrap(), + HeaderValue::from_static("[REDACTED]") + ); + assert_eq!(redacted.get("user-agent").unwrap(), "test-agent"); + } + + #[test] + fn redact_sensitive_headers_for_auth_transport_removes_custom_csrf_header() { + let csrf_header = HeaderName::from_static("x-custom-csrf"); + let config = AuthTransportConfig::default().with_csrf(CsrfConfig::new(csrf_header.clone())); + let headers = headers(&[("x-custom-csrf", "csrf-secret")]); + + let redacted = redact_sensitive_headers_for_auth_transport(&headers, &config); + + assert_eq!( + redacted.get(csrf_header).unwrap(), + HeaderValue::from_static("[REDACTED]") + ); + } +} diff --git a/crates/identity/ras-identity-session/src/lib.rs b/crates/identity/ras-identity-session/src/lib.rs index e62f077..df42077 100644 --- a/crates/identity/ras-identity-session/src/lib.rs +++ b/crates/identity/ras-identity-session/src/lib.rs @@ -210,11 +210,12 @@ fn decode_jwt( .decode(encoded_signature) .map_err(|err| jwt_error(format!("invalid JWT signature encoding: {err}")))?; let signing_input = format!("{encoded_header}.{encoded_claims}"); - let expected_signature = sign_jwt(&signing_input, secret.as_bytes(), expected_algorithm)?; - - if !signatures_match(&expected_signature, &signature) { - return Err(jwt_error("invalid JWT signature")); - } + verify_jwt_signature( + &signing_input, + secret.as_bytes(), + expected_algorithm, + &signature, + )?; let claims = URL_SAFE_NO_PAD .decode(encoded_claims) @@ -249,13 +250,35 @@ fn sign_jwt( } } -fn signatures_match(expected: &[u8], actual: &[u8]) -> bool { - let mut diff = expected.len() ^ actual.len(); - for i in 0..expected.len().max(actual.len()) { - diff |= expected.get(i).copied().unwrap_or_default() as usize - ^ actual.get(i).copied().unwrap_or_default() as usize; +fn verify_jwt_signature( + signing_input: &str, + secret: &[u8], + algorithm: JwtAlgorithm, + signature: &[u8], +) -> Result<(), SessionError> { + match algorithm { + JwtAlgorithm::HS256 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + mac.verify_slice(signature) + .map_err(|_| jwt_error("invalid JWT signature")) + } + JwtAlgorithm::HS384 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + mac.verify_slice(signature) + .map_err(|_| jwt_error("invalid JWT signature")) + } + JwtAlgorithm::HS512 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + mac.verify_slice(signature) + .map_err(|_| jwt_error("invalid JWT signature")) + } } - diff == 0 } pub struct SessionService { diff --git a/crates/rest/ras-file-macro/src/server.rs b/crates/rest/ras-file-macro/src/server.rs index 8e1c92b..7f4cbae 100644 --- a/crates/rest/ras-file-macro/src/server.rs +++ b/crates/rest/ras-file-macro/src/server.rs @@ -31,6 +31,7 @@ pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { pub struct #builder_name { service: S, auth_provider: Option, + auth_transport: ::ras_auth_core::AuthTransportConfig, usage_tracker: Option>, duration_tracker: Option>, } @@ -44,6 +45,7 @@ pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { Self { service, auth_provider: None, + auth_transport: ::ras_auth_core::AuthTransportConfig::default(), usage_tracker: None, duration_tracker: None, } @@ -54,6 +56,21 @@ pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { self } + pub fn auth_cookie(mut self, cookie: ::ras_auth_core::AuthCookieConfig) -> Self { + self.auth_transport.cookie = Some(cookie); + self + } + + pub fn auth_transport(mut self, transport: ::ras_auth_core::AuthTransportConfig) -> Self { + self.auth_transport = transport; + self + } + + pub fn csrf_protection(mut self, csrf: ::ras_auth_core::CsrfConfig) -> Self { + self.auth_transport.csrf = Some(csrf); + self + } + pub fn with_usage_tracker(mut self, tracker: F) -> Self where F: Fn(&::axum::http::HeaderMap, &str, &str) + Send + Sync + 'static, @@ -73,8 +90,13 @@ pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { pub fn build(self) -> ::axum::Router { use ::axum::routing::{get, post}; + self.auth_transport + .validate() + .expect("invalid auth transport configuration"); + let service = ::std::sync::Arc::new(self.service); let auth_provider = self.auth_provider.map(::std::sync::Arc::new); + let auth_transport = self.auth_transport; let usage_tracker = self.usage_tracker.map(::std::sync::Arc::new); let duration_tracker = self.duration_tracker.map(::std::sync::Arc::new); @@ -246,6 +268,7 @@ fn generate_handlers( Option<::std::sync::Arc>, Option<::std::sync::Arc>>, Option<::std::sync::Arc>>, + ::ras_auth_core::AuthTransportConfig, )>, mut req: ::axum::http::Request<::axum::body::Body>, ) -> impl ::axum::response::IntoResponse @@ -261,7 +284,9 @@ fn generate_handlers( // Track usage if let Some(tracker) = &state.2 { - tracker(&parts.headers, method, &path); + let tracker_headers = + ::ras_auth_core::redact_sensitive_headers_for_auth_transport(&parts.headers, &state.4); + tracker(&tracker_headers, method, &path); } #auth_check @@ -294,6 +319,7 @@ fn generate_handlers( Option<::std::sync::Arc>, Option<::std::sync::Arc>>, Option<::std::sync::Arc>>, + ::ras_auth_core::AuthTransportConfig, )>, req: ::axum::http::Request<::axum::body::Body>, ) -> impl ::axum::response::IntoResponse @@ -309,7 +335,9 @@ fn generate_handlers( // Track usage if let Some(tracker) = &state.2 { - tracker(&parts.headers, method, &path); + let tracker_headers = + ::ras_auth_core::redact_sensitive_headers_for_auth_transport(&parts.headers, &state.4); + tracker(&tracker_headers, method, &path); } #auth_check @@ -352,18 +380,20 @@ fn generate_auth_check(auth: &AuthRequirement) -> TokenStream { ), }; - let token = match parts.headers - .get(::axum::http::header::AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .and_then(|h| h.strip_prefix("Bearer ")) - { - Some(t) => t, - None => return <(::axum::http::StatusCode, &str) as ::axum::response::IntoResponse>::into_response( + let auth_credential = match ::ras_auth_core::extract_auth_credential(&parts.headers, &state.4) { + Ok(credential) => credential, + Err(_) => return <(::axum::http::StatusCode, &str) as ::axum::response::IntoResponse>::into_response( (::axum::http::StatusCode::UNAUTHORIZED, "Missing or invalid authorization header") ), }; - let user = match auth_provider.authenticate(token.to_string()).await { + if let Err(_) = ::ras_auth_core::validate_csrf_for_credential(method, &parts.headers, &auth_credential, &state.4) { + return <(::axum::http::StatusCode, &str) as ::axum::response::IntoResponse>::into_response( + (::axum::http::StatusCode::FORBIDDEN, "CSRF validation failed") + ); + } + + let user = match auth_provider.authenticate(auth_credential.token().to_string()).await { Ok(u) => u, Err(_) => return <(::axum::http::StatusCode, &str) as ::axum::response::IntoResponse>::into_response( (::axum::http::StatusCode::UNAUTHORIZED, "Invalid authentication") @@ -430,13 +460,13 @@ fn generate_router_construction( ::axum::Router::new() #(#routes)* .layer(::axum::extract::DefaultBodyLimit::max(#limit_usize)) - .with_state((service, auth_provider, usage_tracker, duration_tracker)) + .with_state((service, auth_provider, usage_tracker, duration_tracker, auth_transport)) } } else { quote! { ::axum::Router::new() #(#routes)* - .with_state((service, auth_provider, usage_tracker, duration_tracker)) + .with_state((service, auth_provider, usage_tracker, duration_tracker, auth_transport)) } }; diff --git a/crates/rest/ras-file-macro/tests/e2e.rs b/crates/rest/ras-file-macro/tests/e2e.rs index f86139f..91a870a 100644 --- a/crates/rest/ras-file-macro/tests/e2e.rs +++ b/crates/rest/ras-file-macro/tests/e2e.rs @@ -10,7 +10,7 @@ use axum::{ response::{IntoResponse, Response}, }; use axum_test::multipart::{MultipartForm, Part}; -use ras_auth_core::AuthenticatedUser; +use ras_auth_core::{AuthCookieConfig, AuthenticatedUser, CsrfConfig}; use ras_file_macro::file_service; use serde::{Deserialize, Serialize}; @@ -28,6 +28,7 @@ file_service!({ base_path: "/files", endpoints: [ DOWNLOAD UNAUTHORIZED download/{file_id: String}(), + DOWNLOAD WITH_PERMISSIONS(["user"]) download_secure/{file_id: String}(), UPLOAD WITH_PERMISSIONS(["user"]) upload() -> UploadResponse, ] }); @@ -55,6 +56,24 @@ impl DemoTrait for DemoImpl { .unwrap()) } + async fn download_secure( + &self, + _user: &AuthenticatedUser, + file_id: String, + ) -> Result { + let store = self.storage.lock().unwrap(); + let bytes = store + .iter() + .find_map(|(id, data)| (id == &file_id).then(|| data.clone())) + .ok_or(DemoFileError::NotFound)?; + + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/octet-stream") + .body(Body::from(bytes)) + .unwrap()) + } + async fn upload( &self, _user: &AuthenticatedUser, @@ -85,6 +104,18 @@ fn router(storage: Storage) -> axum::Router { .build() } +fn cookie_router(storage: Storage, csrf: bool) -> axum::Router { + let mut builder = DemoBuilder::::new(DemoImpl { storage }) + .auth_provider(MockAuthProvider::default()) + .auth_cookie(AuthCookieConfig::default()); + + if csrf { + builder = builder.csrf_protection(CsrfConfig::default()); + } + + builder.build() +} + #[tokio::test] async fn upload_and_download_round_trips_bytes() { let storage: Storage = Arc::new(Mutex::new(Vec::new())); @@ -131,6 +162,121 @@ async fn upload_rejected_without_token() { response.assert_status(StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn cookie_auth_upload_and_download_secure_coexist_with_bearer() { + let storage: Storage = Arc::new(Mutex::new(Vec::new())); + storage + .lock() + .unwrap() + .push(("file-0".to_string(), b"secret file".to_vec())); + let server = mock_http_server(cookie_router(storage.clone(), false)); + + let response = server + .get("/files/download_secure/file-0") + .add_header("Cookie", "__Host-ras-session=user-token") + .await; + + response.assert_status_ok(); + let bytes = response.into_bytes(); + assert_eq!(bytes.as_ref(), b"secret file"); + + let form = MultipartForm::new().add_part( + "file", + Part::bytes("from cookie") + .file_name("cookie.txt") + .mime_type("text/plain"), + ); + let response = server + .post("/files/upload") + .add_header("Cookie", "__Host-ras-session=user-token") + .multipart(form) + .await; + + response.assert_status_ok(); + + let form = MultipartForm::new().add_part( + "file", + Part::bytes("from bearer") + .file_name("bearer.txt") + .mime_type("text/plain"), + ); + let response = server + .post("/files/upload") + .authorization_bearer("user-token") + .add_header("Cookie", "__Host-ras-session=invalid-token") + .multipart(form) + .await; + + response.assert_status_ok(); + + let form = MultipartForm::new().add_part( + "file", + Part::bytes("bad bearer") + .file_name("bad.txt") + .mime_type("text/plain"), + ); + let response = server + .post("/files/upload") + .add_header("Authorization", "Basic invalid") + .add_header("Cookie", "__Host-ras-session=user-token") + .multipart(form) + .await; + + response.assert_status(StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn cookie_auth_upload_requires_csrf_when_enabled() { + let storage: Storage = Arc::new(Mutex::new(Vec::new())); + let server = mock_http_server(cookie_router(storage, true)); + + let form = MultipartForm::new().add_part( + "file", + Part::bytes("missing csrf") + .file_name("missing.txt") + .mime_type("text/plain"), + ); + let response = server + .post("/files/upload") + .add_header("Cookie", "__Host-ras-session=user-token") + .multipart(form) + .await; + + response.assert_status(StatusCode::FORBIDDEN); + + let form = MultipartForm::new().add_part( + "file", + Part::bytes("with csrf") + .file_name("csrf.txt") + .mime_type("text/plain"), + ); + let response = server + .post("/files/upload") + .add_header( + "Cookie", + "__Host-ras-session=user-token; __Host-ras-csrf=csrf-token", + ) + .add_header("x-ras-csrf", "csrf-token") + .multipart(form) + .await; + + response.assert_status_ok(); + + let form = MultipartForm::new().add_part( + "file", + Part::bytes("bearer") + .file_name("bearer.txt") + .mime_type("text/plain"), + ); + let response = server + .post("/files/upload") + .authorization_bearer("user-token") + .multipart(form) + .await; + + response.assert_status_ok(); +} + #[tokio::test] async fn download_unknown_file_returns_404() { let storage: Storage = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/rest/ras-rest-macro/README.md b/crates/rest/ras-rest-macro/README.md index b389fa5..2635ae3 100644 --- a/crates/rest/ras-rest-macro/README.md +++ b/crates/rest/ras-rest-macro/README.md @@ -113,6 +113,25 @@ let service = UserServiceBuilder::new(UserServiceImpl) let app = axum::Router::new().merge(service); ``` +Cookie auth can be enabled on the same service without changing the +`AuthProvider`: + +```rust +use ras_auth_core::{AuthCookieConfig, CsrfConfig}; + +let service = UserServiceBuilder::new(UserServiceImpl) + .auth_provider(my_auth_provider) + .auth_cookie(AuthCookieConfig::default()) + .csrf_protection(CsrfConfig::default()) + .build(); +``` + +Bearer tokens remain accepted by default. If both bearer and cookie credentials +are present, bearer takes precedence. The CSRF guard only applies to +cookie-authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests. +`CsrfConfig::default()` requires the `x-ras-csrf` header to match the +`__Host-ras-csrf` double-submit cookie. + ### OpenAPI Generation ```rust diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index 0958281..26176a4 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -771,6 +771,7 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result { service: std::sync::Arc, auth_provider: Option>, + auth_transport: ras_auth_core::AuthTransportConfig, with_usage_tracker: Option, &str, &str) -> std::pin::Pin + Send>> + Send + Sync>>, with_method_duration_tracker: Option, std::time::Duration) -> std::pin::Pin + Send>> + Send + Sync>>, } @@ -808,6 +809,7 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result syn::Result Self { + self.auth_transport.cookie = Some(cookie); + self + } + + /// Replace the full auth transport configuration. + pub fn auth_transport(mut self, transport: ras_auth_core::AuthTransportConfig) -> Self { + self.auth_transport = transport; + self + } + + /// Require CSRF validation for cookie-authenticated unsafe requests. + pub fn csrf_protection(mut self, csrf: ras_auth_core::CsrfConfig) -> Self { + self.auth_transport.csrf = Some(csrf); + self + } + /// Set the usage tracker - called before each request /// The tracker receives the headers, authenticated user (if any), HTTP method, and path pub fn with_usage_tracker(mut self, tracker: F) -> Self @@ -847,6 +867,10 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result axum::Router { + self.auth_transport + .validate() + .expect("invalid auth transport configuration"); + let mut router = axum::Router::new(); #(#route_registrations)* @@ -1118,6 +1142,7 @@ fn generate_canonical_route_registration( { let service = self.service.clone(); let auth_provider = self.auth_provider.clone(); + let auth_transport = self.auth_transport.clone(); let required_permission_groups: Vec> = #permission_groups_code; let with_usage_tracker = self.with_usage_tracker.clone(); let with_method_duration_tracker = self.with_method_duration_tracker.clone(); @@ -1126,6 +1151,7 @@ fn generate_canonical_route_registration( move |#axum_handler| { let service = service.clone(); let auth_provider = auth_provider.clone(); + let auth_transport = auth_transport.clone(); let required_permission_groups: Vec> = required_permission_groups.clone(); let with_usage_tracker = with_usage_tracker.clone(); let with_method_duration_tracker = with_method_duration_tracker.clone(); @@ -1160,6 +1186,7 @@ fn generate_legacy_route_registration( { let service = self.service.clone(); let auth_provider = self.auth_provider.clone(); + let auth_transport = self.auth_transport.clone(); let required_permission_groups: Vec> = #permission_groups_code; let with_usage_tracker = self.with_usage_tracker.clone(); let with_method_duration_tracker = self.with_method_duration_tracker.clone(); @@ -1168,6 +1195,7 @@ fn generate_legacy_route_registration( move |#axum_handler| { let service = service.clone(); let auth_provider = auth_provider.clone(); + let auth_transport = auth_transport.clone(); let required_permission_groups: Vec> = required_permission_groups.clone(); let with_usage_tracker = with_usage_tracker.clone(); let with_method_duration_tracker = with_method_duration_tracker.clone(); @@ -1232,7 +1260,9 @@ fn generate_legacy_handler_body( #json_handling if let Some(tracker) = &with_usage_tracker { - tracker(&headers, None, #method, #path).await; + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, None, #method, #path).await; } let legacy_parts: #legacy_request_ident = #legacy_parts_init; @@ -1307,13 +1337,9 @@ fn generate_legacy_handler_body( quote! { #json_handling - let token = match headers - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")) - { - Some(token) => token, - None => { + let auth_credential = match ras_auth_core::extract_auth_credential(&headers, &auth_transport) { + Ok(credential) => credential, + Err(_) => { use axum::response::IntoResponse; return ( axum::http::StatusCode::UNAUTHORIZED, @@ -1324,8 +1350,18 @@ fn generate_legacy_handler_body( }, }; + if let Err(_) = ras_auth_core::validate_csrf_for_credential(#method, &headers, &auth_credential, &auth_transport) { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::FORBIDDEN, + axum::Json(serde_json::json!({ + "error": "CSRF validation failed" + })) + ).into_response(); + } + let user = match &auth_provider { - Some(provider) => match provider.authenticate(token.to_string()).await { + Some(provider) => match provider.authenticate(auth_credential.token().to_string()).await { Ok(user) => user, Err(_) => { use axum::response::IntoResponse; @@ -1377,7 +1413,9 @@ fn generate_legacy_handler_body( } if let Some(tracker) = &with_usage_tracker { - tracker(&headers, Some(&user), #method, #path).await; + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, Some(&user), #method, #path).await; } let legacy_parts: #legacy_request_ident = #legacy_parts_init; @@ -1543,7 +1581,9 @@ fn generate_handler_body( // Call usage tracker if configured (for unauthorized endpoints, headers come from handler params) if let Some(tracker) = &with_usage_tracker { - tracker(&headers, None, #method, #path).await; + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, None, #method, #path).await; } // Track duration @@ -1634,13 +1674,9 @@ fn generate_handler_body( #json_handling // Extract and validate auth token - let token = match headers - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")) - { - Some(token) => token, - None => { + let auth_credential = match ras_auth_core::extract_auth_credential(&headers, &auth_transport) { + Ok(credential) => credential, + Err(_) => { use axum::response::IntoResponse; return ( axum::http::StatusCode::UNAUTHORIZED, @@ -1651,9 +1687,19 @@ fn generate_handler_body( }, }; + if let Err(_) = ras_auth_core::validate_csrf_for_credential(#method, &headers, &auth_credential, &auth_transport) { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::FORBIDDEN, + axum::Json(serde_json::json!({ + "error": "CSRF validation failed" + })) + ).into_response(); + } + // Authenticate user let user = match &auth_provider { - Some(provider) => match provider.authenticate(token.to_string()).await { + Some(provider) => match provider.authenticate(auth_credential.token().to_string()).await { Ok(user) => user, Err(_) => { use axum::response::IntoResponse; @@ -1712,7 +1758,9 @@ fn generate_handler_body( // Call usage tracker if configured if let Some(tracker) = &with_usage_tracker { - tracker(&headers, Some(&user), #method, #path).await; + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, Some(&user), #method, #path).await; } // Track duration diff --git a/crates/rest/ras-rest-macro/tests/http_integration.rs b/crates/rest/ras-rest-macro/tests/http_integration.rs index 7fc2da3..65a2eb4 100644 --- a/crates/rest/ras-rest-macro/tests/http_integration.rs +++ b/crates/rest/ras-rest-macro/tests/http_integration.rs @@ -1,7 +1,9 @@ use axum::http::Method; use axum_test::{TestResponse, TestServer}; use rand::Rng; -use ras_jsonrpc_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use ras_jsonrpc_core::{ + AuthCookieConfig, AuthError, AuthFuture, AuthProvider, AuthenticatedUser, CsrfConfig, +}; use ras_rest_core::{RestError, RestResponse}; use ras_rest_macro::rest_service; use serde::{Deserialize, Serialize}; @@ -435,6 +437,19 @@ fn create_rest_test_server() -> TestServer { TestServer::builder().mock_transport().build(app).unwrap() } +fn create_rest_cookie_test_server(csrf: bool) -> TestServer { + let mut builder = TestRestServiceBuilder::new(TestRestServiceImpl) + .auth_provider(TestRestAuthProvider::new()) + .auth_cookie(AuthCookieConfig::default()); + + if csrf { + builder = builder.csrf_protection(CsrfConfig::default()); + } + + let app = builder.build(); + TestServer::builder().mock_transport().build(app).unwrap() +} + async fn make_rest_request( server: &TestServer, method: Method, @@ -555,6 +570,76 @@ async fn test_authentication_required_endpoints() { assert_eq!(post.title, "Protected Post"); } +#[tokio::test] +async fn test_cookie_auth_coexists_with_bearer_tokens() { + let server = create_rest_cookie_test_server(false); + + let response = server + .get("/api/v1/status") + .add_header("Cookie", "__Host-ras-session=user-token") + .await; + + assert_eq!(response.status_code().as_u16(), 200); + let status: Value = response.json(); + assert_eq!(status["user_id"], "regular-user"); + + let response = server + .get("/api/v1/status") + .authorization_bearer("admin-token") + .add_header("Cookie", "__Host-ras-session=user-token") + .await; + + assert_eq!(response.status_code().as_u16(), 200); + let status: Value = response.json(); + assert_eq!(status["user_id"], "admin-user"); + + let response = server + .get("/api/v1/status") + .add_header("Authorization", "Basic invalid") + .add_header("Cookie", "__Host-ras-session=user-token") + .await; + + assert_eq!(response.status_code().as_u16(), 401); +} + +#[tokio::test] +async fn test_cookie_auth_csrf_guard_only_applies_to_cookie_unsafe_requests() { + let server = create_rest_cookie_test_server(true); + let create_user = json!({ + "name": "Cookie User", + "email": "cookie@example.com", + "permissions": ["user"] + }); + + let response = server + .post("/api/v1/users") + .add_header("Cookie", "__Host-ras-session=admin-token") + .json(&create_user) + .await; + + assert_eq!(response.status_code().as_u16(), 403); + + let response = server + .post("/api/v1/users") + .add_header( + "Cookie", + "__Host-ras-session=admin-token; __Host-ras-csrf=csrf-token", + ) + .add_header("x-ras-csrf", "csrf-token") + .json(&create_user) + .await; + + assert_eq!(response.status_code().as_u16(), 201); + + let response = server + .post("/api/v1/users") + .authorization_bearer("admin-token") + .json(&create_user) + .await; + + assert_eq!(response.status_code().as_u16(), 201); +} + #[tokio::test] async fn test_admin_permission_endpoints() { let server = create_rest_test_server(); diff --git a/crates/rpc/ras-jsonrpc-core/README.md b/crates/rpc/ras-jsonrpc-core/README.md index 4ca4ab5..85d6693 100644 --- a/crates/rpc/ras-jsonrpc-core/README.md +++ b/crates/rpc/ras-jsonrpc-core/README.md @@ -274,6 +274,26 @@ users_by_token.insert( let auth_provider = StaticBearerAuthProvider { users_by_token }; ``` +### Browser Cookie Transport + +JSON-RPC HTTP services can accept bearer tokens and secure session cookies on +the same endpoint. The auth provider still validates the token string: + +```rust +use ras_jsonrpc_core::{AuthCookieConfig, CsrfConfig}; + +let router = MyRpcServiceBuilder::new(service_impl) + .auth_provider(auth_provider) + .auth_cookie(AuthCookieConfig::default()) + .csrf_protection(CsrfConfig::default()) + .build()?; +``` + +Bearer auth remains enabled by default and takes precedence when both +credentials are present. JSON-RPC HTTP uses `POST`, so cookie-authenticated calls +must include an `x-ras-csrf` header matching the `__Host-ras-csrf` +double-submit cookie when default CSRF protection is enabled. + ### API Key Authentication ```rust use ras_jsonrpc_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; diff --git a/crates/rpc/ras-jsonrpc-macro/README.md b/crates/rpc/ras-jsonrpc-macro/README.md index 5cc6336..c1aac0e 100644 --- a/crates/rpc/ras-jsonrpc-macro/README.md +++ b/crates/rpc/ras-jsonrpc-macro/README.md @@ -328,7 +328,8 @@ The generated server accepts both `rename_user.v2` and `rename_user.v1`. The gen ## Authentication Flow ### 1. Token Extraction -The generated service automatically extracts Bearer tokens from the `Authorization` header: +The generated service automatically extracts bearer tokens from the +`Authorization` header, and can also accept a configured secure session cookie: ``` Authorization: Bearer ``` diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index fe24859..8e73d0e 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -560,6 +560,7 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt base_url: String, service: std::sync::Arc, auth_provider: Option>, + auth_transport: ras_jsonrpc_core::AuthTransportConfig, usage_tracker: Option, &ras_jsonrpc_types::JsonRpcRequest) -> std::pin::Pin + Send>> + Send + Sync>>, method_duration_tracker: Option, std::time::Duration) -> std::pin::Pin + Send>> + Send + Sync>>, } @@ -573,6 +574,7 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt base_url: "/rpc".to_string(), service: std::sync::Arc::new(service), auth_provider: None, + auth_transport: ras_jsonrpc_core::AuthTransportConfig::default(), usage_tracker: None, method_duration_tracker: None, } @@ -590,6 +592,24 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt self } + /// Enable cookie authentication alongside bearer tokens. + pub fn auth_cookie(mut self, cookie: ras_jsonrpc_core::AuthCookieConfig) -> Self { + self.auth_transport.cookie = Some(cookie); + self + } + + /// Replace the full auth transport configuration. + pub fn auth_transport(mut self, transport: ras_jsonrpc_core::AuthTransportConfig) -> Self { + self.auth_transport = transport; + self + } + + /// Require CSRF validation for cookie-authenticated JSON-RPC requests. + pub fn csrf_protection(mut self, csrf: ras_jsonrpc_core::CsrfConfig) -> Self { + self.auth_transport.csrf = Some(csrf); + self + } + /// Set the usage tracker function /// This function will be called for each request with headers, authenticated user (if any), and the JSON-RPC request pub fn with_usage_tracker(mut self, tracker: F) -> Self @@ -618,6 +638,10 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt /// Build the axum router for the JSON-RPC service pub fn build(self) -> Result { + self.auth_transport + .validate() + .map_err(|err| err.to_string())?; + let base_url = self.base_url.clone(); let service = std::sync::Arc::new(self); @@ -634,6 +658,7 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt ras_jsonrpc_types::error_codes::AUTHENTICATION_REQUIRED => axum::http::StatusCode::UNAUTHORIZED, ras_jsonrpc_types::error_codes::INSUFFICIENT_PERMISSIONS => axum::http::StatusCode::FORBIDDEN, ras_jsonrpc_types::error_codes::TOKEN_EXPIRED => axum::http::StatusCode::UNAUTHORIZED, + ras_jsonrpc_types::error_codes::CSRF_VALIDATION_FAILED => axum::http::StatusCode::FORBIDDEN, _ => axum::http::StatusCode::OK, // Other JSON-RPC errors still return 200 OK } } else { @@ -675,13 +700,19 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt // Try to authenticate user if auth provider is available let auth_result = if let Some(auth_provider) = &self.auth_provider { - if let Some(token) = headers - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")) { - Some(auth_provider.authenticate(token.to_string()).await) - } else { - None + match ras_jsonrpc_core::extract_auth_credential(&headers, &self.auth_transport) { + Ok(credential) => { + if let Err(_) = ras_jsonrpc_core::validate_csrf_for_credential("POST", &headers, &credential, &self.auth_transport) { + return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::csrf_validation_failed(), + request_id + ); + } + + Some(auth_provider.authenticate(credential.token().to_string()).await) + }, + Err(ras_jsonrpc_core::AuthTransportError::MissingCredentials) => None, + Err(_) => Some(Err(ras_jsonrpc_core::AuthError::AuthenticationRequired)), } } else { None @@ -695,13 +726,21 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt request_id ); } - _ => None, + Some(Err(_)) => { + return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::authentication_required(), + request_id + ); + } + None => None, }; // Call usage tracker if configured if let Some(tracker) = &self.usage_tracker { let user_ref = authenticated_user.as_ref(); - tracker(&headers, user_ref, &request).await; + let tracker_headers = + ras_jsonrpc_core::redact_sensitive_headers_for_auth_transport(&headers, &self.auth_transport); + tracker(&tracker_headers, user_ref, &request).await; } // Dispatch method diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs index e07d850..8629037 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs @@ -1,5 +1,7 @@ use rand::Rng; -use ras_jsonrpc_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use ras_jsonrpc_core::{ + AuthCookieConfig, AuthError, AuthFuture, AuthProvider, AuthenticatedUser, CsrfConfig, +}; use ras_jsonrpc_macro::jsonrpc_service; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -263,6 +265,23 @@ fn create_test_server() -> axum_test::TestServer { .unwrap() } +fn create_cookie_test_server(csrf: bool) -> axum_test::TestServer { + let mut builder = TestServiceBuilder::new(TestServiceImpl) + .base_url("/rpc") + .auth_provider(TestAuthProvider::new()) + .auth_cookie(AuthCookieConfig::default()); + + if csrf { + builder = builder.csrf_protection(CsrfConfig::default()); + } + + let app = builder.build().expect("Failed to build app"); + axum_test::TestServer::builder() + .mock_transport() + .build(app) + .unwrap() +} + async fn make_jsonrpc_request( server: &axum_test::TestServer, method: &str, @@ -328,6 +347,21 @@ async fn test_unauthorized_methods() { assert_eq!(response["result"], "This is public information"); + let request_body = json!({ + "jsonrpc": "2.0", + "method": "get_public_info", + "params": (), + "id": 1 + }); + let response = server + .post("/rpc") + .authorization_bearer("not-a-valid-token") + .json(&request_body) + .await; + assert_eq!(response.status_code().as_u16(), 401); + let response: Value = response.json(); + assert_eq!(response["error"]["code"], -32001); + // Test echo_complex let complex_data = json!({ "data": [ @@ -392,6 +426,90 @@ async fn test_authentication_required_methods() { assert_eq!(result["success"].as_bool(), Some(true)); } +#[tokio::test] +async fn test_cookie_auth_coexists_with_bearer_tokens() { + let server = create_cookie_test_server(false); + let request_body = json!({ + "jsonrpc": "2.0", + "method": "get_user_info", + "params": (), + "id": 1 + }); + + let response: Value = server + .post("/rpc") + .add_header("Cookie", "__Host-ras-session=valid-user-token") + .json(&request_body) + .await + .json(); + + assert_eq!(response["result"]["name"], "User regular-user"); + + let response: Value = server + .post("/rpc") + .authorization_bearer("valid-admin-token") + .add_header("Cookie", "__Host-ras-session=valid-user-token") + .json(&request_body) + .await + .json(); + + assert_eq!(response["result"]["name"], "User admin-user"); + + let response = server + .post("/rpc") + .add_header("Authorization", "Basic invalid") + .add_header("Cookie", "__Host-ras-session=valid-user-token") + .json(&request_body) + .await; + + assert_eq!(response.status_code().as_u16(), 401); + let response: Value = response.json(); + assert_eq!(response["error"]["code"], -32001); +} + +#[tokio::test] +async fn test_cookie_auth_csrf_guard_for_jsonrpc_posts() { + let server = create_cookie_test_server(true); + let request_body = json!({ + "jsonrpc": "2.0", + "method": "get_user_info", + "params": (), + "id": 1 + }); + + let response = server + .post("/rpc") + .add_header("Cookie", "__Host-ras-session=valid-user-token") + .json(&request_body) + .await; + + assert_eq!(response.status_code().as_u16(), 403); + let response: Value = response.json(); + assert_eq!(response["error"]["code"], -32004); + + let response = server + .post("/rpc") + .add_header( + "Cookie", + "__Host-ras-session=valid-user-token; __Host-ras-csrf=csrf-token", + ) + .add_header("x-ras-csrf", "csrf-token") + .json(&request_body) + .await; + + assert_eq!(response.status_code().as_u16(), 200); + let response: Value = response.json(); + assert_eq!(response["result"]["name"], "User regular-user"); + + let response = server + .post("/rpc") + .authorization_bearer("valid-user-token") + .json(&request_body) + .await; + + assert_eq!(response.status_code().as_u16(), 200); +} + #[tokio::test] async fn test_admin_permission_methods() { let server = create_test_server(); diff --git a/crates/rpc/ras-jsonrpc-types/src/lib.rs b/crates/rpc/ras-jsonrpc-types/src/lib.rs index 22f1be0..b8bef45 100644 --- a/crates/rpc/ras-jsonrpc-types/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-types/src/lib.rs @@ -81,6 +81,9 @@ pub mod error_codes { /// Token expired. pub const TOKEN_EXPIRED: i32 = -32003; + + /// CSRF validation failed. + pub const CSRF_VALIDATION_FAILED: i32 = -32004; } impl JsonRpcRequest { @@ -201,6 +204,15 @@ impl JsonRpcError { None, ) } + + /// Creates a CSRF validation error. + pub fn csrf_validation_failed() -> Self { + Self::new( + error_codes::CSRF_VALIDATION_FAILED, + "CSRF validation failed".to_string(), + None, + ) + } } #[cfg(test)] @@ -256,6 +268,10 @@ mod tests { JsonRpcError::token_expired().code, error_codes::TOKEN_EXPIRED ); + assert_eq!( + JsonRpcError::csrf_validation_failed().code, + error_codes::CSRF_VALIDATION_FAILED + ); } #[test] diff --git a/documentation/ras-file-macro.md b/documentation/ras-file-macro.md index cfe8c46..8ec469a 100644 --- a/documentation/ras-file-macro.md +++ b/documentation/ras-file-macro.md @@ -496,13 +496,19 @@ UPLOAD WITH_PERMISSIONS([["admin"], ["upload", "premium"]]) special_upload() -> // Requires: admin OR (upload AND premium) ``` -### Bearer Token Handling +### Token Handling -Tokens are extracted from the `Authorization` header: +By default, protected endpoints extract bearer tokens from the `Authorization` +header: ``` Authorization: Bearer validtoken ``` +Generated file services can also opt into secure session cookies with +`auth_cookie(AuthCookieConfig::default())`. When CSRF protection is enabled with +`CsrfConfig::default()`, cookie-authenticated uploads must include an +`x-ras-csrf` header matching the `__Host-ras-csrf` double-submit cookie. + ## Error Handling ### Server-Side Errors @@ -730,6 +736,8 @@ async fn main() { // Create app with CORS let app = Router::new() .merge(file_router) + // For cookie auth, replace permissive CORS with an explicit + // credentialed origin allowlist. .layer(CorsLayer::permissive()); // Start server diff --git a/documentation/ras-identity.md b/documentation/ras-identity.md index 425eb35..21da1ab 100644 --- a/documentation/ras-identity.md +++ b/documentation/ras-identity.md @@ -232,6 +232,45 @@ println!("Permissions: {:?}", claims.permissions); session_service.end_session(&claims.jti).await; ``` +### Browser Session Cookies + +RAS services can accept the same JWT from either `Authorization: Bearer ...` or +from a configured secure cookie. The session verifier stays the same: + +```rust +use ras_auth_core::{AuthCookieConfig, CsrfConfig}; + +let jwt_auth = JwtAuthProvider::new(Arc::new(session_service)); +let cookie = AuthCookieConfig::default(); +let csrf = CsrfConfig::default(); + +let service = MyApiServiceBuilder::new(service_impl) + .auth_provider(jwt_auth) + .auth_cookie(cookie.clone()) + .csrf_protection(csrf.clone()) + .build(); + +// After login: +let token = session_service.begin_session("local", auth_payload).await?; +let set_cookie = cookie.session_cookie_header_value(&token)?; +let csrf_token = "random-per-session-csrf-token"; +let set_csrf_cookie = csrf.csrf_cookie_header_value(csrf_token)?; + +// After logout/revocation: +let clear_cookie = cookie.clear_cookie_header_value()?; +let clear_csrf_cookie = csrf.clear_csrf_cookie_header_value()?; +# Ok::<(), Box>(()) +``` + +Cookie auth is opt-in. Bearer tokens remain enabled by default and take +precedence when both transports are present. The default cookie is `HttpOnly`, +`Secure`, `SameSite=Lax`, `Path=/`, and host-only. `CsrfConfig::default()` uses +a double-submit token: the browser receives a readable `__Host-ras-csrf` cookie +and must echo the same value in the `x-ras-csrf` header on cookie-authenticated +`POST`, `PUT`, `PATCH`, and `DELETE` requests. Bearer requests are unchanged. +For cookie auth, use an explicit credentialed CORS allowlist; do not combine +session cookies with permissive credentialed CORS. + ## Permission Management Implement custom permission logic using the `UserPermissions` trait: @@ -439,6 +478,8 @@ async fn main() -> anyhow::Result<()> { let app = Router::new() .nest("/auth", auth_router) .nest("/api", api_router) + // For cookie auth, replace permissive CORS with an explicit + // credentialed origin allowlist. .layer(CorsLayer::permissive()) .with_state(session_service); @@ -457,6 +498,9 @@ async fn main() -> anyhow::Result<()> { - **Use strong JWT secrets**: Generate cryptographically secure secrets - **Set appropriate TTLs**: Balance security and user experience - **Enable HTTPS**: Always use TLS in production +- **Use secure cookies for browser sessions**: Prefer `HttpOnly`, `Secure`, + `SameSite`, host-prefixed names, double-submit CSRF tokens, and restrictive + credentialed CORS for cookie-authenticated unsafe requests - **Validate permissions**: Check permissions at the service level - **Handle errors gracefully**: Don't leak information in error messages diff --git a/examples/file-service-example/src/main.rs b/examples/file-service-example/src/main.rs index 88c2b73..1459b9d 100644 --- a/examples/file-service-example/src/main.rs +++ b/examples/file-service-example/src/main.rs @@ -169,8 +169,8 @@ async fn main() -> Result<(), Box> { // Build the router let app = DocumentServiceBuilder::new(service) .auth_provider(auth) - .with_usage_tracker(|headers, method, path| { - println!("Request: {} {} - Headers: {:?}", method, path, headers); + .with_usage_tracker(|_headers, method, path| { + println!("Request: {} {}", method, path); }) .with_duration_tracker(|method, path, duration| { println!("Request {} {} took {:?}", method, path, duration); From 88968255db7b0ff4d382f8849a0f93c58d5c3dbd Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sun, 24 May 2026 19:03:52 +0200 Subject: [PATCH 06/14] Fix CI clippy and supply-chain checks --- Cargo.lock | 4 ++-- crates/rpc/ras-jsonrpc-macro/src/lib.rs | 3 +-- deny.toml | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4352d69..4db9e12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3255,9 +3255,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index 8e73d0e..5e20f0f 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -420,8 +420,7 @@ impl Parse for MethodVersionDefinition { fn generate_service_code(service_def: ServiceDefinition) -> syn::Result { // Generate OpenRPC code if enabled in the macro input - let (openrpc_code, schema_checks) = if service_def.openrpc.is_some() { - let openrpc_config = service_def.openrpc.as_ref().unwrap(); + let (openrpc_code, schema_checks) = if let Some(openrpc_config) = &service_def.openrpc { ( openrpc::generate_openrpc_code(&service_def, openrpc_config), openrpc::generate_schema_impl_checks(&service_def), diff --git a/deny.toml b/deny.toml index 4f0cbe2..22852f4 100644 --- a/deny.toml +++ b/deny.toml @@ -23,6 +23,7 @@ allow = [ "ISC", "Unicode-3.0", "CC0-1.0", + "CDLA-Permissive-2.0", # Permissive data license used by webpki-roots "MPL-2.0", # Mozilla Public License, used by some crypto libraries "Zlib", ] From 826241b846f90b7f91124a48670a7f331cef45ea Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sun, 24 May 2026 19:10:02 +0200 Subject: [PATCH 07/14] Fix REST macro clippy lint --- crates/rest/ras-rest-macro/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index 26176a4..6a71e21 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -658,8 +658,7 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result Date: Sun, 24 May 2026 19:16:54 +0200 Subject: [PATCH 08/14] Fix file macro clippy lint --- crates/rest/ras-file-macro/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/rest/ras-file-macro/src/lib.rs b/crates/rest/ras-file-macro/src/lib.rs index 66e1cee..bf7977c 100644 --- a/crates/rest/ras-file-macro/src/lib.rs +++ b/crates/rest/ras-file-macro/src/lib.rs @@ -17,8 +17,7 @@ pub fn file_service(input: TokenStream) -> TokenStream { let client_code = client::generate_client(&definition); // Generate OpenAPI code if enabled - let (openapi_code, schema_checks) = if definition.openapi.is_some() { - let openapi_config = definition.openapi.as_ref().unwrap(); + let (openapi_code, schema_checks) = if let Some(openapi_config) = &definition.openapi { ( openapi::generate_openapi_code(&definition, openapi_config), openapi::generate_schema_impl_checks(&definition), From fca17381e292166de996a2fd088f966cc50d2ad9 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sun, 24 May 2026 19:26:22 +0200 Subject: [PATCH 09/14] Fix Rust 1.95 clippy lints --- crates/rest/ras-rest-macro/src/lib.rs | 1 + crates/rpc/ras-jsonrpc-macro/src/lib.rs | 1 + examples/bidirectional-chat/tui/src/main.rs | 60 ++++++++++----------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index 6a71e21..20ea6c5 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -761,6 +761,7 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result proc_macro2::TokenSt quote! { /// Generated service trait + #[allow(private_interfaces, private_bounds)] pub trait #service_trait_name: Send + Sync + 'static { #(#trait_methods)* } diff --git a/examples/bidirectional-chat/tui/src/main.rs b/examples/bidirectional-chat/tui/src/main.rs index 5c5036a..3781c70 100644 --- a/examples/bidirectional-chat/tui/src/main.rs +++ b/examples/bidirectional-chat/tui/src/main.rs @@ -285,40 +285,38 @@ async fn run_app( let mut app = app_state.lock().await; app.leave_room(&room_id); } - KeyCode::Enter => { - if !app.input_buffer.is_empty() { - let text = app.input_buffer.clone(); - app.input_buffer.clear(); - - // Check for slash commands - if text.starts_with('/') { - let command = text.trim_start_matches('/').to_lowercase(); - match command.as_str() { - "quit" | "exit" => { - drop(app); - let mut client = chat_client.lock().await; - let _ = client.disconnect().await; - return Ok(()); - } - _ => { - app.error_message = - Some(format!("Unknown command: /{}", command)); - } + KeyCode::Enter if !app.input_buffer.is_empty() => { + let text = app.input_buffer.clone(); + app.input_buffer.clear(); + + // Check for slash commands + if text.starts_with('/') { + let command = text.trim_start_matches('/').to_lowercase(); + match command.as_str() { + "quit" | "exit" => { + drop(app); + let mut client = chat_client.lock().await; + let _ = client.disconnect().await; + return Ok(()); } - } else { - // Stop typing when sending message - app.is_typing = false; - app.last_typing_time = None; - drop(app); + _ => { + app.error_message = + Some(format!("Unknown command: /{}", command)); + } + } + } else { + // Stop typing when sending message + app.is_typing = false; + app.last_typing_time = None; + drop(app); - let client = chat_client.lock().await; - // Stop typing notification - let _ = client.stop_typing().await; + let client = chat_client.lock().await; + // Stop typing notification + let _ = client.stop_typing().await; - if let Err(e) = client.send_message(text).await { - app_state.lock().await.error_message = - Some(format!("Failed to send message: {}", e)); - } + if let Err(e) = client.send_message(text).await { + app_state.lock().await.error_message = + Some(format!("Failed to send message: {}", e)); } } } From 8864b68a989d35632176300a4de42ffe7b15173b Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sun, 24 May 2026 19:56:30 +0200 Subject: [PATCH 10/14] Update GitHub Actions for Node 24 --- .github/workflows/bench.yml | 4 ++-- .github/workflows/ci.yml | 32 ++++++++++++++++---------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index ead5953..a76740f 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -19,7 +19,7 @@ jobs: name: Criterion benches runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -45,7 +45,7 @@ jobs: BENCHES - name: Upload bench artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: criterion-results path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91cc6b1..452c9e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: name: Rustfmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt @@ -25,7 +25,7 @@ jobs: name: Clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -38,7 +38,7 @@ jobs: env: RUSTDOCFLAGS: -D warnings steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Build rustdoc @@ -48,7 +48,7 @@ jobs: name: Documentation hygiene runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Check package README targets shell: bash @@ -111,7 +111,7 @@ jobs: name: Supply chain policy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@cargo-deny - uses: Swatinem/rust-cache@v2 @@ -121,7 +121,7 @@ jobs: name: Test (workspace) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Build tests @@ -135,7 +135,7 @@ jobs: name: Feature matrix runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable with: @@ -175,7 +175,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -184,7 +184,7 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6.4.0 with: node-version: 22.13 cache: npm @@ -204,7 +204,7 @@ jobs: - name: Upload Playwright report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: playwright-report path: tests/playwright/playwright-report @@ -213,7 +213,7 @@ jobs: - name: Upload Playwright test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: playwright-test-results path: tests/playwright/test-results @@ -224,7 +224,7 @@ jobs: name: Generated client specs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable @@ -242,7 +242,7 @@ jobs: name: WASM UI example runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable with: @@ -262,7 +262,7 @@ jobs: --locked - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6.4.0 with: node-version: 22.13 cache: npm @@ -279,7 +279,7 @@ jobs: runs-on: ubuntu-latest needs: [test] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable with: components: llvm-tools-preview @@ -290,7 +290,7 @@ jobs: - name: Print summary run: cargo llvm-cov report --summary-only - name: Upload coverage artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: coverage-lcov path: lcov.info From 2d54dcbe4d9094a1604bc57a96d8102c4c234c83 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Mon, 25 May 2026 12:13:47 +0200 Subject: [PATCH 11/14] Improve file service abstraction and macro hygiene --- Cargo.lock | 16 + Cargo.toml | 3 + README.md | 17 +- crates/rest/ras-file-core/Cargo.toml | 18 + crates/rest/ras-file-core/README.md | 7 + crates/rest/ras-file-core/src/lib.rs | 394 ++++++ crates/rest/ras-file-macro/Cargo.toml | 1 + crates/rest/ras-file-macro/README.md | 22 +- .../rest/ras-file-macro/benches/streaming.rs | 98 +- crates/rest/ras-file-macro/examples/simple.rs | 11 +- crates/rest/ras-file-macro/src/client.rs | 497 ++++---- crates/rest/ras-file-macro/src/lib.rs | 22 +- crates/rest/ras-file-macro/src/openapi.rs | 721 +++++------ crates/rest/ras-file-macro/src/parser.rs | 795 ++++++++---- crates/rest/ras-file-macro/src/server.rs | 1104 +++++++++++++---- .../rest/ras-file-macro/tests/drain_test.rs | 165 +++ crates/rest/ras-file-macro/tests/e2e.rs | 669 +++++++--- .../rest/ras-file-macro/tests/integration.rs | 270 +--- .../rest/ras-file-macro/tests/minimal_test.rs | 55 +- .../tests/multiple_invocations.rs | 61 + .../rest/ras-file-macro/tests/paren_test.rs | 48 +- .../rest/ras-file-macro/tests/simple_test.rs | 89 +- crates/rest/ras-rest-macro/src/lib.rs | 24 +- .../tests/multiple_invocations.rs | 50 + .../src/lib.rs | 27 +- crates/rpc/ras-jsonrpc-macro/src/lib.rs | 14 +- .../tests/multiple_invocations.rs | 51 + documentation/ras-file-macro.md | 920 +++----------- examples/file-service-example/Cargo.toml | 1 + examples/file-service-example/src/main.rs | 243 ++-- examples/file-service-wasm/README.md | 11 +- .../file-service-api/Cargo.toml | 1 + .../file-service-api/src/lib.rs | 64 +- .../file-service-backend/Cargo.toml | 1 + .../file-service-backend/README.md | 44 +- .../file-service-backend/src/file_service.rs | 328 +++-- 36 files changed, 4089 insertions(+), 2773 deletions(-) create mode 100644 crates/rest/ras-file-core/Cargo.toml create mode 100644 crates/rest/ras-file-core/README.md create mode 100644 crates/rest/ras-file-core/src/lib.rs create mode 100644 crates/rest/ras-file-macro/tests/drain_test.rs create mode 100644 crates/rest/ras-file-macro/tests/multiple_invocations.rs create mode 100644 crates/rest/ras-rest-macro/tests/multiple_invocations.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/tests/multiple_invocations.rs diff --git a/Cargo.lock b/Cargo.lock index 4db9e12..d53cfdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1079,6 +1079,7 @@ dependencies = [ "http", "js-sys", "ras-auth-core", + "ras-file-core", "ras-file-macro", "reqwest", "schemars", @@ -1105,6 +1106,7 @@ dependencies = [ "file-service-api", "mime_guess", "ras-auth-core", + "ras-file-core", "tempfile", "tokio", "tower-http", @@ -1121,6 +1123,7 @@ dependencies = [ "axum", "axum-test", "ras-auth-core", + "ras-file-core", "ras-file-macro", "reqwest", "serde", @@ -2635,6 +2638,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ras-file-core" +version = "0.1.0" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "ras-auth-core", + "thiserror 2.0.18", +] + [[package]] name = "ras-file-macro" version = "0.1.0" @@ -2646,6 +2661,7 @@ dependencies = [ "proc-macro2", "quote", "ras-auth-core", + "ras-file-core", "reqwest", "schemars", "serde", diff --git a/Cargo.toml b/Cargo.toml index a6bd596..945c112 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ anyhow = "1.0" async-trait = "0.1" axum-extra = { version = "0.10", features = ["query"] } base64 = "0.22" +bytes = "1.0" bon = "3.2" console_error_panic_hook = "0.1" criterion = "0.5" @@ -40,6 +41,8 @@ dotenvy = "0.15" dwind = "0.3.2" dwind-macros = "0.2.2" futures = "0.3" +futures-core = "0.3" +futures-util = "0.3" futures-signals = "0.3" http = "1.0" hmac = "0.12" diff --git a/README.md b/README.md index 6d8bf49..7b95db0 100644 --- a/README.md +++ b/README.md @@ -181,10 +181,21 @@ use ras_file_macro::file_service; file_service!({ service_name: DocumentService, base_path: "/api/documents", - body_limit: 52428800, // 50MB endpoints: [ - UPLOAD WITH_PERMISSIONS(["user"]) upload() -> FileMetadata, - DOWNLOAD UNAUTHORIZED download/{file_id: String}(), + UPLOAD WITH_PERMISSIONS(["user"]) upload multipart { + max_total_bytes: 52428800, + parts: [ + file file { + required: true, + max_bytes: 52428800, + filename: optional, + }, + ], + } -> FileMetadata, + DOWNLOAD UNAUTHORIZED download/{file_id: String} { + content_types: ["application/octet-stream"], + ranges: true, + }, ] }); ``` diff --git a/crates/rest/ras-file-core/Cargo.toml b/crates/rest/ras-file-core/Cargo.toml new file mode 100644 index 0000000..9030bc0 --- /dev/null +++ b/crates/rest/ras-file-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ras-file-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Core runtime types for Rust Agent Stack file upload and download services" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[dependencies] +bytes = { workspace = true } +futures-core = { workspace = true } +futures-util = { workspace = true } +http = { workspace = true } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +thiserror = { workspace = true } diff --git a/crates/rest/ras-file-core/README.md b/crates/rest/ras-file-core/README.md new file mode 100644 index 0000000..d15116f --- /dev/null +++ b/crates/rest/ras-file-core/README.md @@ -0,0 +1,7 @@ +# ras-file-core + +Core runtime types shared by generated file-service servers and clients. + +This crate is transport-neutral: it defines file errors, request context, +incoming upload streams, JSON upload responses, and download response builders. +Generated Axum code adapts HTTP requests into these types. diff --git a/crates/rest/ras-file-core/src/lib.rs b/crates/rest/ras-file-core/src/lib.rs new file mode 100644 index 0000000..9c21e21 --- /dev/null +++ b/crates/rest/ras-file-core/src/lib.rs @@ -0,0 +1,394 @@ +//! Core runtime types for generated file upload and download services. + +use std::{pin::Pin, time::SystemTime}; + +use bytes::Bytes; +use futures_core::Stream; +use futures_util::StreamExt; +use http::{HeaderMap, HeaderValue, StatusCode, header}; +use ras_auth_core::AuthenticatedUser; +use thiserror::Error; + +pub use bytes; +pub use futures_core; +pub use futures_util; +pub use http; + +/// Result type used by generated file services. +pub type FileResult = Result; + +/// Stream of byte chunks used by file upload and download abstractions. +pub type FileByteStream<'a> = Pin> + Send + 'a>>; + +/// Owned stream for download responses. +pub type OwnedFileByteStream = FileByteStream<'static>; + +/// Errors surfaced by generated file services. +#[derive(Debug, Error)] +pub enum FileError { + #[error("Bad request: {0}")] + BadRequest(String), + #[error("Authentication required")] + Unauthorized, + #[error("Forbidden")] + Forbidden, + #[error("Unsupported media type: {0}")] + UnsupportedMediaType(String), + #[error("Payload too large")] + PayloadTooLarge, + #[error("File not found")] + NotFound, + #[error("Conflict: {0}")] + Conflict(String), + #[error("Precondition failed: {0}")] + PreconditionFailed(String), + #[error("Upload failed: {0}")] + UploadFailed(String), + #[error("Download failed: {0}")] + DownloadFailed(String), + #[error("Handler contract violation: {0}")] + HandlerContract(String), + #[error("Internal server error")] + Internal, +} + +impl FileError { + /// HTTP status code associated with this error. + pub fn status(&self) -> StatusCode { + match self { + Self::BadRequest(_) | Self::HandlerContract(_) => StatusCode::BAD_REQUEST, + Self::Unauthorized => StatusCode::UNAUTHORIZED, + Self::Forbidden => StatusCode::FORBIDDEN, + Self::UnsupportedMediaType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + Self::NotFound => StatusCode::NOT_FOUND, + Self::Conflict(_) => StatusCode::CONFLICT, + Self::PreconditionFailed(_) => StatusCode::PRECONDITION_FAILED, + Self::UploadFailed(_) => StatusCode::BAD_REQUEST, + Self::DownloadFailed(_) | Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + /// Sanitized client-facing message. + pub fn client_message(&self) -> String { + match self { + Self::BadRequest(message) + | Self::UnsupportedMediaType(message) + | Self::Conflict(message) + | Self::PreconditionFailed(message) + | Self::HandlerContract(message) => message.clone(), + Self::Unauthorized => "Authentication required".to_string(), + Self::Forbidden => "Forbidden".to_string(), + Self::PayloadTooLarge => "Payload too large".to_string(), + Self::NotFound => "File not found".to_string(), + Self::UploadFailed(_) => "Upload failed".to_string(), + Self::DownloadFailed(_) | Self::Internal => "Internal server error".to_string(), + } + } + + pub fn bad_request(message: impl Into) -> Self { + Self::BadRequest(message.into()) + } + + pub fn unsupported_media_type(message: impl Into) -> Self { + Self::UnsupportedMediaType(message.into()) + } + + pub fn upload_failed(message: impl Into) -> Self { + Self::UploadFailed(message.into()) + } + + pub fn download_failed(message: impl Into) -> Self { + Self::DownloadFailed(message.into()) + } + + pub fn handler_contract(message: impl Into) -> Self { + Self::HandlerContract(message.into()) + } +} + +/// Request metadata passed to file-service handlers. +pub struct FileRequestContext<'a> { + pub method: &'static str, + pub request_path: &'a str, + pub matched_path: &'static str, + pub headers: &'a HeaderMap, + pub user: Option<&'a AuthenticatedUser>, +} + +impl<'a> FileRequestContext<'a> { + pub fn new( + method: &'static str, + request_path: &'a str, + matched_path: &'static str, + headers: &'a HeaderMap, + user: Option<&'a AuthenticatedUser>, + ) -> Self { + Self { + method, + request_path, + matched_path, + headers, + user, + } + } + + pub fn range(&self) -> Option<&'a str> { + self.headers.get(header::RANGE)?.to_str().ok() + } + + pub fn if_none_match(&self) -> Option<&'a str> { + self.headers.get(header::IF_NONE_MATCH)?.to_str().ok() + } + + pub fn if_match(&self) -> Option<&'a str> { + self.headers.get(header::IF_MATCH)?.to_str().ok() + } +} + +/// Streaming upload file part passed to service implementations. +pub struct IncomingFile<'a> { + field_name: String, + file_name: Option, + content_type: Option, + headers: HeaderMap, + limit: u64, + bytes_read: u64, + finished: bool, + stream: FileByteStream<'a>, +} + +impl<'a> IncomingFile<'a> { + pub fn new( + field_name: impl Into, + file_name: Option, + content_type: Option, + headers: HeaderMap, + limit: u64, + stream: FileByteStream<'a>, + ) -> Self { + Self { + field_name: field_name.into(), + file_name, + content_type, + headers, + limit, + bytes_read: 0, + finished: false, + stream, + } + } + + pub fn field_name(&self) -> &str { + &self.field_name + } + + pub fn file_name(&self) -> Option<&str> { + self.file_name.as_deref() + } + + pub fn content_type(&self) -> Option<&str> { + self.content_type.as_deref() + } + + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + pub fn bytes_read(&self) -> u64 { + self.bytes_read + } + + pub fn limit(&self) -> u64 { + self.limit + } + + pub fn is_finished(&self) -> bool { + self.finished + } + + pub async fn next_chunk(&mut self) -> FileResult> { + if self.finished { + return Ok(None); + } + + let Some(chunk) = self.stream.next().await.transpose()? else { + self.finished = true; + return Ok(None); + }; + + let next_total = self + .bytes_read + .checked_add(chunk.len() as u64) + .ok_or(FileError::PayloadTooLarge)?; + + if next_total > self.limit { + return Err(FileError::PayloadTooLarge); + } + + self.bytes_read = next_total; + Ok(Some(chunk)) + } + + pub async fn drain(&mut self) -> FileResult<()> { + while self.next_chunk().await?.is_some() {} + Ok(()) + } +} + +/// Summary of accepted upload fields. +#[derive(Debug, Clone, Default)] +pub struct UploadSummary { + pub total_parts: usize, + pub total_bytes: u64, + pub fields: Vec, +} + +impl UploadSummary { + pub fn record(&mut self, field_name: impl Into, bytes: u64) { + self.total_parts += 1; + self.total_bytes += bytes; + self.fields.push(UploadFieldSummary { + field_name: field_name.into(), + bytes, + }); + } +} + +#[derive(Debug, Clone)] +pub struct UploadFieldSummary { + pub field_name: String, + pub bytes: u64, +} + +/// JSON response returned by upload lifecycle finish handlers. +#[derive(Debug, Clone)] +pub struct JsonResponse { + pub status: StatusCode, + pub headers: HeaderMap, + pub body: T, +} + +impl JsonResponse { + pub fn ok(body: T) -> Self { + Self { + status: StatusCode::OK, + headers: HeaderMap::new(), + body, + } + } + + pub fn created(body: T) -> Self { + Self { + status: StatusCode::CREATED, + headers: HeaderMap::new(), + body, + } + } + + pub fn with_status(status: StatusCode, body: T) -> Self { + Self { + status, + headers: HeaderMap::new(), + body, + } + } + + pub fn header(mut self, name: header::HeaderName, value: HeaderValue) -> Self { + self.headers.insert(name, value); + self + } + + pub fn into_parts(self) -> (StatusCode, HeaderMap, T) { + (self.status, self.headers, self.body) + } +} + +impl From for JsonResponse { + fn from(body: T) -> Self { + Self::ok(body) + } +} + +/// Download body data. +pub enum DownloadBody { + Empty, + Bytes(Bytes), + Stream(OwnedFileByteStream), +} + +/// Streaming download response returned by download handlers. +pub struct DownloadResponse { + pub status: StatusCode, + pub headers: HeaderMap, + pub body: DownloadBody, +} + +impl DownloadResponse { + pub fn empty(status: StatusCode) -> Self { + Self { + status, + headers: HeaderMap::new(), + body: DownloadBody::Empty, + } + } + + pub fn bytes(bytes: impl Into) -> Self { + Self { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: DownloadBody::Bytes(bytes.into()), + } + } + + pub fn stream(stream: OwnedFileByteStream) -> Self { + Self { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: DownloadBody::Stream(stream), + } + } + + pub fn status(mut self, status: StatusCode) -> Self { + self.status = status; + self + } + + pub fn header(mut self, name: header::HeaderName, value: HeaderValue) -> Self { + self.headers.insert(name, value); + self + } + + pub fn content_type(self, value: impl AsRef) -> FileResult { + let value = HeaderValue::from_str(value.as_ref()) + .map_err(|e| FileError::bad_request(format!("invalid content type: {e}")))?; + Ok(self.header(header::CONTENT_TYPE, value)) + } + + pub fn content_length(self, value: u64) -> FileResult { + let value = HeaderValue::from_str(&value.to_string()) + .map_err(|e| FileError::bad_request(format!("invalid content length: {e}")))?; + Ok(self.header(header::CONTENT_LENGTH, value)) + } + + pub fn attachment(self, filename: impl AsRef) -> FileResult { + let escaped = filename.as_ref().replace('"', ""); + let value = HeaderValue::from_str(&format!("attachment; filename=\"{escaped}\"")) + .map_err(|e| FileError::bad_request(format!("invalid filename: {e}")))?; + Ok(self.header(header::CONTENT_DISPOSITION, value)) + } + + pub fn etag(self, value: impl AsRef) -> FileResult { + let value = HeaderValue::from_str(value.as_ref()) + .map_err(|e| FileError::bad_request(format!("invalid etag: {e}")))?; + Ok(self.header(header::ETAG, value)) + } + + pub fn last_modified(self, value: HeaderValue) -> Self { + self.header(header::LAST_MODIFIED, value) + } + + pub fn last_modified_system_time(self, _value: SystemTime) -> Self { + self + } +} diff --git a/crates/rest/ras-file-macro/Cargo.toml b/crates/rest/ras-file-macro/Cargo.toml index 070172d..8537702 100644 --- a/crates/rest/ras-file-macro/Cargo.toml +++ b/crates/rest/ras-file-macro/Cargo.toml @@ -27,6 +27,7 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-file-core = { path = "../ras-file-core", version = "0.1.0" } thiserror = { workspace = true } async-trait = { workspace = true } axum-test = { workspace = true } diff --git a/crates/rest/ras-file-macro/README.md b/crates/rest/ras-file-macro/README.md index 5cbf118..714ba0e 100644 --- a/crates/rest/ras-file-macro/README.md +++ b/crates/rest/ras-file-macro/README.md @@ -4,7 +4,8 @@ Procedural macro for type-safe file upload and download services. The `file_service!` macro generates the service trait, Axum routes, client helpers, OpenAPI output, authentication checks, and file-specific error types -for a file API definition. +for a file API definition. Upload handlers receive typed multipart parts and +download handlers return `ras_file_core::DownloadResponse`. ## Example @@ -25,8 +26,23 @@ file_service!({ base_path: "/api/files", openapi: true, endpoints: [ - UPLOAD WITH_PERMISSIONS(["files:write"]) upload() -> FileMetadata, - DOWNLOAD WITH_PERMISSIONS(["files:read"]) download/{file_id: String}(), + UPLOAD WITH_PERMISSIONS(["files:write"]) upload multipart { + max_total_bytes: 52428800, + reject_unknown_fields: true, + parts: [ + file file { + required: true, + max_count: 1, + max_bytes: 52428800, + content_types: ["application/pdf", "text/plain"], + filename: optional, + }, + ], + } -> FileMetadata, + DOWNLOAD WITH_PERMISSIONS(["files:read"]) download/{file_id: String} { + content_types: ["application/octet-stream"], + ranges: true, + }, ] }); ``` diff --git a/crates/rest/ras-file-macro/benches/streaming.rs b/crates/rest/ras-file-macro/benches/streaming.rs index c225743..b984cae 100644 --- a/crates/rest/ras-file-macro/benches/streaming.rs +++ b/crates/rest/ras-file-macro/benches/streaming.rs @@ -3,14 +3,9 @@ use std::sync::{Arc, Mutex}; -use axum::{ - body::Body, - http::StatusCode, - response::{IntoResponse, Response}, -}; use axum_test::multipart::{MultipartForm, Part}; use criterion::{Criterion, criterion_group, criterion_main}; -use ras_auth_core::AuthenticatedUser; +use ras_file_core::{DownloadResponse, FileRequestContext, JsonResponse}; use ras_file_macro::file_service; use serde::{Deserialize, Serialize}; use tokio::runtime::Runtime; @@ -29,8 +24,23 @@ file_service!({ service_name: BenchSvc, base_path: "/files", endpoints: [ - DOWNLOAD UNAUTHORIZED download/{file_id: String}(), - UPLOAD WITH_PERMISSIONS(["user"]) upload() -> UploadResponse, + DOWNLOAD UNAUTHORIZED download/{file_id: String} { + content_types: ["application/octet-stream"], + ranges: false, + }, + UPLOAD WITH_PERMISSIONS(["user"]) upload multipart { + max_total_bytes: 2097152, + reject_unknown_fields: true, + parts: [ + file file { + required: true, + max_count: 1, + max_bytes: 2097152, + content_types: ["application/octet-stream"], + filename: optional, + }, + ], + } -> UploadResponse, ] }); @@ -41,43 +51,67 @@ struct BenchImpl { storage: Storage, } +#[derive(Default)] +struct UploadState { + response: Option, +} + #[async_trait::async_trait] impl BenchSvcTrait for BenchImpl { - async fn download(&self, file_id: String) -> Result { + type UploadState = UploadState; + + async fn download_by_file_id( + &self, + _ctx: &FileRequestContext<'_>, + path: BenchSvcDownloadByFileIdPath, + ) -> Result { let bytes = self .storage .lock() .unwrap() .iter() - .find_map(|(id, data)| (id == &file_id).then(|| data.clone())) + .find_map(|(id, data)| (id == &path.file_id).then(|| data.clone())) .ok_or(BenchSvcFileError::NotFound)?; - Ok(Response::builder() - .status(StatusCode::OK) - .body(Body::from(bytes)) - .unwrap()) + Ok(DownloadResponse::bytes(bytes)) + } + + async fn upload_begin( + &self, + _ctx: &FileRequestContext<'_>, + _path: &BenchSvcUploadPath, + ) -> Result { + Ok(UploadState::default()) } - async fn upload( + async fn upload_part( &self, - _user: &AuthenticatedUser, - mut multipart: axum::extract::Multipart, - ) -> Result { - let field = multipart - .next_field() - .await - .map_err(|e| BenchSvcFileError::UploadFailed(e.to_string()))? - .ok_or_else(|| BenchSvcFileError::UploadFailed("no field".into()))?; - let data = field - .bytes() - .await - .map_err(|e| BenchSvcFileError::UploadFailed(e.to_string()))?; + _ctx: &FileRequestContext<'_>, + _path: &BenchSvcUploadPath, + state: &mut Self::UploadState, + part: &mut BenchSvcUploadPart<'_>, + ) -> Result<(), BenchSvcFileError> { + let BenchSvcUploadPart::File(file) = part; + let mut data = Vec::new(); + while let Some(chunk) = file.next_chunk().await? { + data.extend_from_slice(&chunk); + } let id = format!("file-{}", self.storage.lock().unwrap().len()); let size = data.len() as u64; - self.storage - .lock() - .unwrap() - .push((id.clone(), data.to_vec())); - Ok(UploadResponse { file_id: id, size }) + self.storage.lock().unwrap().push((id.clone(), data)); + state.response = Some(UploadResponse { file_id: id, size }); + Ok(()) + } + + async fn upload_finish( + &self, + _ctx: &FileRequestContext<'_>, + _path: &BenchSvcUploadPath, + state: Self::UploadState, + _summary: ras_file_core::UploadSummary, + ) -> Result, BenchSvcFileError> { + Ok(JsonResponse::ok(state.response.ok_or_else(|| { + BenchSvcFileError::handler_contract("upload finished without a file") + })?)) } } diff --git a/crates/rest/ras-file-macro/examples/simple.rs b/crates/rest/ras-file-macro/examples/simple.rs index 01038a8..07bbb28 100644 --- a/crates/rest/ras-file-macro/examples/simple.rs +++ b/crates/rest/ras-file-macro/examples/simple.rs @@ -4,7 +4,16 @@ file_service!({ service_name: SimpleService, base_path: "/api", endpoints: [ - UPLOAD UNAUTHORIZED upload() -> (), + UPLOAD UNAUTHORIZED upload multipart { + max_total_bytes: 1024, + parts: [ + file file { + required: true, + max_bytes: 1024, + filename: optional, + }, + ], + } -> (), ] }); diff --git a/crates/rest/ras-file-macro/src/client.rs b/crates/rest/ras-file-macro/src/client.rs index b687c98..2a2dbb6 100644 --- a/crates/rest/ras-file-macro/src/client.rs +++ b/crates/rest/ras-file-macro/src/client.rs @@ -1,19 +1,22 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use crate::parser::{Endpoint, FileServiceDefinition, Operation}; +use crate::parser::{Endpoint, FileServiceDefinition, Operation, UploadPartKind}; pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { let service_name = &definition.service_name; - let base_path = &definition.base_path; + let base_path = definition.base_path.value(); let client_name = format_ident!("{}Client", service_name); let builder_name = format_ident!("{}ClientBuilder", service_name); - - let client_methods = generate_client_methods(&definition.endpoints, &base_path.value()); - - // Generate WASM client wrapper - let wasm_client = generate_wasm_client(definition); + let form_builders = definition + .endpoints + .iter() + .filter_map(|endpoint| generate_multipart_builder(definition, endpoint)); + let client_methods = definition + .endpoints + .iter() + .map(|endpoint| generate_client_method(definition, endpoint, &base_path)); quote! { pub struct #client_name { @@ -28,20 +31,22 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { } pub fn set_bearer_token(&self, token: Option>) { - *self.bearer_token.write().unwrap() = token.map(|t| t.into()); + *self.bearer_token.write().unwrap() = token.map(|token| token.into()); } fn build_request(&self, method: ::reqwest::Method, path: &str) -> ::reqwest::RequestBuilder { - let mut req = self.client.request(method, format!("{}{}", self.base_url, path)); + let base = self.base_url.trim_end_matches('/'); + let path = path.trim_start_matches('/'); + let mut request = self.client.request(method, format!("{}/{}", base, path)); if let Some(token) = self.bearer_token.read().unwrap().as_ref() { - req = req.header("Authorization", format!("Bearer {}", token)); + request = request.header("Authorization", format!("Bearer {}", token)); } - req + request } - #client_methods + #(#client_methods)* } pub struct #builder_name { @@ -72,7 +77,7 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { self } - pub fn build(self) -> Result<#client_name, Box> { + pub fn build(self) -> Result<#client_name, Box> { let client = match self.client { Some(client) => client, None => { @@ -95,305 +100,231 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { } } - #wasm_client + #(#form_builders)* } } -fn generate_client_methods(endpoints: &[Endpoint], base_path: &str) -> TokenStream { - let methods = endpoints.iter().flat_map(|endpoint| { - let method_name = &endpoint.name; - let timeout_method_name = format_ident!("{}_with_timeout", method_name); - - let path = endpoint.path.as_ref() - .map(|p| p.value()) - .unwrap_or_else(|| endpoint.name.to_string()); - let full_path = format!("{}/{}", base_path, path); - - let path_params: Vec<_> = endpoint.path_params.iter().map(|param| { - let name = ¶m.name; - let ty = ¶m.ty; - quote! { #name: #ty } - }).collect(); - - let path_construction = if endpoint.path_params.is_empty() { - quote! { #full_path } - } else { - let replacements = endpoint.path_params.iter().map(|param| { - let name = ¶m.name; - let placeholder = format!("{{{}}}", name); - quote! { .replace(#placeholder, &#name.to_string()) } - }); - quote! { #full_path.to_string()#(#replacements)* } - }; - - let path_arg_names: Vec<_> = endpoint.path_params.iter().map(|param| { - ¶m.name - }).collect(); - - match &endpoint.operation { - Operation::Upload => { - let response_type = endpoint.response_type.as_ref() - .map(|t| quote! { #t }) - .unwrap_or_else(|| quote! { () }); - - let main_method = quote! { - #[cfg(not(target_arch = "wasm32"))] - pub async fn #method_name( - &self, - #(#path_params,)* - file_path: impl AsRef, - file_name: Option<&str>, - content_type: Option<&str> - ) -> Result<#response_type, Box> { - let path = #path_construction; - - let file = ::tokio::fs::File::open(file_path.as_ref()).await?; - let stream = ::tokio_util::io::ReaderStream::new(file); - let body = ::reqwest::Body::wrap_stream(stream); - - let file_name = file_name.unwrap_or_else(|| { - file_path.as_ref() - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - }); - - let part = ::reqwest::multipart::Part::stream(body) - .file_name(file_name.to_string()); - - let part = if let Some(ct) = content_type { - part.mime_str(ct)? - } else { - part - }; - - let form = ::reqwest::multipart::Form::new() - .part("file", part); - - let response = self.build_request(::reqwest::Method::POST, &path) - .multipart(form) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let text = response.text().await?; - return Err(format!("Upload failed with status {}: {}", status, text).into()); - } +fn generate_client_method( + definition: &FileServiceDefinition, + endpoint: &Endpoint, + base_path: &str, +) -> TokenStream { + let method_name = &endpoint.name; + let path = endpoint.path.value(); + let full_path = format!("{}{}", base_path.trim_end_matches('/'), path); + let path_params = endpoint.path_params.iter().map(|param| { + let name = ¶m.name; + let ty = ¶m.ty; + quote! { #name: #ty } + }); + let path_replace = endpoint.path_params.iter().map(|param| { + let name = ¶m.name; + let placeholder = format!("{{{}}}", name); + quote! { .replace(#placeholder, &#name.to_string()) } + }); - Ok(response.json().await?) + match &endpoint.operation { + Operation::Upload { response_type, .. } => { + let form_builder = multipart_builder_name(definition, endpoint); + quote! { + pub async fn #method_name( + &self, + #(#path_params,)* + form: #form_builder, + ) -> Result<#response_type, Box> { + let path = #full_path.to_string()#(#path_replace)*; + let response = self + .build_request(::reqwest::Method::POST, &path) + .multipart(form.into_form()) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await?; + return Err(format!("Upload failed with status {}: {}", status, text).into()); } - #[cfg(target_arch = "wasm32")] - pub async fn #method_name( - &self, - #(#path_params,)* - file_bytes: Vec, - file_name: &str, - content_type: Option<&str> - ) -> Result<#response_type, Box> { - let path = #path_construction; - - let part = ::reqwest::multipart::Part::bytes(file_bytes) - .file_name(file_name.to_string()); - - let part = if let Some(ct) = content_type { - part.mime_str(ct)? - } else { - part - }; - - let form = ::reqwest::multipart::Form::new() - .part("file", part); - - let response = self.build_request(::reqwest::Method::POST, &path) - .multipart(form) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let text = response.text().await?; - return Err(format!("Upload failed with status {}: {}", status, text).into()); - } + Ok(response.json().await?) + } + } + } + Operation::Download { .. } => quote! { + pub async fn #method_name( + &self, + #(#path_params,)* + ) -> Result<::reqwest::Response, Box> { + let path = #full_path.to_string()#(#path_replace)*; + let response = self + .build_request(::reqwest::Method::GET, &path) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await?; + return Err(format!("Download failed with status {}: {}", status, text).into()); + } + + Ok(response) + } + }, + } +} - Ok(response.json().await?) +fn generate_multipart_builder( + definition: &FileServiceDefinition, + endpoint: &Endpoint, +) -> Option { + let Operation::Upload { config, .. } = &endpoint.operation else { + return None; + }; + + let builder_name = multipart_builder_name(definition, endpoint); + let methods = config.parts.iter().map(|part| { + let field_name = part.name.to_string(); + let method_name = &part.name; + let bytes_method_name = format_ident!("{}_bytes", method_name); + + match part.kind { + UploadPartKind::File => quote! { + #[cfg(not(target_arch = "wasm32"))] + pub async fn #method_name( + mut self, + file_path: impl AsRef, + file_name: Option<&str>, + content_type: Option<&str>, + ) -> Result> { + let file = ::tokio::fs::File::open(file_path.as_ref()).await?; + let length = file.metadata().await.ok().map(|metadata| metadata.len()); + let stream = ::tokio_util::io::ReaderStream::new(file); + let body = ::reqwest::Body::wrap_stream(stream); + + let file_name = file_name + .map(ToString::to_string) + .or_else(|| { + file_path + .as_ref() + .file_name() + .and_then(|name| name.to_str()) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "file".to_string()); + + let mut part = if let Some(length) = length { + ::reqwest::multipart::Part::stream_with_length(body, length) + } else { + ::reqwest::multipart::Part::stream(body) } - }; + .file_name(file_name); - let timeout_method = quote! { - #[cfg(not(target_arch = "wasm32"))] - pub async fn #timeout_method_name( - &self, - #(#path_params,)* - file_path: impl AsRef, - file_name: Option<&str>, - content_type: Option<&str>, - timeout: std::time::Duration - ) -> Result<#response_type, Box> { - ::tokio::time::timeout( - timeout, - self.#method_name(#(#path_arg_names,)* file_path, file_name, content_type) - ).await? + if let Some(content_type) = content_type { + part = part.mime_str(content_type)?; } - }; - vec![main_method, timeout_method] - } - Operation::Download => { - let main_method = quote! { - pub async fn #method_name( - &self, - #(#path_params,)* - ) -> Result<::reqwest::Response, Box> { - let path = #path_construction; - - let response = self.build_request(::reqwest::Method::GET, &path) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let text = response.text().await?; - return Err(format!("Download failed with status {}: {}", status, text).into()); - } + self.form = self.form.part(#field_name, part); + Ok(self) + } - Ok(response) + pub fn #bytes_method_name( + mut self, + bytes: impl Into>, + file_name: impl Into, + content_type: Option<&str>, + ) -> Result> { + let mut part = ::reqwest::multipart::Part::bytes(bytes.into()) + .file_name(file_name.into()); + if let Some(content_type) = content_type { + part = part.mime_str(content_type)?; } - }; - - let timeout_method = quote! { - #[cfg(not(target_arch = "wasm32"))] - pub async fn #timeout_method_name( - &self, - #(#path_params,)* - timeout: std::time::Duration - ) -> Result<::reqwest::Response, Box> { - ::tokio::time::timeout( - timeout, - self.#method_name(#(#path_arg_names,)*) - ).await? + self.form = self.form.part(#field_name, part); + Ok(self) + } + }, + UploadPartKind::Json => { + let ty = part.ty.as_ref().expect("json part type"); + quote! { + pub fn #method_name( + mut self, + value: &#ty, + ) -> Result> { + let json = ::serde_json::to_string(value)?; + let part = ::reqwest::multipart::Part::text(json) + .mime_str("application/json")?; + self.form = self.form.part(#field_name, part); + Ok(self) } - }; - - vec![main_method, timeout_method] + } } + UploadPartKind::Text => quote! { + pub fn #method_name(mut self, value: impl Into) -> Self { + self.form = self.form.part(#field_name, ::reqwest::multipart::Part::text(value.into())); + self + } + }, } }); - quote! { #(#methods)* } -} - -fn generate_wasm_client(definition: &FileServiceDefinition) -> TokenStream { - let service_name = &definition.service_name; - let client_name = format_ident!("{}Client", service_name); - let wasm_client_name = format_ident!("Wasm{}Client", service_name); - - let wasm_methods = generate_wasm_methods(&definition.endpoints); + Some(quote! { + pub struct #builder_name { + form: ::reqwest::multipart::Form, + } - quote! { - #[cfg(target_arch = "wasm32")] - pub mod wasm_client { - use super::*; - use wasm_bindgen::prelude::*; - use wasm_bindgen_futures::js_sys; - use web_sys::File; - - #[wasm_bindgen] - pub struct #wasm_client_name { - inner: #client_name, + impl #builder_name { + pub fn new() -> Self { + Self { + form: ::reqwest::multipart::Form::new(), + } } - #[wasm_bindgen] - impl #wasm_client_name { - #[wasm_bindgen(constructor)] - pub fn new(base_url: String) -> Result<#wasm_client_name, JsValue> { - let client = #client_name::builder(base_url) - .build() - .map_err(|e| JsValue::from_str(&e.to_string()))?; + #(#methods)* - Ok(#wasm_client_name { inner: client }) - } - - #[wasm_bindgen] - pub fn set_bearer_token(&self, token: Option) { - self.inner.set_bearer_token(token); - } + pub fn into_form(self) -> ::reqwest::multipart::Form { + self.form + } + } - #wasm_methods + impl Default for #builder_name { + fn default() -> Self { + Self::new() } } - } + }) } -fn generate_wasm_methods(endpoints: &[Endpoint]) -> TokenStream { - let methods = endpoints.iter().map(|endpoint| { - let method_name = &endpoint.name; - let _response_type = endpoint.response_type.as_ref() - .map(|t| quote! { #t }) - .unwrap_or_else(|| quote! { () }); - - let path_params: Vec<_> = endpoint.path_params.iter().map(|param| { - let name = ¶m.name; - quote! { #name: String } - }).collect(); - - let path_args: Vec<_> = endpoint.path_params.iter().map(|param| { - ¶m.name - }).collect(); +fn multipart_builder_name( + definition: &FileServiceDefinition, + endpoint: &Endpoint, +) -> proc_macro2::Ident { + format_ident!( + "{}{}Multipart", + definition.service_name, + pascal_ident_segment(&endpoint.name.to_string()) + ) +} - match &endpoint.operation { - Operation::Upload => { - quote! { - #[wasm_bindgen] - pub async fn #method_name(&self, #(#path_params,)* file: File) -> Result { - // Convert File to bytes - let array_buffer = wasm_bindgen_futures::JsFuture::from(file.array_buffer()) - .await - .map_err(|e| JsValue::from_str(&format!("Failed to read file: {:?}", e)))?; - - let uint8_array = js_sys::Uint8Array::new(&array_buffer); - let mut bytes = vec![0; uint8_array.length() as usize]; - uint8_array.copy_to(&mut bytes); - - let file_type = file.type_(); - let content_type = if file_type.is_empty() { - None - } else { - Some(file_type.as_str()) - }; - - let response = self.inner - .#method_name(#(#path_args,)* bytes, &file.name(), content_type) - .await - .map_err(|e| JsValue::from_str(&e.to_string()))?; - - // Convert response to JsValue - serde_wasm_bindgen::to_value(&response) - .map_err(|e| JsValue::from_str(&e.to_string())) - } - } - } - Operation::Download => { - quote! { - #[wasm_bindgen] - pub async fn #method_name(&self, #(#path_params,)*) -> Result { - let response = self.inner - .#method_name(#(#path_args,)*) - .await - .map_err(|e| JsValue::from_str(&e.to_string()))?; - - // Convert response to blob for browser - let bytes = response.bytes().await - .map_err(|e| JsValue::from_str(&e.to_string()))?; - - // Convert to JS Uint8Array - Ok(js_sys::Uint8Array::from(&bytes[..]).into()) - } - } +fn pascal_ident_segment(value: &str) -> String { + let mut out = String::new(); + let mut uppercase_next = true; + + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + if uppercase_next { + out.push(ch.to_ascii_uppercase()); + uppercase_next = false; + } else { + out.push(ch); } + } else { + uppercase_next = true; } - }); + } - quote! { #(#methods)* } + if out.is_empty() { + "Generated".to_string() + } else if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { + format!("V{out}") + } else { + out + } } diff --git a/crates/rest/ras-file-macro/src/lib.rs b/crates/rest/ras-file-macro/src/lib.rs index bf7977c..71f5467 100644 --- a/crates/rest/ras-file-macro/src/lib.rs +++ b/crates/rest/ras-file-macro/src/lib.rs @@ -1,5 +1,5 @@ use proc_macro::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use syn::parse_macro_input; mod client; @@ -26,16 +26,21 @@ pub fn file_service(input: TokenStream) -> TokenStream { (quote! {}, quote! {}) }; + let service_name_lower = definition.service_name.to_string().to_lowercase(); + let server_mod = format_ident!("__ras_file_{}_server", service_name_lower); + let openapi_mod = format_ident!("__ras_file_{}_openapi", service_name_lower); + let client_mod = format_ident!("__ras_file_{}_client", service_name_lower); + // Only include server code when not targeting wasm32 let expanded = quote! { #[cfg(not(target_arch = "wasm32"))] - mod server_impl { + mod #server_mod { use super::*; #server_code } #[cfg(not(target_arch = "wasm32"))] - pub use server_impl::*; + pub use #server_mod::*; #[cfg(not(target_arch = "wasm32"))] const _: () = { @@ -43,15 +48,20 @@ pub fn file_service(input: TokenStream) -> TokenStream { }; #[cfg(not(target_arch = "wasm32"))] - mod openapi_impl { + mod #openapi_mod { use super::*; #openapi_code } #[cfg(not(target_arch = "wasm32"))] - pub use openapi_impl::*; + pub use #openapi_mod::*; + + mod #client_mod { + use super::*; + #client_code + } - #client_code + pub use #client_mod::*; }; TokenStream::from(expanded) diff --git a/crates/rest/ras-file-macro/src/openapi.rs b/crates/rest/ras-file-macro/src/openapi.rs index e0d3acc..5c1a01f 100644 --- a/crates/rest/ras-file-macro/src/openapi.rs +++ b/crates/rest/ras-file-macro/src/openapi.rs @@ -1,21 +1,17 @@ -//! OpenAPI 3.0 document generation for file services -//! -//! This module provides functionality to generate OpenAPI 3.0 specification documents -//! from the file_service macro definitions, with proper support for multipart uploads -//! and binary file downloads. - -use crate::parser::{AuthRequirement, FileServiceDefinition, OpenApiConfig, Operation}; +use crate::parser::{ + AuthRequirement, FileServiceDefinition, MaxBytes, OpenApiConfig, Operation, UploadPart, + UploadPartKind, +}; use proc_macro2::TokenStream; use quote::quote; use std::collections::HashMap; -/// Generates OpenAPI document creation code pub fn generate_openapi_code( service_def: &FileServiceDefinition, config: &OpenApiConfig, ) -> TokenStream { let service_name = &service_def.service_name; - let base_path_value = service_def.base_path.value(); + let base_path = service_def.base_path.value(); let openapi_fn_name = quote::format_ident!( "generate_{}_openapi", service_name.to_string().to_lowercase() @@ -24,475 +20,303 @@ pub fn generate_openapi_code( "generate_{}_openapi_to_file", service_name.to_string().to_lowercase() ); - let endpoint_info_struct_name = quote::format_ident!("{}OpenApiEndpointInfo", service_name); + let endpoint_info_name = quote::format_ident!("{}OpenApiEndpointInfo", service_name); + let part_info_name = quote::format_ident!("{}OpenApiPartInfo", service_name); - // Generate the output path based on config let output_path_code = match config { OpenApiConfig::Enabled => { let service_name_lower = service_name.to_string().to_lowercase(); - quote! { - format!("target/openapi/{}.json", #service_name_lower) - } - } - OpenApiConfig::WithPath(path) => { - quote! { - #path.to_string() - } + quote! { format!("target/openapi/{}.json", #service_name_lower) } } + OpenApiConfig::WithPath(path) => quote! { #path.to_string() }, }; - // Collect unique types for schema generation - let mut unique_types = HashMap::new(); - for endpoint in &service_def.endpoints { - // For uploads, the response type is specified - if let Some(response_type) = &endpoint.response_type { - let response_type_str = quote!(#response_type).to_string(); - unique_types.insert(response_type_str, quote!(#response_type)); - } - - // Add path parameter types - for path_param in &endpoint.path_params { - let param_type = &path_param.ty; - let param_type_str = quote!(#param_type).to_string(); - unique_types.insert(param_type_str, quote!(#param_type)); - } - } - - // Generate schema generation functions - let schema_fns: Vec = unique_types - .iter() - .filter_map(|(type_name, type_tokens)| { - if type_name == "()" { - None // Skip unit type - } else { - let sanitized_name = type_name - .replace("::", "_") - .replace("<", "_") - .replace(">", "_") - .replace(" ", "_") - .replace("(", "_") - .replace(")", "_"); - let fn_name = quote::format_ident!( - "_generate_schema_for_{}_{}", - service_name.to_string().to_lowercase(), - sanitized_name - ); - Some(quote! { - fn #fn_name() -> serde_json::Value { - let schema = schemars::schema_for!(#type_tokens); - let mut schema_value = serde_json::to_value(&schema).unwrap_or_else(|_| { - serde_json::json!({ - "type": "object", - "description": format!("Schema for {}", #type_name) - }) - }); - - // Post-process schemas for broad OpenAPI explorer compatibility. - normalize_nullable_properties(&mut schema_value); - schema_value + let unique_types = collect_schema_types(service_def); + let schema_fns = generate_schema_fns(service_name, &unique_types); + let schema_insertions = generate_schema_insertions(service_name, &unique_types); + let endpoint_infos = service_def.endpoints.iter().map(|endpoint| { + let method = match endpoint.operation { + Operation::Upload { .. } => "POST", + Operation::Download { .. } => "GET", + }; + let operation = match endpoint.operation { + Operation::Upload { .. } => "upload", + Operation::Download { .. } => "download", + }; + let path = endpoint.path.value(); + let auth_required = matches!(endpoint.auth, AuthRequirement::WithPermissions(_)); + let permissions = permissions_for_openapi(&endpoint.auth); + + let path_params = endpoint.path_params.iter().map(|param| { + let name = param.name.to_string(); + let param_ty = ¶m.ty; + let ty = sanitize_type_name("e!(#param_ty).to_string()); + quote! { (#name.to_string(), #ty.to_string()) } + }); + + match &endpoint.operation { + Operation::Upload { + config, + response_type, + } => { + let response_type_name = sanitize_type_name("e!(#response_type).to_string()); + let max_total = match config.max_total_bytes { + MaxBytes::Limited(limit) => quote! { Some(#limit as u64) }, + MaxBytes::Unlimited => quote! { None }, + }; + let reject_unknown = config.reject_unknown_fields; + let parts = config + .parts + .iter() + .map(|part| part_info_tokens(part, &part_info_name)); + quote! { + #endpoint_info_name { + method: #method.to_string(), + operation: #operation.to_string(), + path: #path.to_string(), + auth_required: #auth_required, + permissions: vec![#(#permissions.to_string()),*], + path_params: vec![#(#path_params),*], + response_type_name: Some(#response_type_name.to_string()), + max_total_bytes: #max_total, + reject_unknown_fields: #reject_unknown, + parts: vec![#(#parts),*], + download_content_types: vec![], + download_ranges: false, } - }) - } - }) - .collect(); - - // Generate schema collection code - let schema_insertions: Vec = unique_types - .keys() - .filter_map(|type_name| { - if type_name == "()" { - None // Skip unit type, handled separately - } else { - let sanitized_name = type_name - .replace("::", "_") - .replace("<", "_") - .replace(">", "_") - .replace(" ", "_") - .replace("(", "_") - .replace(")", "_"); - let fn_name = quote::format_ident!( - "_generate_schema_for_{}_{}", - service_name.to_string().to_lowercase(), - sanitized_name - ); - Some(quote! { - schemas.insert(#type_name.to_string(), #fn_name()); - }) - } - }) - .collect(); - - // Generate endpoint info structs - let endpoint_infos: Vec = service_def - .endpoints - .iter() - .map(|endpoint| { - let operation = match endpoint.operation { - Operation::Upload => "upload", - Operation::Download => "download", - }; - let method = match endpoint.operation { - Operation::Upload => "POST", - Operation::Download => "GET", - }; - - // Build the full path - let path = if let Some(custom_path) = &endpoint.path { - let path_str = custom_path.value(); - if path_str.starts_with('/') { - path_str - } else { - format!("/{}", path_str) } - } else { - format!("/{}", endpoint.name) - }; - - let auth_required = matches!(endpoint.auth, AuthRequirement::WithPermissions(_)); - let permissions = match &endpoint.auth { - AuthRequirement::Unauthorized => vec![], - AuthRequirement::WithPermissions(groups) => { - groups.iter().flatten().cloned().collect() - } - }; - - let response_type_name = if let Some(response_type) = &endpoint.response_type { - let type_str = quote!(#response_type).to_string(); - if type_str == "()" { - "BinaryFileResponse".to_string() - } else { - type_str - } - } else { - // For download endpoints without explicit response type - "BinaryFileResponse".to_string() - }; - - let path_param_infos: Vec = endpoint - .path_params - .iter() - .map(|param| { - let param_name = param.name.to_string(); - let param_type = ¶m.ty; - let param_type_str = quote!(#param_type).to_string(); - quote! { - (#param_name.to_string(), #param_type_str.to_string()) + } + Operation::Download { config } => { + let content_types = config.content_types.iter(); + let ranges = config.ranges; + quote! { + #endpoint_info_name { + method: #method.to_string(), + operation: #operation.to_string(), + path: #path.to_string(), + auth_required: #auth_required, + permissions: vec![#(#permissions.to_string()),*], + path_params: vec![#(#path_params),*], + response_type_name: None, + max_total_bytes: None, + reject_unknown_fields: true, + parts: vec![], + download_content_types: vec![#(#content_types.to_string()),*], + download_ranges: #ranges, } - }) - .collect(); - - quote! { - #endpoint_info_struct_name { - operation: #operation.to_string(), - method: #method.to_string(), - path: #path.to_string(), - auth_required: #auth_required, - permissions: vec![#(#permissions.to_string()),*], - response_type_name: #response_type_name.to_string(), - path_params: vec![#(#path_param_infos),*] as Vec<(String, String)>, } } - }) - .collect(); + } + }); quote! { #[derive(serde::Serialize)] - struct #endpoint_info_struct_name { - operation: String, + struct #part_info_name { + kind: String, + name: String, + type_name: Option, + required: bool, + max_count: usize, + max_bytes: u64, + content_types: Vec, + } + + #[derive(serde::Serialize)] + struct #endpoint_info_name { method: String, + operation: String, path: String, auth_required: bool, permissions: Vec, - response_type_name: String, - path_params: Vec<(String, String)>, // (name, type) + path_params: Vec<(String, String)>, + response_type_name: Option, + max_total_bytes: Option, + reject_unknown_fields: bool, + parts: Vec<#part_info_name>, + download_content_types: Vec, + download_ranges: bool, } - // Helper function to fix schema references and flatten nested definitions fn fix_schema_refs(value: &mut serde_json::Value, schemas: &mut serde_json::Map) { match value { serde_json::Value::Object(obj) => { - // Extract nested definitions and move them to top-level schemas - if let Some(defs) = obj.remove("definitions") { + if let Some(defs) = obj.remove("$defs").or_else(|| obj.remove("definitions")) { if let serde_json::Value::Object(defs_obj) = defs { - for (name, schema) in defs_obj { - let mut schema_copy = schema.clone(); - fix_schema_refs(&mut schema_copy, schemas); - schemas.insert(name, schema_copy); + for (name, mut schema) in defs_obj { + fix_schema_refs(&mut schema, schemas); + schemas.insert(name, schema); } } } - // Extract $defs and move them to top-level schemas - if let Some(defs) = obj.remove("$defs") { - if let serde_json::Value::Object(defs_obj) = defs { - for (name, schema) in defs_obj { - let mut schema_copy = schema.clone(); - fix_schema_refs(&mut schema_copy, schemas); - schemas.insert(name, schema_copy); - } - } - } - - // Fix $ref strings to point to components/schemas - if let Some(ref_val) = obj.get_mut("$ref") { - if let serde_json::Value::String(ref_str) = ref_val { - if ref_str.starts_with("#/definitions/") { - let name = ref_str.trim_start_matches("#/definitions/"); - *ref_str = format!("#/components/schemas/{}", name); - } else if ref_str.starts_with("#/$defs/") { - let name = ref_str.trim_start_matches("#/$defs/"); - *ref_str = format!("#/components/schemas/{}", name); - } + if let Some(serde_json::Value::String(reference)) = obj.get_mut("$ref") { + if reference.starts_with("#/$defs/") { + *reference = format!("#/components/schemas/{}", reference.trim_start_matches("#/$defs/")); + } else if reference.starts_with("#/definitions/") { + *reference = format!("#/components/schemas/{}", reference.trim_start_matches("#/definitions/")); } } - // Remove $schema field as it's not needed in OpenAPI obj.remove("$schema"); - // Recursively process all values - for (_, v) in obj.iter_mut() { - fix_schema_refs(v, schemas); + for value in obj.values_mut() { + fix_schema_refs(value, schemas); } } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - fix_schema_refs(item, schemas); + serde_json::Value::Array(values) => { + for value in values { + fix_schema_refs(value, schemas); } } _ => {} } } - // Helper function to normalize nullable properties - fn normalize_nullable_properties(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(obj) => { - // Process properties object if it exists - if let Some(properties) = obj.get_mut("properties") { - if let serde_json::Value::Object(props) = properties { - for (_, prop_value) in props.iter_mut() { - if let serde_json::Value::Object(prop_obj) = prop_value { - // Check if this property has type: ["string", "null"] pattern - if let Some(type_val) = prop_obj.get("type") { - if let serde_json::Value::Array(type_array) = type_val { - if type_array.len() == 2 { - let null_value = serde_json::Value::String("null".to_string()); - if type_array.contains(&null_value) { - // Find the non-null type - let non_null_type = type_array.iter() - .find(|t| **t != null_value) - .cloned(); - - if let Some(actual_type) = non_null_type { - // Replace with the non-null type and add nullable: true - prop_obj.insert("type".to_string(), actual_type); - prop_obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); - } - } - } - } - } - } - // Recursively process nested objects - normalize_nullable_properties(prop_value); - } - } - } - - // Process definitions object if it exists - if let Some(definitions) = obj.get_mut("definitions") { - normalize_nullable_properties(definitions); - } - - // Process any other nested objects - for (_, v) in obj.iter_mut() { - normalize_nullable_properties(v); - } - } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - normalize_nullable_properties(item); - } - } - _ => {} - } - } - - // Generate schema functions for each type #(#schema_fns)* - /// Generate OpenAPI 3.0 document for this file service pub fn #openapi_fn_name() -> serde_json::Value { use serde_json::json; - use schemars::{schema_for, JsonSchema}; use std::collections::HashMap; - let endpoints: Vec<#endpoint_info_struct_name> = vec![ - #(#endpoint_infos),* - ]; - - // Generate schemas for all unique types + let endpoints: Vec<#endpoint_info_name> = vec![#(#endpoint_infos),*]; let mut schemas = HashMap::new(); - - // Add special schemas for file operations - schemas.insert("FileUploadRequest".to_string(), json!({ - "type": "object", - "properties": { - "file": { - "type": "string", - "format": "binary", - "description": "The file to upload" - } - }, - "required": ["file"] - })); - schemas.insert("BinaryFileResponse".to_string(), json!({ "type": "string", "format": "binary", "description": "Binary file content" })); - - // Insert all the generated schemas #(#schema_insertions)* - // Fix all schema references and flatten nested definitions let mut final_schemas = serde_json::Map::new(); for (name, mut schema) in schemas { fix_schema_refs(&mut schema, &mut final_schemas); final_schemas.insert(name, schema); } - // Group endpoints by path to create OpenAPI paths let mut paths = serde_json::Map::new(); for endpoint in &endpoints { let path_item = paths.entry(endpoint.path.clone()).or_insert_with(|| json!({})); - let method_lower = endpoint.method.to_lowercase(); let mut operation = json!({ "summary": format!("{} {}", endpoint.operation, endpoint.path), - "description": format!("File {} operation at {}", endpoint.operation, endpoint.path), "operationId": format!("{}_{}", endpoint.operation, endpoint.path.replace("/", "_").replace("{", "").replace("}", "").trim_start_matches('_')), "tags": ["File Operations"], }); - // Add parameters (path parameters) - if !endpoint.path_params.is_empty() { - let mut parameters = vec![]; - for (param_name, param_type) in &endpoint.path_params { - parameters.push(json!({ - "name": param_name, - "in": "path", - "required": true, - "description": format!("Path parameter of type {}", param_type), - "schema": { - "$ref": format!("#/components/schemas/{}", param_type) - } - })); - } + let mut parameters = vec![]; + for (name, type_name) in &endpoint.path_params { + parameters.push(json!({ + "name": name, + "in": "path", + "required": true, + "schema": { "$ref": format!("#/components/schemas/{}", type_name) }, + })); + } + if !parameters.is_empty() { operation["parameters"] = json!(parameters); } - // Configure based on operation type if endpoint.operation == "upload" { - // Upload operation - multipart/form-data + let mut properties = serde_json::Map::new(); + let mut required = vec![]; + let mut encoding = serde_json::Map::new(); + + for part in &endpoint.parts { + let schema = match part.kind.as_str() { + "file" if part.max_count > 1 => json!({ + "type": "array", + "items": { "type": "string", "format": "binary" }, + "maxItems": part.max_count, + }), + "file" => json!({ "type": "string", "format": "binary" }), + "text" => json!({ "type": "string", "maxLength": part.max_bytes }), + "json" => json!({ "$ref": format!("#/components/schemas/{}", part.type_name.as_ref().expect("json type")) }), + _ => json!({ "type": "string" }), + }; + properties.insert(part.name.clone(), schema); + + if !part.content_types.is_empty() { + encoding.insert(part.name.clone(), json!({ + "contentType": part.content_types.join(", "), + })); + } + + if part.required { + required.push(part.name.clone()); + } + } + operation["requestBody"] = json!({ - "description": "File to upload", "required": true, "content": { "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/FileUploadRequest" - } + "type": "object", + "properties": properties, + "required": required, + }, + "encoding": encoding, } } }); + operation["x-ras-file"] = json!({ + "maxTotalBytes": endpoint.max_total_bytes, + "rejectUnknownFields": endpoint.reject_unknown_fields, + "parts": endpoint.parts, + }); + operation["responses"] = json!({ "200": { "description": "Successful upload", "content": { "application/json": { "schema": { - "$ref": format!("#/components/schemas/{}", endpoint.response_type_name) + "$ref": format!("#/components/schemas/{}", endpoint.response_type_name.as_ref().expect("upload response type")) } } } }, - "400": { - "description": "Bad request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "413": { - "description": "File too large" - }, - "500": { - "description": "Internal server error" - } + "400": { "description": "Bad request" }, + "401": { "description": "Unauthorized" }, + "403": { "description": "Forbidden" }, + "413": { "description": "Payload too large" }, + "415": { "description": "Unsupported media type" }, + "500": { "description": "Internal server error" } }); } else { - // Download operation - determine response type based on endpoint - let (response_content, response_description) = if endpoint.response_type_name == "BinaryFileResponse" { - // Binary file download - (json!({ - "application/octet-stream": { - "schema": { - "$ref": "#/components/schemas/BinaryFileResponse" - } - } - }), "File download") - } else { - // JSON response (e.g., file metadata) - (json!({ - "application/json": { - "schema": { - "$ref": format!("#/components/schemas/{}", endpoint.response_type_name) - } - } - }), "Successful response") - }; - + operation["x-ras-file"] = json!({ + "contentTypes": endpoint.download_content_types, + "ranges": endpoint.download_ranges, + }); operation["responses"] = json!({ "200": { - "description": response_description, - "content": response_content - }, - "400": { - "description": "Bad request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "File not found" + "description": "File download", + "content": { + "application/octet-stream": { + "schema": { "$ref": "#/components/schemas/BinaryFileResponse" } + } + } }, - "500": { - "description": "Internal server error" - } + "206": { "description": "Partial content" }, + "304": { "description": "Not modified" }, + "400": { "description": "Bad request" }, + "401": { "description": "Unauthorized" }, + "403": { "description": "Forbidden" }, + "404": { "description": "File not found" }, + "412": { "description": "Precondition failed" }, + "500": { "description": "Internal server error" } }); } - // Add security requirements if auth is required if endpoint.auth_required { - operation["security"] = json!([{ - "bearerAuth": [] - }]); - + operation["security"] = json!([{ "bearerAuth": [] }]); if !endpoint.permissions.is_empty() { operation["x-permissions"] = json!(endpoint.permissions); } } - // Add the operation to the path item path_item[method_lower] = operation; } @@ -500,11 +324,11 @@ pub fn generate_openapi_code( "openapi": "3.0.3", "info": { "title": format!("{} File Service API", stringify!(#service_name)), - "version": "1.0.0", + "version": "2.0.0", "description": format!("OpenAPI 3.0 specification for the {} file service", stringify!(#service_name)) }, "servers": [{ - "url": #base_path_value, + "url": #base_path, "description": "File service base path" }], "paths": paths, @@ -513,8 +337,7 @@ pub fn generate_openapi_code( "securitySchemes": { "bearerAuth": { "type": "http", - "scheme": "bearer", - "description": "Bearer token for authentication" + "scheme": "bearer" } } }, @@ -525,58 +348,158 @@ pub fn generate_openapi_code( }) } - /// Write OpenAPI document to the target directory pub fn #openapi_to_file_fn_name() -> std::io::Result<()> { let doc = #openapi_fn_name(); let output_path = #output_path_code; - - // Create parent directories if they don't exist if let Some(parent) = std::path::Path::new(&output_path).parent() { std::fs::create_dir_all(parent)?; } - - let json_string = serde_json::to_string_pretty(&doc)?; - std::fs::write(&output_path, &json_string)?; - - println!("Generated OpenAPI document at: {}", output_path); - + std::fs::write(&output_path, serde_json::to_string_pretty(&doc)?)?; Ok(()) } } } -/// Generates code to check that types implement JsonSchema pub fn generate_schema_impl_checks(service_def: &FileServiceDefinition) -> TokenStream { + let unique_types = collect_schema_types(service_def); + let checks = unique_types.values().map(|ty| { + quote! { + const _: () = { + fn _assert_json_schema() {} + fn _check() { + _assert_json_schema::<#ty>(); + } + }; + } + }); + + quote! { #(#checks)* } +} + +fn collect_schema_types(service_def: &FileServiceDefinition) -> HashMap { let mut unique_types = HashMap::new(); - // Collect unique response types for endpoint in &service_def.endpoints { - if let Some(response_type) = &endpoint.response_type { - unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); + match &endpoint.operation { + Operation::Upload { + response_type, + config, + } => { + unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); + + for part in &config.parts { + if let Some(ty) = &part.ty { + unique_types.insert(quote!(#ty).to_string(), quote!(#ty)); + } + } + } + Operation::Download { .. } => {} } - // Add path parameter types for path_param in &endpoint.path_params { - let param_type = &path_param.ty; - unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); + let ty = &path_param.ty; + unique_types.insert(quote!(#ty).to_string(), quote!(#ty)); } } - let type_checks: Vec = unique_types - .values() - .map(|type_tokens| { + unique_types +} + +fn generate_schema_fns( + service_name: &syn::Ident, + unique_types: &HashMap, +) -> Vec { + unique_types + .iter() + .map(|(type_name, type_tokens)| { + let sanitized_name = sanitize_type_name(type_name); + let fn_name = quote::format_ident!( + "_generate_schema_for_{}_{}", + service_name.to_string().to_lowercase(), + sanitized_name + ); quote! { - const _: () = { - fn _assert_json_schema() {} - fn _check() { - _assert_json_schema::<#type_tokens>(); - } - }; + fn #fn_name() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(#type_tokens)).unwrap_or_else(|_| { + serde_json::json!({ + "type": "object", + "description": format!("Schema for {}", #type_name) + }) + }) + } } }) - .collect(); + .collect() +} + +fn generate_schema_insertions( + service_name: &syn::Ident, + unique_types: &HashMap, +) -> Vec { + unique_types + .keys() + .map(|type_name| { + let sanitized_name = sanitize_type_name(type_name); + let fn_name = quote::format_ident!( + "_generate_schema_for_{}_{}", + service_name.to_string().to_lowercase(), + sanitized_name + ); + quote! { + schemas.insert(#sanitized_name.to_string(), #fn_name()); + } + }) + .collect() +} + +fn part_info_tokens(part: &UploadPart, part_info_name: &syn::Ident) -> TokenStream { + let kind = match part.kind { + UploadPartKind::File => "file", + UploadPartKind::Json => "json", + UploadPartKind::Text => "text", + }; + let name = part.name.to_string(); + let type_name = part + .ty + .as_ref() + .map(|ty| sanitize_type_name("e!(#ty).to_string())); + let type_name_tokens = match type_name { + Some(type_name) => quote! { Some(#type_name.to_string()) }, + None => quote! { None }, + }; + let required = part.required; + let max_count = part.max_count; + let max_bytes = part.max_bytes; + let content_types = part.content_types.iter(); quote! { - #(#type_checks)* + #part_info_name { + kind: #kind.to_string(), + name: #name.to_string(), + type_name: #type_name_tokens, + required: #required, + max_count: #max_count, + max_bytes: #max_bytes, + content_types: vec![#(#content_types.to_string()),*], + } + } +} + +fn permissions_for_openapi(auth: &AuthRequirement) -> Vec { + match auth { + AuthRequirement::Unauthorized => vec![], + AuthRequirement::WithPermissions(groups) => groups.iter().flatten().cloned().collect(), + } +} + +fn sanitize_type_name(type_name: &str) -> String { + if type_name == "()" { + "Unit".to_string() + } else { + type_name + .replace("::", "_") + .replace('<', "_") + .replace(['>', ' '], "") + .replace([',', '(', ')'], "_") } } diff --git a/crates/rest/ras-file-macro/src/parser.rs b/crates/rest/ras-file-macro/src/parser.rs index 018733a..39e387c 100644 --- a/crates/rest/ras-file-macro/src/parser.rs +++ b/crates/rest/ras-file-macro/src/parser.rs @@ -8,7 +8,6 @@ use syn::{ pub struct FileServiceDefinition { pub service_name: Ident, pub base_path: LitStr, - pub body_limit: Option, pub openapi: Option, pub endpoints: Vec, } @@ -24,15 +23,19 @@ pub struct Endpoint { pub operation: Operation, pub auth: AuthRequirement, pub name: Ident, - pub path: Option, + pub path: LitStr, pub path_params: Vec, - pub response_type: Option, } #[derive(Debug)] pub enum Operation { - Upload, - Download, + Upload { + config: UploadConfig, + response_type: Box, + }, + Download { + config: DownloadConfig, + }, } #[derive(Debug)] @@ -41,12 +44,57 @@ pub enum AuthRequirement { WithPermissions(Vec>), } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct PathParam { pub name: Ident, pub ty: Type, } +#[derive(Debug)] +pub struct UploadConfig { + pub max_total_bytes: MaxBytes, + pub reject_unknown_fields: bool, + pub parts: Vec, +} + +#[derive(Debug, Clone)] +pub enum MaxBytes { + Limited(u64), + Unlimited, +} + +#[derive(Debug)] +pub struct UploadPart { + pub kind: UploadPartKind, + pub name: Ident, + pub ty: Option, + pub required: bool, + pub max_count: usize, + pub max_bytes: u64, + pub content_types: Vec, + pub filename: FilenamePolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UploadPartKind { + File, + Json, + Text, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilenamePolicy { + Optional, + Required, + Forbidden, +} + +#[derive(Debug, Default)] +pub struct DownloadConfig { + pub content_types: Vec, + pub ranges: bool, +} + impl Parse for FileServiceDefinition { fn parse(input: ParseStream) -> Result { let content; @@ -54,7 +102,6 @@ impl Parse for FileServiceDefinition { let mut service_name = None; let mut base_path = None; - let mut body_limit = None; let mut openapi = None; let mut endpoints = Vec::new(); @@ -63,18 +110,15 @@ impl Parse for FileServiceDefinition { content.parse::()?; match field_name.to_string().as_str() { - "service_name" => { - service_name = Some(content.parse()?); - } - "base_path" => { - base_path = Some(content.parse()?); - } + "service_name" => service_name = Some(content.parse()?), + "base_path" => base_path = Some(content.parse()?), "body_limit" => { - let lit: syn::LitInt = content.parse()?; - body_limit = Some(lit.base10_parse()?); + return Err(Error::new( + field_name.span(), + "body_limit was removed in file_service v2; use per-upload max_total_bytes", + )); } "openapi" => { - // Parse openapi value - can be true/false or { output: "path" } if content.peek(syn::LitBool) { let enabled = content.parse::()?; if enabled.value() { @@ -84,14 +128,14 @@ impl Parse for FileServiceDefinition { let openapi_content; syn::braced!(openapi_content in content); - // Parse output: "path" let key = openapi_content.parse::()?; if key != "output" { return Err(Error::new(key.span(), "Expected openapi output field")); } openapi_content.parse::()?; let path = openapi_content.parse::()?; - if !openapi_content.is_empty() { + + if openapi_content.peek(Token![,]) { openapi_content.parse::()?; } if !openapi_content.is_empty() { @@ -100,7 +144,13 @@ impl Parse for FileServiceDefinition { "Unexpected field in openapi config", )); } + openapi = Some(OpenApiConfig::WithPath(path.value())); + } else { + return Err(Error::new( + content.span(), + "Expected true, false, or { output: ... }", + )); } } "endpoints" => { @@ -110,7 +160,7 @@ impl Parse for FileServiceDefinition { while !endpoints_content.is_empty() { endpoints.push(endpoints_content.parse()?); - if !endpoints_content.is_empty() { + if endpoints_content.peek(Token![,]) { endpoints_content.parse::()?; } } @@ -118,12 +168,12 @@ impl Parse for FileServiceDefinition { _ => { return Err(Error::new( field_name.span(), - format!("Unknown field: {}", field_name), + format!("Unknown field: {field_name}"), )); } } - if !content.is_empty() { + if content.peek(Token![,]) { content.parse::()?; } } @@ -132,7 +182,6 @@ impl Parse for FileServiceDefinition { service_name: service_name .ok_or_else(|| Error::new(input.span(), "Missing service_name"))?, base_path: base_path.ok_or_else(|| Error::new(input.span(), "Missing base_path"))?, - body_limit, openapi, endpoints, }) @@ -141,24 +190,270 @@ impl Parse for FileServiceDefinition { impl Parse for Endpoint { fn parse(input: ParseStream) -> Result { - // Parse operation type (UPLOAD or DOWNLOAD) - let operation = if input.peek(kw::UPLOAD) { - input.parse::()?; - Operation::Upload - } else if input.peek(kw::DOWNLOAD) { - input.parse::()?; - Operation::Download + let operation_ident: Ident = input.parse()?; + let operation_name = operation_ident.to_string(); + + let auth = parse_auth(input)?; + let (name, path, path_params) = parse_endpoint_path(input)?; + + let operation = match operation_name.as_str() { + "UPLOAD" => { + if input.peek(token::Paren) { + return Err(Error::new( + input.span(), + "Expected `multipart { ... }` after UPLOAD path", + )); + } + let multipart_ident: Ident = input.parse()?; + if multipart_ident != "multipart" { + return Err(Error::new( + multipart_ident.span(), + "Expected `multipart { ... }` after UPLOAD path", + )); + } + let config = input.parse::()?; + input.parse::]>()?; + let response_type = input.parse::()?; + Operation::Upload { + config, + response_type: Box::new(response_type), + } + } + "DOWNLOAD" => { + let config = if input.peek(token::Brace) { + input.parse::()? + } else { + DownloadConfig::default() + }; + + if input.peek(Token![->]) { + return Err(Error::new( + input.span(), + "DOWNLOAD response types were removed in file_service v2; return ras_file_core::DownloadResponse from the generated trait", + )); + } + + Operation::Download { config } + } + _ => { + return Err(Error::new( + operation_ident.span(), + "Expected UPLOAD or DOWNLOAD", + )); + } + }; + + Ok(Self { + operation, + auth, + name, + path, + path_params, + }) + } +} + +impl Parse for UploadConfig { + fn parse(input: ParseStream) -> Result { + let content; + braced!(content in input); + + let mut max_total_bytes = None; + let mut reject_unknown_fields = true; + let mut parts = Vec::new(); + + while !content.is_empty() { + let key: Ident = content.parse()?; + content.parse::()?; + + match key.to_string().as_str() { + "max_total_bytes" => max_total_bytes = Some(parse_max_bytes(&content)?), + "reject_unknown_fields" => { + reject_unknown_fields = content.parse::()?.value(); + } + "parts" => { + let parts_content; + syn::bracketed!(parts_content in content); + while !parts_content.is_empty() { + parts.push(parts_content.parse()?); + if parts_content.peek(Token![,]) { + parts_content.parse::()?; + } + } + } + _ => { + return Err(Error::new( + key.span(), + "Expected max_total_bytes, reject_unknown_fields, or parts", + )); + } + } + + if content.peek(Token![,]) { + content.parse::()?; + } + } + + if parts.is_empty() { + return Err(Error::new( + input.span(), + "UPLOAD multipart requires at least one part", + )); + } + + Ok(Self { + max_total_bytes: max_total_bytes.ok_or_else(|| { + Error::new(input.span(), "UPLOAD multipart requires max_total_bytes") + })?, + reject_unknown_fields, + parts, + }) + } +} + +impl Parse for UploadPart { + fn parse(input: ParseStream) -> Result { + let kind_ident: Ident = input.parse()?; + let kind = match kind_ident.to_string().as_str() { + "file" => UploadPartKind::File, + "json" => UploadPartKind::Json, + "text" => UploadPartKind::Text, + _ => { + return Err(Error::new( + kind_ident.span(), + "Expected file, json, or text", + )); + } + }; + + let name: Ident = input.parse()?; + let ty = if kind == UploadPartKind::Json { + input.parse::()?; + Some(input.parse::()?) } else { - return Err(Error::new(input.span(), "Expected UPLOAD or DOWNLOAD")); + None }; - // Parse auth requirement - let auth = if input.peek(kw::UNAUTHORIZED) { - input.parse::()?; - AuthRequirement::Unauthorized - } else if input.peek(kw::WITH_PERMISSIONS) { - input.parse::()?; + if kind != UploadPartKind::Json && input.peek(Token![:]) { + return Err(Error::new( + input.span(), + "Only json parts declare a Rust type", + )); + } + + let content; + braced!(content in input); + + let mut required = false; + let mut max_count = 1usize; + let mut max_bytes = None; + let mut content_types = Vec::new(); + let mut filename = FilenamePolicy::Optional; + + while !content.is_empty() { + let key: Ident = content.parse()?; + content.parse::()?; + + match key.to_string().as_str() { + "required" => required = content.parse::()?.value(), + "max_count" => { + let lit = content.parse::()?; + max_count = lit.base10_parse()?; + if max_count == 0 { + return Err(Error::new( + lit.span(), + "max_count must be greater than zero", + )); + } + } + "max_bytes" => { + let lit = content.parse::()?; + max_bytes = Some(lit.base10_parse()?); + } + "content_types" => content_types = parse_string_array(&content)?, + "filename" => { + let value: Ident = content.parse()?; + filename = match value.to_string().as_str() { + "optional" => FilenamePolicy::Optional, + "required" => FilenamePolicy::Required, + "forbidden" => FilenamePolicy::Forbidden, + _ => { + return Err(Error::new( + value.span(), + "Expected optional, required, or forbidden", + )); + } + }; + } + _ => { + return Err(Error::new( + key.span(), + "Expected required, max_count, max_bytes, content_types, or filename", + )); + } + } + if content.peek(Token![,]) { + content.parse::()?; + } + } + + let max_bytes = max_bytes + .ok_or_else(|| Error::new(input.span(), "Every multipart part requires max_bytes"))?; + + if kind != UploadPartKind::File && filename != FilenamePolicy::Optional { + return Err(Error::new( + input.span(), + "filename policy is only valid for file parts", + )); + } + + Ok(Self { + kind, + name, + ty, + required, + max_count, + max_bytes, + content_types, + filename, + }) + } +} + +impl Parse for DownloadConfig { + fn parse(input: ParseStream) -> Result { + let content; + braced!(content in input); + + let mut config = DownloadConfig::default(); + + while !content.is_empty() { + let key: Ident = content.parse()?; + content.parse::()?; + + match key.to_string().as_str() { + "content_types" => config.content_types = parse_string_array(&content)?, + "ranges" => config.ranges = content.parse::()?.value(), + _ => { + return Err(Error::new(key.span(), "Expected content_types or ranges")); + } + } + + if content.peek(Token![,]) { + content.parse::()?; + } + } + + Ok(config) + } +} + +fn parse_auth(input: ParseStream) -> Result { + let auth_ident: Ident = input.parse()?; + match auth_ident.to_string().as_str() { + "UNAUTHORIZED" => Ok(AuthRequirement::Unauthorized), + "WITH_PERMISSIONS" => { let content; syn::parenthesized!(content in input); @@ -168,12 +463,10 @@ impl Parse for Endpoint { let mut permission_groups = Vec::new(); while !perms_content.is_empty() { - if perms_content.peek(syn::LitStr) { - // Single permission + if perms_content.peek(LitStr) { let perm: LitStr = perms_content.parse()?; permission_groups.push(vec![perm.value()]); } else if perms_content.peek(token::Bracket) { - // Permission group let group_content; syn::bracketed!(group_content in perms_content); @@ -181,11 +474,11 @@ impl Parse for Endpoint { while !group_content.is_empty() { let perm: LitStr = group_content.parse()?; group.push(perm.value()); - - if !group_content.is_empty() { + if group_content.peek(Token![,]) { group_content.parse::()?; } } + if group.is_empty() { return Err(Error::new( group_content.span(), @@ -193,9 +486,14 @@ impl Parse for Endpoint { )); } permission_groups.push(group); + } else { + return Err(Error::new( + perms_content.span(), + "Expected permission string or group", + )); } - if !perms_content.is_empty() { + if perms_content.peek(Token![,]) { perms_content.parse::()?; } } @@ -207,274 +505,235 @@ impl Parse for Endpoint { )); } - AuthRequirement::WithPermissions(permission_groups) - } else { - return Err(Error::new( - input.span(), - "Expected UNAUTHORIZED or WITH_PERMISSIONS", - )); - }; + Ok(AuthRequirement::WithPermissions(permission_groups)) + } + _ => Err(Error::new( + auth_ident.span(), + "Expected UNAUTHORIZED or WITH_PERMISSIONS", + )), + } +} - // Parse endpoint path and name - let (name, path, path_params) = if input.peek(Ident) && input.peek2(Token![/]) { - // Has custom path - let mut segments = Vec::new(); - let mut params = Vec::new(); - - // Parse path segments - while input.peek(Ident) || input.peek(Token![/]) { - if input.peek(Token![/]) { - input.parse::()?; - segments.push("/".to_string()); - } +fn parse_endpoint_path(input: ParseStream) -> Result<(Ident, LitStr, Vec)> { + let mut segments = Vec::new(); + let mut params = Vec::new(); + let mut method_parts = Vec::new(); - if input.peek(Ident) { - let ident: Ident = input.parse()?; - segments.push(ident.to_string()); - } else if input.peek(token::Brace) { - // Path parameter - let content; - braced!(content in input); - - let param_name: Ident = content.parse()?; - content.parse::()?; - let param_type: Type = content.parse()?; - - segments.push(format!("{{{}}}", param_name)); - params.push(PathParam { - name: param_name, - ty: param_type, - }); - } - } + let first: Ident = input.parse()?; + segments.push(first.to_string()); + method_parts.push(first.to_string()); - // Extract the method name from the path - let method_name = segments - .iter() - .filter(|s| !s.starts_with('/') && !s.starts_with('{')) - .cloned() - .collect::>() - .join("_"); + while input.peek(Token![/]) { + input.parse::()?; - let name = Ident::new(&method_name, input.span()); - let path = LitStr::new(&segments.join(""), input.span()); + if input.peek(token::Brace) { + let content; + braced!(content in input); - (name, Some(path), params) + let param_name: Ident = content.parse()?; + content.parse::()?; + let param_type: Type = content.parse()?; + + segments.push(format!("{{{}}}", param_name)); + method_parts.push(format!("by_{}", param_name)); + params.push(PathParam { + name: param_name, + ty: param_type, + }); } else { - // Just method name - let name: Ident = input.parse()?; - (name, None, Vec::new()) - }; + let segment: Ident = input.parse()?; + segments.push(segment.to_string()); + method_parts.push(segment.to_string()); + } + } - // Parse parameters and response - // For file operations, we expect empty parentheses - let _content; - syn::parenthesized!(_content in input); + let name = Ident::new(&method_parts.join("_"), first.span()); + let path = LitStr::new(&format!("/{}", segments.join("/")), first.span()); - let response_type = if input.peek(Token![->]) { - input.parse::]>()?; - Some(input.parse()?) - } else { - None - }; + Ok((name, path, params)) +} - Ok(Endpoint { - operation, - auth, - name, - path, - path_params, - response_type, - }) +fn parse_max_bytes(input: ParseStream) -> Result { + if input.peek(syn::LitInt) { + let lit = input.parse::()?; + Ok(MaxBytes::Limited(lit.base10_parse()?)) + } else { + let ident: Ident = input.parse()?; + if ident == "unlimited" { + Ok(MaxBytes::Unlimited) + } else { + Err(Error::new( + ident.span(), + "Expected byte limit integer or unlimited", + )) + } } } -mod kw { - syn::custom_keyword!(UPLOAD); - syn::custom_keyword!(DOWNLOAD); - syn::custom_keyword!(UNAUTHORIZED); - syn::custom_keyword!(WITH_PERMISSIONS); +fn parse_string_array(input: ParseStream) -> Result> { + let content; + syn::bracketed!(content in input); + + let mut values = Vec::new(); + while !content.is_empty() { + values.push(content.parse::()?.value()); + if content.peek(Token![,]) { + content.parse::()?; + } + } + + Ok(values) } #[cfg(test)] mod tests { use super::*; - use quote::{ToTokens, quote}; - - fn parse_definition(tokens: proc_macro2::TokenStream) -> FileServiceDefinition { - syn::parse2(tokens).expect("definition should parse") - } - fn parse_endpoint(tokens: proc_macro2::TokenStream) -> Endpoint { - syn::parse2(tokens).expect("endpoint should parse") - } - - fn parse_definition_error(tokens: proc_macro2::TokenStream) -> String { - syn::parse2::(tokens) - .unwrap_err() + fn parse_error(input: &str) -> String { + syn::parse_str::(input) + .expect_err("definition should fail to parse") .to_string() } - fn parse_endpoint_error(tokens: proc_macro2::TokenStream) -> String { - syn::parse2::(tokens).unwrap_err().to_string() - } - - fn type_tokens(ty: &Type) -> String { - ty.to_token_stream().to_string() + fn assert_parse_error_contains(input: &str, expected: &str) { + let error = parse_error(input); + assert!( + error.contains(expected), + "expected parse error to contain `{expected}`, got `{error}`" + ); } #[test] - fn definition_parses_body_limit_openapi_path_and_endpoint_variants() { - let definition = parse_definition(quote!({ - service_name: FilesApi, - base_path: "/api/files", - body_limit: 1048576, - openapi: { output: "target/openapi/files.json", }, - endpoints: [ - UPLOAD WITH_PERMISSIONS(["files:write"]) upload() -> UploadResponse, - DOWNLOAD UNAUTHORIZED files/{bucket: String}/download/{id: u64}() -> axum::response::Response, - ], - })); - - assert_eq!(definition.service_name.to_string(), "FilesApi"); - assert_eq!(definition.base_path.value(), "/api/files"); - assert_eq!(definition.body_limit, Some(1_048_576)); - assert!(matches!( - definition.openapi, - Some(OpenApiConfig::WithPath(ref path)) if path == "target/openapi/files.json" - )); - assert_eq!(definition.endpoints.len(), 2); - - let upload = &definition.endpoints[0]; - assert!(matches!(upload.operation, Operation::Upload)); - assert!(matches!( - upload.auth, - AuthRequirement::WithPermissions(ref groups) if groups == &vec![vec!["files:write".to_string()]] - )); - assert_eq!(upload.name.to_string(), "upload"); - assert!(upload.path.is_none()); - assert_eq!( - type_tokens(upload.response_type.as_ref().unwrap()), - "UploadResponse" + fn rejects_removed_body_limit_field() { + assert_parse_error_contains( + r#"{ + service_name: Files, + base_path: "/files", + body_limit: 1024, + endpoints: [] + }"#, + "body_limit was removed", ); + } - let download = &definition.endpoints[1]; - assert!(matches!(download.operation, Operation::Download)); - assert!(matches!(download.auth, AuthRequirement::Unauthorized)); - assert_eq!(download.name.to_string(), "files_download"); - assert_eq!( - download.path.as_ref().map(LitStr::value).as_deref(), - Some("files/{bucket}/download/{id}") - ); - assert_eq!(download.path_params.len(), 2); - assert_eq!(download.path_params[0].name.to_string(), "bucket"); - assert_eq!(type_tokens(&download.path_params[0].ty), "String"); - assert_eq!(download.path_params[1].name.to_string(), "id"); - assert_eq!(type_tokens(&download.path_params[1].ty), "u64"); - assert_eq!( - type_tokens(download.response_type.as_ref().unwrap()), - "axum :: response :: Response" + #[test] + fn rejects_v1_upload_without_multipart_contract() { + assert_parse_error_contains( + r#"{ + service_name: Files, + base_path: "/files", + endpoints: [ + UPLOAD UNAUTHORIZED upload() -> UploadResponse, + ] + }"#, + "Expected `multipart { ... }` after UPLOAD path", ); } #[test] - fn definition_parses_boolean_openapi_modes() { - let enabled = parse_definition(quote!({ - service_name: FilesApi, - base_path: "/api/files", - openapi: true, - endpoints: [], - })); - assert!(matches!(enabled.openapi, Some(OpenApiConfig::Enabled))); - - let disabled = parse_definition(quote!({ - service_name: FilesApi, - base_path: "/api/files", - openapi: false, - endpoints: [], - })); - assert!(disabled.openapi.is_none()); + fn rejects_upload_without_total_limit() { + assert_parse_error_contains( + r#"{ + service_name: Files, + base_path: "/files", + endpoints: [ + UPLOAD UNAUTHORIZED upload multipart { + parts: [ + file file { + required: true, + max_bytes: 1024, + }, + ], + } -> UploadResponse, + ] + }"#, + "UPLOAD multipart requires max_total_bytes", + ); } #[test] - fn endpoint_parses_permission_singletons_and_groups() { - let endpoint = parse_endpoint(quote! { - UPLOAD WITH_PERMISSIONS(["read", ["write", "verified"]]) upload() - }); - - assert!(matches!( - endpoint.auth, - AuthRequirement::WithPermissions(ref groups) - if groups == &vec![ - vec!["read".to_string()], - vec!["write".to_string(), "verified".to_string()], + fn rejects_upload_part_without_byte_limit() { + assert_parse_error_contains( + r#"{ + service_name: Files, + base_path: "/files", + endpoints: [ + UPLOAD UNAUTHORIZED upload multipart { + max_total_bytes: 1024, + parts: [ + file file { + required: true, + }, + ], + } -> UploadResponse, ] - )); - assert!(endpoint.response_type.is_none()); + }"#, + "Every multipart part requires max_bytes", + ); } #[test] - fn definition_rejects_missing_required_and_unknown_fields() { - let err = parse_definition_error(quote!({ - base_path: "/api", - endpoints: [], - })); - assert!(err.contains("Missing service_name")); - - let err = parse_definition_error(quote!({ - service_name: FilesApi, - endpoints: [], - })); - assert!(err.contains("Missing base_path")); - - let err = parse_definition_error(quote!({ - service_name: FilesApi, - base_path: "/api", - unexpected: true, - endpoints: [], - })); - assert!(err.contains("Unknown field")); + fn rejects_filename_policy_on_non_file_part() { + assert_parse_error_contains( + r#"{ + service_name: Files, + base_path: "/files", + endpoints: [ + UPLOAD UNAUTHORIZED upload multipart { + max_total_bytes: 1024, + parts: [ + text note { + required: true, + max_bytes: 128, + filename: forbidden, + }, + ], + } -> UploadResponse, + ] + }"#, + "filename policy is only valid for file parts", + ); } #[test] - fn openapi_object_rejects_unknown_keys_and_leftover_fields() { - let err = parse_definition_error(quote!({ - service_name: FilesApi, - base_path: "/api", - openapi: { path: "target/openapi.json" }, - endpoints: [], - })); - assert!(err.contains("Expected openapi output field")); - - let err = parse_definition_error(quote!({ - service_name: FilesApi, - base_path: "/api", - openapi: { output: "target/openapi.json", extra: "ignored" }, - endpoints: [], - })); - assert!(err.contains("Unexpected field in openapi config")); + fn rejects_download_response_type() { + assert_parse_error_contains( + r#"{ + service_name: Files, + base_path: "/files", + endpoints: [ + DOWNLOAD UNAUTHORIZED download/{file_id: String} -> (), + ] + }"#, + "DOWNLOAD response types were removed", + ); } #[test] - fn endpoint_rejects_missing_operation_auth_and_empty_permission_groups() { - let err = parse_endpoint_error(quote! { - STREAM UNAUTHORIZED upload() - }); - assert!(err.contains("Expected UPLOAD or DOWNLOAD")); - - let err = parse_endpoint_error(quote! { - UPLOAD upload() - }); - assert!(err.contains("Expected UNAUTHORIZED or WITH_PERMISSIONS")); - - let err = parse_endpoint_error(quote! { - UPLOAD WITH_PERMISSIONS([]) upload() - }); - assert!(err.contains("requires at least one permission")); - - let err = parse_endpoint_error(quote! { - UPLOAD WITH_PERMISSIONS([[]]) upload() - }); - assert!(err.contains("Permission groups cannot be empty")); + fn parses_unlimited_upload_and_reject_unknown_default() { + let definition = syn::parse_str::( + r#"{ + service_name: Files, + base_path: "/files", + endpoints: [ + UPLOAD UNAUTHORIZED upload multipart { + max_total_bytes: unlimited, + parts: [ + text note { + required: false, + max_bytes: 128, + }, + ], + } -> UploadResponse, + ] + }"#, + ) + .expect("definition should parse"); + + let Operation::Upload { config, .. } = &definition.endpoints[0].operation else { + panic!("expected upload endpoint"); + }; + assert!(matches!(config.max_total_bytes, MaxBytes::Unlimited)); + assert!(config.reject_unknown_fields); } } diff --git a/crates/rest/ras-file-macro/src/server.rs b/crates/rest/ras-file-macro/src/server.rs index 7f4cbae..21c1ac9 100644 --- a/crates/rest/ras-file-macro/src/server.rs +++ b/crates/rest/ras-file-macro/src/server.rs @@ -1,8 +1,10 @@ use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; -use syn::LitStr; -use crate::parser::{AuthRequirement, Endpoint, FileServiceDefinition, Operation}; +use crate::parser::{ + AuthRequirement, Endpoint, FileServiceDefinition, FilenamePolicy, MaxBytes, Operation, + PathParam, UploadConfig, UploadPart, UploadPartKind, +}; pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { let service_name = &definition.service_name; @@ -12,19 +14,18 @@ pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { let builder_name = format_ident!("{}Builder", service_name); let error_name = format_ident!("{}FileError", service_name); - let trait_methods = generate_trait_methods(&definition.endpoints, &error_name); - let handler_functions = generate_handlers( - &definition.endpoints, - &trait_name, - &error_name, - definition.body_limit, - ); - let router_construction = - generate_router_construction(&definition.endpoints, base_path, definition.body_limit); + let support_types = generate_support_types(definition); + let trait_methods = generate_trait_methods(definition, &trait_name); + let handler_functions = generate_handlers(definition, &trait_name); + let router_construction = generate_router_construction(&definition.endpoints, base_path); quote! { + pub type #error_name = ::ras_file_core::FileError; + + #support_types + #[async_trait::async_trait] - pub trait #trait_name: Send + Sync { + pub trait #trait_name: Send + Sync + 'static { #trait_methods } @@ -38,7 +39,7 @@ pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { impl #builder_name where - S: #trait_name + Clone + Send + Sync + 'static, + S: #trait_name + Send + Sync + 'static, A: ::ras_auth_core::AuthProvider + Clone + Send + Sync + 'static, { pub fn new(service: S) -> Self { @@ -104,83 +105,236 @@ pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { } } - #[derive(Debug, ::thiserror::Error)] - pub enum #error_name { - #[error("File not found")] - NotFound, - #[error("Upload failed: {0}")] - UploadFailed(String), - #[error("Download failed: {0}")] - DownloadFailed(String), - #[error("Invalid file format")] - InvalidFormat, - #[error("File too large")] - FileTooLarge, - #[error("Internal error: {0}")] - Internal(String), + fn __ras_file_error_response(error: ::ras_file_core::FileError) -> ::axum::response::Response { + use ::axum::response::IntoResponse; + let status = error.status(); + let message = error.client_message(); + ( + status, + ::axum::Json(::serde_json::json!({ "error": message })), + ).into_response() } - impl ::axum::response::IntoResponse for #error_name { - fn into_response(self) -> ::axum::response::Response { - use ::axum::http::StatusCode; - - let (status, message) = match self { - #error_name::NotFound => (StatusCode::NOT_FOUND, self.to_string()), - #error_name::InvalidFormat => (StatusCode::BAD_REQUEST, self.to_string()), - #error_name::FileTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()), - #error_name::UploadFailed(_) => (StatusCode::BAD_REQUEST, "Upload failed".to_string()), - #error_name::DownloadFailed(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Download failed".to_string()), - #error_name::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string()), - }; + fn __ras_file_multipart_error(error: ::axum::extract::multipart::MultipartError) -> ::ras_file_core::FileError { + if error.status() == ::axum::http::StatusCode::PAYLOAD_TOO_LARGE { + ::ras_file_core::FileError::PayloadTooLarge + } else { + ::ras_file_core::FileError::bad_request(error.body_text()) + } + } - <(::axum::http::StatusCode, String) as ::axum::response::IntoResponse>::into_response((status, message)) + fn __ras_file_download_response(response: ::ras_file_core::DownloadResponse) -> ::axum::response::Response { + use ::axum::response::IntoResponse; + let mut builder = ::axum::response::Response::builder().status(response.status); + let headers = builder.headers_mut().expect("response builder is valid before body"); + for (name, value) in response.headers.iter() { + headers.insert(name.clone(), value.clone()); } + + let body = match response.body { + ::ras_file_core::DownloadBody::Empty => ::axum::body::Body::empty(), + ::ras_file_core::DownloadBody::Bytes(bytes) => ::axum::body::Body::from(bytes), + ::ras_file_core::DownloadBody::Stream(stream) => ::axum::body::Body::from_stream(stream), + }; + + builder + .body(body) + .unwrap_or_else(|_| { + ( + ::axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "failed to build file response", + ).into_response() + }) + } + + async fn __ras_read_field_bytes( + mut field: ::axum::extract::multipart::Field<'_>, + max_bytes: u64, + remaining_total: Option, + ) -> ::ras_file_core::FileResult<::ras_file_core::bytes::Bytes> { + let mut bytes = Vec::new(); + + while let Some(chunk) = field.chunk().await.map_err(__ras_file_multipart_error)? { + let next_len = bytes + .len() + .checked_add(chunk.len()) + .ok_or(::ras_file_core::FileError::PayloadTooLarge)?; + + if next_len as u64 > max_bytes { + return Err(::ras_file_core::FileError::PayloadTooLarge); + } + + if let Some(remaining_total) = remaining_total { + if next_len as u64 > remaining_total { + return Err(::ras_file_core::FileError::PayloadTooLarge); + } + } + + bytes.extend_from_slice(&chunk); + } + + Ok(::ras_file_core::bytes::Bytes::from(bytes)) } #handler_functions } } -fn generate_trait_methods(endpoints: &[Endpoint], error_name: &Ident) -> TokenStream { - let methods = endpoints.iter().map(|endpoint| { - let method_name = &endpoint.name; - let auth_param = match &endpoint.auth { - AuthRequirement::Unauthorized => quote! {}, - AuthRequirement::WithPermissions(_) => { - quote! { user: &::ras_auth_core::AuthenticatedUser, } - } - }; - - let path_params = endpoint.path_params.iter().map(|param| { +fn generate_support_types(definition: &FileServiceDefinition) -> TokenStream { + let support = definition.endpoints.iter().flat_map(|endpoint| { + let path_struct = path_struct_name(&definition.service_name, endpoint); + let path_fields = endpoint.path_params.iter().map(|param| { let name = ¶m.name; let ty = ¶m.ty; - quote! { #name: #ty, } + quote! { pub #name: #ty } }); + let mut tokens = vec![quote! { + #[derive(Debug, Clone)] + pub struct #path_struct { + #(#path_fields),* + } + }]; + + if let Operation::Upload { config, .. } = &endpoint.operation { + let part_enum = part_enum_name(&definition.service_name, endpoint); + let has_file_part = config + .parts + .iter() + .any(|part| part.kind == UploadPartKind::File); + let variants = config.parts.iter().map(|part| { + let variant = part_variant_name(part); + match part.kind { + UploadPartKind::File => quote! { #variant(::ras_file_core::IncomingFile<'a>) }, + UploadPartKind::Json => { + let ty = part.ty.as_ref().expect("json part type"); + quote! { #variant(#ty) } + } + UploadPartKind::Text => quote! { #variant(String) }, + } + }); + let lifetime_variant = if has_file_part { + quote! {} + } else { + quote! { #[doc(hidden)] __Lifetime(std::marker::PhantomData<&'a ()>), } + }; + + let consumed_arms = config.parts.iter().map(|part| { + let variant = part_variant_name(part); + match part.kind { + UploadPartKind::File => quote! { Self::#variant(file) => file.is_finished() }, + UploadPartKind::Json | UploadPartKind::Text => { + quote! { Self::#variant(_) => true } + } + } + }); + let lifetime_consumed_arm = if has_file_part { + quote! {} + } else { + quote! { Self::__Lifetime(_) => true, } + }; + + let bytes_arms = config.parts.iter().map(|part| { + let variant = part_variant_name(part); + match part.kind { + UploadPartKind::File => quote! { Self::#variant(file) => file.bytes_read() }, + UploadPartKind::Json | UploadPartKind::Text => { + quote! { Self::#variant(_) => 0 } + } + } + }); + let lifetime_bytes_arm = if has_file_part { + quote! {} + } else { + quote! { Self::__Lifetime(_) => 0, } + }; + + tokens.push(quote! { + pub enum #part_enum<'a> { + #lifetime_variant + #(#variants),* + } + + impl #part_enum<'_> { + pub fn is_consumed(&self) -> bool { + match self { + #lifetime_consumed_arm + #(#consumed_arms),* + } + } + + pub fn bytes_read(&self) -> u64 { + match self { + #lifetime_bytes_arm + #(#bytes_arms),* + } + } + } + }); + } + + tokens + }); + + quote! { #(#support)* } +} + +fn generate_trait_methods(definition: &FileServiceDefinition, _trait_name: &Ident) -> TokenStream { + let methods = definition.endpoints.iter().map(|endpoint| { + let path_struct = path_struct_name(&definition.service_name, endpoint); + let handler_name = &endpoint.name; + match &endpoint.operation { - Operation::Upload => { - let response_type = endpoint - .response_type - .as_ref() - .map(|t| quote! { #t }) - .unwrap_or_else(|| quote! { () }); + Operation::Upload { response_type, .. } => { + let state_type = upload_state_type_name(endpoint); + let begin = format_ident!("{}_begin", handler_name); + let part = format_ident!("{}_part", handler_name); + let finish = format_ident!("{}_finish", handler_name); + let abort = format_ident!("{}_abort", handler_name); + let part_enum = part_enum_name(&definition.service_name, endpoint); quote! { - async fn #method_name( + type #state_type: Send; + + async fn #begin( &self, - #auth_param - #(#path_params)* - multipart: ::axum::extract::Multipart - ) -> Result<#response_type, #error_name>; + ctx: &::ras_file_core::FileRequestContext<'_>, + path: &#path_struct, + ) -> ::ras_file_core::FileResult; + + async fn #part( + &self, + ctx: &::ras_file_core::FileRequestContext<'_>, + path: &#path_struct, + state: &mut Self::#state_type, + part: &mut #part_enum<'_>, + ) -> ::ras_file_core::FileResult<()>; + + async fn #finish( + &self, + ctx: &::ras_file_core::FileRequestContext<'_>, + path: &#path_struct, + state: Self::#state_type, + summary: ::ras_file_core::UploadSummary, + ) -> ::ras_file_core::FileResult<::ras_file_core::JsonResponse<#response_type>>; + + async fn #abort( + &self, + _ctx: &::ras_file_core::FileRequestContext<'_>, + _path: &#path_struct, + _state: Self::#state_type, + _error: &::ras_file_core::FileError, + ) { + } } } - Operation::Download => { + Operation::Download { .. } => { quote! { - async fn #method_name( + async fn #handler_name( &self, - #auth_param - #(#path_params)* - ) -> Result; + ctx: &::ras_file_core::FileRequestContext<'_>, + path: #path_struct, + ) -> ::ras_file_core::FileResult<::ras_file_core::DownloadResponse>; } } } @@ -189,215 +343,577 @@ fn generate_trait_methods(endpoints: &[Endpoint], error_name: &Ident) -> TokenSt quote! { #(#methods)* } } -fn generate_handlers( - endpoints: &[Endpoint], +fn generate_handlers(definition: &FileServiceDefinition, trait_name: &Ident) -> TokenStream { + definition + .endpoints + .iter() + .map(|endpoint| match &endpoint.operation { + Operation::Upload { config, .. } => { + generate_upload_handler(definition, endpoint, config, trait_name) + } + Operation::Download { .. } => { + generate_download_handler(definition, endpoint, trait_name) + } + }) + .collect() +} + +fn generate_upload_handler( + definition: &FileServiceDefinition, + endpoint: &Endpoint, + config: &UploadConfig, trait_name: &Ident, - error_name: &Ident, - _body_limit: Option, ) -> TokenStream { - endpoints.iter().map(|endpoint| { - let handler_name = format_ident!("{}_handler", endpoint.name); - let method_name = &endpoint.name; + let handler_fn = format_ident!("{}_handler", endpoint.name); + let begin = format_ident!("{}_begin", endpoint.name); + let part_method = format_ident!("{}_part", endpoint.name); + let finish = format_ident!("{}_finish", endpoint.name); + let abort = format_ident!("{}_abort", endpoint.name); + let path = endpoint.path.value(); + let path_struct = path_struct_name(&definition.service_name, endpoint); + let part_enum = part_enum_name(&definition.service_name, endpoint); + let auth = generate_auth_check(&endpoint.auth); + let permission_check = generate_permission_check(&endpoint.auth); + let path_extraction = generate_path_extraction(&endpoint.path_params, &path_struct); + let content_length_limit = match &config.max_total_bytes { + MaxBytes::Limited(limit) => quote! { + if let Some(content_length) = parts.headers + .get(::axum::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + { + if content_length > #limit { + return __ras_file_error_response(::ras_file_core::FileError::PayloadTooLarge); + } + } + }, + MaxBytes::Unlimited => quote! {}, + }; + let max_total_limit = match &config.max_total_bytes { + MaxBytes::Limited(limit) => quote! { Some(#limit as u64) }, + MaxBytes::Unlimited => quote! { None }, + }; + let part_dispatch = generate_part_dispatch(config, &part_enum, &part_method, &abort); + let required_checks = generate_required_checks(config, &abort); + let part_count_vars = config.parts.iter().map(|part| { + let count_ident = part_count_ident(part); + quote! { let mut #count_ident: usize = 0; } + }); - let auth_check = generate_auth_check(&endpoint.auth); - let permission_check = generate_permission_check(&endpoint.auth); + quote! { + async fn #handler_fn( + state: ::axum::extract::State<( + ::std::sync::Arc, + Option<::std::sync::Arc>, + Option<::std::sync::Arc>>, + Option<::std::sync::Arc>>, + ::ras_auth_core::AuthTransportConfig, + )>, + req: ::axum::http::Request<::axum::body::Body>, + ) -> ::axum::response::Response + where + S: #trait_name + Send + Sync + 'static, + A: ::ras_auth_core::AuthProvider + Send + Sync + 'static, + { + use ::axum::extract::FromRequest; + use ::axum::response::IntoResponse; - let path_extraction = if !endpoint.path_params.is_empty() { - let param_names: Vec<_> = endpoint.path_params.iter().map(|p| &p.name).collect(); - let param_types: Vec<_> = endpoint.path_params.iter().map(|p| &p.ty).collect(); - quote! { - let ::axum::extract::Path((#(#param_names,)*)) = match <::axum::extract::Path<(#(#param_types,)*)> as ::axum::extract::FromRequestParts<_>>::from_request_parts(&mut parts, &state).await { - Ok(path) => path, - Err(e) => return <(::axum::http::StatusCode, String) as ::axum::response::IntoResponse>::into_response( - (::axum::http::StatusCode::BAD_REQUEST, format!("Invalid path parameters: {}", e)) - ), - }; + let start = std::time::Instant::now(); + let method = "POST"; + let request_path = req.uri().path().to_string(); + let (mut parts, body) = req.into_parts(); + + if let Some(tracker) = &state.2 { + let tracker_headers = + ::ras_auth_core::redact_sensitive_headers_for_auth_transport(&parts.headers, &state.4); + tracker(&tracker_headers, method, &request_path); } - } else { - quote! {} - }; - let method_call = match &endpoint.operation { - Operation::Upload => { - let auth_arg = match &endpoint.auth { - AuthRequirement::Unauthorized => quote! {}, - AuthRequirement::WithPermissions(_) => quote! { &user, }, - }; - let path_args = endpoint.path_params.iter().map(|p| { - let name = &p.name; - quote! { #name, } - }); + #auth + #permission_check + #path_extraction - quote! { - service.0.#method_name(#auth_arg #(#path_args)* multipart).await - } + #content_length_limit + + let content_type = parts.headers + .get(::axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !content_type.starts_with("multipart/form-data") { + return __ras_file_error_response(::ras_file_core::FileError::unsupported_media_type( + "expected multipart/form-data", + )); } - Operation::Download => { - let auth_arg = match &endpoint.auth { - AuthRequirement::Unauthorized => quote! {}, - AuthRequirement::WithPermissions(_) => quote! { &user, }, - }; - let path_args = endpoint.path_params.iter().map(|p| { - let name = &p.name; - quote! { #name, } - }); - quote! { - service.0.#method_name(#auth_arg #(#path_args)*).await + let request_headers = parts.headers.clone(); + let ctx = ::ras_file_core::FileRequestContext::new( + method, + &request_path, + #path, + &request_headers, + user.as_ref(), + ); + + let req = ::axum::http::Request::from_parts(parts, body); + let mut multipart = match <::axum::extract::Multipart as FromRequest<_>>::from_request(req, &state).await { + Ok(multipart) => multipart, + Err(rejection) => return rejection.into_response(), + }; + + let service = &state.0.0; + let mut upload_state = Some(match service.#begin(&ctx, &path_value).await { + Ok(upload_state) => upload_state, + Err(error) => return __ras_file_error_response(error), + }); + + let mut summary = ::ras_file_core::UploadSummary::default(); + let mut total_bytes: u64 = 0; + let max_total_bytes: Option = #max_total_limit; + #(#part_count_vars)* + + while let Some(mut field) = match multipart.next_field().await { + Ok(field) => field, + Err(error) => { + let error = __ras_file_multipart_error(error); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); } + } { + let field_name = field.name().unwrap_or("").to_string(); + #part_dispatch } - }; - let multipart_extraction = if let Operation::Upload = &endpoint.operation { - // Always use the same extraction, body limit is applied at router level - quote! { - let multipart = match <::axum::extract::Multipart as ::axum::extract::FromRequest<_, _>>::from_request(req, &state).await { - Ok(mp) => mp, - Err(e) => return <(::axum::http::StatusCode, String) as ::axum::response::IntoResponse>::into_response((::axum::http::StatusCode::BAD_REQUEST, format!("Invalid multipart data: {}", e))), - }; + #required_checks + + let upload_state = upload_state.take().expect("upload state is present before finish"); + let response = match service.#finish(&ctx, &path_value, upload_state, summary).await { + Ok(response) => response, + Err(error) => return __ras_file_error_response(error), + }; + + if let Some(tracker) = &state.3 { + tracker(method, &request_path, start.elapsed()); } - } else { - quote! {} - }; - match &endpoint.operation { - Operation::Upload => quote! { - async fn #handler_name( - state: ::axum::extract::State<( - ::std::sync::Arc, - Option<::std::sync::Arc>, - Option<::std::sync::Arc>>, - Option<::std::sync::Arc>>, - ::ras_auth_core::AuthTransportConfig, - )>, - mut req: ::axum::http::Request<::axum::body::Body>, - ) -> impl ::axum::response::IntoResponse - where - S: #trait_name + Send + Sync + 'static, - A: ::ras_auth_core::AuthProvider + Send + Sync + 'static, - { - let start = std::time::Instant::now(); - let method = "POST"; - let path = req.uri().path().to_string(); - - let (mut parts, body) = req.into_parts(); - - // Track usage - if let Some(tracker) = &state.2 { - let tracker_headers = - ::ras_auth_core::redact_sensitive_headers_for_auth_transport(&parts.headers, &state.4); - tracker(&tracker_headers, method, &path); + let (status, headers, body) = response.into_parts(); + let mut response = (status, ::axum::Json(body)).into_response(); + response.headers_mut().extend(headers); + response + } + } +} + +fn generate_part_dispatch( + config: &UploadConfig, + part_enum: &Ident, + part_method: &Ident, + abort: &Ident, +) -> TokenStream { + let arms = config + .parts + .iter() + .map(|part| generate_part_arm(part, part_enum, part_method, abort)); + let unknown = if config.reject_unknown_fields { + quote! { + { + let error = ::ras_file_core::FileError::bad_request(format!("unknown multipart field `{}`", field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + } + } else { + quote! { + { + let mut ignored_bytes: u64 = 0; + loop { + let maybe_chunk = match field.chunk().await { + Ok(chunk) => chunk, + Err(error) => { + let error = __ras_file_multipart_error(error); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }; + + let Some(chunk) = maybe_chunk else { + break; + }; + + ignored_bytes = ignored_bytes.saturating_add(chunk.len() as u64); + if let Some(max_total) = max_total_bytes { + if total_bytes.saturating_add(ignored_bytes) > max_total { + let error = ::ras_file_core::FileError::PayloadTooLarge; + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } } + } + total_bytes = total_bytes.saturating_add(ignored_bytes); + } + } + }; + + quote! { + match field_name.as_str() { + #(#arms,)* + _ => #unknown, + } + } +} - #auth_check - #permission_check +fn generate_part_arm( + part: &UploadPart, + part_enum: &Ident, + part_method: &Ident, + abort: &Ident, +) -> TokenStream { + let field_name = part.name.to_string(); + let count_ident = part_count_ident(part); + let max_count = part.max_count; + let max_bytes = part.max_bytes; + let variant = part_variant_name(part); - #path_extraction + let content_type_check = if part.content_types.is_empty() { + quote! {} + } else { + let allowed = part.content_types.iter(); + quote! { + let content_type = field.content_type().unwrap_or("").to_string(); + if ![#(#allowed),*].contains(&content_type.as_str()) { + let error = ::ras_file_core::FileError::unsupported_media_type( + format!("unsupported content type `{}` for field `{}`", content_type, #field_name), + ); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + } + }; - // Reconstruct request for multipart extraction - let req = ::axum::http::Request::from_parts(parts, body); - #multipart_extraction + let count_check = quote! { + if #count_ident >= #max_count { + let error = ::ras_file_core::FileError::bad_request(format!("too many `{}` parts", #field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + #count_ident += 1; + }; - let service = &state.0; - let result = #method_call; + match part.kind { + UploadPartKind::File => { + let filename_check = match part.filename { + FilenamePolicy::Optional => quote! {}, + FilenamePolicy::Required => quote! { + if field.file_name().is_none() { + let error = ::ras_file_core::FileError::bad_request(format!("field `{}` requires a filename", #field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }, + FilenamePolicy::Forbidden => quote! { + if field.file_name().is_some() { + let error = ::ras_file_core::FileError::bad_request(format!("field `{}` must not include a filename", #field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }, + }; - // Track duration - if let Some(tracker) = &state.3 { - tracker(method, &path, start.elapsed()); + quote! { + #field_name => { + #count_check + #content_type_check + #filename_check + + let remaining_total = max_total_bytes + .map(|max| max.saturating_sub(total_bytes)) + .unwrap_or(u64::MAX); + let part_limit = std::cmp::min(#max_bytes as u64, remaining_total); + let file_name = field.file_name().map(ToString::to_string); + let content_type = field.content_type().map(ToString::to_string); + let headers = field.headers().clone(); + let stream = ::ras_file_core::futures_util::StreamExt::map(field, |chunk| { + chunk.map_err(__ras_file_multipart_error) + }); + let file = ::ras_file_core::IncomingFile::new( + #field_name, + file_name, + content_type, + headers, + part_limit, + Box::pin(stream), + ); + let mut part = #part_enum::#variant(file); + + let part_result = { + let upload_state = upload_state.as_mut().expect("upload state is present while handling parts"); + service.#part_method(&ctx, &path_value, upload_state, &mut part).await + }; + if let Err(error) = part_result { + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + + if !part.is_consumed() { + let error = ::ras_file_core::FileError::handler_contract(format!("handler did not consume file field `{}`", #field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); } - match result { - Ok(response) => <::axum::Json<_> as ::axum::response::IntoResponse>::into_response(::axum::Json(response)), - Err(e) => <#error_name as ::axum::response::IntoResponse>::into_response(e), + let bytes_read = part.bytes_read(); + if let Some(max_total) = max_total_bytes { + if total_bytes.saturating_add(bytes_read) > max_total { + let error = ::ras_file_core::FileError::PayloadTooLarge; + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } } + total_bytes = total_bytes.saturating_add(bytes_read); + summary.record(#field_name, bytes_read); } - }, - Operation::Download => quote! { - async fn #handler_name( - state: ::axum::extract::State<( - ::std::sync::Arc, - Option<::std::sync::Arc>, - Option<::std::sync::Arc>>, - Option<::std::sync::Arc>>, - ::ras_auth_core::AuthTransportConfig, - )>, - req: ::axum::http::Request<::axum::body::Body>, - ) -> impl ::axum::response::IntoResponse - where - S: #trait_name + Send + Sync + 'static, - A: ::ras_auth_core::AuthProvider + Send + Sync + 'static, - { - let start = std::time::Instant::now(); - let method = "GET"; - let path = req.uri().path().to_string(); - - let (mut parts, _body) = req.into_parts(); - - // Track usage - if let Some(tracker) = &state.2 { - let tracker_headers = - ::ras_auth_core::redact_sensitive_headers_for_auth_transport(&parts.headers, &state.4); - tracker(&tracker_headers, method, &path); + } + } + UploadPartKind::Json => { + let ty = part.ty.as_ref().expect("json part type"); + quote! { + #field_name => { + #count_check + #content_type_check + let bytes = match __ras_read_field_bytes(field, #max_bytes as u64, max_total_bytes.map(|max| max.saturating_sub(total_bytes))).await { + Ok(bytes) => bytes, + Err(error) => { + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }; + let value: #ty = match ::serde_json::from_slice(&bytes) { + Ok(value) => value, + Err(error) => { + let error = ::ras_file_core::FileError::bad_request(format!("invalid JSON in field `{}`: {}", #field_name, error)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }; + let mut part = #part_enum::#variant(value); + let part_result = { + let upload_state = upload_state.as_mut().expect("upload state is present while handling parts"); + service.#part_method(&ctx, &path_value, upload_state, &mut part).await + }; + if let Err(error) = part_result { + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); } + total_bytes = total_bytes.saturating_add(bytes.len() as u64); + summary.record(#field_name, bytes.len() as u64); + } + } + } + UploadPartKind::Text => { + quote! { + #field_name => { + #count_check + #content_type_check + let bytes = match __ras_read_field_bytes(field, #max_bytes as u64, max_total_bytes.map(|max| max.saturating_sub(total_bytes))).await { + Ok(bytes) => bytes, + Err(error) => { + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }; + let value = match String::from_utf8(bytes.to_vec()) { + Ok(value) => value, + Err(error) => { + let error = ::ras_file_core::FileError::bad_request(format!("invalid UTF-8 in field `{}`: {}", #field_name, error)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }; + let mut part = #part_enum::#variant(value); + let part_result = { + let upload_state = upload_state.as_mut().expect("upload state is present while handling parts"); + service.#part_method(&ctx, &path_value, upload_state, &mut part).await + }; + if let Err(error) = part_result { + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + total_bytes = total_bytes.saturating_add(bytes.len() as u64); + summary.record(#field_name, bytes.len() as u64); + } + } + } + } +} + +fn generate_required_checks(config: &UploadConfig, abort: &Ident) -> TokenStream { + let checks = config.parts.iter().filter(|part| part.required).map(|part| { + let field_name = part.name.to_string(); + let count_ident = part_count_ident(part); + quote! { + if #count_ident == 0 { + let error = ::ras_file_core::FileError::bad_request(format!("missing required multipart field `{}`", #field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + } + }); - #auth_check - #permission_check + quote! { #(#checks)* } +} - #path_extraction +fn generate_download_handler( + definition: &FileServiceDefinition, + endpoint: &Endpoint, + trait_name: &Ident, +) -> TokenStream { + let handler_fn = format_ident!("{}_handler", endpoint.name); + let handler_name = &endpoint.name; + let path = endpoint.path.value(); + let path_struct = path_struct_name(&definition.service_name, endpoint); + let auth = generate_auth_check(&endpoint.auth); + let permission_check = generate_permission_check(&endpoint.auth); + let path_extraction = generate_path_extraction(&endpoint.path_params, &path_struct); - let service = &state.0; - let result = #method_call; + quote! { + async fn #handler_fn( + state: ::axum::extract::State<( + ::std::sync::Arc, + Option<::std::sync::Arc>, + Option<::std::sync::Arc>>, + Option<::std::sync::Arc>>, + ::ras_auth_core::AuthTransportConfig, + )>, + req: ::axum::http::Request<::axum::body::Body>, + ) -> ::axum::response::Response + where + S: #trait_name + Send + Sync + 'static, + A: ::ras_auth_core::AuthProvider + Send + Sync + 'static, + { + let start = std::time::Instant::now(); + let method = "GET"; + let request_path = req.uri().path().to_string(); + let (mut parts, _body) = req.into_parts(); - // Track duration - if let Some(tracker) = &state.3 { - tracker(method, &path, start.elapsed()); - } + if let Some(tracker) = &state.2 { + let tracker_headers = + ::ras_auth_core::redact_sensitive_headers_for_auth_transport(&parts.headers, &state.4); + tracker(&tracker_headers, method, &request_path); + } + + #auth + #permission_check + #path_extraction + + let ctx = ::ras_file_core::FileRequestContext::new( + method, + &request_path, + #path, + &parts.headers, + user.as_ref(), + ); + + let service = &state.0.0; + let response = match service.#handler_name(&ctx, path_value).await { + Ok(response) => response, + Err(error) => return __ras_file_error_response(error), + }; + + if let Some(tracker) = &state.3 { + tracker(method, &request_path, start.elapsed()); + } + + __ras_file_download_response(response) + } + } +} + +fn generate_path_extraction(path_params: &[PathParam], path_struct: &Ident) -> TokenStream { + if path_params.is_empty() { + return quote! { let path_value = #path_struct {}; }; + } + + let fields = path_params.iter().enumerate().map(|(idx, param)| { + let name = ¶m.name; + if path_params.len() == 1 { + quote! { #name: path_params } + } else { + let idx = syn::Index::from(idx); + quote! { #name: path_params.#idx } + } + }); - match result { - Ok(response) => <_ as ::axum::response::IntoResponse>::into_response(response), - Err(e) => <#error_name as ::axum::response::IntoResponse>::into_response(e), + let extraction = if path_params.len() == 1 { + let ty = &path_params[0].ty; + quote! { + let ::axum::extract::Path(path_params) = + match <::axum::extract::Path<#ty> as ::axum::extract::FromRequestParts<_>>::from_request_parts(&mut parts, &state).await { + Ok(path) => path, + Err(error) => { + return __ras_file_error_response(::ras_file_core::FileError::bad_request(format!("invalid path parameters: {}", error))); } - } - }, + }; + } + } else { + let tys = path_params.iter().map(|param| ¶m.ty); + quote! { + let ::axum::extract::Path(path_params) = + match <::axum::extract::Path<(#(#tys),*)> as ::axum::extract::FromRequestParts<_>>::from_request_parts(&mut parts, &state).await { + Ok(path) => path, + Err(error) => { + return __ras_file_error_response(::ras_file_core::FileError::bad_request(format!("invalid path parameters: {}", error))); + } + }; } - }).collect() + }; + + quote! { + #extraction + let path_value = #path_struct { + #(#fields),* + }; + } } fn generate_auth_check(auth: &AuthRequirement) -> TokenStream { match auth { AuthRequirement::Unauthorized => quote! { - let user = ::ras_auth_core::AuthenticatedUser { - user_id: String::new(), - permissions: ::std::collections::HashSet::new(), - metadata: None, - }; + let user: Option<::ras_auth_core::AuthenticatedUser> = None; }, AuthRequirement::WithPermissions(_) => quote! { let auth_provider = match state.1.as_ref() { Some(provider) => provider, - None => return <(::axum::http::StatusCode, &str) as ::axum::response::IntoResponse>::into_response( - (::axum::http::StatusCode::INTERNAL_SERVER_ERROR, "No auth provider configured") - ), + None => return __ras_file_error_response(::ras_file_core::FileError::Internal), }; let auth_credential = match ::ras_auth_core::extract_auth_credential(&parts.headers, &state.4) { Ok(credential) => credential, - Err(_) => return <(::axum::http::StatusCode, &str) as ::axum::response::IntoResponse>::into_response( - (::axum::http::StatusCode::UNAUTHORIZED, "Missing or invalid authorization header") - ), + Err(_) => return __ras_file_error_response(::ras_file_core::FileError::Unauthorized), }; - if let Err(_) = ::ras_auth_core::validate_csrf_for_credential(method, &parts.headers, &auth_credential, &state.4) { - return <(::axum::http::StatusCode, &str) as ::axum::response::IntoResponse>::into_response( - (::axum::http::StatusCode::FORBIDDEN, "CSRF validation failed") - ); + if ::ras_auth_core::validate_csrf_for_credential(method, &parts.headers, &auth_credential, &state.4).is_err() { + return __ras_file_error_response(::ras_file_core::FileError::Forbidden); } let user = match auth_provider.authenticate(auth_credential.token().to_string()).await { - Ok(u) => u, - Err(_) => return <(::axum::http::StatusCode, &str) as ::axum::response::IntoResponse>::into_response( - (::axum::http::StatusCode::UNAUTHORIZED, "Invalid authentication") - ), + Ok(user) => Some(user), + Err(_) => return __ras_file_error_response(::ras_file_core::FileError::Unauthorized), }; }, } @@ -407,74 +923,112 @@ fn generate_permission_check(auth: &AuthRequirement) -> TokenStream { match auth { AuthRequirement::Unauthorized => quote! {}, AuthRequirement::WithPermissions(permission_groups) => { - let group_checks = permission_groups.iter().map(|group| { - let permission_checks = group.iter().map(|perm| { - quote! { user.permissions.contains(#perm) } - }); - quote! { #(#permission_checks)&&* } + let groups = permission_groups.iter().map(|group| { + let perms = group.iter(); + quote! { vec![#(#perms.to_string()),*] } }); quote! { - let has_permission = #(#group_checks)||*; + let required_permission_groups: Vec> = vec![#(#groups),*]; + let authenticated_user = user.as_ref().expect("authenticated user exists after auth check"); + let has_permission = required_permission_groups.iter().any(|group| { + group.is_empty() || auth_provider.check_permissions(authenticated_user, group).is_ok() + }); if !has_permission { - return <(::axum::http::StatusCode, &str) as ::axum::response::IntoResponse>::into_response((::axum::http::StatusCode::FORBIDDEN, "Insufficient permissions")); + return __ras_file_error_response(::ras_file_core::FileError::Forbidden); } } } } } -fn generate_router_construction( - endpoints: &[Endpoint], - base_path: &LitStr, - body_limit: Option, -) -> TokenStream { +fn generate_router_construction(endpoints: &[Endpoint], base_path: &syn::LitStr) -> TokenStream { let routes = endpoints.iter().map(|endpoint| { let handler_name = format_ident!("{}_handler", endpoint.name); - let path = endpoint - .path - .as_ref() - .map(|p| { - let path_str = p.value(); - if path_str.starts_with('/') { - path_str - } else { - format!("/{}", path_str) - } - }) - .unwrap_or_else(|| format!("/{}", endpoint.name)); + let path = endpoint.path.value(); - let http_method = match &endpoint.operation { - Operation::Upload => quote! { post }, - Operation::Download => quote! { get }, - }; - - quote! { - .route(#path, #http_method(#handler_name::)) + match &endpoint.operation { + Operation::Upload { config, .. } => { + let limit_layer = match &config.max_total_bytes { + MaxBytes::Limited(limit) => { + let limit = *limit as usize; + quote! { .layer(::axum::extract::DefaultBodyLimit::max(#limit)) } + } + MaxBytes::Unlimited => { + quote! { .layer(::axum::extract::DefaultBodyLimit::disable()) } + } + }; + quote! { + .route(#path, post(#handler_name::)#limit_layer) + } + } + Operation::Download { .. } => quote! { + .route(#path, get(#handler_name::)) + }, } }); - let router_with_limit = if let Some(limit) = body_limit { - let limit_usize = limit as usize; - quote! { - ::axum::Router::new() - #(#routes)* - .layer(::axum::extract::DefaultBodyLimit::max(#limit_usize)) - .with_state((service, auth_provider, usage_tracker, duration_tracker, auth_transport)) - } - } else { - quote! { - ::axum::Router::new() - #(#routes)* - .with_state((service, auth_provider, usage_tracker, duration_tracker, auth_transport)) - } - }; - quote! { ::axum::Router::new() .nest( #base_path, - #router_with_limit + ::axum::Router::new() + #(#routes)* + .with_state((service, auth_provider, usage_tracker, duration_tracker, auth_transport)) ) } } + +fn path_struct_name(service_name: &Ident, endpoint: &Endpoint) -> Ident { + format_ident!( + "{}{}Path", + service_name, + pascal_ident_segment(&endpoint.name.to_string()) + ) +} + +fn part_enum_name(service_name: &Ident, endpoint: &Endpoint) -> Ident { + format_ident!( + "{}{}Part", + service_name, + pascal_ident_segment(&endpoint.name.to_string()) + ) +} + +fn upload_state_type_name(endpoint: &Endpoint) -> Ident { + format_ident!("{}State", pascal_ident_segment(&endpoint.name.to_string())) +} + +pub fn part_variant_name(part: &UploadPart) -> Ident { + format_ident!("{}", pascal_ident_segment(&part.name.to_string())) +} + +fn part_count_ident(part: &UploadPart) -> Ident { + format_ident!("{}_count", part.name) +} + +fn pascal_ident_segment(value: &str) -> String { + let mut out = String::new(); + let mut uppercase_next = true; + + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + if uppercase_next { + out.push(ch.to_ascii_uppercase()); + uppercase_next = false; + } else { + out.push(ch); + } + } else { + uppercase_next = true; + } + } + + if out.is_empty() { + "Generated".to_string() + } else if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { + format!("V{out}") + } else { + out + } +} diff --git a/crates/rest/ras-file-macro/tests/drain_test.rs b/crates/rest/ras-file-macro/tests/drain_test.rs new file mode 100644 index 0000000..f50bbe2 --- /dev/null +++ b/crates/rest/ras-file-macro/tests/drain_test.rs @@ -0,0 +1,165 @@ +use std::sync::{Arc, Mutex}; + +use axum::http::StatusCode; +use axum_test::multipart::MultipartForm; +use ras_file_core::{FileError, FileRequestContext, JsonResponse, bytes::Bytes}; +use ras_file_macro::file_service; +use serde::{Deserialize, Serialize}; + +mod support; +use support::{MockAuthProvider, mock_http_server}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct DrainUploadResponse { + title: String, +} + +file_service!({ + service_name: DrainDemo, + base_path: "/drain", + endpoints: [ + UPLOAD UNAUTHORIZED upload multipart { + max_total_bytes: 2048, + reject_unknown_fields: false, + parts: [ + text title { + required: true, + max_bytes: 1024, + }, + ], + } -> DrainUploadResponse, + ] +}); + +#[derive(Clone, Default)] +struct DrainImpl { + aborts: Arc>, +} + +#[async_trait::async_trait] +impl DrainDemoTrait for DrainImpl { + type UploadState = Option; + + async fn upload_begin( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DrainDemoUploadPath, + ) -> ras_file_core::FileResult { + Ok(None) + } + + async fn upload_part( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DrainDemoUploadPath, + state: &mut Self::UploadState, + part: &mut DrainDemoUploadPart<'_>, + ) -> ras_file_core::FileResult<()> { + match part { + DrainDemoUploadPart::Title(title) => *state = Some(title.clone()), + DrainDemoUploadPart::__Lifetime(_) => {} + } + Ok(()) + } + + async fn upload_finish( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DrainDemoUploadPath, + state: Self::UploadState, + _summary: ras_file_core::UploadSummary, + ) -> ras_file_core::FileResult> { + Ok(JsonResponse::ok(DrainUploadResponse { + title: state.ok_or_else(|| FileError::bad_request("title missing"))?, + })) + } + + async fn upload_abort( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DrainDemoUploadPath, + _state: Self::UploadState, + _error: &FileError, + ) { + *self.aborts.lock().unwrap() += 1; + } +} + +fn server(service: DrainImpl) -> axum_test::TestServer { + mock_http_server(DrainDemoBuilder::::new(service).build()) +} + +fn raw_multipart(fields: &[(&str, Vec)]) -> (String, Bytes) { + let boundary = "ras-file-test-boundary"; + let mut body = Vec::new(); + + for (name, bytes) in fields { + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(), + ); + body.extend_from_slice(bytes); + body.extend_from_slice(b"\r\n"); + } + + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + ( + format!("multipart/form-data; boundary={boundary}"), + body.into(), + ) +} + +#[tokio::test] +async fn upload_drains_unknown_fields_when_reject_unknown_fields_is_false() { + let service = DrainImpl::default(); + let aborts = service.aborts.clone(); + let server = server(service); + + let form = MultipartForm::new() + .add_text("ignored", "1234567890") + .add_text("title", "demo"); + + let response = server.post("/drain/upload").multipart(form).await; + + response.assert_status_ok(); + let uploaded: DrainUploadResponse = response.json(); + assert_eq!(uploaded.title, "demo"); + assert_eq!(*aborts.lock().unwrap(), 0); +} + +#[tokio::test] +async fn upload_rejects_when_drained_unknown_field_exceeds_total_limit() { + let service = DrainImpl::default(); + let aborts = service.aborts.clone(); + let server = server(service); + + let (content_type, body) = raw_multipart(&[("ignored", vec![b'x'; 3000])]); + + let response = server + .post("/drain/upload") + .content_type(&content_type) + .bytes(body) + .await; + + response.assert_status(StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(*aborts.lock().unwrap(), 1); +} + +#[tokio::test] +async fn upload_rejects_when_known_part_pushes_drained_total_over_limit() { + let service = DrainImpl::default(); + let aborts = service.aborts.clone(); + let server = server(service); + + let (content_type, body) = + raw_multipart(&[("ignored", vec![b'x'; 1500]), ("title", vec![b'y'; 700])]); + + let response = server + .post("/drain/upload") + .content_type(&content_type) + .bytes(body) + .await; + + response.assert_status(StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(*aborts.lock().unwrap(), 1); +} diff --git a/crates/rest/ras-file-macro/tests/e2e.rs b/crates/rest/ras-file-macro/tests/e2e.rs index 91a870a..03f7616 100644 --- a/crates/rest/ras-file-macro/tests/e2e.rs +++ b/crates/rest/ras-file-macro/tests/e2e.rs @@ -1,287 +1,632 @@ -//! End-to-end test for the file_service! macro: in-memory axum-test request -//! -> axum router -> handler. Exercises upload + download with byte-equality -//! and a missing-token rejection. - use std::sync::{Arc, Mutex}; -use axum::{ - body::Body, - http::StatusCode, - response::{IntoResponse, Response}, +use axum::http::StatusCode; +use axum_test::{ + TestServer, + multipart::{MultipartForm, Part}, +}; +use ras_file_core::{ + DownloadResponse, FileError, FileRequestContext, IncomingFile, JsonResponse, bytes::Bytes, }; -use axum_test::multipart::{MultipartForm, Part}; -use ras_auth_core::{AuthCookieConfig, AuthenticatedUser, CsrfConfig}; use ras_file_macro::file_service; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; mod support; use support::{MockAuthProvider, mock_http_server}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct UploadMetadata { + title: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] struct UploadResponse { file_id: String, size: u64, + title: String, + comment: Option, } file_service!({ service_name: Demo, base_path: "/files", + openapi: true, endpoints: [ - DOWNLOAD UNAUTHORIZED download/{file_id: String}(), - DOWNLOAD WITH_PERMISSIONS(["user"]) download_secure/{file_id: String}(), - UPLOAD WITH_PERMISSIONS(["user"]) upload() -> UploadResponse, + UPLOAD WITH_PERMISSIONS(["user"]) upload multipart { + max_total_bytes: 2048, + reject_unknown_fields: true, + parts: [ + file file { + required: true, + max_count: 1, + max_bytes: 1024, + content_types: ["application/octet-stream"], + filename: required, + }, + json metadata: UploadMetadata { + required: true, + max_bytes: 256, + content_types: ["application/json"], + }, + text comment { + required: false, + max_bytes: 128, + }, + ], + } -> UploadResponse, + DOWNLOAD UNAUTHORIZED download/{file_id: String} { + content_types: ["application/octet-stream"], + ranges: true, + }, ] }); +#[derive(Default)] +struct UploadState { + bytes: Vec, + metadata: Option, + comment: Option, +} + type Storage = Arc)>>>; #[derive(Clone)] struct DemoImpl { storage: Storage, + consume_file: bool, + aborts: Arc>, + begins: Arc>, +} + +impl DemoImpl { + fn new() -> Self { + Self { + storage: Arc::new(Mutex::new(Vec::new())), + consume_file: true, + aborts: Arc::new(Mutex::new(0)), + begins: Arc::new(Mutex::new(0)), + } + } + + fn without_file_consumption(mut self) -> Self { + self.consume_file = false; + self + } } #[async_trait::async_trait] impl DemoTrait for DemoImpl { - async fn download(&self, file_id: String) -> Result { - let store = self.storage.lock().unwrap(); - let bytes = store - .iter() - .find_map(|(id, data)| (id == &file_id).then(|| data.clone())) - .ok_or(DemoFileError::NotFound)?; - - Ok(Response::builder() - .status(StatusCode::OK) - .header("content-type", "application/octet-stream") - .body(Body::from(bytes)) - .unwrap()) + type UploadState = UploadState; + + async fn upload_begin( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DemoUploadPath, + ) -> ras_file_core::FileResult { + *self.begins.lock().unwrap() += 1; + Ok(UploadState::default()) } - async fn download_secure( + async fn upload_part( &self, - _user: &AuthenticatedUser, - file_id: String, - ) -> Result { - let store = self.storage.lock().unwrap(); - let bytes = store - .iter() - .find_map(|(id, data)| (id == &file_id).then(|| data.clone())) - .ok_or(DemoFileError::NotFound)?; - - Ok(Response::builder() - .status(StatusCode::OK) - .header("content-type", "application/octet-stream") - .body(Body::from(bytes)) - .unwrap()) + _ctx: &FileRequestContext<'_>, + _path: &DemoUploadPath, + state: &mut Self::UploadState, + part: &mut DemoUploadPart<'_>, + ) -> ras_file_core::FileResult<()> { + match part { + DemoUploadPart::File(file) => { + if self.consume_file { + read_all(file, &mut state.bytes).await?; + } + } + DemoUploadPart::Metadata(metadata) => { + state.metadata = Some(metadata.clone()); + } + DemoUploadPart::Comment(comment) => { + state.comment = Some(comment.clone()); + } + } + Ok(()) } - async fn upload( + async fn upload_finish( &self, - _user: &AuthenticatedUser, - mut multipart: axum::extract::Multipart, - ) -> Result { - let field = multipart - .next_field() - .await - .map_err(|e| DemoFileError::UploadFailed(e.to_string()))? - .ok_or_else(|| DemoFileError::UploadFailed("no field".into()))?; - let data = field - .bytes() - .await - .map_err(|e| DemoFileError::UploadFailed(e.to_string()))?; + _ctx: &FileRequestContext<'_>, + _path: &DemoUploadPath, + state: Self::UploadState, + _summary: ras_file_core::UploadSummary, + ) -> ras_file_core::FileResult> { + let metadata = state + .metadata + .ok_or_else(|| FileError::bad_request("metadata missing"))?; let id = format!("file-{}", self.storage.lock().unwrap().len()); - let size = data.len() as u64; - self.storage + let size = state.bytes.len() as u64; + self.storage.lock().unwrap().push((id.clone(), state.bytes)); + + Ok(JsonResponse::created(UploadResponse { + file_id: id, + size, + title: metadata.title, + comment: state.comment, + })) + } + + async fn upload_abort( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DemoUploadPath, + _state: Self::UploadState, + _error: &FileError, + ) { + *self.aborts.lock().unwrap() += 1; + } + + async fn download_by_file_id( + &self, + _ctx: &FileRequestContext<'_>, + path: DemoDownloadByFileIdPath, + ) -> ras_file_core::FileResult { + let bytes = self + .storage .lock() .unwrap() - .push((id.clone(), data.to_vec())); - Ok(UploadResponse { file_id: id, size }) + .iter() + .find_map(|(id, bytes)| (id == &path.file_id).then(|| bytes.clone())) + .ok_or(FileError::NotFound)?; + + DownloadResponse::bytes(bytes) + .content_type("application/octet-stream")? + .attachment(format!("{}.bin", path.file_id)) } } -fn router(storage: Storage) -> axum::Router { - DemoBuilder::::new(DemoImpl { storage }) - .auth_provider(MockAuthProvider::default()) - .build() +async fn read_all(file: &mut IncomingFile<'_>, out: &mut Vec) -> ras_file_core::FileResult<()> { + while let Some(chunk) = file.next_chunk().await? { + out.extend_from_slice(&chunk); + } + Ok(()) } -fn cookie_router(storage: Storage, csrf: bool) -> axum::Router { - let mut builder = DemoBuilder::::new(DemoImpl { storage }) - .auth_provider(MockAuthProvider::default()) - .auth_cookie(AuthCookieConfig::default()); - - if csrf { - builder = builder.csrf_protection(CsrfConfig::default()); - } +fn form(payload: impl Into>) -> MultipartForm { + MultipartForm::new() + .add_part( + "file", + Part::bytes(payload.into()) + .file_name("blob.bin") + .mime_type("application/octet-stream"), + ) + .add_part( + "metadata", + Part::text(r#"{"title":"demo"}"#).mime_type("application/json"), + ) + .add_text("comment", "hello") +} - builder.build() +fn demo_server(service: DemoImpl) -> TestServer { + mock_http_server( + DemoBuilder::::new(service) + .auth_provider(MockAuthProvider::default()) + .build(), + ) } #[tokio::test] -async fn upload_and_download_round_trips_bytes() { - let storage: Storage = Arc::new(Mutex::new(Vec::new())); - let server = mock_http_server(router(storage.clone())); - - let payload: Vec = (0u8..=255).cycle().take(64 * 1024).collect(); - let form = MultipartForm::new().add_part( - "file", - Part::bytes(payload.clone()) - .file_name("blob.bin") - .mime_type("application/octet-stream"), +async fn upload_and_download_round_trips_declared_multipart_fields() { + let service = DemoImpl::new(); + let storage = service.storage.clone(); + let server = mock_http_server( + DemoBuilder::::new(service) + .auth_provider(MockAuthProvider::default()) + .build(), ); + let payload = b"streamed file".to_vec(); let response = server .post("/files/upload") .authorization_bearer("user-token") - .multipart(form) + .multipart(form(payload.clone())) .await; - response.assert_status_ok(); - let upload: UploadResponse = response.json(); - assert_eq!(upload.size, payload.len() as u64); + + response.assert_status(StatusCode::CREATED); + let uploaded: UploadResponse = response.json(); + assert_eq!(uploaded.size, payload.len() as u64); + assert_eq!(uploaded.title, "demo"); + assert_eq!(uploaded.comment.as_deref(), Some("hello")); + + assert_eq!(storage.lock().unwrap().len(), 1); let response = server - .get(&format!("/files/download/{}", upload.file_id)) + .get(&format!("/files/download/{}", uploaded.file_id)) .await; response.assert_status_ok(); - let bytes = response.into_bytes(); - assert_eq!(bytes.as_ref(), payload.as_slice()); + assert_eq!( + response.headers()["content-type"], + "application/octet-stream" + ); + assert_eq!( + response.headers()["content-disposition"], + "attachment; filename=\"file-0.bin\"" + ); + assert_eq!(response.into_bytes().as_ref(), payload.as_slice()); } #[tokio::test] -async fn upload_rejected_without_token() { - let storage: Storage = Arc::new(Mutex::new(Vec::new())); - let server = mock_http_server(router(storage)); +async fn download_returns_not_found_for_missing_file() { + let server = demo_server(DemoImpl::new()); - let form = MultipartForm::new().add_part( - "file", - Part::bytes("hello world") - .file_name("hi.txt") - .mime_type("text/plain"), + let response = server.get("/files/download/missing").await; + + response.assert_status(StatusCode::NOT_FOUND); +} + +#[test] +fn generated_client_multipart_builder_covers_declared_parts() { + let metadata = UploadMetadata { + title: "demo".to_string(), + }; + + let form = DemoUploadMultipart::new() + .file_bytes( + b"body".to_vec(), + "blob.bin", + Some("application/octet-stream"), + ) + .expect("file part") + .metadata(&metadata) + .expect("json part") + .comment("hello") + .into_form(); + + let _ = form; +} + +#[tokio::test] +async fn upload_rejects_auth_before_beginning_upload() { + let service = DemoImpl::new(); + let begins = service.begins.clone(); + let server = mock_http_server( + DemoBuilder::::new(service) + .auth_provider(MockAuthProvider::default()) + .build(), ); - let response = server.post("/files/upload").multipart(form).await; + let response = server.post("/files/upload").multipart(form("body")).await; + response.assert_status(StatusCode::UNAUTHORIZED); + assert_eq!(*begins.lock().unwrap(), 0); } #[tokio::test] -async fn cookie_auth_upload_and_download_secure_coexist_with_bearer() { - let storage: Storage = Arc::new(Mutex::new(Vec::new())); - storage - .lock() - .unwrap() - .push(("file-0".to_string(), b"secret file".to_vec())); - let server = mock_http_server(cookie_router(storage.clone(), false)); +async fn upload_rejects_request_content_type_before_beginning_upload() { + let service = DemoImpl::new(); + let begins = service.begins.clone(); + let aborts = service.aborts.clone(); + let server = demo_server(service); let response = server - .get("/files/download_secure/file-0") - .add_header("Cookie", "__Host-ras-session=user-token") + .post("/files/upload") + .authorization_bearer("user-token") + .text("not multipart") + .content_type("text/plain") .await; - response.assert_status_ok(); - let bytes = response.into_bytes(); - assert_eq!(bytes.as_ref(), b"secret file"); + response.assert_status(StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!(*begins.lock().unwrap(), 0); + assert_eq!(*aborts.lock().unwrap(), 0); +} - let form = MultipartForm::new().add_part( - "file", - Part::bytes("from cookie") - .file_name("cookie.txt") - .mime_type("text/plain"), +#[tokio::test] +async fn upload_rejects_content_length_over_total_before_beginning_upload() { + let service = DemoImpl::new(); + let begins = service.begins.clone(); + let aborts = service.aborts.clone(); + let server = demo_server(service); + + let response = server + .post("/files/upload") + .authorization_bearer("user-token") + .add_header("content-length", "4096") + .content_type("multipart/form-data; boundary=x") + .bytes(Bytes::new()) + .await; + + response.assert_status(StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(*begins.lock().unwrap(), 0); + assert_eq!(*aborts.lock().unwrap(), 0); +} + +#[tokio::test] +async fn upload_rejects_unsupported_file_content_type_after_begin_and_aborts() { + let service = DemoImpl::new(); + let aborts = service.aborts.clone(); + let server = mock_http_server( + DemoBuilder::::new(service) + .auth_provider(MockAuthProvider::default()) + .build(), ); + + let form = MultipartForm::new() + .add_part( + "file", + Part::bytes("body") + .file_name("blob.txt") + .mime_type("text/plain"), + ) + .add_part( + "metadata", + Part::text(r#"{"title":"demo"}"#).mime_type("application/json"), + ); + let response = server .post("/files/upload") - .add_header("Cookie", "__Host-ras-session=user-token") + .authorization_bearer("user-token") .multipart(form) .await; - response.assert_status_ok(); + response.assert_status(StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!(*aborts.lock().unwrap(), 1); +} + +#[tokio::test] +async fn upload_rejects_wrong_json_content_type_after_begin_and_aborts() { + let service = DemoImpl::new(); + let aborts = service.aborts.clone(); + let server = demo_server(service); + + let form = MultipartForm::new() + .add_part( + "file", + Part::bytes("body") + .file_name("blob.bin") + .mime_type("application/octet-stream"), + ) + .add_part( + "metadata", + Part::text(r#"{"title":"demo"}"#).mime_type("text/plain"), + ); - let form = MultipartForm::new().add_part( - "file", - Part::bytes("from bearer") - .file_name("bearer.txt") - .mime_type("text/plain"), - ); let response = server .post("/files/upload") .authorization_bearer("user-token") - .add_header("Cookie", "__Host-ras-session=invalid-token") .multipart(form) .await; - response.assert_status_ok(); + response.assert_status(StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!(*aborts.lock().unwrap(), 1); +} - let form = MultipartForm::new().add_part( +#[tokio::test] +async fn upload_rejects_unknown_field_when_configured_and_aborts() { + let service = DemoImpl::new(); + let aborts = service.aborts.clone(); + let server = demo_server(service); + + let response = server + .post("/files/upload") + .authorization_bearer("user-token") + .multipart(form("body").add_text("extra", "ignored?")) + .await; + + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!(*aborts.lock().unwrap(), 1); +} + +#[tokio::test] +async fn upload_rejects_duplicate_file_part_and_aborts_once() { + let service = DemoImpl::new(); + let aborts = service.aborts.clone(); + let server = demo_server(service); + + let form = form("first").add_part( "file", - Part::bytes("bad bearer") - .file_name("bad.txt") - .mime_type("text/plain"), + Part::bytes("second") + .file_name("second.bin") + .mime_type("application/octet-stream"), ); + let response = server .post("/files/upload") - .add_header("Authorization", "Basic invalid") - .add_header("Cookie", "__Host-ras-session=user-token") + .authorization_bearer("user-token") .multipart(form) .await; - response.assert_status(StatusCode::UNAUTHORIZED); + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!(*aborts.lock().unwrap(), 1); } #[tokio::test] -async fn cookie_auth_upload_requires_csrf_when_enabled() { - let storage: Storage = Arc::new(Mutex::new(Vec::new())); - let server = mock_http_server(cookie_router(storage, true)); +async fn upload_rejects_missing_required_filename_and_aborts() { + let service = DemoImpl::new(); + let aborts = service.aborts.clone(); + let server = demo_server(service); + + let form = MultipartForm::new() + .add_part( + "file", + Part::bytes("body").mime_type("application/octet-stream"), + ) + .add_part( + "metadata", + Part::text(r#"{"title":"demo"}"#).mime_type("application/json"), + ); - let form = MultipartForm::new().add_part( - "file", - Part::bytes("missing csrf") - .file_name("missing.txt") - .mime_type("text/plain"), - ); let response = server .post("/files/upload") - .add_header("Cookie", "__Host-ras-session=user-token") + .authorization_bearer("user-token") .multipart(form) .await; - response.assert_status(StatusCode::FORBIDDEN); + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!(*aborts.lock().unwrap(), 1); +} + +#[tokio::test] +async fn upload_rejects_file_over_part_limit_and_aborts() { + let service = DemoImpl::new(); + let aborts = service.aborts.clone(); + let server = demo_server(service); - let form = MultipartForm::new().add_part( - "file", - Part::bytes("with csrf") - .file_name("csrf.txt") - .mime_type("text/plain"), - ); let response = server .post("/files/upload") - .add_header( - "Cookie", - "__Host-ras-session=user-token; __Host-ras-csrf=csrf-token", + .authorization_bearer("user-token") + .multipart(form(vec![b'x'; 1025])) + .await; + + response.assert_status(StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(*aborts.lock().unwrap(), 1); +} + +#[tokio::test] +async fn upload_rejects_text_over_part_limit_and_aborts() { + let service = DemoImpl::new(); + let aborts = service.aborts.clone(); + let server = demo_server(service); + + let form = MultipartForm::new() + .add_part( + "file", + Part::bytes("body") + .file_name("blob.bin") + .mime_type("application/octet-stream"), + ) + .add_part( + "metadata", + Part::text(r#"{"title":"demo"}"#).mime_type("application/json"), ) - .add_header("x-ras-csrf", "csrf-token") + .add_text("comment", "x".repeat(129)); + + let response = server + .post("/files/upload") + .authorization_bearer("user-token") .multipart(form) .await; - response.assert_status_ok(); + response.assert_status(StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(*aborts.lock().unwrap(), 1); +} + +#[tokio::test] +async fn upload_rejects_invalid_json_and_aborts() { + let service = DemoImpl::new(); + let aborts = service.aborts.clone(); + let server = demo_server(service); + + let form = MultipartForm::new() + .add_part( + "file", + Part::bytes("body") + .file_name("blob.bin") + .mime_type("application/octet-stream"), + ) + .add_part( + "metadata", + Part::text("{invalid").mime_type("application/json"), + ); + + let response = server + .post("/files/upload") + .authorization_bearer("user-token") + .multipart(form) + .await; + + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!(*aborts.lock().unwrap(), 1); +} + +#[tokio::test] +async fn upload_rejects_invalid_utf8_text_and_aborts() { + let service = DemoImpl::new(); + let aborts = service.aborts.clone(); + let server = demo_server(service); + + let form = MultipartForm::new() + .add_part( + "file", + Part::bytes("body") + .file_name("blob.bin") + .mime_type("application/octet-stream"), + ) + .add_part( + "metadata", + Part::text(r#"{"title":"demo"}"#).mime_type("application/json"), + ) + .add_part("comment", Part::bytes(vec![0xff, 0xfe])); + + let response = server + .post("/files/upload") + .authorization_bearer("user-token") + .multipart(form) + .await; + + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!(*aborts.lock().unwrap(), 1); +} + +#[tokio::test] +async fn upload_rejects_missing_required_field() { + let service = DemoImpl::new(); + let aborts = service.aborts.clone(); + let server = mock_http_server( + DemoBuilder::::new(service) + .auth_provider(MockAuthProvider::default()) + .build(), + ); let form = MultipartForm::new().add_part( "file", - Part::bytes("bearer") - .file_name("bearer.txt") - .mime_type("text/plain"), + Part::bytes("body") + .file_name("blob.bin") + .mime_type("application/octet-stream"), ); + let response = server .post("/files/upload") .authorization_bearer("user-token") .multipart(form) .await; - response.assert_status_ok(); + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!(*aborts.lock().unwrap(), 1); } #[tokio::test] -async fn download_unknown_file_returns_404() { - let storage: Storage = Arc::new(Mutex::new(Vec::new())); - let server = mock_http_server(router(storage)); +async fn upload_rejects_when_handler_does_not_consume_file_stream() { + let service = DemoImpl::new().without_file_consumption(); + let aborts = service.aborts.clone(); + let server = mock_http_server( + DemoBuilder::::new(service) + .auth_provider(MockAuthProvider::default()) + .build(), + ); - let response = server.get("/files/download/does-not-exist").await; - response.assert_status(StatusCode::NOT_FOUND); + let response = server + .post("/files/upload") + .authorization_bearer("user-token") + .multipart(form("body")) + .await; + + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!(*aborts.lock().unwrap(), 1); +} + +#[test] +fn generated_openapi_documents_v2_multipart_contract() { + let doc = generate_demo_openapi(); + + let upload = &doc["paths"]["/upload"]["post"]; + assert_eq!( + upload["requestBody"]["content"]["multipart/form-data"]["schema"]["properties"]["file"]["format"], + "binary" + ); + assert_eq!(upload["x-ras-file"]["maxTotalBytes"], 2048); + assert_eq!(upload["x-permissions"], serde_json::json!(["user"])); + + let download = &doc["paths"]["/download/{file_id}"]["get"]; + assert_eq!( + download["responses"]["200"]["content"]["application/octet-stream"]["schema"]["$ref"], + "#/components/schemas/BinaryFileResponse" + ); + assert_eq!(download["x-ras-file"]["ranges"], true); } diff --git a/crates/rest/ras-file-macro/tests/integration.rs b/crates/rest/ras-file-macro/tests/integration.rs index abe7983..74b01a8 100644 --- a/crates/rest/ras-file-macro/tests/integration.rs +++ b/crates/rest/ras-file-macro/tests/integration.rs @@ -1,242 +1,56 @@ use ras_file_macro::file_service; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize, Deserialize)] -struct UploadResponse { - file_id: String, - size: u64, +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct UploadMetadata { + name: String, } -#[derive(Debug, Serialize, Deserialize)] -struct FileMetadata { +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +struct UploadResponse { id: String, - name: String, - content_type: String, - size: u64, } -// Test basic file service definition file_service!({ - service_name: TestFileService, - base_path: "/api/files", + service_name: IntegrationService, + base_path: "/integration", + openapi: true, endpoints: [ - // Upload endpoints - UPLOAD WITH_PERMISSIONS(["upload"]) upload() -> UploadResponse, - UPLOAD WITH_PERMISSIONS(["admin"]) upload_document/{doc_type: String}() -> UploadResponse, - - // Download endpoints - DOWNLOAD UNAUTHORIZED download/{file_id: String}(), - DOWNLOAD WITH_PERMISSIONS(["user"]) download_secure/{file_id: String}(), - DOWNLOAD WITH_PERMISSIONS([["admin", "moderator"]]) get_metadata/{file_id: String}() -> FileMetadata, + UPLOAD WITH_PERMISSIONS([["admin", "moderator"]]) upload/{bucket: String} multipart { + max_total_bytes: unlimited, + reject_unknown_fields: false, + parts: [ + file file { + required: true, + max_count: 2, + max_bytes: 1024, + content_types: ["application/octet-stream"], + filename: required, + }, + json metadata: UploadMetadata { + required: false, + max_bytes: 128, + content_types: ["application/json"], + }, + ], + } -> UploadResponse, + DOWNLOAD WITH_PERMISSIONS(["admin"]) download/{id: String}, ] }); -#[cfg(test)] -mod tests { - use super::*; - use axum::body::Body; - use axum::extract::Multipart; - use axum::response::{IntoResponse, Response}; - use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; - use std::collections::HashSet; - - // Mock service implementation - #[derive(Clone)] - struct MockFileService; - - #[async_trait::async_trait] - impl TestFileServiceTrait for MockFileService { - async fn upload( - &self, - _user: &AuthenticatedUser, - _multipart: Multipart, - ) -> Result { - Ok(UploadResponse { - file_id: "test-file-123".to_string(), - size: 1024, - }) - } - - async fn upload_document( - &self, - _user: &AuthenticatedUser, - doc_type: String, - _multipart: Multipart, - ) -> Result { - Ok(UploadResponse { - file_id: format!("doc-{}-456", doc_type), - size: 2048, - }) - } - - async fn download( - &self, - file_id: String, - ) -> Result { - if file_id == "not-found" { - return Err(TestFileServiceFileError::NotFound); - } - - let body = Body::from(format!("File content for {}", file_id)); - Ok(Response::builder() - .header("content-type", "application/octet-stream") - .header( - "content-disposition", - format!("attachment; filename=\"{}\"", file_id), - ) - .body(body) - .unwrap()) - } - - async fn download_secure( - &self, - _user: &AuthenticatedUser, - file_id: String, - ) -> Result { - let body = Body::from(format!("Secure file content for {}", file_id)); - Ok(Response::builder() - .header("content-type", "application/pdf") - .body(body) - .unwrap()) - } - - async fn get_metadata( - &self, - _user: &AuthenticatedUser, - file_id: String, - ) -> Result { - let metadata = FileMetadata { - id: file_id.clone(), - name: format!("{}.pdf", file_id), - content_type: "application/pdf".to_string(), - size: 4096, - }; - - Ok(axum::Json(metadata)) - } - } - - // Mock auth provider - #[derive(Clone)] - struct MockAuthProvider; - - impl AuthProvider for MockAuthProvider { - fn authenticate(&self, token: String) -> AuthFuture<'_> { - Box::pin(async move { - match token.as_str() { - "valid-token" => Ok(AuthenticatedUser { - user_id: "user-123".to_string(), - permissions: vec!["user".to_string(), "upload".to_string()] - .into_iter() - .collect::>(), - metadata: None, - }), - "admin-token" => Ok(AuthenticatedUser { - user_id: "admin-456".to_string(), - permissions: vec![ - "user".to_string(), - "upload".to_string(), - "admin".to_string(), - ] - .into_iter() - .collect::>(), - metadata: None, - }), - _ => Err(AuthError::InvalidToken), - } - }) - } - } - - #[test] - fn test_service_trait_exists() { - // This test verifies that the trait is generated correctly - fn assert_trait_impl() {} - assert_trait_impl::(); - } - - #[test] - fn test_builder_creation() { - let service = MockFileService; - let auth = MockAuthProvider; - - let _builder = TestFileServiceBuilder::new(service) - .auth_provider(auth) - .with_usage_tracker(|_headers, method, path| { - println!("Request: {} {}", method, path); - }) - .with_duration_tracker(|method, path, duration| { - println!("Request {} {} took {:?}", method, path, duration); - }); - } - - #[tokio::test] - async fn test_client_builder() { - let client = TestFileServiceClient::builder("http://localhost:3000") - .build() - .expect("Failed to build client"); - - client.set_bearer_token(Some("test-token")); - - // Test that client methods exist - let _ = client; - } - - #[test] - fn test_file_error_variants() { - // Test that all error variants exist - let _errors = [ - TestFileServiceFileError::NotFound, - TestFileServiceFileError::UploadFailed("test".to_string()), - TestFileServiceFileError::DownloadFailed("test".to_string()), - TestFileServiceFileError::InvalidFormat, - TestFileServiceFileError::FileTooLarge, - TestFileServiceFileError::Internal("test".to_string()), - ]; - } - - #[tokio::test] - async fn test_error_into_response() { - use axum::http::StatusCode; - use axum::response::IntoResponse; - - let test_cases = vec![ - (TestFileServiceFileError::NotFound, StatusCode::NOT_FOUND), - ( - TestFileServiceFileError::InvalidFormat, - StatusCode::BAD_REQUEST, - ), - ( - TestFileServiceFileError::FileTooLarge, - StatusCode::PAYLOAD_TOO_LARGE, - ), - ( - TestFileServiceFileError::UploadFailed("test".to_string()), - StatusCode::BAD_REQUEST, - ), - ( - TestFileServiceFileError::DownloadFailed("test".to_string()), - StatusCode::INTERNAL_SERVER_ERROR, - ), - ( - TestFileServiceFileError::Internal("test".to_string()), - StatusCode::INTERNAL_SERVER_ERROR, - ), - ]; - - for (error, expected_status) in test_cases { - let response = error.into_response(); - let (parts, _) = response.into_parts(); - assert_eq!(parts.status, expected_status); - } - - let response = TestFileServiceFileError::Internal("database password leaked".to_string()) - .into_response(); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let body = String::from_utf8(body.to_vec()).unwrap(); - assert!(body.contains("Internal server error")); - assert!(!body.contains("database password leaked")); - } +#[test] +fn generated_openapi_includes_nested_permission_groups_and_unlimited_upload() { + let doc = generate_integrationservice_openapi(); + + let upload = &doc["paths"]["/upload/{bucket}"]["post"]; + assert_eq!(upload["security"][0]["bearerAuth"], serde_json::json!([])); + assert_eq!( + upload["x-permissions"], + serde_json::json!(["admin", "moderator"]) + ); + assert_eq!( + upload["x-ras-file"]["maxTotalBytes"], + serde_json::Value::Null + ); } diff --git a/crates/rest/ras-file-macro/tests/minimal_test.rs b/crates/rest/ras-file-macro/tests/minimal_test.rs index a8da97c..9df458f 100644 --- a/crates/rest/ras-file-macro/tests/minimal_test.rs +++ b/crates/rest/ras-file-macro/tests/minimal_test.rs @@ -1,48 +1,31 @@ -use axum::extract::Multipart; -use ras_auth_core::{AuthError, AuthFuture, AuthProvider}; use ras_file_macro::file_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; -// Define response type -#[derive(serde::Serialize, serde::Deserialize)] -struct TestResponse { - id: String, +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +struct UploadResponse { + ok: bool, } -// Simplest possible test file_service!({ service_name: MinimalService, - base_path: "/api", + base_path: "/minimal", endpoints: [ - UPLOAD UNAUTHORIZED upload() -> TestResponse, + UPLOAD UNAUTHORIZED upload multipart { + max_total_bytes: 512, + parts: [ + file file { + required: true, + max_bytes: 512, + filename: optional, + }, + ], + } -> UploadResponse, ] }); -// Implement the service -#[derive(Clone)] -struct MyService; - -#[async_trait::async_trait] -impl MinimalServiceTrait for MyService { - async fn upload(&self, _multipart: Multipart) -> Result { - Ok(TestResponse { - id: "test".to_string(), - }) - } -} - -// Dummy auth provider for testing -#[derive(Clone)] -struct DummyAuth; - -impl AuthProvider for DummyAuth { - fn authenticate(&self, _token: String) -> AuthFuture<'_> { - Box::pin(async move { Err(AuthError::InvalidToken) }) - } -} - #[test] -fn generated_builder_accepts_service_and_auth_provider() { - let service = MyService; - let auth = DummyAuth; - let _builder = MinimalServiceBuilder::new(service).auth_provider(auth); +fn generated_names_are_available() { + let _ = std::any::type_name::(); + let _ = std::any::type_name::>(); } diff --git a/crates/rest/ras-file-macro/tests/multiple_invocations.rs b/crates/rest/ras-file-macro/tests/multiple_invocations.rs new file mode 100644 index 0000000..1aea70c --- /dev/null +++ b/crates/rest/ras-file-macro/tests/multiple_invocations.rs @@ -0,0 +1,61 @@ +use ras_file_macro::file_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +struct UploadResponse { + id: String, +} + +file_service!({ + service_name: FirstFileService, + base_path: "/first-files", + openapi: true, + endpoints: [ + UPLOAD UNAUTHORIZED upload multipart { + max_total_bytes: 1024, + parts: [ + file file { + required: true, + max_bytes: 1024, + filename: optional, + }, + ], + } -> UploadResponse, + ] +}); + +file_service!({ + service_name: SecondFileService, + base_path: "/second-files", + openapi: true, + endpoints: [ + UPLOAD UNAUTHORIZED upload multipart { + max_total_bytes: 1024, + parts: [ + file file { + required: true, + max_bytes: 1024, + filename: optional, + }, + ], + } -> UploadResponse, + ] +}); + +#[test] +fn multiple_file_services_can_share_a_module() { + let _ = std::any::type_name::>(); + let _ = std::any::type_name::>(); + let _ = std::any::type_name::(); + let _ = std::any::type_name::(); + + assert_eq!( + generate_firstfileservice_openapi()["info"]["title"], + "FirstFileService File Service API" + ); + assert_eq!( + generate_secondfileservice_openapi()["info"]["title"], + "SecondFileService File Service API" + ); +} diff --git a/crates/rest/ras-file-macro/tests/paren_test.rs b/crates/rest/ras-file-macro/tests/paren_test.rs index 86b0056..499c10f 100644 --- a/crates/rest/ras-file-macro/tests/paren_test.rs +++ b/crates/rest/ras-file-macro/tests/paren_test.rs @@ -1,42 +1,22 @@ -use axum::body::Body; -use axum::response::{IntoResponse, Response}; use ras_file_macro::file_service; file_service!({ - service_name: TestParen, - base_path: "/api", + service_name: DownloadOnly, + base_path: "/files", endpoints: [ - DOWNLOAD UNAUTHORIZED download/{id: String}(), + DOWNLOAD UNAUTHORIZED nested/{folder: String}/download/{id: String} { + content_types: ["application/octet-stream"], + ranges: false, + }, ] }); -// Implement the service -#[derive(Clone)] -struct TestService; - -#[async_trait::async_trait] -impl TestParenTrait for TestService { - async fn download(&self, id: String) -> Result { - Ok(Response::builder() - .header("content-type", "text/plain") - .body(Body::from(format!("Download {}", id))) - .unwrap()) - } -} - -#[tokio::test] -async fn generated_trait_handles_download_endpoint_with_path_parameter() { - let response = TestService - .download("report.txt".to_string()) - .await - .expect("download succeeds") - .into_response(); - - let (parts, body) = response.into_parts(); - let body = axum::body::to_bytes(body, usize::MAX) - .await - .expect("body bytes"); - - assert_eq!(parts.status, axum::http::StatusCode::OK); - assert_eq!(&body[..], b"Download report.txt"); +#[test] +fn path_struct_name_tracks_nested_download_path() { + let path = DownloadOnlyNestedByFolderDownloadByIdPath { + folder: "a".to_string(), + id: "b".to_string(), + }; + assert_eq!(path.folder, "a"); + assert_eq!(path.id, "b"); } diff --git a/crates/rest/ras-file-macro/tests/simple_test.rs b/crates/rest/ras-file-macro/tests/simple_test.rs index dc97d89..cb6f121 100644 --- a/crates/rest/ras-file-macro/tests/simple_test.rs +++ b/crates/rest/ras-file-macro/tests/simple_test.rs @@ -1,38 +1,91 @@ -use axum::extract::Multipart; -use ras_auth_core::{AuthError, AuthFuture, AuthProvider}; +use ras_file_core::{DownloadResponse, FileRequestContext, JsonResponse}; use ras_file_macro::file_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +struct UploadResponse { + id: String, +} file_service!({ service_name: SimpleService, - base_path: "/api", + base_path: "/simple", endpoints: [ - UPLOAD UNAUTHORIZED upload() -> (), + UPLOAD UNAUTHORIZED upload multipart { + max_total_bytes: 1024, + parts: [ + text title { + required: true, + max_bytes: 128, + }, + ], + } -> UploadResponse, + DOWNLOAD UNAUTHORIZED download/{id: String}, ] }); -#[derive(Clone)] -struct SimpleServiceImpl; +struct SimpleImpl; #[async_trait::async_trait] -impl SimpleServiceTrait for SimpleServiceImpl { - async fn upload(&self, _multipart: Multipart) -> Result<(), SimpleServiceFileError> { +impl SimpleServiceTrait for SimpleImpl { + type UploadState = Option; + + async fn upload_begin( + &self, + _ctx: &FileRequestContext<'_>, + _path: &SimpleServiceUploadPath, + ) -> ras_file_core::FileResult { + Ok(None) + } + + async fn upload_part( + &self, + _ctx: &FileRequestContext<'_>, + _path: &SimpleServiceUploadPath, + state: &mut Self::UploadState, + part: &mut SimpleServiceUploadPart<'_>, + ) -> ras_file_core::FileResult<()> { + match part { + SimpleServiceUploadPart::Title(title) => *state = Some(title.clone()), + SimpleServiceUploadPart::__Lifetime(_) => {} + } Ok(()) } -} -#[derive(Clone)] -struct RejectingAuth; + async fn upload_finish( + &self, + _ctx: &FileRequestContext<'_>, + _path: &SimpleServiceUploadPath, + state: Self::UploadState, + _summary: ras_file_core::UploadSummary, + ) -> ras_file_core::FileResult> { + Ok(JsonResponse::ok(UploadResponse { + id: state.unwrap_or_default(), + })) + } -impl AuthProvider for RejectingAuth { - fn authenticate(&self, _token: String) -> AuthFuture<'_> { - Box::pin(async move { Err(AuthError::InvalidToken) }) + async fn download_by_id( + &self, + _ctx: &FileRequestContext<'_>, + _path: SimpleServiceDownloadByIdPath, + ) -> ras_file_core::FileResult { + Ok(DownloadResponse::bytes("ok")) } } #[test] -fn generated_builder_accepts_unauthenticated_upload_service() { - fn assert_trait_impl() {} - assert_trait_impl::(); +fn v2_simple_service_expands() { + let _ = SimpleServiceBuilder::::new(SimpleImpl).build(); +} - let _builder = SimpleServiceBuilder::new(SimpleServiceImpl).auth_provider(RejectingAuth); +mod support { + #[derive(Clone)] + pub struct NoAuth; + + impl ras_auth_core::AuthProvider for NoAuth { + fn authenticate(&self, _token: String) -> ras_auth_core::AuthFuture<'_> { + Box::pin(async { Err(ras_auth_core::AuthError::InvalidToken) }) + } + } } diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index 20ea6c5..8ec20b8 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -1,5 +1,5 @@ use proc_macro::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use syn::{Ident, LitStr, Token, Type, parse::Parse, parse_macro_input}; mod client; @@ -656,6 +656,9 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result syn::Result syn::Result () -> ItemResponse, + ] +}); + +rest_service!({ + service_name: SecondRestService, + base_path: "/second", + openapi: true, + serve_docs: true, + endpoints: [ + GET UNAUTHORIZED health ? verbose: Option () -> ItemResponse, + ] +}); + +#[test] +fn multiple_rest_services_can_share_a_module() { + let _ = std::any::type_name::(); + let _ = std::any::type_name::(); + + fn _first_service_trait_exists() {} + fn _second_service_trait_exists() {} + + assert_eq!( + generate_firstrestservice_openapi()["info"]["title"], + "FirstRestService REST API" + ); + assert_eq!( + generate_secondrestservice_openapi()["info"]["title"], + "SecondRestService REST API" + ); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs index b1a4cf7..a1052b0 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs @@ -1,5 +1,5 @@ use proc_macro::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use syn::{Ident, LitStr, Token, Type, parse::Parse, parse_macro_input}; mod client; @@ -263,6 +263,10 @@ impl Parse for NotificationDefinition { fn generate_service_code( service_def: BidirectionalServiceDefinition, ) -> syn::Result { + let service_name_lower = service_def.service_name.to_string().to_lowercase(); + let server_mod = format_ident!("__ras_jsonrpc_bidirectional_{}_server", service_name_lower); + let client_mod = format_ident!("__ras_jsonrpc_bidirectional_{}_client", service_name_lower); + // Generate server code - this will be conditionally compiled by the user let server_code = server::generate_server_code(&service_def); @@ -270,8 +274,25 @@ fn generate_service_code( let client_code = client::generate_client_code(&service_def); let output = quote! { - #server_code - #client_code + #[cfg(feature = "server")] + mod #server_mod { + use super::*; + + #server_code + } + + #[cfg(feature = "server")] + pub use #server_mod::*; + + #[cfg(feature = "client")] + mod #client_mod { + use super::*; + + #client_code + } + + #[cfg(feature = "client")] + pub use #client_mod::*; }; Ok(output) diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index 4f44f50..827a35c 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -1,5 +1,5 @@ use proc_macro::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use syn::{Ident, LitStr, Token, Type, parse::Parse, parse_macro_input}; mod client; @@ -419,6 +419,10 @@ impl Parse for MethodVersionDefinition { } fn generate_service_code(service_def: ServiceDefinition) -> syn::Result { + let service_name_lower = service_def.service_name.to_string().to_lowercase(); + let server_mod = format_ident!("__ras_jsonrpc_{}_server", service_name_lower); + let client_mod = format_ident!("__ras_jsonrpc_{}_client", service_name_lower); + // Generate OpenRPC code if enabled in the macro input let (openrpc_code, schema_checks) = if let Some(openrpc_config) = &service_def.openrpc { ( @@ -461,7 +465,7 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result syn::Result syn::Result PingResponse, + ] +}); + +jsonrpc_service!({ + service_name: SecondRpcService, + openrpc: true, + explorer: true, + methods: [ + UNAUTHORIZED ping(PingRequest) -> PingResponse, + ] +}); + +#[test] +fn multiple_jsonrpc_services_can_share_a_module() { + let _ = std::any::type_name::(); + let _ = std::any::type_name::(); + + fn _first_service_trait_exists() {} + fn _second_service_trait_exists() {} + + assert_eq!( + generate_firstrpcservice_openrpc()["info"]["title"], + "FirstRpcService JSON-RPC API" + ); + assert_eq!( + generate_secondrpcservice_openrpc()["info"]["title"], + "SecondRpcService JSON-RPC API" + ); + + let _ = firstrpcservice_explorer_routes(""); + let _ = secondrpcservice_explorer_routes(""); +} diff --git a/documentation/ras-file-macro.md b/documentation/ras-file-macro.md index 8ec469a..7221d7d 100644 --- a/documentation/ras-file-macro.md +++ b/documentation/ras-file-macro.md @@ -1,824 +1,218 @@ -# ras-file-macro Usage Documentation - -The `ras-file-macro` crate provides a procedural macro for building type-safe file upload and download services with built-in authentication, native Rust clients, OpenAPI documents, and optional browser bindings. - -## Table of Contents -- [Overview](#overview) -- [Installation](#installation) -- [Basic Usage](#basic-usage) -- [Macro Syntax](#macro-syntax) -- [Server Implementation](#server-implementation) -- [Client Usage](#client-usage) -- [TypeScript and WASM Clients](#typescript-and-wasm-clients) -- [Authentication and Permissions](#authentication-and-permissions) -- [Error Handling](#error-handling) -- [Advanced Features](#advanced-features) -- [File API Example](#file-api-example) - -## Overview - -The `file_service!` macro generates: -- A trait for implementing file operations -- Axum router with upload/download endpoints -- Native Rust client with streaming support -- OpenAPI 3.0 specification for TypeScript client generation -- Optional WASM client bindings for direct browser file APIs -- Built-in authentication and permission handling -- File-specific error types - -## Installation - -Add to your `Cargo.toml`: - -```toml -[dependencies] -ras-file-macro = "0.1.0" -ras-auth-core = "0.1.0" -serde = { version = "1.0", features = ["derive"] } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -axum = { version = "0.8", features = ["multipart"] } -tokio = { version = "1.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["io"] } -serde_json = "1.0" -schemars = "1.0.0-alpha.20" -async-trait = "0.1" -thiserror = "2" -reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } -uuid = { version = "1", features = ["v4"] } - -# Optional: only when compiling direct WASM bindings or the generated -# browser-oriented client for wasm32. -[target.'cfg(target_arch = "wasm32")'.dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"] } -wasm-bindgen = { version = "0.2", optional = true } -wasm-bindgen-futures = { version = "0.4", optional = true } -js-sys = { version = "0.3", optional = true } -web-sys = { version = "0.3", features = ["File", "FormData", "Blob"], optional = true } -serde-wasm-bindgen = { version = "0.6", optional = true } - -[features] -default = [] -wasm-client = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys", "serde-wasm-bindgen"] -``` - -## Basic Usage - -### 1. Define Your File Service - -```rust -use ras_file_macro::file_service; -use serde::{Deserialize, Serialize}; -use schemars::JsonSchema; // Required for OpenAPI generation - -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct FileMetadata { - pub id: String, - pub filename: String, - pub size: usize, - pub content_type: String, -} - -file_service!({ - service_name: FileStorage, - base_path: "/api/files", - openapi: true, // Enable OpenAPI generation - body_limit: 52428800, // 50MB limit - endpoints: [ - UPLOAD WITH_PERMISSIONS(["upload"]) upload() -> FileMetadata, - DOWNLOAD UNAUTHORIZED download/{file_id: String}(), - ] -}); -``` - -### 2. Implement the Service Trait - -```rust -use axum::extract::Multipart; -use axum::response::IntoResponse; -use async_trait::async_trait; -use ras_auth_core::AuthenticatedUser; -use std::path::PathBuf; - -pub struct MyFileStorage { - upload_dir: PathBuf, -} - -impl MyFileStorage { - pub fn new(upload_dir: impl Into) -> Self { - Self { - upload_dir: upload_dir.into(), - } - } -} - -#[async_trait] -impl FileStorageTrait for MyFileStorage { - async fn upload( - &self, - _user: &AuthenticatedUser, - mut multipart: Multipart, - ) -> Result { - // Extract file from multipart - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))? - { - if field.name() == Some("file") { - let filename = field.file_name() - .ok_or(FileStorageFileError::InvalidFormat)? - .to_string(); - let content_type = field.content_type() - .unwrap_or("application/octet-stream") - .to_string(); - let data = field.bytes().await - .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))?; - - // Store file and return metadata - let id = uuid::Uuid::new_v4().to_string(); - tokio::fs::create_dir_all(&self.upload_dir) - .await - .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))?; - tokio::fs::write(self.upload_dir.join(&id), &data) - .await - .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))?; - - return Ok(FileMetadata { - id, - filename, - size: data.len(), - content_type, - }); - } - } - Err(FileStorageFileError::InvalidFormat) - } - - async fn download( - &self, - file_id: String, - ) -> Result { - // Retrieve file - let file = tokio::fs::File::open(self.upload_dir.join(&file_id)).await - .map_err(|_| FileStorageFileError::NotFound)?; - - let stream = tokio_util::io::ReaderStream::new(file); - let body = axum::body::Body::from_stream(stream); - - Ok(axum::response::Response::builder() - .header("Content-Type", "application/octet-stream") - .header("Content-Disposition", format!("attachment; filename=\"{}\"", file_id)) - .body(body) - .unwrap()) - } -} -``` - -### 3. Set Up the Server - -```rust -use axum::Router; - -#[tokio::main] -async fn main() { - let storage = MyFileStorage::new("./uploads"); - let auth_provider = MyAuthProvider::new(); - - let file_router = FileStorageBuilder::new(storage) - .auth_provider(auth_provider) - .build(); - - let app = Router::new() - .merge(file_router); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") - .await - .unwrap(); - - axum::serve(listener, app).await.unwrap(); -} -``` - -## Macro Syntax - -```rust -file_service!({ - service_name: ServiceName, - base_path: "/api/path", - body_limit: 10485760, // Optional, in bytes - endpoints: [ - OPERATION AUTH_REQUIREMENT endpoint_name() -> ResponseType, - OPERATION AUTH_REQUIREMENT endpoint_name/{param: Type}() -> ResponseType, - ] -}) -``` - -### Parameters - -- **`service_name`**: Name of the service (used for trait and struct generation) -- **`base_path`**: Base URL path for all endpoints -- **`body_limit`** (optional): Maximum upload size in bytes -- **`endpoints`**: List of endpoints with their configuration - -### Operations - -- **`UPLOAD`**: Creates a POST endpoint accepting multipart/form-data -- **`DOWNLOAD`**: Creates a GET endpoint returning file data - -### Authentication Requirements - -- **`UNAUTHORIZED`**: No authentication required -- **`WITH_PERMISSIONS(["perm1", "perm2"])`**: Requires any of the listed permissions (OR logic) -- **`WITH_PERMISSIONS([["perm1", "perm2"]])`**: Requires all permissions in inner array (AND logic) - -### Path Parameters - -Dynamic path segments are supported: -```rust -DOWNLOAD UNAUTHORIZED download/{file_id: String}() -UPLOAD WITH_PERMISSIONS(["admin"]) upload_to/{folder: String}() -> FileMetadata -``` +# File Service Macro -## Server Implementation +`ras-file-macro` generates focused file upload/download APIs from one service +definition. It is intentionally separate from the JSON REST macro because file +traffic has different constraints: authentication should happen before reading +the body, uploads need per-field and total byte limits, and handlers should be +able to stream bytes instead of receiving a fully buffered request. -### Generated Trait +The generated server adapts Axum multipart requests into runtime-neutral types +from `ras-file-core`: -The macro generates a trait with methods for each endpoint: +- `FileRequestContext<'_>` carries method, matched path, headers, and the + authenticated user. +- `IncomingFile<'_>` streams file chunks and enforces the declared part limit. +- `JsonResponse` is returned by upload finish handlers. +- `DownloadResponse` is returned by download handlers. -```rust -#[async_trait] -pub trait FileStorageTrait: Send + Sync { - // For UPLOAD endpoints - async fn upload( - &self, - user: &AuthenticatedUser, // Only if auth required - param: Type, // Path parameters if any - multipart: Multipart - ) -> Result; - - // For DOWNLOAD endpoints - async fn download( - &self, - user: &AuthenticatedUser, // Only if auth required - param: Type, // Path parameters if any - ) -> Result; -} -``` - -### Service Builder - -Configure your service with authentication and observability: +## Definition Syntax ```rust -let router = FileStorageBuilder::new(my_service) - .auth_provider(auth_provider) - .with_usage_tracker(|_headers, method, path| { - // Track API usage - }) - .with_duration_tracker(|method, path, duration| { - // Track request duration - }) - .build(); -``` - -### Error Handling - -A custom error enum is generated: +use ras_file_macro::file_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; -```rust -pub enum FileStorageFileError { - NotFound, - UploadFailed(String), - DownloadFailed(String), - InvalidFormat, - FileTooLarge, - Internal(String), +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct UploadResponse { + pub file_id: String, + pub size: u64, } -``` - -Each variant maps to appropriate HTTP status codes. - -## Client Usage -### Native Rust Client - -```rust -// Create client -let client = FileStorageClient::builder("http://localhost:3000") - .timeout(Duration::from_secs(30)) // Optional - .build()?; - -// Set authentication -client.set_bearer_token(Some("validtoken")); - -// Upload file -let metadata = client.upload( - "./fixtures/report.pdf", - None, // Optional: override filename - None // Optional: override content type -).await?; - -// Download file -let response = client.download("file-id-123").await?; -let bytes = response.bytes().await?; -``` - -### Client Builder Options - -```rust -let client = FileStorageClient::builder("http://localhost:3000") - .client(custom_reqwest_client) // Optional: custom client - .timeout(Duration::from_secs(60)) // Optional: request timeout - .build()?; -``` - -## TypeScript and WASM Clients - -### OpenAPI-Generated TypeScript Client - -For browser apps, the recommended path is to generate a TypeScript fetch client from the OpenAPI document emitted by the Rust API crate. - -Enable OpenAPI generation in the service definition: - -```rust file_service!({ service_name: DocumentService, base_path: "/api/documents", openapi: true, endpoints: [ - UPLOAD UNAUTHORIZED upload() -> UploadResponse, - UPLOAD WITH_PERMISSIONS(["user"]) upload_profile_picture() -> UploadResponse, - DOWNLOAD UNAUTHORIZED download/{file_id: String}() -> (), - DOWNLOAD WITH_PERMISSIONS(["user"]) download_secure/{file_id: String}() -> (), + UPLOAD WITH_PERMISSIONS(["files:write"]) upload multipart { + max_total_bytes: 52428800, + reject_unknown_fields: true, + parts: [ + file file { + required: true, + max_count: 1, + max_bytes: 52428800, + content_types: ["application/pdf", "text/plain"], + filename: optional, + }, + json metadata: UploadMetadata { + required: false, + max_bytes: 4096, + content_types: ["application/json"], + }, + text comment { + required: false, + max_bytes: 1024, + }, + ], + } -> UploadResponse, + + DOWNLOAD WITH_PERMISSIONS(["files:read"]) download/{file_id: String} { + content_types: ["application/octet-stream"], + ranges: true, + }, ] }); ``` -Build the API or backend crate so the build script writes the OpenAPI document: +`max_total_bytes` is required on every upload and may be `unlimited`. Every +part must declare `max_bytes`. `reject_unknown_fields` defaults to `true`. -```bash -cargo check -p file-service-backend --locked -``` - -Generate a TypeScript fetch client from that OpenAPI document with your -preferred OpenAPI generator. The examples below assume the generated client -exports methods from `./generated`. +File parts support `filename: optional`, `filename: required`, and +`filename: forbidden`. JSON parts require a Rust type after `:` and are decoded +before the service receives the part. Text parts are decoded as UTF-8. -Use the generated functions directly: - -```typescript -import { - downloadDownloadFileId, - uploadUpload, -} from './generated'; - -const file = new File(['hello from TypeScript'], 'hello.txt', { - type: 'text/plain', -}); +## Generated Trait Shape -const baseUrl = 'http://localhost:3000/api/documents'; - -const uploaded = await uploadUpload({ - baseUrl, - body: { file }, -}); -if (uploaded.error || !uploaded.data) throw uploaded.error; - -const downloaded = await downloadDownloadFileId({ - baseUrl, - path: { file_id: uploaded.data.file_id }, -}); -if (downloaded.error || !downloaded.data) throw downloaded.error; -``` - -The runnable usage sample lives in `examples/file-service-wasm/typescript-example` -and intentionally avoids a frontend framework or npm project. - -### Optional WASM Bindings - -If you need direct `wasm-bindgen` bindings instead of an OpenAPI-generated fetch client, add a `wasm-client` feature to your API crate and enable it when building for `wasm32`. The feature belongs to the API crate because the generated WASM module compiles inside that crate. - -```toml -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -ras-file-macro = "0.1.0" -serde = { version = "1.0", features = ["derive"] } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"] } -wasm-bindgen = { version = "0.2", optional = true } -wasm-bindgen-futures = { version = "0.4", optional = true } -js-sys = { version = "0.3", optional = true } -web-sys = { version = "0.3", features = ["File", "Blob", "FormData"], optional = true } -serde-wasm-bindgen = { version = "0.6", optional = true } - -[features] -default = [] -wasm-client = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys", "serde-wasm-bindgen"] -``` - -Build the module with `wasm-pack`: - -```bash -wasm-pack build --target web --out-dir pkg --features wasm-client -``` - -The generated definitions expose a WASM client: - -```typescript -// pkg/my_file_api.d.ts -export class WasmFileStorageClient { - constructor(base_url: string); - set_bearer_token(token?: string | null): void; - upload(file: File): Promise; - download(file_id: string): Promise; -} -``` - -Use it from browser code: - -```typescript -import init, { WasmFileStorageClient } from './pkg/my_file_api'; - -// Initialize WASM module -await init(); - -// Create client -const client = new WasmFileStorageClient('http://localhost:3000'); -client.set_bearer_token('validtoken'); - -// Upload file from input -const fileInput = document.getElementById('file-input') as HTMLInputElement; -const file = fileInput.files[0]; -const metadata = await client.upload(file); -console.log('Uploaded:', metadata); - -// Download file -const data = await client.download('file-id-123'); -const blob = new Blob([data], { type: 'application/octet-stream' }); -const url = URL.createObjectURL(blob); -window.open(url); -``` - -## Authentication and Permissions - -### Integration with ras-auth-core - -The macro integrates with the `ras-auth-core` authentication system: - -```rust -use ras_identity_session::JwtAuthProvider; - -// Use any cloneable AuthProvider implementation. -let auth_provider = JwtAuthProvider::new(session_service.clone()); - -let router = FileStorageBuilder::new(storage) - .auth_provider(auth_provider) - .build(); -``` - -### Permission Checking - -Permissions are automatically validated before calling your trait methods: +Uploads are a lifecycle. The service can allocate state after authentication +but before the body is consumed, handle each accepted part, then finish with a +JSON response. ```rust -// Any listed permission (OR logic) -UPLOAD WITH_PERMISSIONS(["upload", "admin"]) upload() -> FileMetadata +use ras_file_core::{FileRequestContext, FileResult, JsonResponse}; -// Multiple permissions required (AND logic) -UPLOAD WITH_PERMISSIONS([["upload", "verified"]]) upload_verified() -> FileMetadata +#[async_trait::async_trait] +impl DocumentServiceTrait for MyService { + type UploadState = UploadState; -// Complex permission logic -UPLOAD WITH_PERMISSIONS([["admin"], ["upload", "premium"]]) special_upload() -> FileMetadata -// Requires: admin OR (upload AND premium) -``` - -### Token Handling - -By default, protected endpoints extract bearer tokens from the `Authorization` -header: -``` -Authorization: Bearer validtoken -``` - -Generated file services can also opt into secure session cookies with -`auth_cookie(AuthCookieConfig::default())`. When CSRF protection is enabled with -`CsrfConfig::default()`, cookie-authenticated uploads must include an -`x-ras-csrf` header matching the `__Host-ras-csrf` double-submit cookie. - -## Error Handling - -### Server-Side Errors - -The generated error enum provides semantic error types: - -```rust -match result { - Err(FileStorageFileError::NotFound) => { - // Handle 404 - } - Err(FileStorageFileError::FileTooLarge) => { - // Handle 413 - } - Err(FileStorageFileError::UploadFailed(msg)) => { - // Handle upload error + async fn upload_begin( + &self, + ctx: &FileRequestContext<'_>, + path: &DocumentServiceUploadPath, + ) -> FileResult { + Ok(UploadState::default()) } - _ => {} -} -``` - -### Client-Side Errors - -```typescript -try { - await client.upload(file); -} catch (error) { - if (error.message.includes('413')) { - console.error('File too large'); - } else if (error.message.includes('401')) { - console.error('Authentication required'); - } -} -``` - -## Advanced Features - -### Streaming Large Files - -The generated client supports streaming for efficient large file handling: - -```rust -// Server implementation -async fn download(&self, file_id: String) -> Result { - let file = tokio::fs::File::open(path).await?; - let stream = tokio_util::io::ReaderStream::new(file); - let body = axum::body::Body::from_stream(stream); - - Ok(Response::builder() - .header("Content-Type", content_type) - .body(body) - .unwrap()) -} -// Client usage - stream to file -let mut response = client.download("large-file").await?; -let mut file = tokio::fs::File::create("output.bin").await?; -while let Some(chunk) = response.chunk().await? { - file.write_all(&chunk).await?; -} -``` - -### Custom Response Headers - -```rust -async fn download(&self, file_id: String) -> Result { - Ok(Response::builder() - .header("Content-Type", "application/pdf") - .header("Content-Disposition", "inline; filename=\"document.pdf\"") - .header("Cache-Control", "public, max-age=3600") - .body(body) - .unwrap()) -} -``` - -### Progress Tracking - -```typescript -// TypeScript with progress -const formData = new FormData(); -formData.append('file', file); - -const xhr = new XMLHttpRequest(); -xhr.upload.addEventListener('progress', (e) => { - if (e.lengthComputable) { - const percentComplete = (e.loaded / e.total) * 100; - console.log(`Upload progress: ${percentComplete}%`); - } -}); - -xhr.open('POST', `${baseUrl}/api/files/upload`); -xhr.setRequestHeader('Authorization', `Bearer ${token}`); -xhr.send(formData); -``` - -### Multipart Field Handling - -```rust -async fn upload( - &self, - user: &AuthenticatedUser, - mut multipart: Multipart, -) -> Result { - let mut file_data = None; - let mut metadata = HashMap::new(); - - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))? - { - let name = field.name().unwrap_or(""); - - match name { - "file" => { - let filename = field.file_name().unwrap_or("unnamed").to_string(); - let content_type = field.content_type().unwrap_or("application/octet-stream").to_string(); - let data = field - .bytes() - .await - .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))?; - file_data = Some((filename, content_type, data)); + async fn upload_part( + &self, + ctx: &FileRequestContext<'_>, + path: &DocumentServiceUploadPath, + state: &mut Self::UploadState, + part: &mut DocumentServiceUploadPart<'_>, + ) -> FileResult<()> { + match part { + DocumentServiceUploadPart::File(file) => { + while let Some(chunk) = file.next_chunk().await? { + state.write(&chunk).await?; + } + } + DocumentServiceUploadPart::Metadata(metadata) => { + state.metadata = Some(metadata.clone()); } - "description" => { - let value = field - .text() - .await - .map_err(|e| FileStorageFileError::UploadFailed(e.to_string()))?; - metadata.insert("description".to_string(), value); + DocumentServiceUploadPart::Comment(comment) => { + state.comment = Some(comment.clone()); } - _ => {} } + + Ok(()) + } + + async fn upload_finish( + &self, + ctx: &FileRequestContext<'_>, + path: &DocumentServiceUploadPath, + state: Self::UploadState, + summary: ras_file_core::UploadSummary, + ) -> FileResult> { + Ok(JsonResponse::ok(state.into_response())) } - - let (filename, content_type, data) = file_data.ok_or(FileStorageFileError::InvalidFormat)?; - Ok(FileMetadata { - id: uuid::Uuid::new_v4().to_string(), - filename, - size: data.len(), - content_type, - }) } ``` -## File API Example - -Here is a compact file service example with authentication: +If a file part is not fully consumed, the generated handler rejects the request +with a handler contract error. This keeps the multipart stream in a predictable +state and prevents accidental partial reads. -### API Definition +For every upload endpoint the macro also generates an optional `*_abort` +method. Override it when temporary files or external reservations need cleanup: ```rust -// file-api/src/lib.rs -use ras_file_macro::file_service; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, JsonSchema, Clone)] -pub struct UploadResponse { - pub id: String, - pub filename: String, - pub size: usize, - pub url: String, +async fn upload_abort( + &self, + ctx: &FileRequestContext<'_>, + path: &DocumentServiceUploadPath, + state: Self::UploadState, + error: &ras_file_core::FileError, +) { + state.cleanup().await; } - -file_service!({ - service_name: DocumentService, - base_path: "/api/documents", - openapi: true, - body_limit: 104857600, // 100 MB - endpoints: [ - UPLOAD UNAUTHORIZED upload() -> UploadResponse, - UPLOAD WITH_PERMISSIONS(["user"]) upload_secure() -> UploadResponse, - DOWNLOAD UNAUTHORIZED download/{file_id: String}(), - DOWNLOAD WITH_PERMISSIONS(["user"]) download_secure/{file_id: String}(), - ] -}); ``` -### Backend Implementation +Downloads return a `DownloadResponse`: ```rust -// backend/src/main.rs -use axum::Router; -use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; -use std::collections::HashSet; -use std::sync::Arc; -use tower_http::cors::CorsLayer; - -use crate::{file_service::FileServiceImpl, storage::FileStorage}; - -#[derive(Clone)] -struct DemoAuthProvider; - -impl AuthProvider for DemoAuthProvider { - fn authenticate(&self, token: String) -> AuthFuture<'_> { - Box::pin(async move { - if token != "validtoken" { - return Err(AuthError::InvalidToken); - } +use ras_file_core::{DownloadResponse, FileRequestContext, FileResult}; - Ok(AuthenticatedUser { - user_id: "demo-user".to_string(), - permissions: HashSet::from(["user".to_string()]), - metadata: None, - }) - }) - } -} +async fn download_by_file_id( + &self, + ctx: &FileRequestContext<'_>, + path: DocumentServiceDownloadByFileIdPath, +) -> FileResult { + let file = self.storage.open(&path.file_id).await?; -#[tokio::main] -async fn main() { - // Initialize storage and auth - let storage = Arc::new(FileStorage::new("./uploads")); - let service = FileServiceImpl::new(storage); - let auth_provider = DemoAuthProvider; - - // Build file service - let file_router = DocumentServiceBuilder::new(service) - .auth_provider(auth_provider) - .with_usage_tracker(|_headers, method, path| { - println!("File API accessed: {} {}", method, path); - }) - .build(); - - // Create app with CORS - let app = Router::new() - .merge(file_router) - // For cookie auth, replace permissive CORS with an explicit - // credentialed origin allowlist. - .layer(CorsLayer::permissive()); - - // Start server - let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") - .await - .unwrap(); - - println!("File service running on http://127.0.0.1:3000"); - axum::serve(listener, app).await.unwrap(); + DownloadResponse::stream(file.stream) + .content_type(file.content_type)? + .content_length(file.size)? + .attachment(file.original_name) } ``` -### Frontend Usage (TypeScript) +Path parameters become `by_*` method segments. For example, +`download/{file_id: String}` generates `download_by_file_id` and +`DocumentServiceDownloadByFileIdPath`. -```typescript -import { - downloadDownloadSecureFileId, - uploadUploadProfilePicture, -} from './generated'; +## Early Rejection -const baseUrl = 'http://localhost:3000/api/documents'; +The generated server performs these checks before calling service code: -export async function uploadSelectedFile(file: File, token: string) { - const { data, error } = await uploadUploadProfilePicture({ - baseUrl, - headers: { Authorization: `Bearer ${token}` }, - body: { file }, - }); +- authentication, CSRF, and permission checks before reading the upload body; +- `Content-Length` rejection when it exceeds `max_total_bytes`; +- `multipart/form-data` validation for uploads; +- unknown field rejection when `reject_unknown_fields` is true; +- per-part count, content type, filename policy, and byte-limit checks; +- required field checks before `*_finish`. - if (error || !data) { - throw new Error(`Upload failed: ${JSON.stringify(error)}`); - } +This gives the service implementation a narrow job: accept already-declared +parts, stream bytes to storage, and return typed responses. - return data; -} +## Clients -export async function downloadPrivateFile(fileId: string, token: string) { - const { data, error } = await downloadDownloadSecureFileId({ - baseUrl, - headers: { Authorization: `Bearer ${token}` }, - path: { file_id: fileId }, - }); +The generated native client accepts a generated multipart builder: - if (error || !data) { - throw new Error(`Download failed: ${JSON.stringify(error)}`); - } +```rust +let form = DocumentServiceUploadMultipart::new() + .file("report.pdf", Some("report.pdf"), Some("application/pdf")) + .await? + .metadata(&metadata)? + .comment("quarterly report"); - return data; -} +let response = client.upload(form).await?; ``` -## Best Practices - -1. **File Size Limits**: Always set appropriate `body_limit` values to prevent abuse -2. **Content Type Validation**: Validate file types in your implementation -3. **OpenAPI Generation**: Enable `openapi: true` for TypeScript client generation -4. **Type Definitions**: Add `JsonSchema` derive to all types used in endpoints -5. **Virus Scanning**: Consider integrating virus scanning for uploaded files -6. **Storage Strategy**: Use cloud storage (S3, etc.) for production deployments -7. **Cleanup**: Implement file retention policies and cleanup routines -8. **Monitoring**: Use the callback functions to track usage and performance -9. **Security**: Always validate permissions and sanitize file names -10. **CORS**: Configure CORS appropriately for your frontend domains - -## Troubleshooting +Each file part also has a `*_bytes` helper for tests and in-memory uploads. -### Common Issues +## OpenAPI -1. **"File too large" errors**: Check `body_limit` configuration -2. **CORS errors**: Ensure CORS is configured on the server -3. **Authentication failures**: Verify token format and auth provider setup -4. **OpenAPI generation errors**: Ensure `JsonSchema` is derived for all types -5. **TypeScript generation errors**: Check OpenAPI spec is valid JSON -6. **WASM build errors**: Ensure `wasm-pack` is installed and features are enabled -7. **TypeScript type errors**: Regenerate client after API changes +With `openapi: true`, the macro emits `generate__openapi()` and +`generate__openapi_to_file()`. Upload operations include an inline +`multipart/form-data` schema plus an `x-ras-file` extension describing +`maxTotalBytes`, unknown-field policy, and part limits. Download operations +document binary responses and an `x-ras-file` extension for declared content +types and range support. -### Debug Tips +## Checks -- Enable debug logging in your auth provider -- Use browser dev tools to inspect multipart requests -- Check server logs for detailed error messages -- Verify file permissions on upload directory - -This guide covers the core pieces needed to implement a file service with `ras-file-macro`. Production deployments still need project-specific storage, retention, scanning, authentication, and CORS decisions. +```bash +cargo test -p ras-file-macro --locked +cargo test -p file-service-example --locked +cargo test -p file-service-api -p file-service-backend --locked +``` diff --git a/examples/file-service-example/Cargo.toml b/examples/file-service-example/Cargo.toml index 3b72dc8..06ed6ed 100644 --- a/examples/file-service-example/Cargo.toml +++ b/examples/file-service-example/Cargo.toml @@ -16,6 +16,7 @@ tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } ras-file-macro = { path = "../../crates/rest/ras-file-macro", version = "0.1.0" } +ras-file-core = { path = "../../crates/rest/ras-file-core", version = "0.1.0" } ras-auth-core = { path = "../../crates/core/ras-auth-core", version = "0.1.0" } thiserror = { workspace = true } async-trait = { workspace = true } diff --git a/examples/file-service-example/src/main.rs b/examples/file-service-example/src/main.rs index 1459b9d..9f0f3ad 100644 --- a/examples/file-service-example/src/main.rs +++ b/examples/file-service-example/src/main.rs @@ -1,10 +1,5 @@ -use axum::{ - body::Body, - extract::Multipart, - http::StatusCode, - response::{IntoResponse, Response}, -}; use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use ras_file_core::{DownloadResponse, FileRequestContext, JsonResponse}; use ras_file_macro::file_service; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -32,13 +27,30 @@ file_service!({ base_path: "/api/files", endpoints: [ // Public download endpoint - DOWNLOAD UNAUTHORIZED download/{file_id: String}(), + DOWNLOAD UNAUTHORIZED download/{file_id: String} { + content_types: ["text/plain"], + ranges: false, + }, // Authenticated upload endpoint - UPLOAD WITH_PERMISSIONS(["upload"]) upload() -> UploadResponse, + UPLOAD WITH_PERMISSIONS(["upload"]) upload multipart { + max_total_bytes: 52428800, + reject_unknown_fields: true, + parts: [ + file file { + required: true, + max_count: 1, + max_bytes: 52428800, + filename: optional, + }, + ], + } -> UploadResponse, // Admin-only file info endpoint - DOWNLOAD WITH_PERMISSIONS(["admin"]) info/{file_id: String}() -> FileInfo, + DOWNLOAD WITH_PERMISSIONS(["admin"]) info/{file_id: String} { + content_types: ["application/json"], + ranges: false, + }, ] }); @@ -74,86 +86,114 @@ impl AuthProvider for DemoAuthProvider { #[derive(Clone)] struct DocumentServiceImpl; +#[derive(Default)] +struct UploadState { + file_id: Option, + filename: Option, + size: u64, +} + #[async_trait::async_trait] impl DocumentServiceTrait for DocumentServiceImpl { - async fn download( - &self, - file_id: String, - ) -> Result { - // In a real implementation, this would stream from storage - let content = format!("File content for {}", file_id); - let body = Body::from(content); - - Ok(Response::builder() - .status(StatusCode::OK) - .header("content-type", "text/plain") - .header( - "content-disposition", - format!("attachment; filename=\"{}.txt\"", file_id), - ) - .body(body) - .map_err(|e| DocumentServiceFileError::DownloadFailed(e.to_string()))?) - } + type UploadState = UploadState; - async fn upload( + async fn download_by_file_id( &self, - user: &AuthenticatedUser, - mut multipart: Multipart, - ) -> Result { - println!("User {} is uploading a file", user.user_id); - - // Process the first multipart field — that's the uploaded file in the - // demo's contract. Real implementations would loop and accept several. - let field = multipart - .next_field() - .await - .map_err(|e| { - DocumentServiceFileError::UploadFailed(format!("Failed to get next field: {}", e)) - })? - .ok_or_else(|| { - DocumentServiceFileError::UploadFailed("No file in multipart data".to_string()) - })?; - - let name = field.name().unwrap_or("unknown").to_string(); - let file_name = field.file_name().unwrap_or("unknown").to_string(); - let data = field.bytes().await.map_err(|e| { - DocumentServiceFileError::UploadFailed(format!("Failed to read field data: {}", e)) - })?; - - println!( - "Received field '{}' with filename '{}', size: {} bytes", - name, - file_name, - data.len() - ); + _ctx: &FileRequestContext<'_>, + path: DocumentServiceDownloadByFileIdPath, + ) -> Result { + // In a real implementation, this would stream from storage + let content = format!("File content for {}", path.file_id); - // In a real implementation, you would save this to storage - Ok(UploadResponse { - file_id: format!("file_{}", Uuid::new_v4()), - size: data.len() as u64, - filename: file_name, - }) + DownloadResponse::bytes(content) + .content_type("text/plain")? + .attachment(format!("{}.txt", path.file_id)) } - async fn info( + async fn info_by_file_id( &self, - user: &AuthenticatedUser, - file_id: String, - ) -> Result { + ctx: &FileRequestContext<'_>, + path: DocumentServiceInfoByFileIdPath, + ) -> Result { + let user = ctx.user.ok_or(DocumentServiceFileError::Unauthorized)?; println!( "Admin {} requesting info for file {}", - user.user_id, file_id + user.user_id, path.file_id ); // In a real implementation, this would fetch from database let info = FileInfo { - id: file_id.clone(), - name: format!("{}.pdf", file_id), + id: path.file_id.clone(), + name: format!("{}.pdf", path.file_id), size: 1024 * 1024, // 1MB content_type: "application/pdf".to_string(), }; - Ok(axum::Json(info)) + let body = + serde_json::to_vec(&info).map_err(|_error| DocumentServiceFileError::Internal)?; + DownloadResponse::bytes(body).content_type("application/json") + } + + async fn upload_begin( + &self, + ctx: &FileRequestContext<'_>, + _path: &DocumentServiceUploadPath, + ) -> Result { + if let Some(user) = ctx.user { + println!("User {} is uploading a file", user.user_id); + } + + Ok(UploadState::default()) + } + + async fn upload_part( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DocumentServiceUploadPath, + state: &mut Self::UploadState, + part: &mut DocumentServiceUploadPart<'_>, + ) -> Result<(), DocumentServiceFileError> { + match part { + DocumentServiceUploadPart::File(file) => { + let file_name = file.file_name().unwrap_or("unknown").to_string(); + let field_name = file.field_name().to_string(); + let mut size = 0_u64; + + while let Some(chunk) = file.next_chunk().await? { + size += chunk.len() as u64; + } + + println!( + "Received field '{}' with filename '{}', size: {} bytes", + field_name, file_name, size + ); + + // In a real implementation, you would save this to storage. + state.file_id = Some(format!("file_{}", Uuid::new_v4())); + state.size = size; + state.filename = Some(file_name); + } + } + + Ok(()) + } + + async fn upload_finish( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DocumentServiceUploadPath, + state: Self::UploadState, + _summary: ras_file_core::UploadSummary, + ) -> Result, DocumentServiceFileError> { + Ok(JsonResponse::ok(UploadResponse { + file_id: state.file_id.ok_or_else(|| { + DocumentServiceFileError::handler_contract("upload finished without file id") + })?, + size: state.size, + filename: state.filename.ok_or_else(|| { + DocumentServiceFileError::handler_contract("upload finished without filename") + })?, + })) } } @@ -198,11 +238,12 @@ async fn main() -> Result<(), Box> { #[cfg(test)] mod tests { use super::*; - use axum::{body::to_bytes, response::IntoResponse}; + use axum::http::{HeaderMap, StatusCode}; use axum_test::{ TestServer, multipart::{MultipartForm, Part}, }; + use ras_file_core::DownloadBody; fn test_user(user_id: &str, permissions: &[&str]) -> AuthenticatedUser { AuthenticatedUser { @@ -226,6 +267,22 @@ mod tests { .expect("in-memory axum-test server") } + fn test_context<'a>( + headers: &'a HeaderMap, + user: Option<&'a AuthenticatedUser>, + ) -> FileRequestContext<'a> { + FileRequestContext::new("GET", "/test", "/test", headers, user) + } + + fn body_bytes(response: DownloadResponse) -> Vec { + match response.body { + DownloadBody::Bytes(bytes) => bytes.to_vec(), + DownloadBody::Empty | DownloadBody::Stream(_) => { + panic!("expected in-memory response body") + } + } + } + #[tokio::test] async fn demo_auth_provider_maps_user_and_admin_permissions() { let auth = DemoAuthProvider; @@ -256,42 +313,48 @@ mod tests { #[tokio::test] async fn download_returns_text_attachment() { let service = DocumentServiceImpl; + let headers = HeaderMap::new(); + let ctx = test_context(&headers, None); let response = service - .download("test123".to_string()) + .download_by_file_id( + &ctx, + DocumentServiceDownloadByFileIdPath { + file_id: "test123".to_string(), + }, + ) .await - .expect("download response") - .into_response(); + .expect("download response"); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()["content-type"], "text/plain"); + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.headers["content-type"], "text/plain"); assert_eq!( - response.headers()["content-disposition"], + response.headers["content-disposition"], "attachment; filename=\"test123.txt\"" ); - - let body = to_bytes(response.into_body(), usize::MAX) - .await - .expect("body bytes"); - assert_eq!(&body[..], b"File content for test123"); + assert_eq!(body_bytes(response), b"File content for test123"); } #[tokio::test] async fn info_returns_demo_metadata_for_admin_user() { let service = DocumentServiceImpl; let admin = test_user("admin-456", &["admin", "upload"]); + let headers = HeaderMap::new(); + let ctx = test_context(&headers, Some(&admin)); let response = service - .info(&admin, "report".to_string()) + .info_by_file_id( + &ctx, + DocumentServiceInfoByFileIdPath { + file_id: "report".to_string(), + }, + ) .await - .expect("info response") - .into_response(); + .expect("info response"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status, StatusCode::OK); - let body = to_bytes(response.into_body(), usize::MAX) - .await - .expect("body bytes"); + let body = body_bytes(response); let info: FileInfo = serde_json::from_slice(&body).expect("file info json"); assert_eq!(info.id, "report"); diff --git a/examples/file-service-wasm/README.md b/examples/file-service-wasm/README.md index 8d33add..a48ecb2 100644 --- a/examples/file-service-wasm/README.md +++ b/examples/file-service-wasm/README.md @@ -34,12 +34,11 @@ The Rust client and TypeScript usage sample both come from the same API definition. Native Rust uploads stream files from disk: ```rust -pub async fn upload( - &self, - file_path: impl AsRef, - file_name: Option<&str>, - content_type: Option<&str> -) -> Result> +let form = DocumentServiceUploadMultipart::new() + .file("report.pdf", Some("report.pdf"), Some("application/pdf")) + .await?; + +let response = client.upload(form).await?; ``` The TypeScript sample assumes a generated fetch client at diff --git a/examples/file-service-wasm/file-service-api/Cargo.toml b/examples/file-service-wasm/file-service-api/Cargo.toml index 90aa94f..3003b8d 100644 --- a/examples/file-service-wasm/file-service-api/Cargo.toml +++ b/examples/file-service-wasm/file-service-api/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["rlib"] [dependencies] ras-file-macro = { path = "../../../crates/rest/ras-file-macro", version = "0.1.0" } +ras-file-core = { path = "../../../crates/rest/ras-file-core", version = "0.1.0" } ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } serde = { workspace = true, features = ["derive"] } async-trait = { workspace = true } diff --git a/examples/file-service-wasm/file-service-api/src/lib.rs b/examples/file-service-wasm/file-service-api/src/lib.rs index 683e7e2..519f1db 100644 --- a/examples/file-service-wasm/file-service-api/src/lib.rs +++ b/examples/file-service-wasm/file-service-api/src/lib.rs @@ -25,19 +25,43 @@ file_service!({ service_name: DocumentService, base_path: "/api/documents", openapi: true, - body_limit: 104857600, // 100 MB endpoints: [ - UPLOAD UNAUTHORIZED upload() -> UploadResponse, - UPLOAD WITH_PERMISSIONS(["user"]) upload_profile_picture() -> UploadResponse, - DOWNLOAD UNAUTHORIZED download/{file_id:String}() -> (), - DOWNLOAD WITH_PERMISSIONS(["user"]) download_secure/{file_id:String}() -> (), + UPLOAD UNAUTHORIZED upload multipart { + max_total_bytes: 104857600, + reject_unknown_fields: true, + parts: [ + file file { + required: true, + max_count: 1, + max_bytes: 104857600, + filename: optional, + }, + ], + } -> UploadResponse, + UPLOAD WITH_PERMISSIONS(["user"]) upload_profile_picture multipart { + max_total_bytes: 104857600, + reject_unknown_fields: true, + parts: [ + file file { + required: true, + max_count: 1, + max_bytes: 104857600, + content_types: ["image/png", "image/jpeg", "image/webp"], + filename: required, + }, + ], + } -> UploadResponse, + DOWNLOAD UNAUTHORIZED download/{file_id:String} { + content_types: ["application/octet-stream"], + ranges: true, + }, + DOWNLOAD WITH_PERMISSIONS(["user"]) download_secure/{file_id:String} { + content_types: ["application/octet-stream"], + ranges: true, + }, ] }); -// Re-export the macro-generated WASM client when the feature is enabled -#[cfg(all(target_arch = "wasm32", feature = "wasm-client"))] -pub use wasm_client::*; - #[cfg(test)] mod tests { use super::*; @@ -137,15 +161,26 @@ mod tests { assert_eq!(doc["servers"][0]["url"], "/api/documents"); let public_upload = &doc["paths"]["/upload"]["post"]; + let public_upload_schema = + &public_upload["requestBody"]["content"]["multipart/form-data"]["schema"]; assert_eq!( - public_upload["requestBody"]["content"]["multipart/form-data"]["schema"]["$ref"], - "#/components/schemas/FileUploadRequest" + public_upload_schema["properties"]["file"]["format"], + json!("binary") + ); + assert_eq!(public_upload_schema["required"], json!(["file"])); + assert_eq!( + public_upload["x-ras-file"]["maxTotalBytes"], + json!(104857600) ); assert!(public_upload.get("security").is_none()); let profile_upload = &doc["paths"]["/upload_profile_picture"]["post"]; assert_eq!(profile_upload["security"][0]["bearerAuth"], json!([])); assert_eq!(profile_upload["x-permissions"], json!(["user"])); + assert_eq!( + profile_upload["requestBody"]["content"]["multipart/form-data"]["encoding"]["file"]["contentType"], + json!("image/png, image/jpeg, image/webp") + ); } #[test] @@ -173,13 +208,6 @@ mod tests { fn generated_openapi_includes_file_operation_component_schemas() { let doc = generate_documentservice_openapi(); - let upload_schema = &doc["components"]["schemas"]["FileUploadRequest"]; - assert_eq!(upload_schema["required"], json!(["file"])); - assert_eq!( - upload_schema["properties"]["file"]["format"], - json!("binary") - ); - let download_schema = &doc["components"]["schemas"]["BinaryFileResponse"]; assert_eq!(download_schema["type"], json!("string")); assert_eq!(download_schema["format"], json!("binary")); diff --git a/examples/file-service-wasm/file-service-backend/Cargo.toml b/examples/file-service-wasm/file-service-backend/Cargo.toml index 83ef640..56857d1 100644 --- a/examples/file-service-wasm/file-service-backend/Cargo.toml +++ b/examples/file-service-wasm/file-service-backend/Cargo.toml @@ -21,6 +21,7 @@ tower-http = { workspace = true, features = ["cors", "fs"] } # Authentication ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } +ras-file-core = { path = "../../../crates/rest/ras-file-core", version = "0.1.0" } async-trait = { workspace = true } # Error handling diff --git a/examples/file-service-wasm/file-service-backend/README.md b/examples/file-service-wasm/file-service-backend/README.md index 1460718..87df746 100644 --- a/examples/file-service-wasm/file-service-backend/README.md +++ b/examples/file-service-wasm/file-service-backend/README.md @@ -85,39 +85,49 @@ Environment variables: - Files are stored with UUID names to prevent path traversal - The mock bearer token includes user permissions for role-based access - CORS is configured to allow all origins for the demo; restrict it for shared environments. -- The generated service applies the API crate's 100 MB `body_limit`; choose a limit that matches your deployment. +- The generated service applies each upload endpoint's declared `max_total_bytes` and per-part `max_bytes`; choose limits that match your deployment. ## Development The service implementation follows the trait generated by the `file_service!` macro: ```rust -use axum::{body::Body, extract::Multipart, response::Response}; -use ras_auth_core::AuthenticatedUser; +use ras_file_core::{DownloadResponse, FileRequestContext, JsonResponse}; #[async_trait] pub trait DocumentServiceTrait: Send + Sync { - async fn upload( + type UploadState: Send; + type UploadProfilePictureState: Send; + + async fn upload_begin( &self, - multipart: Multipart, - ) -> Result; + ctx: &FileRequestContext<'_>, + path: &DocumentServiceUploadPath, + ) -> Result; - async fn upload_profile_picture( + async fn upload_part( &self, - user: &AuthenticatedUser, - multipart: Multipart, - ) -> Result; + ctx: &FileRequestContext<'_>, + path: &DocumentServiceUploadPath, + state: &mut Self::UploadState, + part: &mut DocumentServiceUploadPart<'_>, + ) -> Result<(), DocumentServiceFileError>; - async fn download( + async fn upload_finish( &self, - file_id: String, - ) -> Result, DocumentServiceFileError>; + ctx: &FileRequestContext<'_>, + path: &DocumentServiceUploadPath, + state: Self::UploadState, + summary: ras_file_core::UploadSummary, + ) -> Result, DocumentServiceFileError>; - async fn download_secure( + async fn download_by_file_id( &self, - user: &AuthenticatedUser, - file_id: String, - ) -> Result, DocumentServiceFileError>; + ctx: &FileRequestContext<'_>, + path: DocumentServiceDownloadByFileIdPath, + ) -> Result; + + // The secured upload/download endpoints generate the same lifecycle shape. } ``` diff --git a/examples/file-service-wasm/file-service-backend/src/file_service.rs b/examples/file-service-wasm/file-service-backend/src/file_service.rs index ca483a3..3821c18 100644 --- a/examples/file-service-wasm/file-service-backend/src/file_service.rs +++ b/examples/file-service-wasm/file-service-backend/src/file_service.rs @@ -1,9 +1,11 @@ use async_trait::async_trait; -use axum::body::Body; -use axum::http::{StatusCode, header}; -use axum::response::Response; -use file_service_api::{DocumentServiceFileError, DocumentServiceTrait, UploadResponse}; -use ras_auth_core::AuthenticatedUser; +use file_service_api::{ + DocumentServiceDownloadByFileIdPath, DocumentServiceDownloadSecureByFileIdPath, + DocumentServiceFileError, DocumentServiceTrait, DocumentServiceUploadPart, + DocumentServiceUploadPath, DocumentServiceUploadProfilePicturePart, + DocumentServiceUploadProfilePicturePath, UploadResponse, +}; +use ras_file_core::{DownloadResponse, FileRequestContext, IncomingFile, JsonResponse}; use std::sync::Arc; use tracing::{debug, error}; @@ -19,137 +21,187 @@ impl FileServiceImpl { Self { storage } } - async fn handle_multipart_upload( + async fn handle_file_upload( &self, - mut multipart: axum::extract::Multipart, + file: &mut IncomingFile<'_>, ) -> Result { - debug!("Starting multipart upload processing"); - - while let Some(field) = multipart.next_field().await.map_err(|e| { - error!("Failed to get next multipart field: {}", e); - DocumentServiceFileError::UploadFailed(format!("Error parsing multipart: {}", e)) - })? { - debug!("Processing field: {:?}", field.name()); - if field.name() == Some("file") { - let file_name = field.file_name().unwrap_or("unknown").to_string(); - - let content_type = field.content_type().map(|ct| ct.to_string()); - - debug!("Receiving file: {} (type: {:?})", file_name, content_type); - - // Read file data - let data = field.bytes().await.map_err(|e| { - error!("Failed to read field bytes: {:?}", e); - error!("Error type: {}", std::any::type_name_of_val(&e)); - DocumentServiceFileError::UploadFailed(format!( - "Failed to read file data: {}", - e - )) - })?; - let data_vec = data.to_vec(); - - // Save to storage - let metadata = self - .storage - .save_file(data_vec, &file_name, content_type) - .await - .map_err(|e| { - error!("Failed to save file: {}", e); - DocumentServiceFileError::Internal(e.to_string()) - })?; - - debug!( - file_id = %metadata.id, - stored_path = %metadata.stored_path.display(), - "Saved uploaded file" - ); - - return Ok(UploadResponse { - file_id: metadata.id, - file_name: metadata.original_name, - size: metadata.size, - }); + let file_name = file.file_name().unwrap_or("unknown").to_string(); + let content_type = file.content_type().map(ToString::to_string); + + debug!("Receiving file: {} (type: {:?})", file_name, content_type); + + let mut data = Vec::new(); + while let Some(chunk) = file.next_chunk().await? { + data.extend_from_slice(&chunk); + } + + let metadata = self + .storage + .save_file(data, &file_name, content_type) + .await + .map_err(|e| { + error!("Failed to save file: {}", e); + DocumentServiceFileError::Internal + })?; + + debug!( + file_id = %metadata.id, + stored_path = %metadata.stored_path.display(), + "Saved uploaded file" + ); + + Ok(UploadResponse { + file_id: metadata.id, + file_name: metadata.original_name, + size: metadata.size, + }) + } + + async fn download_response( + &self, + file_id: String, + ) -> Result { + let (data, metadata) = self.storage.get_file(&file_id).await.map_err(|e| { + error!("Failed to get file: {}", e); + match e.to_string().contains("not found") { + true => DocumentServiceFileError::NotFound, + false => DocumentServiceFileError::download_failed(e.to_string()), } + })?; + + let size = data.len() as u64; + let mut response = DownloadResponse::bytes(data).content_length(size)?; + + if let Some(meta) = metadata { + if let Some(content_type) = meta.content_type { + response = response.content_type(content_type)?; + } + + response = response.attachment(meta.original_name)?; } - Err(DocumentServiceFileError::UploadFailed( - "No file field found in multipart data".to_string(), - )) + Ok(response) } } +#[derive(Default)] +pub struct UploadState { + response: Option, +} + #[async_trait] impl DocumentServiceTrait for FileServiceImpl { - async fn upload( + type UploadState = UploadState; + type UploadProfilePictureState = UploadState; + + async fn upload_begin( &self, - multipart: axum::extract::Multipart, - ) -> Result { + _ctx: &FileRequestContext<'_>, + _path: &DocumentServiceUploadPath, + ) -> Result { debug!("Handling public file upload"); - self.handle_multipart_upload(multipart).await + Ok(UploadState::default()) } - async fn upload_profile_picture( + async fn upload_part( &self, - user: &AuthenticatedUser, - multipart: axum::extract::Multipart, - ) -> Result { - debug!("Handling secure file upload for user: {}", user.user_id); - - self.handle_multipart_upload(multipart).await + _ctx: &FileRequestContext<'_>, + _path: &DocumentServiceUploadPath, + state: &mut Self::UploadState, + part: &mut DocumentServiceUploadPart<'_>, + ) -> Result<(), DocumentServiceFileError> { + match part { + DocumentServiceUploadPart::File(file) => { + state.response = Some(self.handle_file_upload(file).await?); + } + } + Ok(()) } - async fn download(&self, file_id: String) -> Result, DocumentServiceFileError> { - debug!("Handling public file download: {}", file_id); - - let (data, metadata) = self.storage.get_file(&file_id).await.map_err(|e| { - error!("Failed to get file: {}", e); - match e.to_string().contains("not found") { - true => DocumentServiceFileError::NotFound, - false => DocumentServiceFileError::DownloadFailed(e.to_string()), - } + async fn upload_finish( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DocumentServiceUploadPath, + state: Self::UploadState, + _summary: ras_file_core::UploadSummary, + ) -> Result, DocumentServiceFileError> { + let response = state.response.ok_or_else(|| { + DocumentServiceFileError::handler_contract("upload finished without a file") })?; + Ok(JsonResponse::ok(response)) + } - let mut response = Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_LENGTH, data.len()); + async fn upload_profile_picture_begin( + &self, + ctx: &FileRequestContext<'_>, + _path: &DocumentServiceUploadProfilePicturePath, + ) -> Result { + if let Some(user) = ctx.user { + debug!("Handling secure file upload for user: {}", user.user_id); + } + Ok(UploadState::default()) + } - // Set content type if available - if let Some(meta) = metadata { - if let Some(content_type) = meta.content_type { - response = response.header(header::CONTENT_TYPE, content_type); + async fn upload_profile_picture_part( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DocumentServiceUploadProfilePicturePath, + state: &mut Self::UploadProfilePictureState, + part: &mut DocumentServiceUploadProfilePicturePart<'_>, + ) -> Result<(), DocumentServiceFileError> { + match part { + DocumentServiceUploadProfilePicturePart::File(file) => { + state.response = Some(self.handle_file_upload(file).await?); } - - // Set content disposition for download - response = response.header( - header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", meta.original_name), - ); } + Ok(()) + } - response - .body(Body::from(data)) - .map_err(|_| DocumentServiceFileError::Internal("Failed to build response".to_string())) + async fn upload_profile_picture_finish( + &self, + _ctx: &FileRequestContext<'_>, + _path: &DocumentServiceUploadProfilePicturePath, + state: Self::UploadProfilePictureState, + _summary: ras_file_core::UploadSummary, + ) -> Result, DocumentServiceFileError> { + let response = state.response.ok_or_else(|| { + DocumentServiceFileError::handler_contract("profile upload finished without a file") + })?; + Ok(JsonResponse::ok(response)) } - async fn download_secure( + async fn download_by_file_id( &self, - user: &AuthenticatedUser, - file_id: String, - ) -> Result, DocumentServiceFileError> { - debug!( - "Handling secure file download for user {}: {}", - user.user_id, file_id - ); + _ctx: &FileRequestContext<'_>, + path: DocumentServiceDownloadByFileIdPath, + ) -> Result { + debug!("Handling public file download: {}", path.file_id); + self.download_response(path.file_id).await + } + + async fn download_secure_by_file_id( + &self, + ctx: &FileRequestContext<'_>, + path: DocumentServiceDownloadSecureByFileIdPath, + ) -> Result { + if let Some(user) = ctx.user { + debug!( + "Handling secure file download for user {}: {}", + user.user_id, path.file_id + ); + } // In a real app, you might check if the user has access to this file - self.download(file_id).await + self.download_response(path.file_id).await } } #[cfg(test)] mod tests { use super::*; - use axum::body::to_bytes; + use axum::http::{HeaderMap, StatusCode, header}; + use ras_auth_core::AuthenticatedUser; + use ras_file_core::DownloadBody; use std::collections::HashSet; use tempfile::TempDir; @@ -165,6 +217,22 @@ mod tests { FileServiceImpl::new(Arc::new(FileStorage::new(temp_dir.path()))) } + fn test_context<'a>( + headers: &'a HeaderMap, + user: Option<&'a ras_auth_core::AuthenticatedUser>, + ) -> FileRequestContext<'a> { + FileRequestContext::new("GET", "/test", "/test", headers, user) + } + + fn body_bytes(response: DownloadResponse) -> Vec { + match response.body { + DownloadBody::Bytes(bytes) => bytes.to_vec(), + DownloadBody::Empty | DownloadBody::Stream(_) => { + panic!("expected in-memory response body") + } + } + } + #[tokio::test] async fn download_returns_saved_file_with_headers() { let temp_dir = TempDir::new().expect("temp dir"); @@ -178,32 +246,47 @@ mod tests { .await .expect("save file"); let service = FileServiceImpl::new(storage); + let headers = HeaderMap::new(); + let ctx = test_context(&headers, None); - let response = service.download(saved.id).await.expect("download response"); + let response = service + .download_by_file_id( + &ctx, + DocumentServiceDownloadByFileIdPath { file_id: saved.id }, + ) + .await + .expect("download response"); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[header::CONTENT_LENGTH], "13"); - assert_eq!(response.headers()[header::CONTENT_TYPE], "text/plain"); + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.headers[header::CONTENT_LENGTH], "13"); + assert_eq!(response.headers[header::CONTENT_TYPE], "text/plain"); assert_eq!( - response.headers()[header::CONTENT_DISPOSITION], + response.headers[header::CONTENT_DISPOSITION], "attachment; filename=\"file.txt\"" ); - let body = to_bytes(response.into_body(), usize::MAX) - .await - .expect("response body"); - assert_eq!(&body[..], b"download body"); + assert_eq!(body_bytes(response), b"download body"); } #[tokio::test] async fn download_missing_file_maps_to_not_found() { let temp_dir = TempDir::new().expect("temp dir"); let service = test_service(&temp_dir); - - let error = service - .download("missing".to_string()) - .await - .expect_err("missing file should be not found"); + let headers = HeaderMap::new(); + let ctx = test_context(&headers, None); + + let result = service + .download_by_file_id( + &ctx, + DocumentServiceDownloadByFileIdPath { + file_id: "missing".to_string(), + }, + ) + .await; + let error = match result { + Ok(_) => panic!("missing file should be not found"), + Err(error) => error, + }; assert!(matches!(error, DocumentServiceFileError::NotFound)); } @@ -217,15 +300,18 @@ mod tests { .await .expect("save file"); let service = FileServiceImpl::new(storage); + let headers = HeaderMap::new(); + let user = test_user(); + let ctx = test_context(&headers, Some(&user)); let response = service - .download_secure(&test_user(), saved.id) + .download_secure_by_file_id( + &ctx, + DocumentServiceDownloadSecureByFileIdPath { file_id: saved.id }, + ) .await .expect("secure download response"); - let body = to_bytes(response.into_body(), usize::MAX) - .await - .expect("response body"); - assert_eq!(&body[..], b"secure body"); + assert_eq!(body_bytes(response), b"secure body"); } } From 96b11a6146518a8a485f8d916384ef98031f1855 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Mon, 25 May 2026 13:57:49 +0200 Subject: [PATCH 12/14] Add mdBook docs and API feature guidance --- .github/workflows/ci.yml | 54 +- Cargo.lock | 6 - README.md | 28 +- book.toml | 14 + crates/rest/ras-file-macro/Cargo.toml | 5 + crates/rest/ras-file-macro/README.md | 5 +- crates/rest/ras-file-macro/src/lib.rs | 13 +- crates/rest/ras-file-macro/src/parser.rs | 132 ++- .../rest/ras-file-macro/tests/integration.rs | 4 +- crates/rest/ras-rest-macro/README.md | 4 + .../ras-jsonrpc-bidirectional-macro/README.md | 17 +- crates/rpc/ras-jsonrpc-macro/README.md | 12 +- crates/rpc/ras-jsonrpc-macro/src/lib.rs | 95 +- documentation/ras-file-macro.md | 219 +---- documentation/ras-identity.md | 671 +------------ documentation/ras-observability.md | 465 +-------- documentation/ras-rest-macro.md | 891 +----------------- documentation/src/SUMMARY.md | 28 + documentation/src/auth-in-api-contract.md | 71 ++ .../src/generated-specs-and-clients.md | 90 ++ documentation/src/identity-and-sessions.md | 54 ++ documentation/src/introduction.md | 27 + .../macros/bidirectional-jsonrpc-service.md | 170 ++++ documentation/src/macros/file-service.md | 290 ++++++ documentation/src/macros/jsonrpc-service.md | 188 ++++ documentation/src/macros/rest-service.md | 212 +++++ documentation/src/observability.md | 53 ++ documentation/src/tutorial/build-clients.md | 135 +++ .../src/tutorial/create-the-api-crate.md | 184 ++++ .../src/tutorial/design-the-contract.md | 104 ++ .../src/tutorial/implement-the-server.md | 202 ++++ documentation/src/tutorial/index.md | 66 ++ .../src/tutorial/test-ship-and-evolve.md | 115 +++ .../src/why-typed-service-definitions.md | 41 + examples/basic-jsonrpc/api/Cargo.toml | 8 +- examples/basic-jsonrpc/api/README.md | 5 +- examples/basic-jsonrpc/service/Cargo.toml | 2 +- examples/bidirectional-chat/api/Cargo.toml | 8 +- examples/bidirectional-chat/api/README.md | 4 +- examples/bidirectional-chat/server/Cargo.toml | 5 +- examples/bidirectional-chat/tui/Cargo.toml | 2 +- examples/file-service-example/Cargo.toml | 7 +- examples/file-service-example/README.md | 1 + .../file-service-api/Cargo.toml | 44 +- .../file-service-api/README.md | 5 +- .../file-service-api/src/lib.rs | 14 +- examples/oauth2-demo/api/Cargo.toml | 14 +- examples/oauth2-demo/api/README.md | 3 + examples/oauth2-demo/server/Cargo.toml | 4 +- .../rest-wasm-example/rest-api/Cargo.toml | 31 +- 50 files changed, 2399 insertions(+), 2423 deletions(-) create mode 100644 book.toml create mode 100644 documentation/src/SUMMARY.md create mode 100644 documentation/src/auth-in-api-contract.md create mode 100644 documentation/src/generated-specs-and-clients.md create mode 100644 documentation/src/identity-and-sessions.md create mode 100644 documentation/src/introduction.md create mode 100644 documentation/src/macros/bidirectional-jsonrpc-service.md create mode 100644 documentation/src/macros/file-service.md create mode 100644 documentation/src/macros/jsonrpc-service.md create mode 100644 documentation/src/macros/rest-service.md create mode 100644 documentation/src/observability.md create mode 100644 documentation/src/tutorial/build-clients.md create mode 100644 documentation/src/tutorial/create-the-api-crate.md create mode 100644 documentation/src/tutorial/design-the-contract.md create mode 100644 documentation/src/tutorial/implement-the-server.md create mode 100644 documentation/src/tutorial/index.md create mode 100644 documentation/src/tutorial/test-ship-and-evolve.md create mode 100644 documentation/src/why-typed-service-definitions.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 452c9e6..4da35d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,44 @@ jobs: - name: Build rustdoc run: cargo doc --workspace --all-features --no-deps --locked + mdbook: + name: mdBook + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - uses: taiki-e/install-action@mdbook + + - name: Build mdBook + run: mdbook build + + - name: Configure GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: actions/configure-pages@v5 + + - name: Upload GitHub Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: actions/upload-pages-artifact@v4 + with: + path: target/mdbook + + deploy-pages: + name: Deploy GitHub Pages + runs-on: ubuntu-latest + needs: mdbook + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy artifact + id: deployment + uses: actions/deploy-pages@v4 + docs-hygiene: name: Documentation hygiene runs-on: ubuntu-latest @@ -144,7 +182,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Check macro crates without defaults - run: cargo check -p ras-rest-macro -p ras-jsonrpc-macro -p ras-jsonrpc-bidirectional-macro --no-default-features --locked + run: cargo check -p ras-rest-macro -p ras-jsonrpc-macro -p ras-jsonrpc-bidirectional-macro -p ras-file-macro --no-default-features --locked - name: Check REST macro server-only run: cargo check -p ras-rest-macro --no-default-features --features server --locked @@ -170,6 +208,18 @@ jobs: - name: Check bidirectional WASM client run: cargo check -p ras-jsonrpc-bidirectional-client --no-default-features --features wasm --target wasm32-unknown-unknown --locked + - name: Check example API crates without defaults + run: cargo check -p basic-jsonrpc-api -p rest-api -p file-service-api -p bidirectional-chat-api -p oauth2-demo-api --no-default-features --locked + + - name: Check example API crates server-only + run: cargo check -p basic-jsonrpc-api -p rest-api -p file-service-api -p bidirectional-chat-api -p oauth2-demo-api --no-default-features --features basic-jsonrpc-api/server,rest-api/server,file-service-api/server,bidirectional-chat-api/server,oauth2-demo-api/server --locked + + - name: Check example API crates client-only + run: cargo check -p basic-jsonrpc-api -p rest-api -p file-service-api -p bidirectional-chat-api -p oauth2-demo-api --no-default-features --features basic-jsonrpc-api/client,rest-api/client,file-service-api/client,bidirectional-chat-api/client,oauth2-demo-api/client --locked + + - name: Check inline file-service example server feature + run: cargo check -p file-service-example --no-default-features --features server --locked + playwright: name: Playwright E2E runs-on: ubuntu-latest @@ -258,7 +308,7 @@ jobs: -p file-service-api --target wasm32-unknown-unknown --no-default-features - --features basic-jsonrpc-api/client,rest-api/client,file-service-api/wasm-client + --features basic-jsonrpc-api/client,rest-api/client,file-service-api/client --locked - name: Setup Node diff --git a/Cargo.lock b/Cargo.lock index d53cfdd..ec4ea0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,7 +298,6 @@ dependencies = [ "ras-identity-core", "ras-identity-local", "ras-identity-session", - "ras-jsonrpc-bidirectional-macro", "ras-jsonrpc-bidirectional-server", "ras-jsonrpc-bidirectional-types", "ras-jsonrpc-types", @@ -1075,8 +1074,6 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", - "axum-extra", - "http", "js-sys", "ras-auth-core", "ras-file-core", @@ -1089,7 +1086,6 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", - "tower", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -3126,7 +3122,6 @@ dependencies = [ "async-trait", "axum", "axum-extra", - "http", "ras-auth-core", "ras-rest-core", "ras-rest-macro", @@ -3136,7 +3131,6 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tower", "tracing", ] diff --git a/README.md b/README.md index 7b95db0..b1baefc 100644 --- a/README.md +++ b/README.md @@ -260,16 +260,20 @@ Reactive web UI with Dominator and the generated JSON-RPC client. ## Documentation -Detailed guides: -- [REST Macro Guide](documentation/ras-rest-macro.md) - REST API guide -- [File Service Guide](documentation/ras-file-macro.md) - File upload/download services -- [Identity Providers](documentation/ras-identity.md) - Authentication system guide -- [Observability](documentation/ras-observability.md) - Metrics and monitoring - -Package-level guides: -- [JSON-RPC Macro](crates/rpc/ras-jsonrpc-macro/README.md) - JSON-RPC service generation, OpenRPC output, and generated clients +The canonical documentation is the mdBook source under +[documentation/src](documentation/src/SUMMARY.md). Start with: + +- [Build A Typed Workspace App](documentation/src/tutorial/index.md) +- [Why Typed Service Definitions](documentation/src/why-typed-service-definitions.md) +- [Auth In The API Contract](documentation/src/auth-in-api-contract.md) +- [`jsonrpc_service!`](documentation/src/macros/jsonrpc-service.md) +- [`rest_service!`](documentation/src/macros/rest-service.md) +- [`file_service!`](documentation/src/macros/file-service.md) +- [`jsonrpc_bidirectional_service!`](documentation/src/macros/bidirectional-jsonrpc-service.md) + +Package-level README files remain available for crate-specific details: + - [JSON-RPC Core](crates/rpc/ras-jsonrpc-core/README.md) - runtime auth and JSON-RPC support types -- [Bidirectional JSON-RPC Macro](crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md) - WebSocket service generation - [Bidirectional JSON-RPC Server](crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/README.md) - server-side WebSocket runtime - [Bidirectional JSON-RPC Client](crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/README.md) - native and WASM WebSocket clients @@ -348,6 +352,12 @@ Markdown links resolve. The checks are implemented directly in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) so the repository does not need separate verification scripts. +The mdBook can be built locally with: + +```bash +mdbook build +``` + ### Supply Chain Policy The tracked [`deny.toml`](deny.toml) is enforced in CI with `cargo-deny`. diff --git a/book.toml b/book.toml new file mode 100644 index 0000000..d23d056 --- /dev/null +++ b/book.toml @@ -0,0 +1,14 @@ +[book] +title = "Rust Agent Stack API Builder Guide" +description = "Rationale and usage guide for the Rust Agent Stack service macros." +authors = ["Rust Agent Stack contributors"] +language = "en" +multilingual = false +src = "documentation/src" + +[build] +build-dir = "target/mdbook" + +[output.html] +git-repository-url = "https://github.com/JedimEmO/rust-api-stack" +edit-url-template = "https://github.com/JedimEmO/rust-api-stack/edit/master/{path}" diff --git a/crates/rest/ras-file-macro/Cargo.toml b/crates/rest/ras-file-macro/Cargo.toml index 8537702..6c34cbd 100644 --- a/crates/rest/ras-file-macro/Cargo.toml +++ b/crates/rest/ras-file-macro/Cargo.toml @@ -12,6 +12,11 @@ readme = "README.md" [lib] proc-macro = true +[features] +default = ["server", "client"] +server = [] +client = [] + [dependencies] syn = { workspace = true, features = ["full", "extra-traits", "visit-mut"] } quote = { workspace = true } diff --git a/crates/rest/ras-file-macro/README.md b/crates/rest/ras-file-macro/README.md index 714ba0e..728d681 100644 --- a/crates/rest/ras-file-macro/README.md +++ b/crates/rest/ras-file-macro/README.md @@ -47,8 +47,9 @@ file_service!({ }); ``` -See [documentation/ras-file-macro.md](../../../documentation/ras-file-macro.md) -for the usage guide and runnable examples. +See the canonical mdBook +[`file_service!` guide](../../../documentation/src/macros/file-service.md) for +the usage guide and runnable examples. ## Checks diff --git a/crates/rest/ras-file-macro/src/lib.rs b/crates/rest/ras-file-macro/src/lib.rs index 71f5467..4fd24ea 100644 --- a/crates/rest/ras-file-macro/src/lib.rs +++ b/crates/rest/ras-file-macro/src/lib.rs @@ -31,36 +31,37 @@ pub fn file_service(input: TokenStream) -> TokenStream { let openapi_mod = format_ident!("__ras_file_{}_openapi", service_name_lower); let client_mod = format_ident!("__ras_file_{}_client", service_name_lower); - // Only include server code when not targeting wasm32 let expanded = quote! { - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "server")] mod #server_mod { use super::*; #server_code } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "server")] pub use #server_mod::*; - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "server")] const _: () = { #schema_checks }; - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "server")] mod #openapi_mod { use super::*; #openapi_code } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "server")] pub use #openapi_mod::*; + #[cfg(feature = "client")] mod #client_mod { use super::*; #client_code } + #[cfg(feature = "client")] pub use #client_mod::*; }; diff --git a/crates/rest/ras-file-macro/src/parser.rs b/crates/rest/ras-file-macro/src/parser.rs index 39e387c..4d1c712 100644 --- a/crates/rest/ras-file-macro/src/parser.rs +++ b/crates/rest/ras-file-macro/src/parser.rs @@ -457,51 +457,22 @@ fn parse_auth(input: ParseStream) -> Result { let content; syn::parenthesized!(content in input); - let perms_content; - syn::bracketed!(perms_content in content); - - let mut permission_groups = Vec::new(); - - while !perms_content.is_empty() { - if perms_content.peek(LitStr) { - let perm: LitStr = perms_content.parse()?; - permission_groups.push(vec![perm.value()]); - } else if perms_content.peek(token::Bracket) { - let group_content; - syn::bracketed!(group_content in perms_content); - - let mut group = Vec::new(); - while !group_content.is_empty() { - let perm: LitStr = group_content.parse()?; - group.push(perm.value()); - if group_content.peek(Token![,]) { - group_content.parse::()?; - } - } + let first_group_content; + syn::bracketed!(first_group_content in content); + let mut permission_groups = vec![parse_permission_group(&first_group_content)?]; - if group.is_empty() { - return Err(Error::new( - group_content.span(), - "Permission groups cannot be empty", - )); - } - permission_groups.push(group); - } else { - return Err(Error::new( - perms_content.span(), - "Expected permission string or group", - )); - } + while content.peek(Token![|]) { + content.parse::()?; - if perms_content.peek(Token![,]) { - perms_content.parse::()?; - } + let group_content; + syn::bracketed!(group_content in content); + permission_groups.push(parse_permission_group(&group_content)?); } - if permission_groups.is_empty() { + if !content.is_empty() { return Err(Error::new( - perms_content.span(), - "WITH_PERMISSIONS requires at least one permission", + content.span(), + "Expected `|` followed by another permission group", )); } @@ -514,6 +485,20 @@ fn parse_auth(input: ParseStream) -> Result { } } +fn parse_permission_group(input: ParseStream) -> Result> { + let mut group = Vec::new(); + while !input.is_empty() { + let perm: LitStr = input.parse()?; + group.push(perm.value()); + + if input.peek(Token![,]) { + input.parse::()?; + } + } + + Ok(group) +} + fn parse_endpoint_path(input: ParseStream) -> Result<(Ident, LitStr, Vec)> { let mut segments = Vec::new(); let mut params = Vec::new(); @@ -603,6 +588,73 @@ mod tests { ); } + fn parse_definition_with_auth(auth: &str) -> FileServiceDefinition { + syn::parse_str::(&format!( + r#"{{ + service_name: Files, + base_path: "/files", + endpoints: [ + DOWNLOAD {auth} download/{{file_id: String}}, + ] + }}"# + )) + .expect("definition should parse") + } + + #[test] + fn parses_permission_group_with_and_semantics() { + let definition = parse_definition_with_auth(r#"WITH_PERMISSIONS(["admin", "moderator"])"#); + let AuthRequirement::WithPermissions(groups) = &definition.endpoints[0].auth else { + panic!("expected permission auth"); + }; + + assert_eq!( + groups, + &[vec!["admin".to_string(), "moderator".to_string()]] + ); + } + + #[test] + fn parses_permission_groups_with_or_semantics() { + let definition = + parse_definition_with_auth(r#"WITH_PERMISSIONS(["admin"] | ["moderator", "editor"])"#); + let AuthRequirement::WithPermissions(groups) = &definition.endpoints[0].auth else { + panic!("expected permission auth"); + }; + + assert_eq!( + groups, + &[ + vec!["admin".to_string()], + vec!["moderator".to_string(), "editor".to_string()], + ] + ); + } + + #[test] + fn parses_empty_permission_group_as_authenticated_only() { + let definition = parse_definition_with_auth("WITH_PERMISSIONS([])"); + let AuthRequirement::WithPermissions(groups) = &definition.endpoints[0].auth else { + panic!("expected permission auth"); + }; + + assert_eq!(groups, &[Vec::::new()]); + } + + #[test] + fn rejects_legacy_nested_permission_group_syntax() { + assert_parse_error_contains( + r#"{ + service_name: Files, + base_path: "/files", + endpoints: [ + DOWNLOAD WITH_PERMISSIONS([["admin", "moderator"]]) download/{file_id: String}, + ] + }"#, + "expected string literal", + ); + } + #[test] fn rejects_removed_body_limit_field() { assert_parse_error_contains( diff --git a/crates/rest/ras-file-macro/tests/integration.rs b/crates/rest/ras-file-macro/tests/integration.rs index 74b01a8..a3fe6d0 100644 --- a/crates/rest/ras-file-macro/tests/integration.rs +++ b/crates/rest/ras-file-macro/tests/integration.rs @@ -17,7 +17,7 @@ file_service!({ base_path: "/integration", openapi: true, endpoints: [ - UPLOAD WITH_PERMISSIONS([["admin", "moderator"]]) upload/{bucket: String} multipart { + UPLOAD WITH_PERMISSIONS(["admin", "moderator"]) upload/{bucket: String} multipart { max_total_bytes: unlimited, reject_unknown_fields: false, parts: [ @@ -40,7 +40,7 @@ file_service!({ }); #[test] -fn generated_openapi_includes_nested_permission_groups_and_unlimited_upload() { +fn generated_openapi_includes_permission_groups_and_unlimited_upload() { let doc = generate_integrationservice_openapi(); let upload = &doc["paths"]["/upload/{bucket}"]["post"]; diff --git a/crates/rest/ras-rest-macro/README.md b/crates/rest/ras-rest-macro/README.md index 2635ae3..6f094f6 100644 --- a/crates/rest/ras-rest-macro/README.md +++ b/crates/rest/ras-rest-macro/README.md @@ -2,6 +2,10 @@ A procedural macro for creating type-safe REST APIs with authentication integration and OpenAPI document generation. +See the canonical mdBook +[`rest_service!` guide](../../../documentation/src/macros/rest-service.md) for +the rationale, auth model, usage flow, and runnable examples. + ## Features - **Type-safe REST endpoints**: Generate axum-based REST services from macro definitions diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md index e67fa18..6fab1ad 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md @@ -2,6 +2,10 @@ Procedural macro for generating type-safe bidirectional JSON-RPC services over WebSockets. +See the canonical mdBook +[`jsonrpc_bidirectional_service!` guide](../../../../documentation/src/macros/bidirectional-jsonrpc-service.md) +for the rationale, auth model, usage flow, and runnable examples. + This crate provides the `jsonrpc_bidirectional_service!` macro that generates both server and client code for bidirectional JSON-RPC communication, including authentication support and type-safe message enums. ## Features @@ -29,18 +33,18 @@ ras-jsonrpc-bidirectional-server = { version = "0.1.0", optional = true } ras-jsonrpc-bidirectional-client = { version = "0.1.0", optional = true } [features] -default = ["server", "client"] +default = [] server = [ - "ras-jsonrpc-bidirectional-macro/server", "dep:ras-jsonrpc-bidirectional-server", ] client = [ - "ras-jsonrpc-bidirectional-macro/client", "dep:ras-jsonrpc-bidirectional-client", ] ``` -The generated code checks the consuming crate's `server` and `client` features, so keep the macro features and optional runtime crates behind the same feature names. +The generated code checks the API crate's `server` and `client` features. +Downstream server and client crates select behavior by enabling those features +on the shared API crate dependency. If you define `server_to_client_calls`, also add `tokio = { version = "1.0", features = ["sync", "time"], optional = true }` and `uuid = { version = "1", features = ["v4"], optional = true }`, then include `dep:tokio` and `dep:uuid` in the `server` feature. The generated server-side client handle uses them for pending response channels, timeouts, and request IDs. @@ -350,9 +354,10 @@ cargo clippy -p ras-jsonrpc-bidirectional-macro --all-targets --all-features --l The macro generates code conditionally compiled based on features: - `#[cfg(feature = "server")]`: Server traits, handlers, and builders -- `#[cfg(feature = "client")]`: Client structs, builders, and message enums +- `#[cfg(feature = "client")]`: Client structs, builders, and message enums -This allows consuming crates to enable only the functionality they need. +This allows each API crate to expose only the generated surface its downstream +server or client crates need. ## Error Handling diff --git a/crates/rpc/ras-jsonrpc-macro/README.md b/crates/rpc/ras-jsonrpc-macro/README.md index c1aac0e..c2550f3 100644 --- a/crates/rpc/ras-jsonrpc-macro/README.md +++ b/crates/rpc/ras-jsonrpc-macro/README.md @@ -2,6 +2,10 @@ Procedural macros for generating type-safe JSON-RPC services with authentication and axum integration. +See the canonical mdBook +[`jsonrpc_service!` guide](../../../documentation/src/macros/jsonrpc-service.md) +for the rationale, auth model, usage flow, and runnable examples. + ## Overview This crate provides the `jsonrpc_service!` procedural macro that generates type-safe JSON-RPC services with built-in authentication, authorization, and axum integration. It transforms a declarative service definition into a JSON-RPC router with compile-time checks for the generated service trait. @@ -40,16 +44,18 @@ reqwest = { version = "0.12", features = ["json"], optional = true } reqwest = { version = "0.12", default-features = false, features = ["json"], optional = true } [features] -default = ["server"] +default = [] server = [ - "ras-jsonrpc-macro/server", "dep:ras-jsonrpc-core", "dep:axum", "dep:tokio", ] -client = ["ras-jsonrpc-macro/client", "dep:reqwest"] +client = ["dep:reqwest"] ``` +Define `server` and `client` on the API crate that invokes the macro. Downstream +server and client crates enable the API crate feature they need. + ## Quick Start ### 1. Define Your Service diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index 827a35c..ffbe587 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -433,70 +433,57 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result static_hosting::StaticHostingConfig { - serve_explorer: true, - explorer_path: "/explorer".to_string(), - }, - Some(ExplorerConfig::WithPath(path)) => static_hosting::StaticHostingConfig { - serve_explorer: true, - explorer_path: path.clone(), - }, - None => static_hosting::StaticHostingConfig::default(), - }; - - // JSON-RPC services in this macro expose the explorer next to a single endpoint. - // The static host generator still accepts a base path for future reuse. - static_hosting::generate_static_hosting_code( - &explorer_config, - &service_def.service_name, - "", - ) - } else { - quote! {} - }; + let server_impl = generate_server_code(&service_def); - // Wrap all server code in a cfg attribute to ensure it's only compiled when server feature is enabled - quote! { - #[cfg(feature = "server")] - mod #server_mod { - use super::*; - - #server_impl - #explorer_code - } + let explorer_code = if service_def.explorer.is_some() && service_def.openrpc.is_some() { + let explorer_config = match &service_def.explorer { + Some(ExplorerConfig::Enabled) => static_hosting::StaticHostingConfig { + serve_explorer: true, + explorer_path: "/explorer".to_string(), + }, + Some(ExplorerConfig::WithPath(path)) => static_hosting::StaticHostingConfig { + serve_explorer: true, + explorer_path: path.clone(), + }, + None => static_hosting::StaticHostingConfig::default(), + }; - #[cfg(feature = "server")] - pub use #server_mod::*; - } + // JSON-RPC services in this macro expose the explorer next to a single endpoint. + // The static host generator still accepts a base path for future reuse. + static_hosting::generate_static_hosting_code( + &explorer_config, + &service_def.service_name, + "", + ) } else { quote! {} }; - // Generate client code only if client feature is enabled in the macro crate - let client_code = if cfg!(feature = "client") { - let client_impl = crate::client::generate_client_code(&service_def); + let server_code = quote! { + #[cfg(feature = "server")] + mod #server_mod { + use super::*; - // Wrap all client code in a cfg attribute to ensure it's only compiled when client feature is enabled - quote! { - #[cfg(feature = "client")] - mod #client_mod { - use super::*; + #server_impl + #explorer_code + } - #client_impl - } + #[cfg(feature = "server")] + pub use #server_mod::*; + }; - #[cfg(feature = "client")] - pub use #client_mod::*; + let client_impl = crate::client::generate_client_code(&service_def); + + let client_code = quote! { + #[cfg(feature = "client")] + mod #client_mod { + use super::*; + + #client_impl } - } else { - quote! {} + + #[cfg(feature = "client")] + pub use #client_mod::*; }; let output = quote! { diff --git a/documentation/ras-file-macro.md b/documentation/ras-file-macro.md index 7221d7d..2b46b79 100644 --- a/documentation/ras-file-macro.md +++ b/documentation/ras-file-macro.md @@ -1,218 +1,5 @@ -# File Service Macro +# File Service Macro Guide -`ras-file-macro` generates focused file upload/download APIs from one service -definition. It is intentionally separate from the JSON REST macro because file -traffic has different constraints: authentication should happen before reading -the body, uploads need per-field and total byte limits, and handlers should be -able to stream bytes instead of receiving a fully buffered request. +This guide has moved into the canonical mdBook: -The generated server adapts Axum multipart requests into runtime-neutral types -from `ras-file-core`: - -- `FileRequestContext<'_>` carries method, matched path, headers, and the - authenticated user. -- `IncomingFile<'_>` streams file chunks and enforces the declared part limit. -- `JsonResponse` is returned by upload finish handlers. -- `DownloadResponse` is returned by download handlers. - -## Definition Syntax - -```rust -use ras_file_macro::file_service; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct UploadResponse { - pub file_id: String, - pub size: u64, -} - -file_service!({ - service_name: DocumentService, - base_path: "/api/documents", - openapi: true, - endpoints: [ - UPLOAD WITH_PERMISSIONS(["files:write"]) upload multipart { - max_total_bytes: 52428800, - reject_unknown_fields: true, - parts: [ - file file { - required: true, - max_count: 1, - max_bytes: 52428800, - content_types: ["application/pdf", "text/plain"], - filename: optional, - }, - json metadata: UploadMetadata { - required: false, - max_bytes: 4096, - content_types: ["application/json"], - }, - text comment { - required: false, - max_bytes: 1024, - }, - ], - } -> UploadResponse, - - DOWNLOAD WITH_PERMISSIONS(["files:read"]) download/{file_id: String} { - content_types: ["application/octet-stream"], - ranges: true, - }, - ] -}); -``` - -`max_total_bytes` is required on every upload and may be `unlimited`. Every -part must declare `max_bytes`. `reject_unknown_fields` defaults to `true`. - -File parts support `filename: optional`, `filename: required`, and -`filename: forbidden`. JSON parts require a Rust type after `:` and are decoded -before the service receives the part. Text parts are decoded as UTF-8. - -## Generated Trait Shape - -Uploads are a lifecycle. The service can allocate state after authentication -but before the body is consumed, handle each accepted part, then finish with a -JSON response. - -```rust -use ras_file_core::{FileRequestContext, FileResult, JsonResponse}; - -#[async_trait::async_trait] -impl DocumentServiceTrait for MyService { - type UploadState = UploadState; - - async fn upload_begin( - &self, - ctx: &FileRequestContext<'_>, - path: &DocumentServiceUploadPath, - ) -> FileResult { - Ok(UploadState::default()) - } - - async fn upload_part( - &self, - ctx: &FileRequestContext<'_>, - path: &DocumentServiceUploadPath, - state: &mut Self::UploadState, - part: &mut DocumentServiceUploadPart<'_>, - ) -> FileResult<()> { - match part { - DocumentServiceUploadPart::File(file) => { - while let Some(chunk) = file.next_chunk().await? { - state.write(&chunk).await?; - } - } - DocumentServiceUploadPart::Metadata(metadata) => { - state.metadata = Some(metadata.clone()); - } - DocumentServiceUploadPart::Comment(comment) => { - state.comment = Some(comment.clone()); - } - } - - Ok(()) - } - - async fn upload_finish( - &self, - ctx: &FileRequestContext<'_>, - path: &DocumentServiceUploadPath, - state: Self::UploadState, - summary: ras_file_core::UploadSummary, - ) -> FileResult> { - Ok(JsonResponse::ok(state.into_response())) - } -} -``` - -If a file part is not fully consumed, the generated handler rejects the request -with a handler contract error. This keeps the multipart stream in a predictable -state and prevents accidental partial reads. - -For every upload endpoint the macro also generates an optional `*_abort` -method. Override it when temporary files or external reservations need cleanup: - -```rust -async fn upload_abort( - &self, - ctx: &FileRequestContext<'_>, - path: &DocumentServiceUploadPath, - state: Self::UploadState, - error: &ras_file_core::FileError, -) { - state.cleanup().await; -} -``` - -Downloads return a `DownloadResponse`: - -```rust -use ras_file_core::{DownloadResponse, FileRequestContext, FileResult}; - -async fn download_by_file_id( - &self, - ctx: &FileRequestContext<'_>, - path: DocumentServiceDownloadByFileIdPath, -) -> FileResult { - let file = self.storage.open(&path.file_id).await?; - - DownloadResponse::stream(file.stream) - .content_type(file.content_type)? - .content_length(file.size)? - .attachment(file.original_name) -} -``` - -Path parameters become `by_*` method segments. For example, -`download/{file_id: String}` generates `download_by_file_id` and -`DocumentServiceDownloadByFileIdPath`. - -## Early Rejection - -The generated server performs these checks before calling service code: - -- authentication, CSRF, and permission checks before reading the upload body; -- `Content-Length` rejection when it exceeds `max_total_bytes`; -- `multipart/form-data` validation for uploads; -- unknown field rejection when `reject_unknown_fields` is true; -- per-part count, content type, filename policy, and byte-limit checks; -- required field checks before `*_finish`. - -This gives the service implementation a narrow job: accept already-declared -parts, stream bytes to storage, and return typed responses. - -## Clients - -The generated native client accepts a generated multipart builder: - -```rust -let form = DocumentServiceUploadMultipart::new() - .file("report.pdf", Some("report.pdf"), Some("application/pdf")) - .await? - .metadata(&metadata)? - .comment("quarterly report"); - -let response = client.upload(form).await?; -``` - -Each file part also has a `*_bytes` helper for tests and in-memory uploads. - -## OpenAPI - -With `openapi: true`, the macro emits `generate__openapi()` and -`generate__openapi_to_file()`. Upload operations include an inline -`multipart/form-data` schema plus an `x-ras-file` extension describing -`maxTotalBytes`, unknown-field policy, and part limits. Download operations -document binary responses and an `x-ras-file` extension for declared content -types and range support. - -## Checks - -```bash -cargo test -p ras-file-macro --locked -cargo test -p file-service-example --locked -cargo test -p file-service-api -p file-service-backend --locked -``` +[Read the `file_service!` guide](src/macros/file-service.md) diff --git a/documentation/ras-identity.md b/documentation/ras-identity.md index 21da1ab..ab29b76 100644 --- a/documentation/ras-identity.md +++ b/documentation/ras-identity.md @@ -1,670 +1,5 @@ -# RAS Identity System Usage Guide +# Identity And Sessions Guide -This guide covers the common setup for adding authentication and authorization to a RAS stack application using the identity crates. +This guide has moved into the canonical mdBook: -## Overview - -The RAS identity system provides a flexible, secure authentication framework with: -- Multiple authentication providers (local users, OAuth2) -- JWT-based session management -- Fine-grained permission control -- Integration with JSON-RPC and REST services - -## Architecture - -``` -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ Client App │────▶│ Identity Provider│────▶│ Session Service │ -└─────────────────┘ └──────────────────┘ └─────────────────┘ - │ - ▼ - ┌─────────────────┐ ┌─────────────────┐ - │ JSON-RPC/REST │◀──────│ JwtAuthProvider │ - │ Service │ └─────────────────┘ - └─────────────────┘ -``` - -## Quick Start - -### 1. Add Dependencies - -```toml -[dependencies] -# Core authentication traits -ras-auth-core = "0.1.0" -ras-identity-core = "0.1.1" - -# Session management (required) -ras-identity-session = "0.2.0" - -# Identity providers (choose what you need) -ras-identity-local = "0.2.0" -ras-identity-oauth2 = "0.1.2" - -# For JSON-RPC services -ras-jsonrpc-core = "0.1.2" -``` - -### 2. Basic Setup with Local Authentication - -```rust -use ras_identity_session::{JwtAuthProvider, SessionConfig, SessionService}; -use ras_identity_local::LocalUserProvider; -use ras_auth_core::AuthProvider; -use std::sync::Arc; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Create session service with an example-length secret. Real services - // should load a random secret from environment or secret storage. - let session_config = SessionConfig::new("use-at-least-32-bytes-of-random-secret")?; - let session_service = SessionService::new(session_config)?; - - // Create and configure local user provider - let local_provider = LocalUserProvider::new(); - - // Add some users - local_provider.add_user( - "admin".to_string(), - "secure_password123".to_string(), - Some("admin@example.com".to_string()), - Some("Administrator".to_string()) - ).await?; - - // Register the provider with session service - session_service.register_provider(Box::new(local_provider)).await; - - // Create JWT auth provider for your services - let jwt_auth = JwtAuthProvider::new(Arc::new(session_service)); - - // Now use jwt_auth with your JSON-RPC or REST services - Ok(()) -} -``` - -## Identity Providers - -### Local User Provider - -The local user provider handles username/password authentication with secure password hashing: - -```rust -use ras_identity_core::IdentityProvider; -use ras_identity_local::LocalUserProvider; -use serde_json::json; - -// Create provider -let provider = LocalUserProvider::new(); - -// Add users -provider.add_user( - "alice".to_string(), - "password123".to_string(), - Some("alice@example.com".to_string()), - Some("Alice Smith".to_string()), -).await?; - -// Authenticate -let auth_payload = json!({ - "username": "alice", - "password": "password123" -}); - -let identity = provider.verify(auth_payload).await?; -println!("Authenticated: {}", identity.display_name.unwrap_or_default()); -``` - -**Security Features:** -- Argon2 password hashing -- Timing attack mitigation for missing users -- Uniform invalid-credentials errors -- Rate limiting (5 concurrent attempts) - -### OAuth2 Provider - -The OAuth2 provider supports external authentication providers like Google: - -```rust -use ras_identity_core::{IdentityError, IdentityProvider}; -use ras_identity_oauth2::{ - InMemoryStateStore, OAuth2Config, OAuth2Provider, OAuth2ProviderConfig, OAuth2Response, -}; -use std::{collections::HashMap, sync::Arc}; - -// Configure OAuth2 provider -let google_config = OAuth2ProviderConfig { - provider_id: "google".to_string(), - client_id: std::env::var("GOOGLE_CLIENT_ID") - .expect("GOOGLE_CLIENT_ID must be set"), - client_secret: std::env::var("GOOGLE_CLIENT_SECRET") - .expect("GOOGLE_CLIENT_SECRET must be set"), - authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth".to_string(), - token_endpoint: "https://oauth2.googleapis.com/token".to_string(), - userinfo_endpoint: Some("https://www.googleapis.com/oauth2/v2/userinfo".to_string()), - redirect_uri: "http://localhost:3000/auth/callback".to_string(), - scopes: vec!["openid".to_string(), "email".to_string(), "profile".to_string()], - auth_params: HashMap::new(), - use_pkce: true, - user_info_mapping: None, -}; - -let config = OAuth2Config::new().add_provider(google_config); -let state_store = Arc::new(InMemoryStateStore::new()); -let oauth_provider = OAuth2Provider::new(config, state_store); -``` - -**OAuth2 Flow:** - -1. **Start Authorization:** -```rust -let start_payload = json!({ - "type": "StartFlow", - "provider_id": "google" -}); - -match oauth_provider.verify(start_payload).await { - Err(IdentityError::ProviderError(response_json)) => { - let response: OAuth2Response = serde_json::from_str(&response_json)?; - if let OAuth2Response::AuthorizationUrl { url, state } = response { - // Redirect user to authorization URL and keep state for the callback. - println!("Redirect to: {url}, state: {state}"); - } - } - Ok(_) => eprintln!("OAuth2 start flow completed without a redirect"), - Err(err) => eprintln!("OAuth2 start flow failed: {err}"), -} -``` - -2. **Handle Callback:** -```rust -let callback_payload = json!({ - "type": "Callback", - "provider_id": "google", - "code": "authorization_code_from_provider", - "state": "stored_csrf_state" -}); - -let identity = oauth_provider.verify(callback_payload).await?; -``` - -## Session Management - -The `SessionService` orchestrates the login-to-session flow: - -```rust -use chrono::Duration; -use ras_identity_session::{JwtAlgorithm, SessionConfig, SessionService}; - -// Configure session service -let config = SessionConfig { - jwt_secret: "use-at-least-32-bytes-of-random-secret".to_string(), - jwt_ttl: Duration::hours(1), - refresh_enabled: false, - enforce_active_sessions: true, - algorithm: JwtAlgorithm::HS256, -}; - -let session_service = SessionService::new(config)?; - -// Register multiple providers -session_service.register_provider(Box::new(local_provider)).await; -session_service.register_provider(Box::new(oauth_provider)).await; -``` - -### Creating Sessions - -```rust -// Authenticate and create session -let auth_payload = json!({ - "username": "alice", - "password": "password123" -}); - -let jwt_token = session_service.begin_session("local", auth_payload).await?; -println!("JWT Token: {}", jwt_token); - -// Verify session -let claims = session_service.verify_session(&jwt_token).await?; -println!("Subject: {}", claims.sub); -println!("Permissions: {:?}", claims.permissions); - -// End session (logout) -session_service.end_session(&claims.jti).await; -``` - -### Browser Session Cookies - -RAS services can accept the same JWT from either `Authorization: Bearer ...` or -from a configured secure cookie. The session verifier stays the same: - -```rust -use ras_auth_core::{AuthCookieConfig, CsrfConfig}; - -let jwt_auth = JwtAuthProvider::new(Arc::new(session_service)); -let cookie = AuthCookieConfig::default(); -let csrf = CsrfConfig::default(); - -let service = MyApiServiceBuilder::new(service_impl) - .auth_provider(jwt_auth) - .auth_cookie(cookie.clone()) - .csrf_protection(csrf.clone()) - .build(); - -// After login: -let token = session_service.begin_session("local", auth_payload).await?; -let set_cookie = cookie.session_cookie_header_value(&token)?; -let csrf_token = "random-per-session-csrf-token"; -let set_csrf_cookie = csrf.csrf_cookie_header_value(csrf_token)?; - -// After logout/revocation: -let clear_cookie = cookie.clear_cookie_header_value()?; -let clear_csrf_cookie = csrf.clear_csrf_cookie_header_value()?; -# Ok::<(), Box>(()) -``` - -Cookie auth is opt-in. Bearer tokens remain enabled by default and take -precedence when both transports are present. The default cookie is `HttpOnly`, -`Secure`, `SameSite=Lax`, `Path=/`, and host-only. `CsrfConfig::default()` uses -a double-submit token: the browser receives a readable `__Host-ras-csrf` cookie -and must echo the same value in the `x-ras-csrf` header on cookie-authenticated -`POST`, `PUT`, `PATCH`, and `DELETE` requests. Bearer requests are unchanged. -For cookie auth, use an explicit credentialed CORS allowlist; do not combine -session cookies with permissive credentialed CORS. - -## Permission Management - -Implement custom permission logic using the `UserPermissions` trait: - -```rust -use async_trait::async_trait; -use ras_identity_core::{IdentityResult, UserPermissions, VerifiedIdentity}; -use std::sync::Arc; - -struct RoleBasedPermissions { - // Your permission logic -} - -#[async_trait] -impl UserPermissions for RoleBasedPermissions { - async fn get_permissions(&self, identity: &VerifiedIdentity) -> IdentityResult> { - // Example: Grant permissions based on email domain - match &identity.email { - Some(email) if email.ends_with("@admin.com") => { - Ok(vec!["admin".to_string(), "user".to_string()]) - } - Some(_) => Ok(vec!["user".to_string()]), - None => Ok(vec![]), - } - } -} - -// Configure the session service before sharing it with handlers. -let mut session_service = SessionService::new(session_config)?; -session_service.set_permissions_provider(Arc::new(RoleBasedPermissions {})); -``` - -## Integration with Services - -### JSON-RPC Service Integration - -```rust -use ras_jsonrpc_macro::jsonrpc_service; -use ras_identity_session::JwtAuthProvider; - -// Define your service with authentication -jsonrpc_service!({ - service_name: MyApiService, - methods: [ - // Public method - UNAUTHORIZED get_status(()) -> Status, - - // Requires authentication but no specific permission - WITH_PERMISSIONS([]) get_profile(()) -> UserProfile, - - // Requires specific permissions - WITH_PERMISSIONS(["admin"]) delete_user(DeleteUserRequest) -> (), - ] -}); - -// Implement service -struct MyApiServiceImpl; - -impl MyApiServiceTrait for MyApiServiceImpl { - async fn get_status(&self) -> Result { - Ok(Status { healthy: true }) - } - - async fn get_profile(&self, user: &AuthenticatedUser, _request: ()) -> Result { - // Access user.user_id, user.permissions, etc. - Ok(UserProfile { - id: user.user_id.clone(), - permissions: user.permissions.iter().cloned().collect(), - }) - } - - async fn delete_user(&self, _user: &AuthenticatedUser, req: DeleteUserRequest) -> Result<(), Error> { - // Only users with "admin" permission can reach here - Ok(()) - } -} - -// Set up with Axum -use axum::Router; - -let jwt_auth = JwtAuthProvider::new(Arc::new(session_service)); -let service = MyApiServiceImpl; - -let app = Router::new() - .nest("/api", - MyApiServiceBuilder::new(service) - .base_url("/rpc") - .auth_provider(jwt_auth) - .build()? - ); -``` - -### REST Service Integration - -```rust -use ras_rest_macro::rest_service; - -rest_service!({ - service_name: UserApi, - base_path: "/api/v1", - endpoints: [ - // Public endpoint - GET UNAUTHORIZED health() -> HealthResponse, - - // Authenticated endpoint with no specific permission - GET WITH_PERMISSIONS([]) me() -> UserResponse, - - // Permission-protected endpoint - DELETE WITH_PERMISSIONS(["admin"]) users/{id: String}() -> (), - ] -}); -``` - -## Service Composition Example - -Here is a typical setup sketch showing how the identity pieces fit together with generated service routes. The request/response DTOs and handler bodies are application-specific. - -```rust -use ras_identity_session::{JwtAlgorithm, JwtAuthProvider, SessionConfig, SessionService}; -use ras_identity_local::LocalUserProvider; -use ras_jsonrpc_macro::jsonrpc_service; -use axum::{Router, routing::get}; -use std::sync::Arc; -use tower_http::cors::CorsLayer; - -// Define your API -jsonrpc_service!({ - service_name: TodoService, - methods: [ - UNAUTHORIZED health_check(()) -> HealthStatus, - WITH_PERMISSIONS([]) list_todos(()) -> Vec, - WITH_PERMISSIONS(["user"]) create_todo(CreateTodoRequest) -> Todo, - WITH_PERMISSIONS(["admin"]) delete_all_todos(()) -> (), - ] -}); - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // 1. Set up session service - let session_config = SessionConfig { - jwt_secret: std::env::var("JWT_SECRET") - .expect("JWT_SECRET must be at least 32 bytes"), - jwt_ttl: chrono::Duration::hours(1), - refresh_enabled: false, - enforce_active_sessions: true, - algorithm: JwtAlgorithm::HS256, - }; - - // 2. Set up permissions - use async_trait::async_trait; - use ras_identity_core::{IdentityResult, UserPermissions, VerifiedIdentity}; - - struct SimplePermissions; - - #[async_trait] - impl UserPermissions for SimplePermissions { - async fn get_permissions(&self, identity: &VerifiedIdentity) -> IdentityResult> { - match identity.subject.as_str() { - "admin" => Ok(vec!["user".to_string(), "admin".to_string()]), - _ => Ok(vec!["user".to_string()]), - } - } - } - - let mut session_service = SessionService::new(session_config)?; - session_service.set_permissions_provider(Arc::new(SimplePermissions)); - let session_service = Arc::new(session_service); - - // 3. Set up local authentication - let local_provider = LocalUserProvider::new(); - local_provider - .add_user( - "user".to_string(), - "password123".to_string(), - Some("user@example.com".to_string()), - Some("User".to_string()), - ) - .await?; - local_provider - .add_user( - "admin".to_string(), - "admin12345".to_string(), - Some("admin@example.com".to_string()), - Some("Admin".to_string()), - ) - .await?; - - session_service.register_provider(Box::new(local_provider)).await; - - // 4. Create authentication endpoints - let auth_router = Router::new() - .route("/login", get(login_handler)) - .route("/logout", get(logout_handler)); - - // 5. Create API with authentication - let jwt_auth = JwtAuthProvider::new(session_service.clone()); - let todo_service = TodoServiceImpl::new(); - - let api_router = TodoServiceBuilder::new(todo_service) - .base_url("/rpc") - .auth_provider(jwt_auth) - .build()?; - - // 6. Combine everything - let app = Router::new() - .nest("/auth", auth_router) - .nest("/api", api_router) - // For cookie auth, replace permissive CORS with an explicit - // credentialed origin allowlist. - .layer(CorsLayer::permissive()) - .with_state(session_service); - - // 7. Start server - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; - axum::serve(listener, app).await?; - - Ok(()) -} -``` - -## Best Practices - -### 1. Security - -- **Use strong JWT secrets**: Generate cryptographically secure secrets -- **Set appropriate TTLs**: Balance security and user experience -- **Enable HTTPS**: Always use TLS in production -- **Use secure cookies for browser sessions**: Prefer `HttpOnly`, `Secure`, - `SameSite`, host-prefixed names, double-submit CSRF tokens, and restrictive - credentialed CORS for cookie-authenticated unsafe requests -- **Validate permissions**: Check permissions at the service level -- **Handle errors gracefully**: Don't leak information in error messages - -### 2. Configuration - -```rust -// Use environment variables for sensitive config -let mut config = SessionConfig::new( - std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"), -)?; -config.jwt_ttl = chrono::Duration::seconds( - std::env::var("JWT_TTL_SECONDS") - .unwrap_or_else(|_| "3600".to_string()) - .parse()?, -); -config.refresh_enabled = false; -``` - -### 3. Error Handling - -```rust -use ras_identity_core::IdentityError; -use ras_identity_session::SessionError; - -match session_service.begin_session("local", payload).await { - Ok(token) => { - // Success - } - Err(SessionError::IdentityError(IdentityError::InvalidCredentials)) => { - // Wrong username/password - } - Err(SessionError::IdentityError(IdentityError::ProviderNotFound(_))) => { - // Provider not registered - } - Err(e) => { - // Other errors - } -} -``` - -### 4. Testing - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_authentication_flow() { - // Set up test providers - let provider = LocalUserProvider::new(); - provider - .add_user("test".to_string(), "test123".to_string(), None, None) - .await - .unwrap(); - - let session_config = - SessionConfig::new("test-secret-key-that-is-at-least-32-bytes").unwrap(); - let session_service = SessionService::new(session_config).unwrap(); - session_service.register_provider(Box::new(provider)).await; - - // Test authentication - let token = session_service.begin_session("local", json!({ - "username": "test", - "password": "test123" - })).await.unwrap(); - - // Verify token - let claims = session_service.verify_session(&token).await.unwrap(); - assert_eq!(claims.sub, "test"); - } -} -``` - -## Troubleshooting - -### Common Issues - -1. **"Provider not found" error** - - Ensure you've registered the provider with `session_service.register_provider()` - - Check the provider ID matches (e.g., "local" for LocalUserProvider) - -2. **JWT validation failures** - - Verify the JWT secret is consistent across services - - Check token hasn't expired; `SessionConfig::new` defaults to 24 hours, while the examples above configure one hour explicitly - - Ensure the token is passed in the correct format - -3. **Permission denied errors** - - Verify your `UserPermissions` implementation returns expected permissions - - Check the method annotation matches required permissions - - Use `WITH_PERMISSIONS([])` for methods that only need login, not specific permissions - -4. **OAuth2 redirect issues** - - Ensure redirect URLs are correctly configured in provider settings - - Check CORS settings allow the callback domain - - Verify state parameter is preserved through the flow - -## Advanced Topics - -### Custom Identity Providers - -Implement the `IdentityProvider` trait for custom authentication: - -```rust -use ras_identity_core::{IdentityError, IdentityProvider, IdentityResult, VerifiedIdentity}; -use async_trait::async_trait; - -struct LdapProvider { - // LDAP configuration -} - -#[async_trait] -impl IdentityProvider for LdapProvider { - fn provider_id(&self) -> &str { - "ldap" - } - - async fn verify(&self, payload: serde_json::Value) -> IdentityResult { - let username = payload - .get("username") - .and_then(|value| value.as_str()) - .ok_or(IdentityError::InvalidPayload)?; - - Ok(VerifiedIdentity { - provider_id: self.provider_id().to_string(), - subject: username.to_string(), - email: None, - display_name: Some(username.to_string()), - metadata: None, - }) - } -} -``` - -### Session Revocation - -Implement immediate session revocation: - -```rust -// End specific session -let claims = session_service.verify_session(&jwt_token).await?; -session_service.end_session(&claims.jti).await; - -// End all sessions for a user -// (Requires custom implementation tracking user->session mapping) -``` - -### Refresh Tokens - -`SessionConfig::refresh_enabled` is reserved for applications that add their own refresh-token storage and rotation. The current `SessionService` issues and verifies access JWTs; long-lived refresh tokens should be implemented as an application-level flow with server-side persistence and token rotation. - -```rust -let mut config = SessionConfig::new("use-at-least-32-bytes-of-random-secret")?; -config.refresh_enabled = false; -``` - -## Conclusion - -The RAS identity crates provide local authentication, OAuth2 callbacks, JWT -sessions, and permission lookup traits that can be composed for application -authentication flows. Start with basic local authentication, then add OAuth2 -providers and custom permission logic as needed. - -For more examples, check out: -- [`examples/oauth2-demo`](../examples/oauth2-demo/) - OAuth2 integration demo -- [`examples/basic-jsonrpc`](../examples/basic-jsonrpc/) - JSON-RPC with authentication -- [`examples/bidirectional-chat`](../examples/bidirectional-chat/) - WebSocket authentication +[Read the identity and sessions guide](src/identity-and-sessions.md) diff --git a/documentation/ras-observability.md b/documentation/ras-observability.md index 683550c..445a819 100644 --- a/documentation/ras-observability.md +++ b/documentation/ras-observability.md @@ -1,464 +1,5 @@ -# RAS Observability Guide +# Observability Guide -This guide covers the common setup for adding observability to RAS stack applications using the built-in OpenTelemetry-based observability crates. +This guide has moved into the canonical mdBook: -## Overview - -The RAS observability system provides operational metrics and monitoring for your applications with: -- Convenience setup with sensible defaults -- Support for REST, JSON-RPC, and WebSocket protocols -- OpenTelemetry metrics with Prometheus export -- Built-in cardinality protection -- Integration with RAS service macros - -## Quick Start - -Set up observability with the convenience builder and merge the metrics router -into your Axum application: - -```rust -use axum::{Router, routing::get}; -use ras_observability_otel::standard_setup; - -async fn handler() -> &'static str { - "ok" -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Initialize observability with your service name - let otel = standard_setup("my-service")?; - - // The setup provides a Prometheus registry, trackers, and a metrics router. - - // Add the metrics endpoint to your router - let app = Router::new() - .route("/api/hello", get(handler)) - .merge(otel.metrics_router()); // Adds /metrics endpoint - - // Start your server - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; - axum::serve(listener, app).await?; - - Ok(()) -} -``` - -## Metrics Exposed - -When the trackers are wired into service builders, or metrics are recorded -manually, the Prometheus endpoint exposes the following metrics: - -### Counters -- **`requests_started_total`** - Total number of requests initiated - - Labels: `method`, `protocol` -- **`requests_completed_total`** - Total number of requests completed - - Labels: `method`, `protocol`, `success` (true/false) - -### Histograms -- **`method_duration_milliseconds`** - Method execution time in milliseconds - - Labels: `method`, `protocol` - - Histogram bucket boundaries are reported in milliseconds by the Prometheus exporter - -### Labels -Labels are kept minimal to avoid cardinality explosion: -- **`method`** - The method being called (e.g., "GET /users", "createUser") -- **`protocol`** - One of: "REST", "JSON-RPC", "WebSocket" -- **`success`** - "true" or "false" (only on completion counter) - -## Integration with RAS Services - -### JSON-RPC Service Integration - -The RAS JSON-RPC macro exposes observability hooks on the generated service builder: - -```rust -use axum::Router; -use ras_observability_core::{MethodDurationTracker, RequestContext, UsageTracker}; -use ras_observability_otel::OtelSetupBuilder; -use ras_jsonrpc_macro::jsonrpc_service; - -// Define your service -jsonrpc_service!({ - service_name: MyService, - methods: [ - UNAUTHORIZED health(()) -> String, - ] -}); - -// Implement the service -struct MyServiceImpl; - -impl MyServiceTrait for MyServiceImpl { - async fn health(&self, _params: ()) -> Result> { - Ok("healthy".to_string()) - } -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Set up observability - let otel = OtelSetupBuilder::new("my-jsonrpc-service").build()?; - - // Build your service with observability hooks - let rpc_router = MyServiceBuilder::new(MyServiceImpl) - .base_url("/rpc") - .with_usage_tracker({ - let usage_tracker = otel.usage_tracker(); - move |headers, user, payload| { - let context = RequestContext::jsonrpc(payload.method.clone()); - let usage_tracker = usage_tracker.clone(); - let headers = headers.clone(); - let user = user.cloned(); - async move { - usage_tracker - .track_request(&headers, user.as_ref(), &context) - .await; - } - } - }) - .with_method_duration_tracker({ - let duration_tracker = otel.method_duration_tracker(); - move |method, user, duration| { - let context = RequestContext::jsonrpc(method.to_string()); - let duration_tracker = duration_tracker.clone(); - let user = user.cloned(); - async move { - duration_tracker - .track_duration(&context, user.as_ref(), duration) - .await; - } - } - }) - .build()?; - - // Combine with metrics endpoint - let app = Router::new() - .merge(rpc_router) - .merge(otel.metrics_router()); - - // Start server - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; - axum::serve(listener, app).await?; - - Ok(()) -} -``` - -### REST Service Integration - -For REST services using the RAS REST macro: - -```rust -use ras_observability_core::{MethodDurationTracker, RequestContext, UsageTracker}; -use ras_observability_otel::OtelSetupBuilder; -use ras_rest_core::{RestResponse, RestResult}; -use ras_rest_macro::rest_service; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -struct HealthResponse { - status: String, -} - -// Define your REST service -rest_service!({ - service_name: UserService, - base_path: "/api/v1", - endpoints: [ - GET UNAUTHORIZED health() -> HealthResponse, - ] -}); - -struct UserServiceImpl; - -#[async_trait::async_trait] -impl UserServiceTrait for UserServiceImpl { - async fn get_health(&self) -> RestResult { - Ok(RestResponse::ok(HealthResponse { - status: "healthy".to_string(), - })) - } -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Set up observability - let otel = OtelSetupBuilder::new("my-rest-service").build()?; - - // Build your service with observability hooks - let app = UserServiceBuilder::new(UserServiceImpl) - .with_usage_tracker({ - let usage_tracker = otel.usage_tracker(); - move |headers, user, method, path| { - let context = RequestContext::rest(method, path); - let usage_tracker = usage_tracker.clone(); - let headers = headers.clone(); - let user = user.cloned(); - async move { - usage_tracker - .track_request(&headers, user.as_ref(), &context) - .await; - } - } - }) - .with_method_duration_tracker({ - let duration_tracker = otel.method_duration_tracker(); - move |method, path, user, duration| { - let context = RequestContext::rest(method, path); - let duration_tracker = duration_tracker.clone(); - let user = user.cloned(); - async move { - duration_tracker - .track_duration(&context, user.as_ref(), duration) - .await; - } - } - }) - .build(); - - // Add metrics endpoint - let app = app.merge(otel.metrics_router()); - - // Start server - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; - axum::serve(listener, app).await?; - - Ok(()) -} -``` - -### WebSocket Service Integration - -For bidirectional WebSocket services: - -```rust -use axum::http::HeaderMap; -use ras_auth_core::AuthenticatedUser; -use ras_observability_core::{MethodDurationTracker, RequestContext, UsageTracker}; -use ras_observability_otel::OtelSetup; -use std::{sync::Arc, time::Duration}; - -async fn record_websocket_activity( - otel: Arc, - headers: &HeaderMap, - user: Option<&AuthenticatedUser>, - connection_id: &str, - method: &str, - duration: Duration, -) { - let context = RequestContext::websocket("connect") - .with_metadata("connection_id", connection_id); - otel.usage_tracker() - .track_request(headers, user, &context) - .await; - - let method_context = RequestContext::websocket(method.to_string()) - .with_metadata("connection_id", connection_id); - otel.method_duration_tracker() - .track_duration(&method_context, user, duration) - .await; -} -``` - -## Manual Metrics Tracking - -For custom metrics or manual tracking outside of the service macros: - -```rust -use ras_observability_core::{RequestContext, ServiceMetrics}; -use ras_observability_otel::standard_setup; -use std::time::{Duration, Instant}; - -let otel = standard_setup("my-service")?; -let metrics = otel.metrics(); - -// Track a custom operation -let context = RequestContext::rest("POST", "/api/v1/process"); -metrics.increment_requests_started(&context); - -let start = Instant::now(); -tokio::time::sleep(Duration::from_millis(25)).await; -let success = true; - -// Track completion -metrics.increment_requests_completed(&context, success); -metrics.record_method_duration(&context, start.elapsed()); -``` - -## Advanced Configuration - -### Custom Prometheus Registry - -```rust -use prometheus::Registry; -use ras_observability_otel::OtelSetupBuilder; - -// Create custom registry -let custom_registry = Registry::new(); - -// Add custom metrics -let custom_counter = prometheus::Counter::new("custom_metric", "Description")?; -custom_registry.register(Box::new(custom_counter.clone()))?; - -// Use with observability -let otel = OtelSetupBuilder::new("my-service") - .with_prometheus_registry(custom_registry) - .build()?; -``` - -### Adding Request Metadata - -Use metadata for request-specific information that shouldn't be in metrics: - -```rust -use ras_observability_core::{RequestContext, UsageTracker}; - -let context = RequestContext::rest("POST", "/api/orders") - .with_metadata("request_id", request_id) - .with_metadata("customer_id", customer_id) - .with_metadata("order_type", "express"); - -// Metadata is included in structured logs but not metrics -otel.usage_tracker() - .track_request(&headers, user.as_ref(), &context) - .await; -``` - -## Production Deployment - -### 1. Prometheus Scraping - -Configure Prometheus to scrape your service: - -```yaml -# prometheus.yml -scrape_configs: - - job_name: 'my-service' - static_configs: - - targets: ['my-service:3000'] - metrics_path: '/metrics' -``` - -### 2. OpenTelemetry Collector - -For OTLP export, use an OpenTelemetry Collector: - -```yaml -# otel-collector-config.yml -receivers: - prometheus: - config: - scrape_configs: - - job_name: 'my-service' - static_configs: - - targets: ['my-service:3000'] - metrics_path: '/metrics' - -exporters: - otlp: - endpoint: "tempo:4317" - -service: - pipelines: - metrics: - receivers: [prometheus] - exporters: [otlp] -``` - -### 3. Security - -Protect the metrics endpoint in production: - -```rust -use axum::middleware; -use tower_http::auth::RequireAuthorizationLayer; - -let metrics_token = std::env::var("METRICS_BEARER_TOKEN") - .expect("METRICS_BEARER_TOKEN must be set"); - -let app = Router::new() - .merge(api_routes) - .nest( - "/metrics", - otel.metrics_router() - .layer(RequireAuthorizationLayer::bearer(metrics_token.as_str())) - ); -``` - -### 4. Dashboards - -Example Grafana queries for your dashboards: - -```promql -# Request rate by method -rate(requests_completed_total[5m]) - -# Success rate -sum(rate(requests_completed_total{success="true"}[5m])) / -sum(rate(requests_completed_total[5m])) - -# P95 latency by method -histogram_quantile(0.95, - sum(rate(method_duration_milliseconds_bucket[5m])) by (method, le) -) - -# Error rate by protocol -sum(rate(requests_completed_total{success="false"}[5m])) by (protocol) -``` - -## Best Practices - -1. **Use standard context types**: Always use `RequestContext::rest(method, path)`, `RequestContext::jsonrpc(method)`, or `RequestContext::websocket(method)` for consistency. - -2. **Avoid custom labels**: Keep user-specific or high-cardinality data in structured logs, not metrics. - -3. **Let macros handle integration**: Use the built-in hooks in RAS service macros when possible. - -4. **Monitor cardinality**: Keep an eye on your metric cardinality in production. - -5. **Use metadata wisely**: Add request-specific data as metadata for correlation in logs. - -## Troubleshooting - -### Metrics not appearing - -1. Check that the metrics endpoint is accessible: - ```bash - curl http://localhost:3000/metrics - ``` - -2. Verify the OtelSetup is initialized before handling requests - -3. Ensure trackers are properly wired to your service - -### High cardinality warnings - -If you see warnings about high cardinality: -1. Review your method names - they should be generic (e.g., "GET /users/:id" not "GET /users/123") -2. Avoid adding custom labels -3. Use metadata instead of labels for request-specific data - -### Missing authentication info - -The system tracks authenticated vs anonymous requests. Ensure your `AuthProvider` is properly configured and returning user information. - -## Examples - -Runnable examples are available in the repository: -- `examples/basic-jsonrpc/service` - JSON-RPC service with metrics -- `examples/rest-wasm-example/rest-backend` - REST API with generated OpenAPI docs -- `crates/observability/ras-observability-otel/examples/` - Standalone examples - -## Dependencies - -Add these to your `Cargo.toml`: - -```toml -[dependencies] -ras-observability-core = "0.1.0" -ras-observability-otel = "0.1.0" -``` - -The observability system is designed to be lightweight with minimal dependencies while providing useful runtime metrics for your RAS stack applications. +[Read the observability guide](src/observability.md) diff --git a/documentation/ras-rest-macro.md b/documentation/ras-rest-macro.md index e47227a..6a482a4 100644 --- a/documentation/ras-rest-macro.md +++ b/documentation/ras-rest-macro.md @@ -1,890 +1,5 @@ -# RAS REST Macro Documentation +# REST Macro Guide -The `ras-rest-macro` crate provides a procedural macro for building type-safe REST APIs in Rust with generated native Rust clients and OpenAPI documents for TypeScript client generation. +This guide has moved into the canonical mdBook: -## Table of Contents - -1. [Overview](#overview) -2. [Installation](#installation) -3. [Basic Usage](#basic-usage) -4. [Macro Syntax](#macro-syntax) -5. [Authentication & Authorization](#authentication--authorization) -6. [Versioned Endpoints](#versioned-endpoints) -7. [Generated Code](#generated-code) -8. [TypeScript Client Usage](#typescript-client-usage) -9. [OpenAPI Documentation](#openapi-documentation) -10. [Error Handling](#error-handling) -11. [Advanced Features](#advanced-features) -12. [Task API Example](#task-api-example) - -## Overview - -The `rest_service!` macro generates: -- A service trait for implementing your REST API -- An Axum router builder with authentication support -- Native Rust client with async/await support -- OpenAPI 3.0 specification for TypeScript client generation -- Built-in API explorer hosting (optional) -- Optional compatibility routes that migrate legacy request/response shapes - -## Installation - -Add to your `Cargo.toml`: - -```toml -[dependencies] -ras-rest-macro = { version = "0.2.1", default-features = false } -ras-rest-core = { version = "0.1.1", optional = true } -ras-auth-core = { version = "0.1.0", optional = true } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -schemars = "1.0.0-alpha.20" -async-trait = { version = "0.1", optional = true } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -axum = { version = "0.8", optional = true } -axum-extra = { version = "0.10", features = ["query"], optional = true } -tokio = { version = "1.0", features = ["full"], optional = true } -reqwest = { version = "0.12", features = ["json"], optional = true } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json"], optional = true } - -[features] -default = ["server"] -server = [ - "ras-rest-macro/server", - "dep:ras-rest-core", - "dep:ras-auth-core", - "dep:async-trait", - "dep:axum", - "dep:axum-extra", - "dep:tokio", -] -client = ["ras-rest-macro/client", "dep:reqwest"] -``` - -## Basic Usage - -### 1. Define Your API Types - -All request and response types must implement `Serialize`, `Deserialize`, and `JsonSchema`: - -```rust -use serde::{Deserialize, Serialize}; -use schemars::JsonSchema; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct User { - pub id: String, - pub name: String, - pub email: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CreateUserRequest { - pub name: String, - pub email: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct UpdateUserRequest { - pub name: String, - pub email: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct UsersResponse { - pub users: Vec, - pub total: usize, -} -``` - -### 2. Define Your REST Service - -```rust -use ras_rest_macro::rest_service; - -rest_service!({ - service_name: UserService, - base_path: "/api/v1", - openapi: true, - serve_docs: true, - docs_path: "/docs", - endpoints: [ - // Public endpoints (no auth required) - GET UNAUTHORIZED users() -> UsersResponse, - GET UNAUTHORIZED users/{id: String}() -> User, - - // Protected endpoints (auth required) - POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User, - PUT WITH_PERMISSIONS(["admin"]) users/{id: String}(UpdateUserRequest) -> User, - DELETE WITH_PERMISSIONS(["admin"]) users/{id: String}() -> (), - ] -}); -``` - -### 3. Implement the Generated Trait - -```rust -use ras_auth_core::AuthenticatedUser; -use ras_rest_core::{RestError, RestResponse, RestResult}; -use std::collections::HashMap; -use std::sync::Mutex; - -struct UserServiceImpl { - users: Mutex>, -} - -impl UserServiceImpl { - fn new() -> Self { - Self { - users: Mutex::new(HashMap::new()), - } - } -} - -#[async_trait::async_trait] -impl UserServiceTrait for UserServiceImpl { - async fn get_users(&self) -> RestResult { - let users: Vec = self - .users - .lock() - .expect("users lock") - .values() - .cloned() - .collect(); - - Ok(RestResponse::ok(UsersResponse { - total: users.len(), - users, - })) - } - - async fn get_users_by_id(&self, id: String) -> RestResult { - self.users - .lock() - .expect("users lock") - .get(&id) - .cloned() - .map(RestResponse::ok) - .ok_or_else(|| RestError::not_found("User not found")) - } - - async fn post_users( - &self, - _user: &AuthenticatedUser, // Auto-injected for authenticated endpoints - request: CreateUserRequest, - ) -> RestResult { - let mut users = self.users.lock().expect("users lock"); - let id = format!("user-{}", users.len() + 1); - let user = User { - id: id.clone(), - name: request.name, - email: request.email, - }; - users.insert(id, user.clone()); - - Ok(RestResponse::created(user)) - } - - async fn put_users_by_id( - &self, - _user: &AuthenticatedUser, - id: String, - request: UpdateUserRequest, - ) -> RestResult { - let mut users = self.users.lock().expect("users lock"); - let user = users - .get_mut(&id) - .ok_or_else(|| RestError::not_found("User not found"))?; - user.name = request.name; - user.email = request.email; - - Ok(RestResponse::ok(user.clone())) - } - - async fn delete_users_by_id( - &self, - _user: &AuthenticatedUser, - id: String, - ) -> RestResult<()> { - let removed = self.users.lock().expect("users lock").remove(&id); - if removed.is_some() { - Ok(RestResponse::no_content()) - } else { - Err(RestError::not_found("User not found")) - } - } -} -``` - -### 4. Create and Run the Server - -```rust -use axum::Router; -use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; -use std::collections::HashSet; - -struct DemoAuthProvider; - -impl AuthProvider for DemoAuthProvider { - fn authenticate(&self, token: String) -> AuthFuture<'_> { - Box::pin(async move { - if token != "admin-token" { - return Err(AuthError::InvalidToken); - } - - Ok(AuthenticatedUser { - user_id: "admin-user".to_string(), - permissions: HashSet::from(["admin".to_string()]), - metadata: None, - }) - }) - } -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let service = UserServiceImpl::new(); - let auth_provider = DemoAuthProvider; - - let api_router = UserServiceBuilder::new(service) - .auth_provider(auth_provider) - .with_usage_tracker(|_headers, user, method, path| { - let method = method.to_string(); - let path = path.to_string(); - let user_id = user.map(|user| user.user_id.clone()); - async move { - println!("{} {} user={:?}", method, path, user_id); - } - }) - .with_method_duration_tracker(|method, path, _user, duration| { - let method = method.to_string(); - let path = path.to_string(); - async move { - println!("{} {} took {:?}", method, path, duration); - } - }) - .build(); - - let app = Router::new().merge(api_router); - - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; - axum::serve(listener, app).await?; - Ok(()) -} -``` - -## Macro Syntax - -### Full Syntax - -```rust -rest_service!({ - service_name: ServiceName, // Required: Name for generated types - base_path: "/api/v1", // Required: Base URL path - openapi: true, // Optional: Enable OpenAPI generation - serve_docs: true, // Optional: Enable the built-in API explorer - docs_path: "/docs", // Optional: API explorer path (default: "/docs") - ui_theme: "dark", // Optional: retained for compatibility - endpoints: [ - GET UNAUTHORIZED users() -> UsersResponse, - POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User, - ] -}); -``` - -Use `openapi: { output: "api.json" }` instead of `openapi: true` when you -want a custom OpenAPI output path. - -### Endpoint Syntax - -``` -METHOD AUTH_REQUIREMENT path/{param: Type}/segments(RequestType) -> ResponseType -``` - -- **METHOD**: `GET`, `POST`, `PUT`, `DELETE`, `PATCH` -- **AUTH_REQUIREMENT**: - - `UNAUTHORIZED` - No authentication required - - `WITH_PERMISSIONS(["permission1", "permission2"])` - Requires all listed permissions (AND) - - `WITH_PERMISSIONS(["perm1"] | ["perm2"])` - Requires any permission group (OR) -- **Path**: URL path with optional parameters in `{name: Type}` format -- **RequestType**: Optional request body type (omit for GET/DELETE) -- **ResponseType**: Response body type (use `()` for empty responses) - -### Path Parameters - -Path parameters are defined inline using `{name: Type}` syntax: - -```rust -GET UNAUTHORIZED users/{id: String}() -> User, -PUT WITH_PERMISSIONS(["admin"]) posts/{post_id: i32}/comments/{comment_id: i32}(UpdateCommentRequest) -> Comment, -``` - -## Authentication & Authorization - -### Setting Up Authentication - -The macro integrates with `ras-auth-core` for authentication: - -```rust -use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser, AuthResult}; -use std::collections::HashSet; - -struct DemoAuthProvider; - -impl AuthProvider for DemoAuthProvider { - fn authenticate(&self, token: String) -> AuthFuture<'_> { - Box::pin(async move { - if token != "admin-token" { - return Err(AuthError::InvalidToken); - } - - Ok(AuthenticatedUser { - user_id: "admin-user".to_string(), - permissions: HashSet::from(["admin".to_string()]), - metadata: None, - }) - }) - } - - fn check_permissions( - &self, - user: &AuthenticatedUser, - required_permissions: &[String], - ) -> AuthResult<()> { - let missing: Vec = required_permissions - .iter() - .filter(|permission| !user.permissions.contains(*permission)) - .cloned() - .collect(); - - if missing.is_empty() { - Ok(()) - } else { - Err(AuthError::InsufficientPermissions { - required: required_permissions.to_vec(), - has: user.permissions.iter().cloned().collect(), - }) - } - } -} -``` - -### Permission Groups - -Use OR logic between permission groups and AND logic within groups: - -```rust -// Requires either admin OR (moderator AND editor) -WITH_PERMISSIONS(["admin"] | ["moderator", "editor"]) -``` - -## Versioned Endpoints - -Versioned endpoints are opt-in. The canonical route stays implemented by the generated service trait. Each legacy route declares its own path, body, response, and migration type. The generated server migrates legacy request parts into the canonical request parts, calls the canonical service method, then migrates the response body back to the legacy response type. - -```rust -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RenameWidgetV1 { - pub name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RenameWidgetV2 { - pub display_name: String, - pub notify: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RenameWidgetResponseV1 { - pub name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RenameWidgetResponseV2 { - pub display_name: String, - pub notified: bool, -} - -rest_service!({ - service_name: WidgetService, - base_path: "/api", - openapi: true, - endpoints: [ - POST UNAUTHORIZED v2/widgets/{id: String}/rename(RenameWidgetV2) -> RenameWidgetResponseV2 { - version: v2, - versions: [ - v1 { - path: v1/widgets/{id: String}/rename, - body: RenameWidgetV1, - response: RenameWidgetResponseV1, - migration: RenameWidgetCompat, - }, - ], - }, - ] -}); - -struct RenameWidgetCompat; - -impl ras_rest_core::VersionMigration< - WidgetServicePostV2WidgetsByIdRenameV1Request, - WidgetServicePostV2WidgetsByIdRenameV2Request, -> for RenameWidgetCompat { - type Error = std::convert::Infallible; - - fn migrate( - value: WidgetServicePostV2WidgetsByIdRenameV1Request, - ) -> Result { - Ok(WidgetServicePostV2WidgetsByIdRenameV2Request { - path: WidgetServicePostV2WidgetsByIdRenameV2Path { id: value.path.id }, - query: WidgetServicePostV2WidgetsByIdRenameV2Query {}, - body: RenameWidgetV2 { - display_name: value.body.name, - notify: false, - }, - }) - } -} - -impl ras_rest_core::VersionMigration - for RenameWidgetCompat -{ - type Error = std::convert::Infallible; - - fn migrate(value: RenameWidgetResponseV2) -> Result { - Ok(RenameWidgetResponseV1 { - name: value.display_name, - }) - } -} -``` - -OpenAPI output includes both canonical and legacy paths. Versioned operations include `x-ras-version`, `x-ras-canonical-version`, and `x-ras-canonical-path` extensions. - -## Generated Code - -The macro generates several components: - -### 1. Service Trait - -```rust -#[async_trait::async_trait] -pub trait UserServiceTrait: Send + Sync + 'static { - async fn get_users(&self) -> RestResult; - async fn get_users_by_id(&self, id: String) -> RestResult; - async fn post_users(&self, user: &AuthenticatedUser, request: CreateUserRequest) -> RestResult; - async fn put_users_by_id(&self, user: &AuthenticatedUser, id: String, request: UpdateUserRequest) -> RestResult; - async fn delete_users_by_id(&self, user: &AuthenticatedUser, id: String) -> RestResult<()>; -} -``` - -### 2. Service Builder - -```rust -impl UserServiceBuilder { - pub fn new(service: T) -> Self; - pub fn auth_provider(self, provider: A) -> Self; - pub fn with_usage_tracker(self, tracker: F) -> Self; - pub fn with_method_duration_tracker(self, tracker: F) -> Self; - pub fn build(self) -> axum::Router; -} -``` - -### 3. Native Rust Client - -```rust -impl UserServiceClient { - pub fn builder(server_url: impl Into) -> UserServiceClientBuilder; - pub fn set_bearer_token(&mut self, token: Option>); - - // Generated methods matching endpoints - pub async fn get_users(&self) -> Result>; - pub async fn get_users_by_id(&self, id: String) -> Result>; - pub async fn post_users(&self, body: CreateUserRequest) -> Result>; - - // Methods with custom timeout - pub async fn get_users_with_timeout(&self, timeout: Option) -> Result>; -} -``` - -### 4. OpenAPI Generation - -The macro generates an OpenAPI 3.0 specification that can be used to generate TypeScript clients: - -```rust -// Generated function to create OpenAPI spec -pub fn generate_userservice_openapi() -> serde_json::Value { - // Returns the OpenAPI 3.0 JSON document -} - -// Generated function to write OpenAPI spec to file -pub fn generate_userservice_openapi_to_file() -> std::io::Result<()> { - // Writes to target/openapi/userservice.json -} -``` - -## TypeScript Client Usage - -### 1. Generate OpenAPI Specification - -Add a `build.rs` file to your backend crate to generate the OpenAPI spec at compile time: - -```rust -// backend/build.rs -fn main() { - // Import your API module - use rest_api; - - // Generate OpenAPI spec to target directory - rest_api::generate_userservice_openapi_to_file() - .expect("Failed to generate OpenAPI spec"); -} -``` - -This creates `target/openapi/userservice.json` during compilation. - -### 2. TypeScript Usage - -Generate a TypeScript fetch client from the OpenAPI document with your preferred -OpenAPI generator. The examples below assume the generated client exports -methods and schemas from `./generated`. - -```typescript -import * as api from './generated'; -import type { CreateUserRequest } from './generated'; - -// Shared configuration object for all requests -const baseConfig = { - baseUrl: 'http://localhost:3000/api/v1', - headers: { - Authorization: 'Bearer admin-token' - } -}; - -// Make API calls with named methods -const response = await api.getUsers(baseConfig); -if (response.data) { - const users = response.data.users; -} - -// GET with path parameter -const userResponse = await api.getUsersId( - Object.assign({}, baseConfig, { path: { id: '123' } }) -); - -// POST with typed body -const newUser: CreateUserRequest = { - name: 'John Doe', - email: 'john@example.com' -}; - -const created = await api.postUsers( - Object.assign({}, baseConfig, { body: newUser }) -); - -// DELETE request -await api.deleteUsersId( - Object.assign({}, baseConfig, { path: { id: '123' } }) -); -``` - -### Why Use An OpenAPI-Generated Fetch Client - -- **Small browser surface**: Standard fetch client code instead of a full app scaffold -- **Better developer experience**: Standard TypeScript/JavaScript -- **Runtime flexibility**: Fetch-based clients can be used in common JavaScript runtimes and browsers -- **Tree-shaking friendly**: Standard JavaScript optimization applies -- **Easier Debugging**: Standard network requests in DevTools - -## OpenAPI Documentation - -### Enabling OpenAPI Generation - -```rust -rest_service!({ - service_name: UserService, - base_path: "/api/v1", - openapi: true, // Generate to target/openapi/userservice.json - serve_docs: true, // Enable the built-in API explorer - docs_path: "/docs", // API explorer path - endpoints: [ - GET UNAUTHORIZED users() -> UsersResponse, - POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User, - ] -}); -``` - -Use `openapi: { output: "api.json" }` instead when you need a custom output path. - -### Generated OpenAPI Features - -- Endpoint documentation with request/response schemas -- Authentication requirements via `x-authentication` extension -- Permission requirements via `x-permissions` extension -- JSON Schema generation for all types -- Built-in API explorer integration - -### Accessing OpenAPI Documentation - -1. **API explorer**: Navigate to `http://localhost:3000/api/v1/docs` -2. **OpenAPI JSON**: Available at `http://localhost:3000/api/v1/docs/openapi.json` -3. **Generated File**: Check `target/openapi/.json` or custom path - -## Error Handling - -### Using RestResult and RestError - -The macro uses `RestResult` for all endpoints, allowing explicit HTTP status codes: - -```rust -use ras_rest_core::{RestResult, RestResponse, RestError}; - -async fn get_user(&self, id: String) -> RestResult { - if id.trim().is_empty() { - return Err(RestError::bad_request("Invalid user ID")); - } - - let user = self - .users - .lock() - .expect("users lock") - .get(&id) - .cloned() - .ok_or_else(|| RestError::not_found("User not found"))?; - - Ok(RestResponse::ok(user)) -} - -async fn create_user(&self, request: CreateUserRequest) -> RestResult { - let user = User { - id: "user-1".to_string(), - name: request.name, - email: request.email, - }; - - Ok(RestResponse::created(user)) -} -``` - -### Client Error Handling - -```typescript -try { - const user = await client.get_users_by_id('invalid-id'); -} catch (error) { - // Error includes HTTP status and message - console.error('Failed to get user:', error); -} -``` - -## Advanced Features - -### 1. Usage Tracking - -Track API usage for analytics or rate limiting: - -```rust -.with_usage_tracker(|_headers, user, method, path| { - let method = method.to_string(); - let path = path.to_string(); - let user_id = user.map(|user| user.user_id.clone()); - async move { - println!("API call: {} {} by {:?}", method, path, user_id); - } -}) -``` - -### 2. Performance Monitoring - -Track endpoint execution time: - -```rust -.with_method_duration_tracker(|method, path, _user, duration| { - let method = method.to_string(); - let path = path.to_string(); - async move { - println!("{} {} took {:?}", method, path, duration); - } -}) -``` - -### 3. Complex Path Parameters - -Support for multiple path parameters: - -```rust -PUT WITH_PERMISSIONS(["user"]) - users/{user_id: String}/projects/{project_id: i32}/tasks/{task_id: Uuid}(UpdateTaskRequest) - -> Task, -``` - -### 4. Multiple Permission Groups - -OR logic between groups, AND logic within: - -```rust -// User needs either: -// - admin permission, OR -// - both moderator AND editor permissions, OR -// - all three: viewer, commenter, and subscriber -WITH_PERMISSIONS(["admin"] | ["moderator", "editor"] | ["viewer", "commenter", "subscriber"]) -``` - -## Task API Example - -This example shows a task management API definition with public, authenticated, -and permission-gated routes: - -```rust -use ras_rest_macro::rest_service; -use serde::{Deserialize, Serialize}; -use schemars::JsonSchema; - -// API Types -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct Task { - pub id: String, - pub title: String, - pub description: String, - pub completed: bool, - pub user_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CreateTaskRequest { - pub title: String, - pub description: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct UpdateTaskRequest { - pub title: Option, - pub description: Option, - pub completed: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct TasksResponse { - pub tasks: Vec, - pub total: usize, -} - -// Define REST API -rest_service!({ - service_name: TaskService, - base_path: "/api/v1", - openapi: true, - serve_docs: true, - endpoints: [ - // List all tasks (public) - GET UNAUTHORIZED tasks() -> TasksResponse, - - // Get specific task (public) - GET UNAUTHORIZED tasks/{id: String}() -> Task, - - // Create task (requires authentication) - POST WITH_PERMISSIONS(["user"]) tasks(CreateTaskRequest) -> Task, - - // Update task (owner or admin) - PUT WITH_PERMISSIONS(["owner"] | ["admin"]) tasks/{id: String}(UpdateTaskRequest) -> Task, - - // Delete task (owner or admin) - DELETE WITH_PERMISSIONS(["owner"] | ["admin"]) tasks/{id: String}() -> (), - - // Get user's tasks - GET WITH_PERMISSIONS(["user"]) users/{user_id: String}/tasks() -> TasksResponse, - ] -}); - -// TypeScript usage -/* -import * as api from './generated'; - -const userToken = 'user-token'; -const baseConfig = { - baseUrl: 'http://localhost:3000/api/v1', - headers: { Authorization: `Bearer ${userToken}` } -}; - -// Create a task -const newTask = await api.postTasks( - Object.assign({}, baseConfig, { - body: { - title: 'Complete documentation', - description: 'Document REST endpoints' - } - }) -); - -// Update task -await api.putTasksId( - Object.assign({}, baseConfig, { - path: { id: newTask.data.id }, - body: { completed: true } - }) -); - -// Get user's tasks -const myTasks = await api.getUsersUserIdTasks( - Object.assign({}, baseConfig, { path: { user_id: userId } }) -); -*/ -``` - -## Best Practices - -1. **Type Safety**: Always use strongly-typed request/response objects -2. **Error Handling**: Use appropriate HTTP status codes via `RestError` -3. **Authentication**: Implement proper bearer token validation in your `AuthProvider` -4. **Documentation**: Enable OpenAPI generation for API documentation -5. **Monitoring**: Use usage and duration trackers for observability -6. **CORS**: Configure CORS appropriately for frontend clients -7. **Validation**: Validate request data in your service implementation -8. **Logging**: Log internal errors while keeping client messages generic -9. **OpenAPI Output**: Use `build.rs` when you want to emit the OpenAPI spec at compile time -10. **Client Generation**: Generate clients from the OpenAPI document when you need frontend bindings - -## Troubleshooting - -### Common Issues - -1. **Missing `JsonSchema` implementation**: All types must implement `JsonSchema` for OpenAPI generation -2. **OpenAPI generation fails**: Ensure `openapi: true` is set and all types implement `JsonSchema` -3. **TypeScript generation issues**: Verify the OpenAPI spec exists at the configured path -4. **Authentication fails**: Check that your `AuthProvider` is properly configured -5. **CORS errors**: Add appropriate CORS middleware to your Axum router - -### Feature Flags - -Control code generation with feature flags: - -```toml -[features] -default = ["server"] -server = [ - "ras-rest-macro/server", - "dep:ras-rest-core", - "dep:ras-auth-core", - "dep:async-trait", - "dep:axum", - "dep:axum-extra", - "dep:tokio", -] -client = ["ras-rest-macro/client", "dep:reqwest"] -``` - -## Conclusion - -The `ras-rest-macro` provides a typed workflow for building REST APIs in Rust with automatic client generation. By defining your API once, you get: - -- Type-safe server implementation -- Native Rust client -- OpenAPI specification for TypeScript client generation -- Typed TypeScript clients when generated from the OpenAPI document -- Built-in authentication and authorization -- Performance monitoring and usage tracking - -This approach avoids hand-maintained client DTOs and keeps browser clients aligned with the server contract. OpenAPI-based TypeScript generation also keeps the browser path easy to inspect and debug with standard network tooling. +[Read the `rest_service!` guide](src/macros/rest-service.md) diff --git a/documentation/src/SUMMARY.md b/documentation/src/SUMMARY.md new file mode 100644 index 0000000..3ae8558 --- /dev/null +++ b/documentation/src/SUMMARY.md @@ -0,0 +1,28 @@ +# Summary + +[Introduction](introduction.md) + +- [Why Typed Service Definitions](why-typed-service-definitions.md) +- [Auth In The API Contract](auth-in-api-contract.md) + +# Application Tutorial + +- [Build A Typed Workspace App](tutorial/index.md) + - [1. Design The Contract](tutorial/design-the-contract.md) + - [2. Create The API Crate](tutorial/create-the-api-crate.md) + - [3. Implement The Server](tutorial/implement-the-server.md) + - [4. Build Clients](tutorial/build-clients.md) + - [5. Test, Ship, And Evolve](tutorial/test-ship-and-evolve.md) + +# Macro Guides + +- [`jsonrpc_service!`](macros/jsonrpc-service.md) +- [`rest_service!`](macros/rest-service.md) +- [`file_service!`](macros/file-service.md) +- [`jsonrpc_bidirectional_service!`](macros/bidirectional-jsonrpc-service.md) + +# Integration Topics + +- [Generated Specs And Clients](generated-specs-and-clients.md) +- [Identity And Sessions](identity-and-sessions.md) +- [Observability](observability.md) diff --git a/documentation/src/auth-in-api-contract.md b/documentation/src/auth-in-api-contract.md new file mode 100644 index 0000000..8b64b34 --- /dev/null +++ b/documentation/src/auth-in-api-contract.md @@ -0,0 +1,71 @@ +# Auth In The API Contract + +RAS puts auth requirements next to the endpoint or method declaration: + +```rust,ignore +UNAUTHORIZED health(()) -> HealthStatus, +WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile, +WITH_PERMISSIONS(["admin"] | ["owner", "editor"]) update_project(UpdateProject) -> Project, +``` + +This is deliberate. When auth is part of the API definition, generated code can +enforce it consistently and generated API documents can expose it to clients. + +## Shared Runtime Model + +All service macros integrate with `ras-auth-core`: + +```rust,ignore +use ras_auth_core::{AuthFuture, AuthProvider, AuthenticatedUser}; + +struct MyAuthProvider; + +impl AuthProvider for MyAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + // Validate a JWT, API key, session token, or other credential. + todo!("return an AuthenticatedUser or AuthError") + }) + } +} +``` + +`AuthProvider::authenticate` turns a credential into an +`AuthenticatedUser`. The default `check_permissions` implementation requires +that the user has every permission listed in the group, and providers may +override that method for custom policy. + +## Auth Syntax + +`UNAUTHORIZED` means the generated server does not require a credential for the +operation. + +`WITH_PERMISSIONS(["a", "b"])` means the generated server requires a valid +credential and a permission group containing both `a` and `b`. + +Groups use OR logic between groups and AND logic within a group: + +```rust,ignore +WITH_PERMISSIONS(["admin"] | ["moderator", "editor"]) +``` + +That allows either `admin`, or both `moderator` and `editor`. + +An empty group is the authenticated-only form: + +```rust,ignore +WITH_PERMISSIONS([]) +``` + +It requires a valid user but no specific permission. + +The same syntax is accepted by JSON-RPC, REST, file, and bidirectional JSON-RPC +service macros. + +## What Gets Documented + +When OpenRPC or OpenAPI generation is enabled, protected operations include +authentication metadata. REST and file services expose bearer auth security +requirements in OpenAPI, and JSON-RPC methods expose `x-authentication`. +Permission names are also emitted as extension metadata so explorer UIs and +client-generation workflows can show what a call requires. diff --git a/documentation/src/generated-specs-and-clients.md b/documentation/src/generated-specs-and-clients.md new file mode 100644 index 0000000..74796e0 --- /dev/null +++ b/documentation/src/generated-specs-and-clients.md @@ -0,0 +1,90 @@ +# Generated Specs And Clients + +The service macros use the Rust API definition to generate machine-readable +contracts and client helpers. + +## OpenRPC + +`jsonrpc_service!` can generate OpenRPC when the service enables +`openrpc: true` or `openrpc: { output: "path/to/file.json" }`. + +```rust,ignore +pub fn generate_userservice_openrpc() -> serde_json::Value; +pub fn generate_userservice_openrpc_to_file() -> Result<(), std::io::Error>; +``` + +The document includes method names, request and response schemas, auth +extensions, permissions, and version metadata for versioned methods. + +## OpenAPI + +`rest_service!` and `file_service!` can generate OpenAPI with `openapi: true` +or a custom output path. + +```rust,ignore +pub fn generate_userservice_openapi() -> serde_json::Value; +pub fn generate_userservice_openapi_to_file() -> std::io::Result<()>; +``` + +REST operations include routes, HTTP methods, JSON schemas, bearer auth +requirements, and permission metadata. File-service operations also include +multipart schemas, binary download responses, and `x-ras-file` metadata for +upload limits, part policies, content types, and range support. + +## Rust Clients + +The shared API crate's `client` feature generates typed Rust clients. The +examples keep API definitions in separate API crates so server and browser +crates can depend on the same contract while enabling different API-crate +features. + +For browser targets, compile client crates with `--target wasm32-unknown-unknown` +and enable only the API crate's client-side feature set. See: + +- [examples/wasm-ui-demo](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/wasm-ui-demo) +- [examples/rest-wasm-example](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/rest-wasm-example) +- [examples/file-service-wasm](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/file-service-wasm) + +## Build-Time Spec Generation + +Backend crates can emit OpenRPC or OpenAPI during compilation from `build.rs`. +That keeps generated client input tied to the same Rust API contract used by +the server. + +```rust,ignore +fn main() { + rest_api::generate_userservice_openapi_to_file() + .expect("generate OpenAPI"); +} +``` + +The REST and file-service examples write specs under `target/openapi`. A +frontend build can then point its OpenAPI generator at that file. + +## TypeScript Call Shape + +The generated TypeScript fetch clients used in the examples accept one config +object per call: + +```typescript +await postUsers({ + baseUrl: 'http://localhost:3000/api/v1', + headers: { Authorization: `Bearer ${token}` }, + path: { id: 'user-123' }, + query: { include_archived: false }, + body: { name: 'Alice' }, +}); +``` + +Only include the fields the operation needs. Public `GET` calls often need only +`baseUrl`; protected uploads usually include `headers` and `body`. + +## Versioned Methods And Endpoints + +JSON-RPC and REST macros support opt-in compatibility definitions. A canonical +operation can declare legacy wire names, legacy request/response types, and a +migration type. The generated server accepts both shapes while the service +implementation only handles the canonical Rust type. + +Use versioning when a deployed client still depends on an old wire contract and +the server can safely migrate requests and responses at the API boundary. diff --git a/documentation/src/identity-and-sessions.md b/documentation/src/identity-and-sessions.md new file mode 100644 index 0000000..bb6f74a --- /dev/null +++ b/documentation/src/identity-and-sessions.md @@ -0,0 +1,54 @@ +# Identity And Sessions + +RAS separates service-level authorization from identity-provider concerns. The +service macros ask an `AuthProvider` to authenticate credentials and check +permissions. The identity crates help build those credentials and permission +sets. + +## Core Pieces + +- `ras-auth-core` defines `AuthProvider`, `AuthenticatedUser`, `AuthError`, + bearer/cookie transport helpers, and CSRF configuration. +- `ras-identity-core` defines identity-provider traits. +- `ras-identity-local` provides username/password verification with Argon2. +- `ras-identity-oauth2` provides OAuth2 with PKCE support. +- `ras-identity-session` issues and verifies JWT sessions and can attach + permissions to authenticated identities. + +## Typical Flow + +1. A public endpoint such as `sign_in` or an OAuth2 callback verifies an + identity. +2. The application creates a JWT session through the session crate. +3. Protected generated services receive bearer tokens or configured secure + cookies. +4. The generated service calls the configured `AuthProvider`. +5. Handler methods receive `&AuthenticatedUser` only after auth succeeds. + +```rust,ignore +let jwt_auth = JwtAuthProvider::new(Arc::new(session_service)); + +let app = UserServiceBuilder::new(UserServiceImpl) + .auth_provider(jwt_auth) + .build(); +``` + +## Permissions + +Permissions are ordinary strings stored on `AuthenticatedUser`. The default +`AuthProvider::check_permissions` requires all permissions in a group. Override +it when permissions are tenant-aware, role-derived, time-bound, or backed by an +external policy service. + +Use `WITH_PERMISSIONS([])` when an operation only needs a logged-in user and no +specific permission. + +## Secure Browser Sessions + +Browser-facing services can use secure `HttpOnly` cookies instead of manually +placing bearer tokens in JavaScript. The same generated builders support cookie +auth transport and double-submit CSRF protection for unsafe cookie-authenticated +requests. + +See the OAuth2 example in +[examples/oauth2-demo](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/oauth2-demo). diff --git a/documentation/src/introduction.md b/documentation/src/introduction.md new file mode 100644 index 0000000..07af67c --- /dev/null +++ b/documentation/src/introduction.md @@ -0,0 +1,27 @@ +# Introduction + +Rust Agent Stack (RAS) is a set of Rust crates for building type-safe, +authenticated service APIs. The central idea is that the API contract should be +declared once, in Rust, and then used to generate the server boundary, handler +trait, clients, API documents, and authentication checks. + +The main service macros are: + +- `jsonrpc_service!` for HTTP JSON-RPC services. +- `rest_service!` for conventional JSON REST APIs. +- `file_service!` for streaming upload and download APIs. +- `jsonrpc_bidirectional_service!` for typed bidirectional JSON-RPC over + WebSockets. + +Each macro follows the same shape: define the wire contract, implement the +generated trait, configure an auth provider when protected calls exist, then +mount the generated server or use the generated client. + +If you want a guided application design flow, start with the +[application tutorial](tutorial/index.md). It walks through crate boundaries, +auth, server implementation, generated clients, tests, and evolution. + +The repository contains runnable +[examples](https://github.com/JedimEmO/rust-api-stack/tree/master/examples), +including JSON-RPC, REST, file services, OAuth2, bidirectional chat, and +browser/WASM clients. diff --git a/documentation/src/macros/bidirectional-jsonrpc-service.md b/documentation/src/macros/bidirectional-jsonrpc-service.md new file mode 100644 index 0000000..082fffc --- /dev/null +++ b/documentation/src/macros/bidirectional-jsonrpc-service.md @@ -0,0 +1,170 @@ +# `jsonrpc_bidirectional_service!` + +Use `jsonrpc_bidirectional_service!` for typed JSON-RPC traffic over +WebSockets. It generates server-side dispatch for client calls, client-side +method helpers, typed notification handling, and optional server-to-client +request support. + +## Dependencies And Features + +```toml +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ras-auth-core = "0.1.0" +ras-jsonrpc-types = "0.1.1" +ras-jsonrpc-bidirectional-types = "0.1.0" +ras-jsonrpc-bidirectional-macro = { version = "0.1.0", default-features = false } +ras-jsonrpc-bidirectional-server = { version = "0.1.0", optional = true } +ras-jsonrpc-bidirectional-client = { version = "0.1.0", optional = true } + +[features] +default = [] +server = [ + "dep:ras-jsonrpc-bidirectional-server", +] +client = [ + "dep:ras-jsonrpc-bidirectional-client", +] +``` + +Define these features on the shared API crate. The WebSocket server depends on +that API crate with `features = ["server"]`; TUI, native, or browser clients +depend on it with `features = ["client"]`. + +If `server_to_client_calls` is used, the server feature also needs optional +`tokio` and `uuid` dependencies because generated server-side client handles +track pending responses and timeouts. + +## Define The Service + +```rust,ignore +use ras_jsonrpc_bidirectional_macro::jsonrpc_bidirectional_service; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendMessageRequest { + pub channel: String, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendMessageResponse { + pub message_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageReceived { + pub channel: String, + pub body: String, +} + +jsonrpc_bidirectional_service!({ + service_name: ChatService, + client_to_server: [ + WITH_PERMISSIONS(["user"]) send_message(SendMessageRequest) -> SendMessageResponse, + ], + server_to_client: [ + message_received(MessageReceived), + ], + server_to_client_calls: [ + ] +}); +``` + +`client_to_server` methods support the same `UNAUTHORIZED` and +`WITH_PERMISSIONS(["a"] | ["b", "c"])` style as the HTTP JSON-RPC macro. + +## Implement And Mount The Server + +Server handlers receive the connection id and connection manager. Protected +methods also receive `&AuthenticatedUser`. + +```rust,ignore +#[async_trait::async_trait] +impl ChatServiceService for ChatServiceImpl { + async fn send_message( + &self, + client_id: ras_jsonrpc_bidirectional_types::ConnectionId, + connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, + user: &ras_auth_core::AuthenticatedUser, + request: SendMessageRequest, + ) -> Result> { + todo!("persist and broadcast the message") + } + + async fn notify_message_received( + &self, + connection_id: ras_jsonrpc_bidirectional_types::ConnectionId, + params: MessageReceived, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } +} +``` + +```rust,ignore +let websocket_service = ChatServiceBuilder::new(ChatServiceImpl, my_auth_provider) + .require_auth(false) + .build(); + +let app = axum::Router::new() + .route("/ws", axum::routing::get(ras_jsonrpc_bidirectional_server::websocket_handler::<_>)) + .with_state(websocket_service); +``` + +`require_auth(true)` requires credentials for the connection as a whole. +Method-level permissions are still enforced for protected calls. + +## Client Usage + +The client feature generates a typed client builder, method calls, connection +helpers, and notification registration: + +```rust,ignore +let mut client = ChatServiceClientBuilder::new("ws://localhost:3000/ws") + .with_jwt_token(token) + .build() + .await?; + +client.on_message_received(|message| { + println!("{}: {}", message.channel, message.body); +}); + +client.connect().await?; +let sent = client.send_message(SendMessageRequest { + channel: "general".to_string(), + body: "hello".to_string(), +}).await?; +``` + +In application code, it is usually useful to register all notification handlers +before `connect`, then wrap common calls behind a small app-level client: + +```rust,ignore +client.on_message_received(|message| { + println!("{}: {}", message.channel, message.body); +}); + +client.on_user_joined(|event| { + println!("{} joined", event.username); +}); + +client.connect().await?; + +let rooms = client.list_rooms(ListRoomsRequest {}).await?; + +client + .send_message(SendMessageRequest { + channel: rooms.default_channel, + body: "hello".to_string(), + }) + .await?; +``` + +This macro does not currently generate OpenRPC. Use HTTP +`jsonrpc_service!` when an OpenRPC document is required. + +See +[examples/bidirectional-chat](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/bidirectional-chat). diff --git a/documentation/src/macros/file-service.md b/documentation/src/macros/file-service.md new file mode 100644 index 0000000..90b724d --- /dev/null +++ b/documentation/src/macros/file-service.md @@ -0,0 +1,290 @@ +# `file_service!` + +Use `file_service!` when the API handles uploads or downloads. It is separate +from the JSON REST macro because file traffic has different constraints: +authenticate before reading the body, reject oversized requests early, validate +multipart fields, and stream bytes instead of buffering entire files. + +## Dependencies And Features + +Put the file service definition in a shared API crate and expose generated +transport code through API-crate features: + +```toml +[dependencies] +ras-file-macro = { version = "0.1.0", default-features = false } +ras-file-core = { version = "0.1.0", optional = true } +ras-auth-core = { version = "0.1.0", optional = true } +serde = { version = "1.0", features = ["derive"] } +async-trait = { version = "0.1", optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +axum = { version = "0.8", optional = true } +reqwest = { version = "0.12", optional = true } +tokio = { version = "1.0", optional = true } +tokio-util = { version = "0.7", optional = true } +schemars = { version = "1.0.0-alpha.20", optional = true } +serde_json = { version = "1.0", optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"], optional = true } + +[features] +default = [] +server = [ + "dep:ras-file-core", + "dep:ras-auth-core", + "dep:async-trait", + "dep:axum", + "dep:schemars", + "dep:serde_json", +] +client = ["dep:reqwest", "dep:tokio", "dep:tokio-util"] +``` + +Server crates depend on the API crate with `features = ["server"]`. Native and +browser clients depend on the same API crate with `features = ["client"]`. + +## Define The Service + +```rust,ignore +use ras_file_macro::file_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct UploadResponse { + pub file_id: String, + pub size: u64, +} + +file_service!({ + service_name: DocumentService, + base_path: "/api/documents", + openapi: true, + endpoints: [ + UPLOAD WITH_PERMISSIONS(["files:write"]) upload multipart { + max_total_bytes: 52428800, + reject_unknown_fields: true, + parts: [ + file file { + required: true, + max_count: 1, + max_bytes: 52428800, + content_types: ["application/pdf", "text/plain"], + filename: optional, + }, + json metadata: UploadMetadata { + required: false, + max_bytes: 4096, + content_types: ["application/json"], + }, + ], + } -> UploadResponse, + + DOWNLOAD WITH_PERMISSIONS(["files:read"]) download/{file_id: String} { + content_types: ["application/octet-stream"], + ranges: true, + }, + ] +}); +``` + +Every upload declares `max_total_bytes`, each part declares `max_bytes`, and +`reject_unknown_fields` defaults to `true`. File parts can require, forbid, or +allow filenames. + +## Implement The Upload Lifecycle + +Uploads are processed in phases. The generated server authenticates and checks +permissions before consuming the body, then calls service code as accepted parts +arrive. + +```rust,ignore +use ras_file_core::{FileRequestContext, FileResult, JsonResponse}; + +#[async_trait::async_trait] +impl DocumentServiceTrait for MyService { + type UploadState = UploadState; + + async fn upload_begin( + &self, + ctx: &FileRequestContext<'_>, + path: &DocumentServiceUploadPath, + ) -> FileResult { + Ok(UploadState::default()) + } + + async fn upload_part( + &self, + ctx: &FileRequestContext<'_>, + path: &DocumentServiceUploadPath, + state: &mut Self::UploadState, + part: &mut DocumentServiceUploadPart<'_>, + ) -> FileResult<()> { + match part { + DocumentServiceUploadPart::File(file) => { + while let Some(chunk) = file.next_chunk().await? { + state.write(&chunk).await?; + } + } + DocumentServiceUploadPart::Metadata(metadata) => { + state.metadata = Some(metadata.clone()); + } + } + + Ok(()) + } + + async fn upload_finish( + &self, + ctx: &FileRequestContext<'_>, + path: &DocumentServiceUploadPath, + state: Self::UploadState, + summary: ras_file_core::UploadSummary, + ) -> FileResult> { + Ok(JsonResponse::ok(state.into_response())) + } +} +``` + +If a file part is not fully consumed, the generated handler rejects the request. +Override the generated `*_abort` hook when temporary files or external +reservations need cleanup after an upload error. + +## Downloads + +Download handlers return `DownloadResponse`: + +```rust,ignore +use ras_file_core::{DownloadResponse, FileRequestContext, FileResult}; + +async fn download_by_file_id( + &self, + ctx: &FileRequestContext<'_>, + path: DocumentServiceDownloadByFileIdPath, +) -> FileResult { + let file = self.storage.open(&path.file_id).await?; + + DownloadResponse::stream(file.stream) + .content_type(file.content_type)? + .content_length(file.size)? + .attachment(file.original_name) +} +``` + +Path parameters become `by_*` method name segments. For example, +`download/{file_id: String}` generates `download_by_file_id`. + +## Auth Syntax + +File services use the same auth syntax as the other service macros: + +```rust,ignore +WITH_PERMISSIONS(["files:write"]) +WITH_PERMISSIONS(["files:write", "tenant:active"]) +WITH_PERMISSIONS(["admin"] | ["files:write", "tenant:active"]) +WITH_PERMISSIONS([]) +``` + +Use `WITH_PERMISSIONS([])` for authenticated-only file operations. + +## Use The Generated Rust Client + +The generated native client handles bearer auth, multipart construction, upload +methods, and download requests. + +Enable it through the API crate dependency: + +```toml +[dependencies] +document-api = { path = "../file-service-api", default-features = false, features = ["client"] } +``` + +```rust,ignore +let client = DocumentServiceClient::builder("http://localhost:3000") + .with_timeout(std::time::Duration::from_secs(30)) + .build()?; + +client.set_bearer_token(Some(user_token)); + +let metadata = UploadMetadata { + title: "Quarterly report".to_string(), +}; + +let form = DocumentServiceUploadMultipart::new() + .file("report.pdf", Some("report.pdf"), Some("application/pdf")) + .await? + .metadata(&metadata)?; + +let uploaded = client.upload(form).await?; + +let response = client.download_by_file_id(uploaded.file_id).await?; +let bytes = response.bytes().await?; +``` + +For tests, browser-like flows, or already-buffered content, use the generated +`*_bytes` helper for file parts: + +```rust,ignore +let form = DocumentServiceUploadMultipart::new() + .file_bytes( + b"hello".to_vec(), + "hello.txt", + Some("text/plain"), + )?; + +let uploaded = client.upload(form).await?; +``` + +## Use An OpenAPI TypeScript Client + +OpenAPI-generated browser clients usually model multipart uploads as an object +whose fields match the declared parts: + +```typescript +import { + downloadDownloadFileId, + uploadUpload, + uploadUploadProfilePicture, +} from './generated'; + +const baseUrl = 'http://localhost:3000/api/documents'; + +const uploaded = await uploadUpload({ + baseUrl, + body: { file }, +}); + +const secureUpload = await uploadUploadProfilePicture({ + baseUrl, + headers: { Authorization: `Bearer ${token}` }, + body: { file }, +}); + +const downloaded = await downloadDownloadFileId({ + baseUrl, + path: { file_id: uploaded.data.file_id }, +}); +``` + +## OpenAPI And Clients + +With `openapi: true`, the macro emits: + +```rust,ignore +pub fn generate_documentservice_openapi() -> serde_json::Value; +pub fn generate_documentservice_openapi_to_file() -> std::io::Result<()>; +``` + +Upload operations include `multipart/form-data` schemas and an `x-ras-file` +extension for limits and part policies. Download operations document binary +responses, content types, and range support. + +The native client feature generates multipart builders, including in-memory +`*_bytes` helpers for tests. + +See +[examples/file-service-example](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/file-service-example) +and +[examples/file-service-wasm](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/file-service-wasm). diff --git a/documentation/src/macros/jsonrpc-service.md b/documentation/src/macros/jsonrpc-service.md new file mode 100644 index 0000000..8c7c373 --- /dev/null +++ b/documentation/src/macros/jsonrpc-service.md @@ -0,0 +1,188 @@ +# `jsonrpc_service!` + +Use `jsonrpc_service!` when you want an HTTP JSON-RPC API with typed request +and response payloads, generated server dispatch, generated Rust clients, and +optional OpenRPC output. + +## Dependencies And Features + +Put the macro in the shared API definition crate and make `server` and +`client` features on that API crate. Server binaries then depend on +`my-api` with `features = ["server"]`; clients depend on the same API crate +with `features = ["client"]`. + +```toml +[dependencies] +ras-jsonrpc-macro = { version = "0.2.0", default-features = false } +ras-jsonrpc-types = "0.1.1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +schemars = "1.0.0-alpha.20" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +ras-jsonrpc-core = { version = "0.1.2", optional = true } +axum = { version = "0.8", optional = true } +tokio = { version = "1.0", features = ["full"], optional = true } +reqwest = { version = "0.12", features = ["json"], optional = true } + +[features] +default = [] +server = ["dep:ras-jsonrpc-core", "dep:axum", "dep:tokio"] +client = ["dep:reqwest"] +``` + +## Define The Service + +```rust,ignore +use ras_jsonrpc_macro::jsonrpc_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct SignInRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct SignInResponse { + pub token: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct UserProfile { + pub user_id: String, +} + +jsonrpc_service!({ + service_name: UserService, + openrpc: true, + methods: [ + UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, + WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile, + WITH_PERMISSIONS(["admin"] | ["support", "users:write"]) disable_user(String) -> (), + ] +}); +``` + +The Rust method name is the JSON-RPC wire method unless a versioned method block +sets an explicit `wire` name. + +## Implement The Generated Trait + +Protected methods receive `&AuthenticatedUser` before their request payload: + +```rust,ignore +struct UserServiceImpl; + +impl UserServiceTrait for UserServiceImpl { + async fn sign_in( + &self, + request: SignInRequest, + ) -> Result> { + todo!("verify credentials and issue token") + } + + async fn get_profile( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + _request: (), + ) -> Result> { + Ok(UserProfile { + user_id: user.user_id.clone(), + }) + } +} +``` + +## Build The Router + +```rust,ignore +let rpc = UserServiceBuilder::new(UserServiceImpl) + .base_url("/rpc") + .auth_provider(my_auth_provider) + .build()?; + +let app = axum::Router::new().merge(rpc); +``` + +The generated server extracts bearer credentials from `Authorization`, can be +configured for secure session cookies, routes by JSON-RPC method name, parses +typed params, checks auth, and converts handler errors into JSON-RPC error +responses. + +## Use The Generated Rust Client + +Enable the shared API crate's `client` feature in the crate that makes outbound +calls: + +```toml +[dependencies] +my-api = { path = "../api", default-features = false, features = ["client"] } +``` + +The generated client calls methods by their Rust names and sends the correct +JSON-RPC wire method internally. + +```rust,ignore +let mut client = UserServiceClientBuilder::new() + .server_url("http://localhost:3000/rpc") + .with_timeout(std::time::Duration::from_secs(10)) + .build()?; + +let signed_in = client + .sign_in(SignInRequest { + email: "alice@example.com".to_string(), + password: "correct horse battery staple".to_string(), + }) + .await?; + +client.set_bearer_token(Some(signed_in.token)); + +let profile = client.get_profile(()).await?; + +client + .disable_user("user-123".to_string()) + .await?; +``` + +For browser/WASM clients, use the same generated client with a browser URL and +set the bearer token on a cloned client before protected calls: + +```rust,ignore +let client = UserServiceClientBuilder::new() + .server_url("/rpc") + .build()?; + +let mut authed = client.clone(); +authed.set_bearer_token(Some(token)); + +let profile = authed.get_profile(()).await?; +``` + +## OpenRPC And Clients + +With `openrpc: true`, the macro generates: + +```rust,ignore +pub fn generate_userservice_openrpc() -> serde_json::Value; +pub fn generate_userservice_openrpc_to_file() -> Result<(), std::io::Error>; +``` + +Request and response types must implement `schemars::JsonSchema` for OpenRPC +generation. The generated document includes schemas, method names, auth +metadata, permission metadata, and version metadata. + +The API crate's `client` feature emits typed Rust methods for the current +operation names and, when a method declares versioned compatibility, for the +legacy Rust method aliases too. Each generated method still sends the configured +wire method name, so old and new clients can coexist while the server migrates +requests at the API boundary. + +Browser clients can compile to WASM when the API crate dependency is enabled +with `features = ["client"]` for `wasm32`. + +See the runnable service in +[examples/basic-jsonrpc](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/basic-jsonrpc) +and the WASM client usage in +[examples/wasm-ui-demo](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/wasm-ui-demo). diff --git a/documentation/src/macros/rest-service.md b/documentation/src/macros/rest-service.md new file mode 100644 index 0000000..067d34f --- /dev/null +++ b/documentation/src/macros/rest-service.md @@ -0,0 +1,212 @@ +# `rest_service!` + +Use `rest_service!` for JSON REST APIs that should generate Axum routes, typed +handler traits, native Rust clients, OpenAPI documents, and an optional API +explorer. + +## Dependencies And Features + +```toml +[dependencies] +ras-rest-macro = { version = "0.2.1", default-features = false } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +schemars = "1.0.0-alpha.20" +async-trait = { version = "0.1", optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +ras-rest-core = { version = "0.1.1", optional = true } +ras-auth-core = { version = "0.1.0", optional = true } +axum = { version = "0.8", optional = true } +axum-extra = { version = "0.10", features = ["query"], optional = true } +tokio = { version = "1.0", features = ["full"], optional = true } +reqwest = { version = "0.12", features = ["json"], optional = true } + +[features] +default = [] +server = [ + "dep:ras-rest-core", + "dep:ras-auth-core", + "dep:async-trait", + "dep:axum", + "dep:axum-extra", + "dep:tokio", +] +client = ["dep:reqwest"] +``` + +These features belong on the shared API definition crate. A backend depends on +that crate with `features = ["server"]`; a Rust client or WASM crate depends on +the same crate with `features = ["client"]`. + +## Define The Service + +```rust,ignore +use ras_rest_macro::rest_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct User { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateUserRequest { + pub name: String, +} + +rest_service!({ + service_name: UserService, + base_path: "/api/v1", + openapi: true, + serve_docs: true, + docs_path: "/docs", + endpoints: [ + GET UNAUTHORIZED users() -> Vec, + GET WITH_PERMISSIONS(["user"]) users/{id: String}() -> User, + POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User, + DELETE WITH_PERMISSIONS(["admin"] | ["support", "users:delete"]) users/{id: String}() -> (), + ] +}); +``` + +Endpoint syntax is: + +```text +METHOD AUTH_REQUIREMENT path/{param: Type}/segments(RequestType) -> ResponseType +``` + +Supported methods are `GET`, `POST`, `PUT`, `DELETE`, and `PATCH`. + +## Implement The Generated Trait + +REST handlers return `RestResult`, usually through `RestResponse` helpers: + +```rust,ignore +use ras_auth_core::AuthenticatedUser; +use ras_rest_core::{RestError, RestResponse, RestResult}; + +struct UserServiceImpl; + +#[async_trait::async_trait] +impl UserServiceTrait for UserServiceImpl { + async fn get_users(&self) -> RestResult> { + Ok(RestResponse::ok(vec![])) + } + + async fn get_users_by_id( + &self, + user: &AuthenticatedUser, + id: String, + ) -> RestResult { + todo!("load a user visible to user.user_id") + } + + async fn post_users( + &self, + user: &AuthenticatedUser, + request: CreateUserRequest, + ) -> RestResult { + todo!("create user as admin") + } +} +``` + +Path parameters become ordinary typed arguments. Protected endpoints receive +`&AuthenticatedUser` before path and body arguments. + +## Build The Router + +```rust,ignore +let app = UserServiceBuilder::new(UserServiceImpl) + .auth_provider(my_auth_provider) + .build(); +``` + +The builder can also be configured for secure cookie auth and CSRF protection +without changing the `AuthProvider`. + +## Use The Generated Rust Client + +Enable the shared API crate's `client` feature in the crate that makes outbound +calls: + +```toml +[dependencies] +my-rest-api = { path = "../rest-api", default-features = false, features = ["client"] } +``` + +Pass the server origin to the generated client; the macro's `base_path` is +joined automatically. + +```rust,ignore +let mut client = UserServiceClient::builder("http://localhost:3000") + .with_timeout(std::time::Duration::from_secs(10)) + .build()?; + +let users = client.get_users().await?; +let alice = client.get_users_by_id("alice".to_string()).await?; + +client.set_bearer_token(Some(admin_token)); + +let created = client + .post_users(CreateUserRequest { + name: "Alice".to_string(), + }) + .await?; + +client.delete_users_by_id(created.id).await?; +``` + +Path parameters, query parameters, and request bodies become ordinary method +arguments in that order. + +## Use An OpenAPI TypeScript Client + +The REST examples also show the browser-oriented path: generate a fetch client +from the OpenAPI document, then call named functions with `baseUrl`, optional +headers, path parameters, query parameters, and body values. + +```typescript +import { getUsers, getUsersId, postUsers } from './generated'; +import type { CreateUserRequest } from './generated'; + +const baseUrl = 'http://localhost:3000/api/v1'; + +const users = await getUsers({ baseUrl }); + +const alice = await getUsersId({ + baseUrl, + path: { id: 'alice' }, +}); + +const request: CreateUserRequest = { name: 'Alice' }; + +const created = await postUsers({ + baseUrl, + headers: { Authorization: `Bearer ${adminToken}` }, + body: request, +}); +``` + +## OpenAPI, Explorer, And Clients + +With `openapi: true`, the macro generates: + +```rust,ignore +pub fn generate_userservice_openapi() -> serde_json::Value; +pub fn generate_userservice_openapi_to_file() -> std::io::Result<()>; +``` + +With `serve_docs: true`, the generated router serves the built-in API explorer +under `docs_path` relative to `base_path`. + +The OpenAPI document includes JSON schemas, routes, HTTP methods, bearer auth +requirements, and `x-permissions` metadata. It can be checked into build output +or consumed by TypeScript client generators. + +See +[examples/rest-wasm-example](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/rest-wasm-example) +for a REST API with OpenAPI output and browser client usage. diff --git a/documentation/src/observability.md b/documentation/src/observability.md new file mode 100644 index 0000000..f543b78 --- /dev/null +++ b/documentation/src/observability.md @@ -0,0 +1,53 @@ +# Observability + +RAS exposes observability hooks so generated services can report request usage +and method durations without baking one metrics backend into every macro. + +The OpenTelemetry implementation lives in `ras-observability-otel`, and the +core traits live in `ras-observability-core`. + +## Standard Setup + +```rust,ignore +use ras_observability_otel::standard_setup; + +let otel = standard_setup("my-service")?; +let usage_tracker = otel.usage_tracker(); +let duration_tracker = otel.method_duration_tracker(); +let metrics_router = otel.metrics_router(); +``` + +Generated service builders expose hooks such as `with_usage_tracker` and +`with_method_duration_tracker` where supported: + +```rust,ignore +let service = UserServiceBuilder::new(UserServiceImpl) + .auth_provider(my_auth_provider) + .with_usage_tracker(usage_tracker) + .with_method_duration_tracker(duration_tracker) + .build(); + +let app = axum::Router::new() + .merge(service) + .merge(metrics_router); +``` + +## Request Contexts + +Use standard context constructors when recording custom metrics outside a +generated service: + +```rust,ignore +use ras_observability_core::RequestContext; + +let rest = RequestContext::rest("POST", "/api/orders"); +let rpc = RequestContext::jsonrpc("create_order"); +let ws = RequestContext::websocket("send_message"); +``` + +Consistent context names keep metrics comparable across REST, JSON-RPC, file, +and WebSocket APIs. + +See +[crates/observability/ras-observability-otel](https://github.com/JedimEmO/rust-api-stack/tree/master/crates/observability/ras-observability-otel) +for crate-level details and examples. diff --git a/documentation/src/tutorial/build-clients.md b/documentation/src/tutorial/build-clients.md new file mode 100644 index 0000000..5a69224 --- /dev/null +++ b/documentation/src/tutorial/build-clients.md @@ -0,0 +1,135 @@ +# 4. Build Clients + +Client crates depend on the same API crate with `features = ["client"]`. + +```toml +[dependencies] +workspace-api = { path = "../workspace-api", default-features = false, features = ["client"] } +``` + +## Rust REST Client + +The generated REST client turns paths, query values, and request bodies into +typed method arguments. + +```rust,ignore +use workspace_api::{CreateTaskRequest, TaskServiceClient}; + +let mut client = TaskServiceClient::builder("https://workspace.example.com") + .with_timeout(std::time::Duration::from_secs(10)) + .build()?; + +client.set_bearer_token(Some(token)); + +let tasks = client + .get_projects_by_project_id_tasks("project-123".to_string()) + .await?; + +let created = client + .post_projects_by_project_id_tasks( + "project-123".to_string(), + CreateTaskRequest { + title: "Write release notes".to_string(), + assignee_id: None, + }, + ) + .await?; +``` + +The client method names mirror the generated handler names, so compiler errors +surface contract changes immediately. + +## Rust File Client + +The generated file client builds multipart requests and download requests. + +```rust,ignore +use workspace_api::{AttachmentServiceClient, AttachmentServiceTasksByTaskIdUploadMultipart}; + +let mut client = AttachmentServiceClient::builder("https://workspace.example.com") + .with_timeout(std::time::Duration::from_secs(30)) + .build()?; + +client.set_bearer_token(Some(token)); + +let form = AttachmentServiceTasksByTaskIdUploadMultipart::new() + .file("notes.pdf", Some("notes.pdf"), Some("application/pdf")) + .await?; + +let uploaded = client + .tasks_by_task_id_upload("task-123".to_string(), form) + .await?; + +let response = client + .download_by_attachment_id(uploaded.attachment_id) + .await?; +let bytes = response.bytes().await?; +``` + +For tests or browser-like buffered content, generated multipart builders also +provide `*_bytes` helpers where file parts are declared. + +## TypeScript Clients From OpenAPI + +If your browser app is TypeScript, generate a fetch client from the OpenAPI +files emitted by the server build. Generated clients usually accept one config +object per call: + +```typescript +import { + getProjectsProjectIdTasks, + postProjectsProjectIdTasks, +} from './generated/task-client'; + +const baseUrl = 'https://workspace.example.com/api/v1'; + +const tasks = await getProjectsProjectIdTasks({ + baseUrl, + headers: { Authorization: `Bearer ${token}` }, + path: { project_id: 'project-123' }, +}); + +const created = await postProjectsProjectIdTasks({ + baseUrl, + headers: { Authorization: `Bearer ${token}` }, + path: { project_id: 'project-123' }, + body: { + title: 'Write release notes', + assignee_id: null, + }, +}); +``` + +File uploads use `FormData` or the generator's multipart object shape: + +```typescript +await postTasksTaskIdUpload({ + baseUrl: 'https://workspace.example.com/api/v1/attachments', + headers: { Authorization: `Bearer ${token}` }, + path: { task_id: 'task-123' }, + body: { file }, +}); +``` + +## WebSocket Notifications + +The bidirectional client registers typed notification handlers before +connecting: + +```rust,ignore +let mut activity = ActivityServiceClientBuilder::new("wss://workspace.example.com/ws") + .with_jwt_token(token) + .build() + .await?; + +activity.on_task_changed(|event| { + println!("task changed: {}", event.task_id); +}); + +activity.connect().await?; +activity.subscribe_project("project-123".to_string()).await?; +``` + +Use generated clients directly at application edges, then wrap them in small +domain-specific adapters if the UI needs a simpler interface. + diff --git a/documentation/src/tutorial/create-the-api-crate.md b/documentation/src/tutorial/create-the-api-crate.md new file mode 100644 index 0000000..6774492 --- /dev/null +++ b/documentation/src/tutorial/create-the-api-crate.md @@ -0,0 +1,184 @@ +# 2. Create The API Crate + +The API crate owns DTOs and service declarations. It should not own database +connections, runtime configuration, or concrete auth logic. + +## Cargo Features + +Use API-crate features to select generated transport code: + +```toml +[package] +name = "workspace-api" +edition = "2024" + +[dependencies] +ras-rest-macro = { version = "0.2.1", default-features = false } +ras-file-macro = { version = "0.1.0", default-features = false } +ras-jsonrpc-bidirectional-macro = { version = "0.1.0", default-features = false } +serde = { version = "1.0", features = ["derive"] } +schemars = { version = "1.0.0-alpha.20", optional = true } +serde_json = { version = "1.0", optional = true } +async-trait = { version = "0.1", optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +ras-auth-core = { version = "0.1.0", optional = true } +ras-rest-core = { version = "0.1.1", optional = true } +ras-file-core = { version = "0.1.0", optional = true } +ras-jsonrpc-bidirectional-server = { version = "0.1.0", optional = true } +axum = { version = "0.8", optional = true } +axum-extra = { version = "0.10", optional = true } +tokio = { version = "1.0", optional = true } +tokio-util = { version = "0.7", optional = true } +reqwest = { version = "0.12", optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"], optional = true } + +[features] +default = [] +server = [ + "dep:schemars", + "dep:serde_json", + "dep:async-trait", + "dep:ras-auth-core", + "dep:ras-rest-core", + "dep:ras-file-core", + "dep:ras-jsonrpc-bidirectional-server", + "dep:axum", + "dep:axum-extra", + "dep:tokio", +] +client = ["dep:reqwest", "dep:tokio", "dep:tokio-util"] +``` + +Server crates enable `workspace-api/server`. Rust or WASM clients enable +`workspace-api/client`. + +## Source Layout + +Split by service boundary: + +```text +src/ + lib.rs + tasks.rs + attachments.rs + activity.rs +``` + +`lib.rs` re-exports the generated surface: + +```rust,ignore +pub mod activity; +pub mod attachments; +pub mod tasks; + +pub use activity::*; +pub use attachments::*; +pub use tasks::*; +``` + +## Task Service + +`tasks.rs` contains DTOs and the REST declaration: + +```rust,ignore +use ras_rest_macro::rest_service; +#[cfg(feature = "server")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "server", derive(JsonSchema))] +pub struct TasksResponse { + pub tasks: Vec, +} + +rest_service!({ + service_name: TaskService, + base_path: "/api/v1", + openapi: true, + endpoints: [ + GET WITH_PERMISSIONS(["project:read"]) projects/{project_id: String}/tasks() -> TasksResponse, + POST WITH_PERMISSIONS(["task:write"]) projects/{project_id: String}/tasks(CreateTaskRequest) -> Task, + ] +}); +``` + +## Attachment Service + +`attachments.rs` uses the file macro because attachments should be streamed and +validated before service code sees them: + +```rust,ignore +use ras_file_macro::file_service; +#[cfg(feature = "server")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "server", derive(JsonSchema))] +pub struct AttachmentUploadResponse { + pub attachment_id: String, + pub file_name: String, + pub size: u64, +} + +file_service!({ + service_name: AttachmentService, + base_path: "/api/v1/attachments", + openapi: true, + endpoints: [ + UPLOAD WITH_PERMISSIONS(["attachment:write"]) tasks/{task_id: String}/upload multipart { + max_total_bytes: 52428800, + reject_unknown_fields: true, + parts: [ + file file { + required: true, + max_count: 1, + max_bytes: 52428800, + filename: required, + }, + ], + } -> AttachmentUploadResponse, + + DOWNLOAD WITH_PERMISSIONS(["attachment:read"]) download/{attachment_id: String} { + content_types: ["application/octet-stream"], + ranges: true, + }, + ] +}); +``` + +## Activity Notifications + +`activity.rs` defines live notifications. The server sends typed events and the +client registers typed handlers: + +```rust,ignore +use ras_jsonrpc_bidirectional_macro::jsonrpc_bidirectional_service; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskChanged { + pub task_id: String, + pub project_id: String, +} + +jsonrpc_bidirectional_service!({ + service_name: ActivityService, + client_to_server: [ + WITH_PERMISSIONS(["project:read"]) subscribe_project(String) -> (), + ], + server_to_client: [ + task_changed(TaskChanged), + ], + server_to_client_calls: [ + ] +}); +``` + +The API crate now describes the externally visible application boundary. The +server crate can focus on persistence, auth, and business rules. + diff --git a/documentation/src/tutorial/design-the-contract.md b/documentation/src/tutorial/design-the-contract.md new file mode 100644 index 0000000..252cdb0 --- /dev/null +++ b/documentation/src/tutorial/design-the-contract.md @@ -0,0 +1,104 @@ +# 1. Design The Contract + +Start with workflows, not with Axum routes or database tables. Write down what +clients need to do and which operations need identity. + +For the team workspace, the first pass looks like this: + +| Workflow | Macro | Reason | +| --- | --- | --- | +| list projects, read tasks, create tasks | [`rest_service!`](../macros/rest-service.md) | conventional JSON resources, OpenAPI, browser clients | +| upload and download task attachments | [`file_service!`](../macros/file-service.md) | streaming, multipart validation, early auth checks | +| live task activity | [`jsonrpc_bidirectional_service!`](../macros/bidirectional-jsonrpc-service.md) | typed WebSocket notifications | +| command-heavy workflows | [`jsonrpc_service!`](../macros/jsonrpc-service.md) | optional alternative for RPC-style APIs | + +## Name Permissions Early + +Permissions should be stable application concepts, not incidental handler +details. Good permission names usually describe the capability: + +```text +project:read +project:write +task:write +attachment:read +attachment:write +admin +``` + +Each protected operation declares those requirements in the API definition: + +```rust,ignore +GET WITH_PERMISSIONS(["project:read"]) projects() -> ProjectsResponse, +POST WITH_PERMISSIONS(["task:write"]) projects/{project_id: String}/tasks(CreateTaskRequest) -> Task, +DELETE WITH_PERMISSIONS(["admin"] | ["project:owner"]) projects/{project_id: String}() -> (), +``` + +`WITH_PERMISSIONS(["a", "b"])` means the authenticated user needs both +permissions. `WITH_PERMISSIONS(["a"] | ["b", "c"])` means either the first group +or the second group is enough. `WITH_PERMISSIONS([])` means authenticated, with +no extra permission requirement. + +## Keep DTOs Boring + +DTOs should be explicit, serializable, and independent of storage models. Avoid +exposing database-specific fields just because they exist. + +```rust,ignore +#[cfg(feature = "server")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "server", derive(JsonSchema))] +pub struct Task { + pub id: String, + pub project_id: String, + pub title: String, + pub status: TaskStatus, + pub assignee_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "server", derive(JsonSchema))] +pub enum TaskStatus { + Open, + InProgress, + Done, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "server", derive(JsonSchema))] +pub struct CreateTaskRequest { + pub title: String, + pub assignee_id: Option, +} +``` + +The `JsonSchema` derive is gated because only server/spec generation needs it. +Shared serialization stays available with no transport feature enabled. + +## Sketch The Service + +A REST task service definition can stay close to the client workflow: + +```rust,ignore +rest_service!({ + service_name: TaskService, + base_path: "/api/v1", + openapi: true, + serve_docs: true, + docs_path: "/docs", + endpoints: [ + GET WITH_PERMISSIONS(["project:read"]) projects() -> ProjectsResponse, + GET WITH_PERMISSIONS(["project:read"]) projects/{project_id: String}/tasks() -> TasksResponse, + POST WITH_PERMISSIONS(["task:write"]) projects/{project_id: String}/tasks(CreateTaskRequest) -> Task, + PATCH WITH_PERMISSIONS(["task:write"]) tasks/{task_id: String}(UpdateTaskRequest) -> Task, + ] +}); +``` + +At this point you have made the most important design decisions: operation +names, path parameters, request/response types, and auth requirements. The +server implementation can change later without changing this contract. + diff --git a/documentation/src/tutorial/implement-the-server.md b/documentation/src/tutorial/implement-the-server.md new file mode 100644 index 0000000..5a653b8 --- /dev/null +++ b/documentation/src/tutorial/implement-the-server.md @@ -0,0 +1,202 @@ +# 3. Implement The Server + +The server crate depends on the API crate with `features = ["server"]` and +implements the generated traits. + +```toml +[dependencies] +workspace-api = { path = "../workspace-api", default-features = false, features = ["server"] } +ras-auth-core = "0.1.0" +ras-rest-core = "0.1.1" +ras-file-core = "0.1.0" +axum = "0.8" +tokio = { version = "1.0", features = ["full"] } +async-trait = "0.1" +``` + +## Auth Provider + +RAS auth providers turn credentials into an `AuthenticatedUser`. Permission +checks declared in the API definition run after authentication and before the +handler. + +```rust,ignore +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use std::collections::HashSet; + +#[derive(Clone)] +pub struct AppAuthProvider { + sessions: SessionStore, +} + +impl AuthProvider for AppAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + let session = self + .sessions + .lookup(&token) + .await + .map_err(|_| AuthError::InvalidToken)?; + + Ok(AuthenticatedUser { + user_id: session.user_id, + permissions: session.permissions.into_iter().collect::>(), + metadata: None, + }) + }) + } +} +``` + +Handlers do not parse tokens. Protected generated methods receive the +authenticated user as a typed argument. + +## REST Handler Implementation + +Generated REST traits return `RestResult`. + +```rust,ignore +use ras_auth_core::AuthenticatedUser; +use ras_rest_core::{RestResponse, RestResult}; +use workspace_api::{ + CreateTaskRequest, Task, TaskServiceTrait, TasksResponse, +}; + +#[derive(Clone)] +pub struct TaskHandlers { + tasks: TaskRepository, +} + +#[async_trait::async_trait] +impl TaskServiceTrait for TaskHandlers { + async fn get_projects_by_project_id_tasks( + &self, + user: &AuthenticatedUser, + project_id: String, + ) -> RestResult { + let tasks = self.tasks.visible_to(user, &project_id).await?; + Ok(RestResponse::ok(TasksResponse { tasks })) + } + + async fn post_projects_by_project_id_tasks( + &self, + user: &AuthenticatedUser, + project_id: String, + request: CreateTaskRequest, + ) -> RestResult { + let task = self.tasks.create(user, project_id, request).await?; + Ok(RestResponse::created(task)) + } +} +``` + +The generated signature reflects the API declaration: protected endpoints get +`&AuthenticatedUser`, path parameters are typed arguments, and request bodies +are typed structs. + +## File Handler Implementation + +Uploads run in phases. This lets generated code authenticate first, enforce +size limits, reject unknown fields, and ensure file streams are consumed. + +```rust,ignore +use ras_file_core::{FileRequestContext, FileResult, JsonResponse}; +use workspace_api::{ + AttachmentServiceTrait, AttachmentServiceTasksByTaskIdUploadPart, + AttachmentServiceTasksByTaskIdUploadPath, AttachmentUploadResponse, +}; + +pub struct UploadState { + attachment_id: Option, + file_name: Option, + size: u64, +} + +#[async_trait::async_trait] +impl AttachmentServiceTrait for AttachmentHandlers { + type TasksByTaskIdUploadState = UploadState; + + async fn tasks_by_task_id_upload_begin( + &self, + ctx: &FileRequestContext<'_>, + path: &AttachmentServiceTasksByTaskIdUploadPath, + ) -> FileResult { + self.attachments.reserve(ctx.user, &path.task_id).await + } + + async fn tasks_by_task_id_upload_part( + &self, + _ctx: &FileRequestContext<'_>, + _path: &AttachmentServiceTasksByTaskIdUploadPath, + state: &mut Self::TasksByTaskIdUploadState, + part: &mut AttachmentServiceTasksByTaskIdUploadPart<'_>, + ) -> FileResult<()> { + match part { + AttachmentServiceTasksByTaskIdUploadPart::File(file) => { + while let Some(chunk) = file.next_chunk().await? { + state.size += chunk.len() as u64; + self.attachments.write_chunk(state, &chunk).await?; + } + state.file_name = file.file_name().map(str::to_owned); + } + } + + Ok(()) + } + + async fn tasks_by_task_id_upload_finish( + &self, + _ctx: &FileRequestContext<'_>, + _path: &AttachmentServiceTasksByTaskIdUploadPath, + state: Self::TasksByTaskIdUploadState, + _summary: ras_file_core::UploadSummary, + ) -> FileResult> { + Ok(JsonResponse::ok(state.into_response()?)) + } +} +``` + +Generated names include path segments so multiple uploads can coexist in one +service. + +## Mount The App + +Build generated routers and merge them into one Axum app: + +```rust,ignore +let auth = AppAuthProvider::new(session_store); + +let task_routes = workspace_api::TaskServiceBuilder::new(TaskHandlers { tasks }) + .auth_provider(auth.clone()) + .build(); + +let attachment_routes = + workspace_api::AttachmentServiceBuilder::new(AttachmentHandlers { attachments }) + .auth_provider(auth.clone()) + .build(); + +let app = axum::Router::new() + .merge(task_routes) + .merge(attachment_routes); +``` + +For WebSocket services, mount the generated service state on an Axum route as +shown in the [bidirectional macro guide](../macros/bidirectional-jsonrpc-service.md). + +## Generate Specs During Build + +For REST and file services, a server `build.rs` can write OpenAPI documents for +frontends: + +```rust,ignore +fn main() { + workspace_api::generate_taskservice_openapi_to_file() + .expect("generate task OpenAPI"); + workspace_api::generate_attachmentservice_openapi_to_file() + .expect("generate attachment OpenAPI"); +} +``` + +That keeps generated client input tied to the exact Rust API contract the server +compiled against. + diff --git a/documentation/src/tutorial/index.md b/documentation/src/tutorial/index.md new file mode 100644 index 0000000..d19b0a0 --- /dev/null +++ b/documentation/src/tutorial/index.md @@ -0,0 +1,66 @@ +# Build A Typed Workspace App + +This tutorial walks through designing an application with RAS from the first API +boundary decision to clients, tests, and deployment wiring. + +The example application is a small team workspace: + +- users list projects and tasks; +- users create and update tasks; +- users upload and download task attachments; +- clients can receive live activity notifications; +- admins can perform wider maintenance operations. + +The important part is not the domain. The important part is the shape: the API +contract lives in a shared Rust crate, generated server code is enabled by the +server feature, generated client code is enabled by the client feature, and auth +requirements are declared beside the operation definitions. + +## Target Architecture + +Use a workspace with clear crate boundaries: + +```text +team-workspace/ + Cargo.toml + crates/ + workspace-api/ # DTOs and service macro declarations + workspace-server/ # Axum server, storage, auth provider, service impls + workspace-web/ # optional Rust/WASM client + web/ # optional TypeScript app generated from OpenAPI +``` + +The `workspace-api` crate is the center. Server and client crates depend on it +with different features: + +```toml +[dependencies] +workspace-api = { path = "../workspace-api", default-features = false, features = ["server"] } +``` + +```toml +[dependencies] +workspace-api = { path = "../workspace-api", default-features = false, features = ["client"] } +``` + +This keeps generated transport code out of crates that do not need it, while +keeping request and response types shared. + +## What You Will Build + +By the end of the tutorial you will have: + +- a typed API crate with REST, file, and optional WebSocket contracts; +- explicit permission requirements in the API definitions; +- an Axum server that implements generated traits; +- generated OpenAPI and Rust client usage; +- checks that prove no-default, server-only, client-only, and WASM builds keep + working; +- a practical strategy for evolving the API without silently breaking clients. + +The tutorial uses REST for project/task workflows, file services for +attachments, and bidirectional JSON-RPC for live notifications. If your +application is more command-oriented, the same structure works with +[`jsonrpc_service!`](../macros/jsonrpc-service.md) instead of +[`rest_service!`](../macros/rest-service.md). + diff --git a/documentation/src/tutorial/test-ship-and-evolve.md b/documentation/src/tutorial/test-ship-and-evolve.md new file mode 100644 index 0000000..cf4f843 --- /dev/null +++ b/documentation/src/tutorial/test-ship-and-evolve.md @@ -0,0 +1,115 @@ +# 5. Test, Ship, And Evolve + +Strict API definitions are most useful when CI checks the important feature +combinations and when tests assert that auth metadata stays visible in the +generated specs. + +## Contract Tests + +Test DTO serialization for wire stability: + +```rust,ignore +#[test] +fn create_task_request_serializes_expected_shape() { + let value = serde_json::to_value(CreateTaskRequest { + title: "Write release notes".to_string(), + assignee_id: None, + }) + .unwrap(); + + assert_eq!( + value, + serde_json::json!({ + "title": "Write release notes", + "assignee_id": null + }) + ); +} +``` + +Test generated OpenAPI or OpenRPC output for route shape and permission +metadata: + +```rust,ignore +#[test] +fn openapi_documents_task_permissions() { + let doc = workspace_api::generate_taskservice_openapi(); + let create = &doc["paths"]["/projects/{project_id}/tasks"]["post"]; + + assert_eq!(create["security"][0]["bearerAuth"], serde_json::json!([])); + assert_eq!(create["x-permissions"], serde_json::json!(["task:write"])); +} +``` + +These tests catch accidental auth changes before a client discovers them. + +## Server Tests + +Use an in-memory Axum test server for generated routes: + +```rust,ignore +#[tokio::test] +async fn create_task_requires_write_permission() { + let app = build_app_with_test_auth(); + let server = axum_test::TestServer::new(app).unwrap(); + + let response = server + .post("/api/v1/projects/project-123/tasks") + .authorization_bearer("read-only-token") + .json(&CreateTaskRequest { + title: "Write release notes".to_string(), + assignee_id: None, + }) + .await; + + response.assert_status_forbidden(); +} +``` + +File-service tests should include oversized requests, missing required parts, +wrong content types, and auth rejection before upload handling begins. + +## Feature Matrix + +Add CI checks for the API crate itself: + +```bash +cargo check -p workspace-api --no-default-features --locked +cargo check -p workspace-api --no-default-features --features server --locked +cargo check -p workspace-api --no-default-features --features client --locked +cargo check -p workspace-api --target wasm32-unknown-unknown --no-default-features --features client --locked +``` + +This proves DTO-only, server-only, native client, and browser client builds stay +separate. + +## Deployment Shape + +A typical release pipeline does three things: + +- build and test the Rust workspace; +- generate OpenAPI/OpenRPC documents from the API crate; +- publish generated docs and client inputs alongside the server artifact. + +The mdBook in this repository is built in CI and published to GitHub Pages from +the `master` branch. Application repositories can use the same pattern for +project-specific API documentation. + +## Evolving The API + +Prefer additive changes when possible: + +- add optional request fields instead of requiring new fields immediately; +- add response fields that old clients can ignore; +- add new operations before removing old operations; +- preserve permission names unless the capability truly changed. + +When a wire shape must change, use versioning support where the macro provides +it. Keep the service implementation canonical and let the API boundary migrate +legacy request and response shapes. That makes compatibility an explicit part +of the contract instead of an untested handler branch. + +Before removing a legacy operation, check generated specs, client usage, and +server logs. The contract should tell you what still exists; telemetry should +tell you what is still used. + diff --git a/documentation/src/why-typed-service-definitions.md b/documentation/src/why-typed-service-definitions.md new file mode 100644 index 0000000..f567c49 --- /dev/null +++ b/documentation/src/why-typed-service-definitions.md @@ -0,0 +1,41 @@ +# Why Typed Service Definitions + +RAS service macros are intentionally strict. They ask you to describe endpoints +with concrete Rust request and response types, then generate the repetitive +boundary code from that description. + +This matters for API builders because the API boundary is where drift usually +appears: + +- server handlers accept one shape while clients send another; +- documentation falls behind the real implementation; +- auth requirements live in middleware or comments instead of the operation + definition; +- file uploads buffer too much data or validate too late; +- renamed fields break older clients without an explicit migration path. + +With RAS, the service definition is the source of truth. The generated trait +forces every declared endpoint to be implemented. Request and response types are +serialized through `serde`, documented through `schemars` where specs are +generated, and reused by generated clients. When an endpoint is protected, the +generated trait signature receives an `AuthenticatedUser`, so handler code can +depend on authenticated identity without repeating token parsing. + +The macros do not remove runtime validation. They move the easy-to-forget +plumbing to generated code and leave the service implementation focused on +domain behavior: read typed input, apply business rules, return typed output. + +## What The Macros Generate + +Depending on the macro and enabled features, a service definition can generate: + +- a trait that lists every handler method with typed parameters; +- an Axum router, WebSocket service, or runtime adapter; +- authentication and permission checks before handlers run; +- native Rust clients, including WASM-compatible clients for browser use; +- OpenRPC or OpenAPI documents with schemas and auth metadata; +- explorer UIs for supported HTTP services; +- version compatibility routes or methods where the macro supports migrations. + +The result is a narrower contract between API design, server implementation, +client usage, and published documentation. diff --git a/examples/basic-jsonrpc/api/Cargo.toml b/examples/basic-jsonrpc/api/Cargo.toml index 5af3c99..d3c1abf 100644 --- a/examples/basic-jsonrpc/api/Cargo.toml +++ b/examples/basic-jsonrpc/api/Cargo.toml @@ -11,12 +11,12 @@ publish = false readme = "README.md" [features] -default = ["server"] -server = ["ras-jsonrpc-macro/server", "axum", "ras-jsonrpc-core"] -client = ["ras-jsonrpc-macro/client", "reqwest"] +default = [] +server = ["dep:axum", "dep:ras-jsonrpc-core"] +client = ["dep:reqwest"] [dependencies] -ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0" } +ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false } ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2", optional = true } ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } serde = { workspace = true } diff --git a/examples/basic-jsonrpc/api/README.md b/examples/basic-jsonrpc/api/README.md index 10dcc2a..d4e3562 100644 --- a/examples/basic-jsonrpc/api/README.md +++ b/examples/basic-jsonrpc/api/README.md @@ -15,15 +15,18 @@ The service route is selected by the server crate when it builds the generated r ## Features -- `server` - enables generated server-side types and Axum integration. This is the default feature. +- `server` - enables generated server-side types and Axum integration. - `client` - enables the generated HTTP client. +- default: no generated transport code. ## Checks ```bash cargo check -p basic-jsonrpc-api --locked +cargo check -p basic-jsonrpc-api --features server --locked cargo check -p basic-jsonrpc-api --features client --locked cargo test -p basic-jsonrpc-api --locked +cargo test -p basic-jsonrpc-api --features server --locked cargo test -p basic-jsonrpc-api --features client --locked cargo clippy -p basic-jsonrpc-api --all-targets --all-features --locked -- -D warnings ``` diff --git a/examples/basic-jsonrpc/service/Cargo.toml b/examples/basic-jsonrpc/service/Cargo.toml index 0d32173..d953764 100644 --- a/examples/basic-jsonrpc/service/Cargo.toml +++ b/examples/basic-jsonrpc/service/Cargo.toml @@ -16,7 +16,7 @@ server = [] client = [] [dependencies] -basic-jsonrpc-api = { path = "../api", version = "0.1.0" } +basic-jsonrpc-api = { path = "../api", version = "0.1.0", features = ["server"] } ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } axum = { workspace = true } diff --git a/examples/bidirectional-chat/api/Cargo.toml b/examples/bidirectional-chat/api/Cargo.toml index 5410aca..634b746 100644 --- a/examples/bidirectional-chat/api/Cargo.toml +++ b/examples/bidirectional-chat/api/Cargo.toml @@ -11,9 +11,9 @@ publish = false readme = "README.md" [features] -default = ["server", "client"] -server = ["ras-jsonrpc-bidirectional-server", "axum"] -client = ["ras-jsonrpc-bidirectional-client"] +default = [] +server = ["dep:ras-jsonrpc-bidirectional-server", "dep:axum"] +client = ["dep:ras-jsonrpc-bidirectional-client"] [dependencies] serde = { workspace = true } @@ -21,7 +21,7 @@ serde_json = { workspace = true } schemars = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } -ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro", version = "0.1.0" } +ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro", version = "0.1.0", default-features = false } ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types", version = "0.1.0" } ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server", version = "0.1.0", optional = true } ras-jsonrpc-bidirectional-client = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client", version = "0.1.0", optional = true } diff --git a/examples/bidirectional-chat/api/README.md b/examples/bidirectional-chat/api/README.md index 8802e7c..e3be055 100644 --- a/examples/bidirectional-chat/api/README.md +++ b/examples/bidirectional-chat/api/README.md @@ -24,14 +24,16 @@ The runnable server is documented in [../server/README.md](../server/README.md), - `server` - enables generated server integration and Axum support. - `client` - enables the generated bidirectional client. -- The default feature set enables both. +- default: no generated transport code. ## Checks ```bash cargo check -p bidirectional-chat-api --locked +cargo check -p bidirectional-chat-api --no-default-features --features server --locked cargo check -p bidirectional-chat-api --no-default-features --features client --locked cargo test -p bidirectional-chat-api --locked +cargo test -p bidirectional-chat-api --no-default-features --features server --locked cargo test -p bidirectional-chat-api --no-default-features --features client --locked cargo clippy -p bidirectional-chat-api --all-targets --all-features --locked -- -D warnings ``` diff --git a/examples/bidirectional-chat/server/Cargo.toml b/examples/bidirectional-chat/server/Cargo.toml index 5db40f8..248fa57 100644 --- a/examples/bidirectional-chat/server/Cargo.toml +++ b/examples/bidirectional-chat/server/Cargo.toml @@ -12,10 +12,9 @@ readme = "README.md" [dependencies] # Local dependencies -bidirectional-chat-api = { path = "../api", version = "0.1.0" } +bidirectional-chat-api = { path = "../api", version = "0.1.0", features = ["server"] } ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } -ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro", version = "0.1.0" } ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server", version = "0.1.0" } ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types", version = "0.1.0" } ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1", features = ["server"] } @@ -47,4 +46,4 @@ axum-test = { workspace = true } [features] default = ["server"] -server = ["ras-jsonrpc-bidirectional-macro/server"] +server = [] diff --git a/examples/bidirectional-chat/tui/Cargo.toml b/examples/bidirectional-chat/tui/Cargo.toml index f5d75ae..de5dea8 100644 --- a/examples/bidirectional-chat/tui/Cargo.toml +++ b/examples/bidirectional-chat/tui/Cargo.toml @@ -19,7 +19,7 @@ crossterm = { workspace = true } tokio = { workspace = true, features = ["full"] } # Chat API client -bidirectional-chat-api = { path = "../api", version = "0.1.0" } +bidirectional-chat-api = { path = "../api", version = "0.1.0", default-features = false, features = ["client"] } # Error handling anyhow = { workspace = true } diff --git a/examples/file-service-example/Cargo.toml b/examples/file-service-example/Cargo.toml index 06ed6ed..772b879 100644 --- a/examples/file-service-example/Cargo.toml +++ b/examples/file-service-example/Cargo.toml @@ -10,12 +10,17 @@ homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" +[features] +default = ["server"] +server = [] +client = [] + [dependencies] axum = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -ras-file-macro = { path = "../../crates/rest/ras-file-macro", version = "0.1.0" } +ras-file-macro = { path = "../../crates/rest/ras-file-macro", version = "0.1.0", default-features = false } ras-file-core = { path = "../../crates/rest/ras-file-core", version = "0.1.0" } ras-auth-core = { path = "../../crates/core/ras-auth-core", version = "0.1.0" } thiserror = { workspace = true } diff --git a/examples/file-service-example/README.md b/examples/file-service-example/README.md index f9b71ce..7f9f51b 100644 --- a/examples/file-service-example/README.md +++ b/examples/file-service-example/README.md @@ -54,6 +54,7 @@ Any other bearer token is rejected. ```bash cargo test -p file-service-example --locked +cargo check -p file-service-example --no-default-features --features server --locked cargo clippy -p file-service-example --all-targets --all-features --locked -- -D warnings ``` diff --git a/examples/file-service-wasm/file-service-api/Cargo.toml b/examples/file-service-wasm/file-service-api/Cargo.toml index 3003b8d..32cc64c 100644 --- a/examples/file-service-wasm/file-service-api/Cargo.toml +++ b/examples/file-service-wasm/file-service-api/Cargo.toml @@ -14,11 +14,11 @@ readme = "README.md" crate-type = ["rlib"] [dependencies] -ras-file-macro = { path = "../../../crates/rest/ras-file-macro", version = "0.1.0" } -ras-file-core = { path = "../../../crates/rest/ras-file-core", version = "0.1.0" } -ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } +ras-file-macro = { path = "../../../crates/rest/ras-file-macro", version = "0.1.0", default-features = false } +ras-file-core = { path = "../../../crates/rest/ras-file-core", version = "0.1.0", optional = true } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0", optional = true } serde = { workspace = true, features = ["derive"] } -async-trait = { workspace = true } +async-trait = { workspace = true, optional = true } thiserror = { workspace = true } wasm-bindgen = { version = "0.2", optional = true } wasm-bindgen-futures = { version = "0.4", optional = true } @@ -27,20 +27,32 @@ web-sys = { version = "0.3", features = ["File", "Blob", "FormData"], optional = serde-wasm-bindgen = { version = "0.6", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -axum = { workspace = true } -axum-extra = { workspace = true } -tower = { workspace = true } -http = { workspace = true } -reqwest = { workspace = true } -tokio = { workspace = true } -tokio-util = { workspace = true } -schemars = { workspace = true } -serde_json = { workspace = true } +axum = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } +schemars = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"], optional = true } + +[dev-dependencies] +serde_json = { workspace = true } [features] default = [] -wasm-client = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys", "serde-wasm-bindgen"] -server = [] +server = [ + "dep:ras-file-core", + "dep:ras-auth-core", + "dep:async-trait", + "dep:axum", + "dep:schemars", + "dep:serde_json", +] +client = [ + "dep:reqwest", + "dep:tokio", + "dep:tokio-util", +] +wasm-client = ["client", "wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys", "serde-wasm-bindgen"] diff --git a/examples/file-service-wasm/file-service-api/README.md b/examples/file-service-wasm/file-service-api/README.md index ca411e5..f641cc8 100644 --- a/examples/file-service-wasm/file-service-api/README.md +++ b/examples/file-service-wasm/file-service-api/README.md @@ -16,13 +16,16 @@ The backend implementation is documented in [../file-service-backend/README.md]( ## Features - `server` - marker feature used by the backend package when depending on this shared API crate. -- `wasm-client` - re-exports the macro-generated WASM client on `wasm32`. +- `client` - enables the macro-generated upload/download client for native or `wasm32` callers. +- `wasm-client` - compatibility alias that also enables the extra WASM helper dependencies. ## Checks ```bash cargo check -p file-service-api --locked cargo check -p file-service-api --features server --locked +cargo check -p file-service-api --features client --locked cargo test -p file-service-api --locked +cargo test -p file-service-api --features server --locked cargo clippy -p file-service-api --all-targets --all-features --locked -- -D warnings ``` diff --git a/examples/file-service-wasm/file-service-api/src/lib.rs b/examples/file-service-wasm/file-service-api/src/lib.rs index 519f1db..bc4273c 100644 --- a/examples/file-service-wasm/file-service-api/src/lib.rs +++ b/examples/file-service-wasm/file-service-api/src/lib.rs @@ -1,10 +1,10 @@ use ras_file_macro::file_service; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "server")] use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(not(target_arch = "wasm32"), derive(JsonSchema))] +#[cfg_attr(feature = "server", derive(JsonSchema))] pub struct UploadResponse { pub file_id: String, pub file_name: String, @@ -12,7 +12,7 @@ pub struct UploadResponse { } #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(not(target_arch = "wasm32"), derive(JsonSchema))] +#[cfg_attr(feature = "server", derive(JsonSchema))] pub struct FileMetadata { pub id: String, pub name: String, @@ -65,7 +65,9 @@ file_service!({ #[cfg(test)] mod tests { use super::*; - use serde_json::{Value, json}; + #[cfg(feature = "server")] + use serde_json::Value; + use serde_json::json; #[test] fn upload_response_serializes_file_identity_and_size() { @@ -143,6 +145,7 @@ mod tests { assert_eq!(response.size, 4096); } + #[cfg(feature = "server")] fn parameter<'a>(operation: &'a Value, name: &str) -> &'a Value { operation["parameters"] .as_array() @@ -152,6 +155,7 @@ mod tests { .unwrap_or_else(|| panic!("missing parameter {name}")) } + #[cfg(feature = "server")] #[test] fn generated_openapi_documents_upload_routes_and_multipart_body() { let doc = generate_documentservice_openapi(); @@ -183,6 +187,7 @@ mod tests { ); } + #[cfg(feature = "server")] #[test] fn generated_openapi_documents_download_path_parameters_and_auth() { let doc = generate_documentservice_openapi(); @@ -204,6 +209,7 @@ mod tests { assert_eq!(secure_download["x-permissions"], json!(["user"])); } + #[cfg(feature = "server")] #[test] fn generated_openapi_includes_file_operation_component_schemas() { let doc = generate_documentservice_openapi(); diff --git a/examples/oauth2-demo/api/Cargo.toml b/examples/oauth2-demo/api/Cargo.toml index 6984bfd..cc871cf 100644 --- a/examples/oauth2-demo/api/Cargo.toml +++ b/examples/oauth2-demo/api/Cargo.toml @@ -11,19 +11,19 @@ publish = false readme = "README.md" [features] -default = ["server"] -server = [] -client = [] +default = [] +server = ["dep:axum", "dep:ras-jsonrpc-core"] +client = ["dep:reqwest"] [dependencies] # JSON-RPC infrastructure -ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", features = ["server"] } -ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } +ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false } +ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2", optional = true } ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } # Web framework and utilities -axum = { workspace = true } +axum = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } schemars = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, optional = true } diff --git a/examples/oauth2-demo/api/README.md b/examples/oauth2-demo/api/README.md index ce213cd..f78292f 100644 --- a/examples/oauth2-demo/api/README.md +++ b/examples/oauth2-demo/api/README.md @@ -31,6 +31,9 @@ The server decides how OAuth2 identities map to those permissions. ```bash cargo check -p oauth2-demo-api --locked +cargo check -p oauth2-demo-api --features server --locked +cargo check -p oauth2-demo-api --features client --locked cargo test -p oauth2-demo-api --locked +cargo test -p oauth2-demo-api --features server --locked cargo clippy -p oauth2-demo-api --all-targets --all-features --locked -- -D warnings ``` diff --git a/examples/oauth2-demo/server/Cargo.toml b/examples/oauth2-demo/server/Cargo.toml index fb93a1f..9f3f71d 100644 --- a/examples/oauth2-demo/server/Cargo.toml +++ b/examples/oauth2-demo/server/Cargo.toml @@ -11,7 +11,7 @@ publish = false readme = "README.md" [dependencies] -oauth2-demo-api = { path = "../api", version = "0.1.0" } +oauth2-demo-api = { path = "../api", version = "0.1.0", features = ["server"] } # JSON-RPC infrastructure ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0" } ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } @@ -42,5 +42,5 @@ dotenvy = { workspace = true } mime_guess = { workspace = true } [build-dependencies] -oauth2-demo-api = { path = "../api", version = "0.1.0" } +oauth2-demo-api = { path = "../api", version = "0.1.0", features = ["server"] } serde_json = { workspace = true } diff --git a/examples/rest-wasm-example/rest-api/Cargo.toml b/examples/rest-wasm-example/rest-api/Cargo.toml index 3fdf98c..eb8f7d7 100644 --- a/examples/rest-wasm-example/rest-api/Cargo.toml +++ b/examples/rest-wasm-example/rest-api/Cargo.toml @@ -14,28 +14,33 @@ readme = "README.md" crate-type = ["rlib"] [dependencies] -ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1" } -ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } -ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1" } +ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0", optional = true } +ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1", optional = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } schemars = { workspace = true } tracing = { workspace = true } -async-trait = { workspace = true } +async-trait = { workspace = true, optional = true } thiserror = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -axum = { workspace = true } -axum-extra = { workspace = true } -tower = { workspace = true } -http = { workspace = true } -reqwest = { workspace = true } -tokio = { workspace = true } +axum = { workspace = true, optional = true } +axum-extra = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json"] } +reqwest = { version = "0.12", default-features = false, features = ["json"], optional = true } [features] default = [] -server = ["ras-rest-macro/server"] -client = ["ras-rest-macro/client"] +server = [ + "dep:ras-auth-core", + "dep:ras-rest-core", + "dep:async-trait", + "dep:axum", + "dep:axum-extra", + "dep:tokio", +] +client = ["dep:reqwest"] From be562c474675a49175deadab553839c710c316be Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Mon, 25 May 2026 17:05:53 +0200 Subject: [PATCH 13/14] Fix mdBook config for CI --- book.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/book.toml b/book.toml index d23d056..51713d3 100644 --- a/book.toml +++ b/book.toml @@ -3,7 +3,6 @@ title = "Rust Agent Stack API Builder Guide" description = "Rationale and usage guide for the Rust Agent Stack service macros." authors = ["Rust Agent Stack contributors"] language = "en" -multilingual = false src = "documentation/src" [build] From 79006982072d4827c6bff1ac4e32ed21157ff9d0 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Mon, 25 May 2026 21:08:42 +0200 Subject: [PATCH 14/14] Add permission manifests and macro feature gating --- Cargo.lock | 23 ++ crates/rest/ras-file-macro/Cargo.toml | 2 + crates/rest/ras-file-macro/src/lib.rs | 31 +- crates/rest/ras-file-macro/src/openapi.rs | 22 + crates/rest/ras-file-macro/src/permissions.rs | 259 ++++++++++++ crates/rest/ras-rest-macro/Cargo.toml | 2 + crates/rest/ras-rest-macro/src/client.rs | 5 - crates/rest/ras-rest-macro/src/lib.rs | 40 +- crates/rest/ras-rest-macro/src/openapi.rs | 31 +- crates/rest/ras-rest-macro/src/permissions.rs | 293 +++++++++++++ .../rest/ras-rest-macro/src/static_hosting.rs | 3 - .../Cargo.toml | 2 + .../src/client.rs | 6 - .../src/lib.rs | 32 +- .../src/permissions.rs | 259 ++++++++++++ .../src/server.rs | 8 - crates/rpc/ras-jsonrpc-macro/Cargo.toml | 2 + crates/rpc/ras-jsonrpc-macro/src/lib.rs | 23 +- crates/rpc/ras-jsonrpc-macro/src/openrpc.rs | 24 ++ .../rpc/ras-jsonrpc-macro/src/permissions.rs | 265 ++++++++++++ .../specs/ras-permission-manifest/Cargo.toml | 16 + .../specs/ras-permission-manifest/README.md | 5 + .../specs/ras-permission-manifest/src/lib.rs | 387 ++++++++++++++++++ documentation/src/SUMMARY.md | 1 + documentation/src/auth-in-api-contract.md | 4 +- .../src/generated-specs-and-clients.md | 15 +- .../macros/bidirectional-jsonrpc-service.md | 9 +- documentation/src/macros/file-service.md | 10 +- documentation/src/macros/jsonrpc-service.md | 17 +- documentation/src/macros/rest-service.md | 16 +- documentation/src/permission-manifests.md | 121 ++++++ .../src/tutorial/create-the-api-crate.md | 20 +- documentation/src/tutorial/index.md | 10 +- examples/basic-jsonrpc/api/Cargo.toml | 7 +- examples/basic-jsonrpc/service/Cargo.toml | 2 +- examples/bidirectional-chat/api/Cargo.toml | 18 +- examples/bidirectional-chat/api/src/lib.rs | 52 +++ examples/bidirectional-chat/server/Cargo.toml | 1 + examples/bidirectional-chat/tui/Cargo.toml | 4 + examples/file-service-example/Cargo.toml | 5 +- .../file-service-api/Cargo.toml | 5 +- .../file-service-api/src/lib.rs | 53 +++ .../file-service-backend/Cargo.toml | 5 + .../file-service-backend/build.rs | 10 + examples/oauth2-demo/api/Cargo.toml | 7 +- examples/oauth2-demo/api/src/lib.rs | 52 +++ examples/oauth2-demo/server/Cargo.toml | 5 + examples/oauth2-demo/server/build.rs | 6 + .../rest-wasm-example/rest-api/Cargo.toml | 6 +- .../rest-wasm-example/rest-api/src/lib.rs | 56 +++ .../rest-wasm-example/rest-backend/Cargo.toml | 5 + .../rest-wasm-example/rest-backend/build.rs | 13 + .../fixtures/jsonrpc-fixture/Cargo.toml | 3 +- .../fixtures/rest-fixture/Cargo.toml | 3 +- 54 files changed, 2166 insertions(+), 115 deletions(-) create mode 100644 crates/rest/ras-file-macro/src/permissions.rs create mode 100644 crates/rest/ras-rest-macro/src/permissions.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/permissions.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/src/permissions.rs create mode 100644 crates/specs/ras-permission-manifest/Cargo.toml create mode 100644 crates/specs/ras-permission-manifest/README.md create mode 100644 crates/specs/ras-permission-manifest/src/lib.rs create mode 100644 documentation/src/permission-manifests.md diff --git a/Cargo.lock b/Cargo.lock index ec4ea0a..a9c822d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,7 @@ dependencies = [ "ras-jsonrpc-core", "ras-jsonrpc-macro", "ras-jsonrpc-types", + "ras-permission-manifest", "reqwest", "schemars", "serde", @@ -271,6 +272,7 @@ dependencies = [ "ras-jsonrpc-bidirectional-server", "ras-jsonrpc-bidirectional-types", "ras-jsonrpc-types", + "ras-permission-manifest", "ras-rest-core", "ras-rest-macro", "reqwest", @@ -1078,6 +1080,7 @@ dependencies = [ "ras-auth-core", "ras-file-core", "ras-file-macro", + "ras-permission-manifest", "reqwest", "schemars", "serde", @@ -1103,6 +1106,7 @@ dependencies = [ "mime_guess", "ras-auth-core", "ras-file-core", + "ras-permission-manifest", "tempfile", "tokio", "tower-http", @@ -1121,6 +1125,7 @@ dependencies = [ "ras-auth-core", "ras-file-core", "ras-file-macro", + "ras-permission-manifest", "reqwest", "serde", "serde_json", @@ -2062,6 +2067,7 @@ dependencies = [ "ras-jsonrpc-core", "ras-jsonrpc-macro", "ras-jsonrpc-types", + "ras-permission-manifest", "reqwest", "schemars", "serde", @@ -2085,6 +2091,7 @@ dependencies = [ "ras-jsonrpc-core", "ras-jsonrpc-macro", "ras-jsonrpc-types", + "ras-permission-manifest", "schemars", "serde", "serde_json", @@ -2339,6 +2346,7 @@ dependencies = [ "ras-jsonrpc-core", "ras-jsonrpc-macro", "ras-jsonrpc-types", + "ras-permission-manifest", "reqwest", "schemars", "serde", @@ -2356,6 +2364,7 @@ dependencies = [ "axum-extra", "axum-test", "ras-auth-core", + "ras-permission-manifest", "ras-rest-core", "ras-rest-macro", "reqwest", @@ -2658,6 +2667,7 @@ dependencies = [ "quote", "ras-auth-core", "ras-file-core", + "ras-permission-manifest", "reqwest", "schemars", "serde", @@ -2784,6 +2794,7 @@ dependencies = [ "ras-jsonrpc-bidirectional-server", "ras-jsonrpc-bidirectional-types", "ras-jsonrpc-types", + "ras-permission-manifest", "serde", "serde_json", "syn", @@ -2861,6 +2872,7 @@ dependencies = [ "ras-identity-session", "ras-jsonrpc-core", "ras-jsonrpc-types", + "ras-permission-manifest", "reqwest", "schemars", "serde", @@ -2920,6 +2932,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ras-permission-manifest" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "ras-rest-core" version = "0.1.1" @@ -2947,6 +2967,7 @@ dependencies = [ "ras-auth-core", "ras-identity-session", "ras-jsonrpc-core", + "ras-permission-manifest", "ras-rest-core", "reqwest", "schemars", @@ -3123,6 +3144,7 @@ dependencies = [ "axum", "axum-extra", "ras-auth-core", + "ras-permission-manifest", "ras-rest-core", "ras-rest-macro", "reqwest", @@ -3144,6 +3166,7 @@ dependencies = [ "axum-extra", "http", "ras-auth-core", + "ras-permission-manifest", "ras-rest-core", "rest-api", "serde", diff --git a/crates/rest/ras-file-macro/Cargo.toml b/crates/rest/ras-file-macro/Cargo.toml index 6c34cbd..3be5cfb 100644 --- a/crates/rest/ras-file-macro/Cargo.toml +++ b/crates/rest/ras-file-macro/Cargo.toml @@ -16,6 +16,7 @@ proc-macro = true default = ["server", "client"] server = [] client = [] +permissions = [] [dependencies] syn = { workspace = true, features = ["full", "extra-traits", "visit-mut"] } @@ -33,6 +34,7 @@ serde = { workspace = true } serde_json = { workspace = true } ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } ras-file-core = { path = "../ras-file-core", version = "0.1.0" } +ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" } thiserror = { workspace = true } async-trait = { workspace = true } axum-test = { workspace = true } diff --git a/crates/rest/ras-file-macro/src/lib.rs b/crates/rest/ras-file-macro/src/lib.rs index 4fd24ea..19f2968 100644 --- a/crates/rest/ras-file-macro/src/lib.rs +++ b/crates/rest/ras-file-macro/src/lib.rs @@ -5,6 +5,7 @@ use syn::parse_macro_input; mod client; mod openapi; mod parser; +mod permissions; mod server; use parser::FileServiceDefinition; @@ -30,39 +31,53 @@ pub fn file_service(input: TokenStream) -> TokenStream { let server_mod = format_ident!("__ras_file_{}_server", service_name_lower); let openapi_mod = format_ident!("__ras_file_{}_openapi", service_name_lower); let client_mod = format_ident!("__ras_file_{}_client", service_name_lower); + let permissions_code = if cfg!(feature = "permissions") { + permissions::generate_permissions_code(&definition) + } else { + quote! {} + }; - let expanded = quote! { - #[cfg(feature = "server")] + let server_output = if cfg!(feature = "server") { + quote! { mod #server_mod { use super::*; #server_code } - #[cfg(feature = "server")] pub use #server_mod::*; - #[cfg(feature = "server")] const _: () = { #schema_checks }; - #[cfg(feature = "server")] mod #openapi_mod { use super::*; #openapi_code } - #[cfg(feature = "server")] pub use #openapi_mod::*; + } + } else { + quote! {} + }; - #[cfg(feature = "client")] + let client_output = if cfg!(feature = "client") { + quote! { mod #client_mod { use super::*; #client_code } - #[cfg(feature = "client")] pub use #client_mod::*; + } + } else { + quote! {} + }; + + let expanded = quote! { + #permissions_code + #server_output + #client_output }; TokenStream::from(expanded) diff --git a/crates/rest/ras-file-macro/src/openapi.rs b/crates/rest/ras-file-macro/src/openapi.rs index 5c1a01f..0677c03 100644 --- a/crates/rest/ras-file-macro/src/openapi.rs +++ b/crates/rest/ras-file-macro/src/openapi.rs @@ -46,6 +46,8 @@ pub fn generate_openapi_code( let path = endpoint.path.value(); let auth_required = matches!(endpoint.auth, AuthRequirement::WithPermissions(_)); let permissions = permissions_for_openapi(&endpoint.auth); + let permission_groups = permission_groups_for_openapi(&endpoint.auth); + let permission_groups_tokens = permission_groups_tokens(&permission_groups); let path_params = endpoint.path_params.iter().map(|param| { let name = param.name.to_string(); @@ -76,6 +78,7 @@ pub fn generate_openapi_code( path: #path.to_string(), auth_required: #auth_required, permissions: vec![#(#permissions.to_string()),*], + permission_groups: #permission_groups_tokens, path_params: vec![#(#path_params),*], response_type_name: Some(#response_type_name.to_string()), max_total_bytes: #max_total, @@ -96,6 +99,7 @@ pub fn generate_openapi_code( path: #path.to_string(), auth_required: #auth_required, permissions: vec![#(#permissions.to_string()),*], + permission_groups: #permission_groups_tokens, path_params: vec![#(#path_params),*], response_type_name: None, max_total_bytes: None, @@ -128,6 +132,7 @@ pub fn generate_openapi_code( path: String, auth_required: bool, permissions: Vec, + permission_groups: Vec>, path_params: Vec<(String, String)>, response_type_name: Option, max_total_bytes: Option, @@ -315,6 +320,9 @@ pub fn generate_openapi_code( if !endpoint.permissions.is_empty() { operation["x-permissions"] = json!(endpoint.permissions); } + if !endpoint.permission_groups.is_empty() { + operation["x-permission-groups"] = json!(endpoint.permission_groups); + } } path_item[method_lower] = operation; @@ -492,6 +500,20 @@ fn permissions_for_openapi(auth: &AuthRequirement) -> Vec { } } +fn permission_groups_for_openapi(auth: &AuthRequirement) -> Vec> { + match auth { + AuthRequirement::Unauthorized => vec![], + AuthRequirement::WithPermissions(groups) => groups.clone(), + } +} + +fn permission_groups_tokens(groups: &[Vec]) -> TokenStream { + let groups = groups + .iter() + .map(|group| quote! { vec![#(#group.to_string()),*] }); + quote! { vec![#(#groups),*] } +} + fn sanitize_type_name(type_name: &str) -> String { if type_name == "()" { "Unit".to_string() diff --git a/crates/rest/ras-file-macro/src/permissions.rs b/crates/rest/ras-file-macro/src/permissions.rs new file mode 100644 index 0000000..61b3674 --- /dev/null +++ b/crates/rest/ras-file-macro/src/permissions.rs @@ -0,0 +1,259 @@ +use crate::parser::{AuthRequirement, FileServiceDefinition, Operation}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use std::collections::{BTreeMap, BTreeSet}; + +pub fn generate_permissions_code(definition: &FileServiceDefinition) -> TokenStream { + let service_name = definition.service_name.to_string(); + let service_lower = service_name.to_lowercase(); + let manifest_fn_name = format_ident!("generate_{}_permission_manifest", service_lower); + let permissions_mod_name = format_ident!("{}_permissions", service_lower); + + let permission_names = collect_permissions(definition); + let permission_idents = unique_const_idents(permission_names.iter().map(String::as_str)); + let permission_consts = permission_names.iter().map(|permission| { + let ident = permission_idents + .get(permission) + .expect("permission ident must exist"); + quote! { + pub const #ident: ras_permission_manifest::PermissionRef = + ras_permission_manifest::PermissionRef::new(#permission); + } + }); + + let operations = operation_entries(definition); + let operation_const_idents = unique_const_idents(operations.iter().filter_map(|operation| { + if operation.is_protected { + Some(operation.const_base.as_str()) + } else { + None + } + })); + let operation_consts = operations.iter().filter_map(|operation| { + if !operation.is_protected { + return None; + } + let ident = operation_const_idents + .get(&operation.const_base) + .expect("operation ident must exist"); + let requirement = static_requirement_tokens(operation.auth); + Some(quote! { + pub const #ident: ras_permission_manifest::StaticPermissionRequirement = #requirement; + }) + }); + let manifest_operations = operations + .iter() + .map(|operation| operation.manifest_tokens()); + + quote! { + pub fn #manifest_fn_name() -> ras_permission_manifest::ServicePermissions { + ras_permission_manifest::ServicePermissions { + service_name: #service_name.to_string(), + transport: ras_permission_manifest::TransportKind::File, + operations: vec![#(#manifest_operations),*], + } + } + + pub mod #permissions_mod_name { + #(#permission_consts)* + + pub mod operations { + #(#operation_consts)* + } + } + } +} + +fn collect_permissions(definition: &FileServiceDefinition) -> Vec { + let mut permissions = BTreeSet::new(); + for endpoint in &definition.endpoints { + if let AuthRequirement::WithPermissions(groups) = &endpoint.auth { + for group in groups { + permissions.extend(group.iter().cloned()); + } + } + } + permissions.into_iter().collect() +} + +struct OperationEntry<'a> { + operation_id: String, + operation_name: String, + const_base: String, + method: &'static str, + path: String, + kind: TokenStream, + auth: &'a AuthRequirement, + is_protected: bool, +} + +impl OperationEntry<'_> { + fn manifest_tokens(&self) -> TokenStream { + let operation_id = &self.operation_id; + let operation_name = &self.operation_name; + let method = self.method; + let path = &self.path; + let kind = &self.kind; + let auth = auth_tokens(self.auth); + + quote! { + ras_permission_manifest::OperationPermissions { + operation_id: #operation_id.to_string(), + operation_name: #operation_name.to_string(), + kind: #kind, + wire: ras_permission_manifest::WireTarget::File { + method: #method.to_string(), + path: #path.to_string(), + }, + auth: #auth, + version: None, + canonical_operation_id: None, + } + } + } +} + +fn operation_entries(definition: &FileServiceDefinition) -> Vec> { + definition + .endpoints + .iter() + .map(|endpoint| { + let (method, kind) = match endpoint.operation { + Operation::Upload { .. } => ( + "POST", + quote! { ras_permission_manifest::OperationKind::FileUpload }, + ), + Operation::Download { .. } => ( + "GET", + quote! { ras_permission_manifest::OperationKind::FileDownload }, + ), + }; + let is_protected = !matches!(endpoint.auth, AuthRequirement::Unauthorized); + OperationEntry { + operation_id: format!("{}.{}", definition.service_name, endpoint.name), + operation_name: endpoint.name.to_string(), + const_base: endpoint.name.to_string(), + method, + path: join_paths(&definition.base_path.value(), &endpoint.path.value()), + kind, + auth: &endpoint.auth, + is_protected, + } + }) + .collect() +} + +fn join_paths(base_path: &str, path: &str) -> String { + let base = base_path.trim_end_matches('/'); + let path = path.trim_start_matches('/'); + match (base.is_empty(), path.is_empty()) { + (true, true) => "/".to_string(), + (true, false) => format!("/{path}"), + (false, true) => base.to_string(), + (false, false) => format!("{base}/{path}"), + } +} + +fn auth_tokens(auth: &AuthRequirement) -> TokenStream { + match auth { + AuthRequirement::Unauthorized => { + quote! { ras_permission_manifest::AuthRequirementInfo::Public } + } + AuthRequirement::WithPermissions(groups) => { + if groups.is_empty() || groups.iter().any(Vec::is_empty) { + quote! { ras_permission_manifest::AuthRequirementInfo::Authenticated } + } else { + let group_tokens = groups.iter().map(|group| { + quote! { + ras_permission_manifest::PermissionGroupInfo { + all_of: vec![#(#group.to_string()),*], + } + } + }); + quote! { + ras_permission_manifest::AuthRequirementInfo::Permissions { + any_of: vec![#(#group_tokens),*], + } + } + } + } + } +} + +fn static_requirement_tokens(auth: &AuthRequirement) -> TokenStream { + match auth { + AuthRequirement::Unauthorized => { + quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } + } + AuthRequirement::WithPermissions(groups) => { + if groups.is_empty() || groups.iter().any(Vec::is_empty) { + quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } + } else { + let group_tokens = groups.iter().map(|group| quote! { &[#(#group),*] }); + quote! { ras_permission_manifest::StaticPermissionRequirement::new(&[#(#group_tokens),*]) } + } + } + } +} + +fn unique_const_idents<'a>( + names: impl IntoIterator, +) -> BTreeMap { + let mut by_base: BTreeMap> = BTreeMap::new(); + for name in names { + by_base + .entry(sanitize_const_base(name)) + .or_default() + .push(name.to_string()); + } + + let mut idents = BTreeMap::new(); + for (base, mut names) in by_base { + names.sort(); + names.dedup(); + let has_collision = names.len() > 1; + for name in names { + let ident = if has_collision { + format!("{}_{}", base, stable_hash_hex(&name)) + } else { + base.clone() + }; + idents.insert(name, format_ident!("{}", ident)); + } + } + idents +} + +fn sanitize_const_base(value: &str) -> String { + let mut out = String::new(); + let mut last_was_underscore = false; + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_uppercase()); + last_was_underscore = false; + } else if !last_was_underscore { + out.push('_'); + last_was_underscore = true; + } + } + let out = out.trim_matches('_').to_string(); + let out = if out.is_empty() { + "PERMISSION".to_string() + } else { + out + }; + if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { + format!("PERMISSION_{}", out) + } else { + out + } +} + +fn stable_hash_hex(value: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{:08X}", hash as u32) +} diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index 3158105..6bd9803 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -16,6 +16,7 @@ proc-macro = true default = ["server", "client"] # Enable server by default for backward compatibility server = ["axum", "ras-auth-core", "ras-rest-core", "async-trait"] client = ["reqwest"] +permissions = [] [dependencies] syn = { workspace = true } @@ -50,6 +51,7 @@ axum = { workspace = true } axum-extra = { workspace = true } ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } ras-rest-core = { path = "../ras-rest-core", version = "0.1.1" } +ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" } axum-test = { workspace = true } schemars = { workspace = true } criterion = { workspace = true, features = ["async_tokio"] } diff --git a/crates/rest/ras-rest-macro/src/client.rs b/crates/rest/ras-rest-macro/src/client.rs index c6d7941..f0bb776 100644 --- a/crates/rest/ras-rest-macro/src/client.rs +++ b/crates/rest/ras-rest-macro/src/client.rs @@ -58,7 +58,6 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok .flat_map(generate_client_methods_with_timeout_for_endpoint); let output = quote! { - #[cfg(feature = "client")] /// Helper function to join URL segments properly fn join_url_segments(base: &str, path: &str) -> String { let base = base.trim_end_matches('/'); @@ -70,7 +69,6 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok } } - #[cfg(feature = "client")] /// Generated client for the REST service #[derive(Clone)] pub struct #client_name { @@ -81,14 +79,12 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok default_timeout: Option, } - #[cfg(feature = "client")] /// Builder for the REST client pub struct #client_builder_name { server_url: String, timeout: Option, } - #[cfg(feature = "client")] impl #client_builder_name { /// Create a new client builder with the required server URL pub fn new(server_url: impl Into) -> Self { @@ -148,7 +144,6 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok } } - #[cfg(feature = "client")] impl #client_name { /// Set the bearer token for authentication pub fn set_bearer_token(&mut self, token: Option>) { diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index 8ec20b8..6d33a01 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -4,6 +4,7 @@ use syn::{Ident, LitStr, Token, Type, parse::Parse, parse_macro_input}; mod client; mod openapi; +mod permissions; mod static_hosting; /// Macro to generate a REST service with authentication support @@ -678,7 +679,12 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result syn::Result syn::Result { service: std::sync::Arc, @@ -783,22 +787,18 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result, std::time::Duration) -> std::pin::Pin + Send>> + Send + Sync>>, } - #[cfg(feature = "server")] const _: () = { #schema_checks }; // Generate OpenAPI function at module level if serve_docs is enabled - #[cfg(feature = "server")] #openapi_code #static_hosting_code // Define query parameter structs - #[cfg(feature = "server")] use self::query_params::*; - #[cfg(feature = "server")] mod query_params { #[allow(unused_imports)] use super::*; @@ -806,10 +806,8 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result #builder_name { /// Create a new builder with the service implementation pub fn new(service: T) -> Self { @@ -896,18 +894,30 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result serde_json::Value { let schema = schemars::schema_for!(#type_tokens); let mut schema_value = serde_json::to_value(&schema).unwrap_or_else(|_| { @@ -196,6 +195,8 @@ pub fn generate_openapi_code( groups.iter().flatten().cloned().collect() } }; + let permission_groups = permission_groups_for_spec(&endpoint.auth); + let permission_groups_tokens = permission_groups_tokens(&permission_groups); let request_type_name = if let Some(request_type) = &endpoint.request_type { sanitize_type_name("e!(#request_type).to_string()) @@ -243,6 +244,7 @@ pub fn generate_openapi_code( description: #description, auth_required: #auth_required, permissions: vec![#(#permissions.to_string()),*], + permission_groups: #permission_groups_tokens, request_type_name: #request_type_name.to_string(), response_type_name: #response_type_name.to_string(), path_params: vec![#(#path_param_infos),*] as Vec<(String, String)>, @@ -296,6 +298,7 @@ pub fn generate_openapi_code( }) .collect(); let permissions = permissions.clone(); + let permission_groups_tokens = permission_groups_tokens.clone(); let summary = summary.clone(); let description = description.clone(); @@ -307,6 +310,7 @@ pub fn generate_openapi_code( description: #description, auth_required: #auth_required, permissions: vec![#(#permissions.to_string()),*], + permission_groups: #permission_groups_tokens, request_type_name: #request_type_name.to_string(), response_type_name: #response_type_name.to_string(), path_params: vec![#(#path_param_infos),*] as Vec<(String, String)>, @@ -323,7 +327,6 @@ pub fn generate_openapi_code( .collect(); quote! { - #[cfg(feature = "server")] #[derive(serde::Serialize)] struct #endpoint_info_struct_name { method: String, @@ -332,6 +335,7 @@ pub fn generate_openapi_code( description: Option, auth_required: bool, permissions: Vec, + permission_groups: Vec>, request_type_name: String, response_type_name: String, path_params: Vec<(String, String)>, // (name, type) @@ -342,7 +346,6 @@ pub fn generate_openapi_code( } // Helper function to fix schema references and flatten nested definitions - #[cfg(feature = "server")] fn fix_schema_refs(value: &mut serde_json::Value, schemas: &mut serde_json::Map) { match value { serde_json::Value::Object(obj) => { @@ -402,7 +405,6 @@ pub fn generate_openapi_code( } // Helper function to normalize nullable properties for better OpenAPI explorer compatibility. - #[cfg(feature = "server")] fn normalize_nullable_properties(value: &mut serde_json::Value) { match value { serde_json::Value::Object(obj) => { @@ -458,7 +460,6 @@ pub fn generate_openapi_code( } // Helper function to fix Option types that use anyOf with null or type arrays - #[cfg(feature = "server")] fn fix_option_types(value: &mut serde_json::Value) { match value { serde_json::Value::Object(obj) => { @@ -547,7 +548,6 @@ pub fn generate_openapi_code( #(#schema_fns)* /// Generate OpenAPI 3.0 document for this service - #[cfg(feature = "server")] pub fn #openapi_fn_name() -> serde_json::Value { use serde_json::json; use schemars::{schema_for, JsonSchema}; @@ -684,6 +684,10 @@ pub fn generate_openapi_code( if !endpoint.permissions.is_empty() { operation["x-permissions"] = json!(endpoint.permissions); } + + if !endpoint.permission_groups.is_empty() { + operation["x-permission-groups"] = json!(endpoint.permission_groups); + } } // Add the operation to the path item @@ -712,7 +716,6 @@ pub fn generate_openapi_code( } /// Write OpenAPI document to the target directory - #[cfg(feature = "server")] pub fn #openapi_to_file_fn_name() -> std::io::Result<()> { let doc = #openapi_fn_name(); let output_path = #output_path_code; @@ -733,6 +736,20 @@ pub fn generate_openapi_code( } } +fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { + match auth { + AuthRequirement::Unauthorized => vec![], + AuthRequirement::WithPermissions(groups) => groups.clone(), + } +} + +fn permission_groups_tokens(groups: &[Vec]) -> TokenStream { + let groups = groups + .iter() + .map(|group| quote! { vec![#(#group.to_string()),*] }); + quote! { vec![#(#groups),*] } +} + /// Generates code to include schema generation for types when schemars is available pub fn generate_schema_impl_checks(service_def: &ServiceDefinition) -> TokenStream { let mut unique_types = HashMap::new(); diff --git a/crates/rest/ras-rest-macro/src/permissions.rs b/crates/rest/ras-rest-macro/src/permissions.rs new file mode 100644 index 0000000..9d4965d --- /dev/null +++ b/crates/rest/ras-rest-macro/src/permissions.rs @@ -0,0 +1,293 @@ +use crate::{AuthRequirement, HttpMethod, ServiceDefinition}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use std::collections::{BTreeMap, BTreeSet}; + +pub fn generate_permissions_code(service_def: &ServiceDefinition) -> TokenStream { + let service_name = service_def.service_name.to_string(); + let service_lower = service_name.to_lowercase(); + let manifest_fn_name = format_ident!("generate_{}_permission_manifest", service_lower); + let permissions_mod_name = format_ident!("{}_permissions", service_lower); + + let permission_names = collect_permissions(service_def); + let permission_idents = unique_const_idents(permission_names.iter().map(String::as_str)); + let permission_consts = permission_names.iter().map(|permission| { + let ident = permission_idents + .get(permission) + .expect("permission ident must exist"); + quote! { + pub const #ident: ras_permission_manifest::PermissionRef = + ras_permission_manifest::PermissionRef::new(#permission); + } + }); + + let operations = operation_entries(service_def); + let operation_const_idents = unique_const_idents(operations.iter().filter_map(|operation| { + if operation.is_protected { + Some(operation.const_base.as_str()) + } else { + None + } + })); + let operation_consts = operations.iter().filter_map(|operation| { + if !operation.is_protected { + return None; + } + let ident = operation_const_idents + .get(&operation.const_base) + .expect("operation ident must exist"); + let requirement = static_requirement_tokens(operation.auth); + Some(quote! { + pub const #ident: ras_permission_manifest::StaticPermissionRequirement = #requirement; + }) + }); + let manifest_operations = operations + .iter() + .map(|operation| operation.manifest_tokens()); + + quote! { + pub fn #manifest_fn_name() -> ras_permission_manifest::ServicePermissions { + ras_permission_manifest::ServicePermissions { + service_name: #service_name.to_string(), + transport: ras_permission_manifest::TransportKind::Rest, + operations: vec![#(#manifest_operations),*], + } + } + + pub mod #permissions_mod_name { + #(#permission_consts)* + + pub mod operations { + #(#operation_consts)* + } + } + } +} + +fn collect_permissions(service_def: &ServiceDefinition) -> Vec { + let mut permissions = BTreeSet::new(); + for endpoint in &service_def.endpoints { + if let AuthRequirement::WithPermissions(groups) = &endpoint.auth { + for group in groups { + permissions.extend(group.iter().cloned()); + } + } + } + permissions.into_iter().collect() +} + +struct OperationEntry<'a> { + operation_id: String, + operation_name: String, + const_base: String, + method: &'static str, + path: String, + auth: &'a AuthRequirement, + version: Option, + canonical_operation_id: Option, + is_protected: bool, +} + +impl OperationEntry<'_> { + fn manifest_tokens(&self) -> TokenStream { + let operation_id = &self.operation_id; + let operation_name = &self.operation_name; + let method = self.method; + let path = &self.path; + let auth = auth_tokens(self.auth); + let version = option_string_tokens(self.version.as_deref()); + let canonical_operation_id = option_string_tokens(self.canonical_operation_id.as_deref()); + + quote! { + ras_permission_manifest::OperationPermissions { + operation_id: #operation_id.to_string(), + operation_name: #operation_name.to_string(), + kind: ras_permission_manifest::OperationKind::RestEndpoint, + wire: ras_permission_manifest::WireTarget::Rest { + method: #method.to_string(), + path: #path.to_string(), + }, + auth: #auth, + version: #version, + canonical_operation_id: #canonical_operation_id, + } + } + } +} + +fn operation_entries(service_def: &ServiceDefinition) -> Vec> { + let mut entries = Vec::new(); + for endpoint in &service_def.endpoints { + let canonical_operation_id = + format!("{}.{}", service_def.service_name, endpoint.handler_name); + let is_protected = !matches!(endpoint.auth, AuthRequirement::Unauthorized); + + entries.push(OperationEntry { + operation_id: canonical_operation_id.clone(), + operation_name: endpoint.handler_name.to_string(), + const_base: endpoint.handler_name.to_string(), + method: endpoint.method.as_str(), + path: join_paths(&service_def.base_path, &endpoint.path), + auth: &endpoint.auth, + version: endpoint.version.clone(), + canonical_operation_id: None, + is_protected, + }); + + for version in &endpoint.versions { + let version_name = handler_name_for_path(&endpoint.method, &version.path); + entries.push(OperationEntry { + operation_id: format!( + "{}.{}@{}", + service_def.service_name, version_name, version.version + ), + operation_name: version_name.clone(), + const_base: format!("{}_{}", version_name, version.version), + method: endpoint.method.as_str(), + path: join_paths(&service_def.base_path, &version.path), + auth: &endpoint.auth, + version: Some(version.version.clone()), + canonical_operation_id: Some(canonical_operation_id.clone()), + is_protected, + }); + } + } + entries +} + +fn handler_name_for_path(method: &HttpMethod, path: &str) -> String { + let method_str = method.as_str().to_ascii_lowercase(); + let mut parts = Vec::new(); + for segment in path.trim_start_matches('/').split('/') { + if segment.starts_with('{') && segment.ends_with('}') { + let inner = &segment[1..segment.len() - 1]; + let name = inner.split(':').next().unwrap_or(inner).trim(); + parts.push(format!("by_{name}")); + } else if !segment.is_empty() { + parts.push(segment.to_string()); + } + } + format!("{}_{}", method_str, parts.join("_")) +} + +fn join_paths(base_path: &str, path: &str) -> String { + let base = base_path.trim_end_matches('/'); + let path = path.trim_start_matches('/'); + match (base.is_empty(), path.is_empty()) { + (true, true) => "/".to_string(), + (true, false) => format!("/{path}"), + (false, true) => base.to_string(), + (false, false) => format!("{base}/{path}"), + } +} + +fn auth_tokens(auth: &AuthRequirement) -> TokenStream { + match auth { + AuthRequirement::Unauthorized => { + quote! { ras_permission_manifest::AuthRequirementInfo::Public } + } + AuthRequirement::WithPermissions(groups) => { + if groups.is_empty() || groups.iter().any(Vec::is_empty) { + quote! { ras_permission_manifest::AuthRequirementInfo::Authenticated } + } else { + let group_tokens = groups.iter().map(|group| { + quote! { + ras_permission_manifest::PermissionGroupInfo { + all_of: vec![#(#group.to_string()),*], + } + } + }); + quote! { + ras_permission_manifest::AuthRequirementInfo::Permissions { + any_of: vec![#(#group_tokens),*], + } + } + } + } + } +} + +fn static_requirement_tokens(auth: &AuthRequirement) -> TokenStream { + match auth { + AuthRequirement::Unauthorized => { + quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } + } + AuthRequirement::WithPermissions(groups) => { + if groups.is_empty() || groups.iter().any(Vec::is_empty) { + quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } + } else { + let group_tokens = groups.iter().map(|group| quote! { &[#(#group),*] }); + quote! { ras_permission_manifest::StaticPermissionRequirement::new(&[#(#group_tokens),*]) } + } + } + } +} + +fn option_string_tokens(value: Option<&str>) -> TokenStream { + match value { + Some(value) => quote! { Some(#value.to_string()) }, + None => quote! { None }, + } +} + +fn unique_const_idents<'a>( + names: impl IntoIterator, +) -> BTreeMap { + let mut by_base: BTreeMap> = BTreeMap::new(); + for name in names { + by_base + .entry(sanitize_const_base(name)) + .or_default() + .push(name.to_string()); + } + + let mut idents = BTreeMap::new(); + for (base, mut names) in by_base { + names.sort(); + names.dedup(); + let has_collision = names.len() > 1; + for name in names { + let ident = if has_collision { + format!("{}_{}", base, stable_hash_hex(&name)) + } else { + base.clone() + }; + idents.insert(name, format_ident!("{}", ident)); + } + } + idents +} + +fn sanitize_const_base(value: &str) -> String { + let mut out = String::new(); + let mut last_was_underscore = false; + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_uppercase()); + last_was_underscore = false; + } else if !last_was_underscore { + out.push('_'); + last_was_underscore = true; + } + } + let out = out.trim_matches('_').to_string(); + let out = if out.is_empty() { + "PERMISSION".to_string() + } else { + out + }; + if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { + format!("PERMISSION_{}", out) + } else { + out + } +} + +fn stable_hash_hex(value: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{:08X}", hash as u32) +} diff --git a/crates/rest/ras-rest-macro/src/static_hosting.rs b/crates/rest/ras-rest-macro/src/static_hosting.rs index 119cbb2..39ac0e2 100644 --- a/crates/rest/ras-rest-macro/src/static_hosting.rs +++ b/crates/rest/ras-rest-macro/src/static_hosting.rs @@ -56,7 +56,6 @@ pub fn generate_static_hosting_code( let template_lit = syn::LitStr::new(TEMPLATE_CONTENT, proc_macro2::Span::call_site()); quote! { - #[cfg(feature = "server")] async fn #docs_handler_name() -> ::axum::response::Html { static HTML: ::std::sync::OnceLock = ::std::sync::OnceLock::new(); @@ -77,7 +76,6 @@ pub fn generate_static_hosting_code( ::axum::response::Html(html.clone()) } - #[cfg(feature = "server")] async fn openapi_json_handler() -> ::axum::Json<::serde_json::Value> { ::axum::Json(#openapi_fn_name()) } @@ -101,7 +99,6 @@ pub fn generate_static_routes( ); quote! { - #[cfg(feature = "server")] { router = router .route(#docs_path, ::axum::routing::get(#docs_handler_name)) diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml index ae4d4f7..b61bb83 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml @@ -23,12 +23,14 @@ serde_json = { workspace = true } default = ["server", "client"] server = [] client = [] +permissions = [] [dev-dependencies] ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types", version = "0.1.0" } ras-jsonrpc-bidirectional-server = { path = "../ras-jsonrpc-bidirectional-server", version = "0.1.0" } ras-jsonrpc-bidirectional-client = { path = "../ras-jsonrpc-bidirectional-client", version = "0.1.0" } ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.1.0" } +ras-permission-manifest = { path = "../../../specs/ras-permission-manifest", version = "0.1.0" } ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } tokio = { workspace = true } serde = { workspace = true } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/client.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/client.rs index 65cfbbf..b8291ea 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/client.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/client.rs @@ -176,13 +176,11 @@ pub fn generate_client_code( }); quote! { - #[cfg(feature = "client")] /// Generated client for the bidirectional service pub struct #client_name { client: ras_jsonrpc_bidirectional_client::Client, } - #[cfg(feature = "client")] impl #client_name { /// Create a new client from a pre-configured Client pub fn new(client: ras_jsonrpc_bidirectional_client::Client) -> Self { @@ -231,7 +229,6 @@ pub fn generate_client_code( } } - #[cfg(feature = "client")] /// Builder for the bidirectional client pub struct #client_builder_name { url: String, @@ -239,7 +236,6 @@ pub fn generate_client_code( timeout: Option, } - #[cfg(feature = "client")] impl #client_builder_name { /// Create a new client builder pub fn new(url: impl Into) -> Self { @@ -279,14 +275,12 @@ pub fn generate_client_code( } } - #[cfg(feature = "client")] /// Type-safe enum for client-to-server messages #[derive(Debug)] pub enum #client_to_server_message_name { #(#client_to_server_methods)* } - #[cfg(feature = "client")] /// Type-safe enum for server-to-client notifications #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum #server_to_client_notification_name { diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs index a1052b0..68afd86 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs @@ -3,6 +3,7 @@ use quote::{format_ident, quote}; use syn::{Ident, LitStr, Token, Type, parse::Parse, parse_macro_input}; mod client; +mod permissions; mod server; #[cfg(test)] @@ -267,32 +268,49 @@ fn generate_service_code( let server_mod = format_ident!("__ras_jsonrpc_bidirectional_{}_server", service_name_lower); let client_mod = format_ident!("__ras_jsonrpc_bidirectional_{}_client", service_name_lower); - // Generate server code - this will be conditionally compiled by the user + // Generate server code only when the macro crate's server feature is enabled. let server_code = server::generate_server_code(&service_def); - // Generate client code - this will be conditionally compiled by the user + // Generate client code only when the macro crate's client feature is enabled. let client_code = client::generate_client_code(&service_def); + let permissions_code = if cfg!(feature = "permissions") { + permissions::generate_permissions_code(&service_def) + } else { + quote! {} + }; - let output = quote! { - #[cfg(feature = "server")] + let server_output = if cfg!(feature = "server") { + quote! { mod #server_mod { use super::*; #server_code } - #[cfg(feature = "server")] pub use #server_mod::*; + } + } else { + quote! {} + }; - #[cfg(feature = "client")] + let client_output = if cfg!(feature = "client") { + quote! { mod #client_mod { use super::*; #client_code } - #[cfg(feature = "client")] pub use #client_mod::*; + } + } else { + quote! {} + }; + + let output = quote! { + #permissions_code + #server_output + #client_output }; Ok(output) diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/permissions.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/permissions.rs new file mode 100644 index 0000000..bc4db94 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/permissions.rs @@ -0,0 +1,259 @@ +use crate::{AuthRequirement, BidirectionalServiceDefinition}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use std::collections::{BTreeMap, BTreeSet}; + +pub fn generate_permissions_code(service_def: &BidirectionalServiceDefinition) -> TokenStream { + let service_name = service_def.service_name.to_string(); + let service_lower = service_name.to_lowercase(); + let manifest_fn_name = format_ident!("generate_{}_permission_manifest", service_lower); + let permissions_mod_name = format_ident!("{}_permissions", service_lower); + + let permission_names = collect_permissions(service_def); + let permission_idents = unique_const_idents(permission_names.iter().map(String::as_str)); + let permission_consts = permission_names.iter().map(|permission| { + let ident = permission_idents + .get(permission) + .expect("permission ident must exist"); + quote! { + pub const #ident: ras_permission_manifest::PermissionRef = + ras_permission_manifest::PermissionRef::new(#permission); + } + }); + + let operations = operation_entries(service_def); + let operation_const_idents = unique_const_idents(operations.iter().filter_map(|operation| { + if operation.is_protected { + Some(operation.const_base.as_str()) + } else { + None + } + })); + let operation_consts = operations.iter().filter_map(|operation| { + if !operation.is_protected { + return None; + } + let ident = operation_const_idents + .get(&operation.const_base) + .expect("operation ident must exist"); + let requirement = static_requirement_tokens(operation.auth); + Some(quote! { + pub const #ident: ras_permission_manifest::StaticPermissionRequirement = #requirement; + }) + }); + let manifest_operations = operations + .iter() + .map(|operation| operation.manifest_tokens()); + + quote! { + pub fn #manifest_fn_name() -> ras_permission_manifest::ServicePermissions { + ras_permission_manifest::ServicePermissions { + service_name: #service_name.to_string(), + transport: ras_permission_manifest::TransportKind::JsonRpcBidirectional, + operations: vec![#(#manifest_operations),*], + } + } + + pub mod #permissions_mod_name { + #(#permission_consts)* + + pub mod operations { + #(#operation_consts)* + } + } + } +} + +fn collect_permissions(service_def: &BidirectionalServiceDefinition) -> Vec { + let mut permissions = BTreeSet::new(); + for method in service_def + .client_to_server + .iter() + .chain(service_def.server_to_client_calls.iter()) + { + if let AuthRequirement::WithPermissions(groups) = &method.auth { + for group in groups { + permissions.extend(group.iter().cloned()); + } + } + } + permissions.into_iter().collect() +} + +struct OperationEntry<'a> { + operation_id: String, + operation_name: String, + const_base: String, + direction: &'static str, + method: String, + kind: TokenStream, + auth: &'a AuthRequirement, + is_protected: bool, +} + +impl OperationEntry<'_> { + fn manifest_tokens(&self) -> TokenStream { + let operation_id = &self.operation_id; + let operation_name = &self.operation_name; + let direction = self.direction; + let method = &self.method; + let kind = &self.kind; + let auth = auth_tokens(self.auth); + + quote! { + ras_permission_manifest::OperationPermissions { + operation_id: #operation_id.to_string(), + operation_name: #operation_name.to_string(), + kind: #kind, + wire: ras_permission_manifest::WireTarget::BidirectionalJsonRpc { + direction: #direction.to_string(), + method: #method.to_string(), + }, + auth: #auth, + version: None, + canonical_operation_id: None, + } + } + } +} + +fn operation_entries(service_def: &BidirectionalServiceDefinition) -> Vec> { + let mut entries = Vec::new(); + for method in &service_def.client_to_server { + let is_protected = !matches!(method.auth, AuthRequirement::Unauthorized); + entries.push(OperationEntry { + operation_id: format!( + "{}.client_to_server.{}", + service_def.service_name, method.name + ), + operation_name: method.name.to_string(), + const_base: format!("client_to_server_{}", method.name), + direction: "client_to_server", + method: method.name.to_string(), + kind: quote! { ras_permission_manifest::OperationKind::BidirectionalClientToServer }, + auth: &method.auth, + is_protected, + }); + } + for method in &service_def.server_to_client_calls { + let is_protected = !matches!(method.auth, AuthRequirement::Unauthorized); + entries.push(OperationEntry { + operation_id: format!( + "{}.server_to_client_call.{}", + service_def.service_name, method.name + ), + operation_name: method.name.to_string(), + const_base: format!("server_to_client_call_{}", method.name), + direction: "server_to_client_call", + method: method.name.to_string(), + kind: quote! { ras_permission_manifest::OperationKind::BidirectionalServerToClientCall }, + auth: &method.auth, + is_protected, + }); + } + entries +} + +fn auth_tokens(auth: &AuthRequirement) -> TokenStream { + match auth { + AuthRequirement::Unauthorized => { + quote! { ras_permission_manifest::AuthRequirementInfo::Public } + } + AuthRequirement::WithPermissions(groups) => { + if groups.is_empty() || groups.iter().any(Vec::is_empty) { + quote! { ras_permission_manifest::AuthRequirementInfo::Authenticated } + } else { + let group_tokens = groups.iter().map(|group| { + quote! { + ras_permission_manifest::PermissionGroupInfo { + all_of: vec![#(#group.to_string()),*], + } + } + }); + quote! { + ras_permission_manifest::AuthRequirementInfo::Permissions { + any_of: vec![#(#group_tokens),*], + } + } + } + } + } +} + +fn static_requirement_tokens(auth: &AuthRequirement) -> TokenStream { + match auth { + AuthRequirement::Unauthorized => { + quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } + } + AuthRequirement::WithPermissions(groups) => { + if groups.is_empty() || groups.iter().any(Vec::is_empty) { + quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } + } else { + let group_tokens = groups.iter().map(|group| quote! { &[#(#group),*] }); + quote! { ras_permission_manifest::StaticPermissionRequirement::new(&[#(#group_tokens),*]) } + } + } + } +} + +fn unique_const_idents<'a>( + names: impl IntoIterator, +) -> BTreeMap { + let mut by_base: BTreeMap> = BTreeMap::new(); + for name in names { + by_base + .entry(sanitize_const_base(name)) + .or_default() + .push(name.to_string()); + } + + let mut idents = BTreeMap::new(); + for (base, mut names) in by_base { + names.sort(); + names.dedup(); + let has_collision = names.len() > 1; + for name in names { + let ident = if has_collision { + format!("{}_{}", base, stable_hash_hex(&name)) + } else { + base.clone() + }; + idents.insert(name, format_ident!("{}", ident)); + } + } + idents +} + +fn sanitize_const_base(value: &str) -> String { + let mut out = String::new(); + let mut last_was_underscore = false; + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_uppercase()); + last_was_underscore = false; + } else if !last_was_underscore { + out.push('_'); + last_was_underscore = true; + } + } + let out = out.trim_matches('_').to_string(); + let out = if out.is_empty() { + "PERMISSION".to_string() + } else { + out + }; + if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { + format!("PERMISSION_{}", out) + } else { + out + } +} + +fn stable_hash_hex(value: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{:08X}", hash as u32) +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs index 9719f49..631be47 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs @@ -273,14 +273,12 @@ pub fn generate_server_code( }); quote! { - #[cfg(feature = "server")] /// Typed client handle for server-side client management pub struct #client_handle_name<'a> { client_id: ras_jsonrpc_bidirectional_types::ConnectionId, connection_manager: &'a dyn ras_jsonrpc_bidirectional_types::ConnectionManager, } - #[cfg(feature = "server")] impl<'a> #client_handle_name<'a> { /// Create a new client handle pub fn new(client_id: ras_jsonrpc_bidirectional_types::ConnectionId, connection_manager: &'a dyn ras_jsonrpc_bidirectional_types::ConnectionManager) -> Self { @@ -313,7 +311,6 @@ pub fn generate_server_code( #(#client_handle_call_methods)* } - #[cfg(feature = "server")] /// Generated bidirectional service trait #[async_trait::async_trait] pub trait #service_trait_name: Send + Sync + 'static { @@ -339,14 +336,12 @@ pub fn generate_server_code( } } - #[cfg(feature = "server")] /// Generated message handler for the bidirectional service pub struct #handler_name { service: std::sync::Arc, connection_manager: std::sync::Arc, } - #[cfg(feature = "server")] impl #handler_name { pub fn new( service: std::sync::Arc, @@ -393,7 +388,6 @@ pub fn generate_server_code( #(#default_notification_impls)* } - #[cfg(feature = "server")] #[async_trait::async_trait] impl ras_jsonrpc_bidirectional_server::MessageHandler for #handler_name { async fn handle_request( @@ -433,7 +427,6 @@ pub fn generate_server_code( } } - #[cfg(feature = "server")] /// Builder for the bidirectional WebSocket service pub struct #builder_name { service: std::sync::Arc, @@ -441,7 +434,6 @@ pub fn generate_server_code( require_auth: bool, } - #[cfg(feature = "server")] impl #builder_name { /// Create a new builder pub fn new(service: T, auth_provider: A) -> Self { diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index 76e5b41..5910c21 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -16,6 +16,7 @@ proc-macro = true default = ["server", "client"] # Enable server by default for backward compatibility server = ["axum", "ras-jsonrpc-core"] client = ["reqwest"] +permissions = [] [dependencies] syn = { workspace = true } @@ -47,6 +48,7 @@ futures = { workspace = true } axum = { workspace = true } ras-jsonrpc-core = { path = "../ras-jsonrpc-core", version = "0.1.2" } ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" } async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index ffbe587..67aad46 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -4,6 +4,7 @@ use syn::{Ident, LitStr, Token, Type, parse::Parse, parse_macro_input}; mod client; mod openrpc; +mod permissions; mod static_hosting; /// Macro to generate a JSON-RPC service with authentication support @@ -459,8 +460,8 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result syn::Result, auth_required: bool, permissions: Vec, + permission_groups: Vec>, request_type_name: String, response_type_name: String, version: Option, @@ -420,6 +426,10 @@ pub fn generate_openrpc_code( if !method.permissions.is_empty() { extensions.insert("x-permissions".to_string(), json!(method.permissions)); } + + if !method.permission_groups.is_empty() { + extensions.insert("x-permission-groups".to_string(), json!(method.permission_groups)); + } } if let Some(version) = &method.version { @@ -568,6 +578,20 @@ pub fn generate_openrpc_code( } } +fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { + match auth { + AuthRequirement::Unauthorized => vec![], + AuthRequirement::WithPermissions(groups) => groups.clone(), + } +} + +fn permission_groups_tokens(groups: &[Vec]) -> TokenStream { + let groups = groups + .iter() + .map(|group| quote! { vec![#(#group.to_string()),*] }); + quote! { vec![#(#groups),*] } +} + /// Generates code to include schema generation for types when schemars is available pub fn generate_schema_impl_checks(service_def: &ServiceDefinition) -> TokenStream { let mut unique_types = HashMap::new(); diff --git a/crates/rpc/ras-jsonrpc-macro/src/permissions.rs b/crates/rpc/ras-jsonrpc-macro/src/permissions.rs new file mode 100644 index 0000000..69d4527 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/permissions.rs @@ -0,0 +1,265 @@ +use crate::{AuthRequirement, MethodDefinition, ServiceDefinition}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use std::collections::{BTreeMap, BTreeSet}; + +pub fn generate_permissions_code(service_def: &ServiceDefinition) -> TokenStream { + let service_name = service_def.service_name.to_string(); + let service_lower = service_name.to_lowercase(); + let manifest_fn_name = format_ident!("generate_{}_permission_manifest", service_lower); + let permissions_mod_name = format_ident!("{}_permissions", service_lower); + + let permission_names = collect_permissions(service_def); + let permission_idents = unique_const_idents(permission_names.iter().map(String::as_str)); + let permission_consts = permission_names.iter().map(|permission| { + let ident = permission_idents + .get(permission) + .expect("permission ident must exist"); + quote! { + pub const #ident: ras_permission_manifest::PermissionRef = + ras_permission_manifest::PermissionRef::new(#permission); + } + }); + + let operations = operation_entries(service_def); + let operation_const_idents = unique_const_idents(operations.iter().filter_map(|operation| { + if operation.is_protected { + Some(operation.const_base.as_str()) + } else { + None + } + })); + let operation_consts = operations.iter().filter_map(|operation| { + if !operation.is_protected { + return None; + } + let ident = operation_const_idents + .get(&operation.const_base) + .expect("operation ident must exist"); + let requirement = static_requirement_tokens(operation.auth); + Some(quote! { + pub const #ident: ras_permission_manifest::StaticPermissionRequirement = #requirement; + }) + }); + let manifest_operations = operations + .iter() + .map(|operation| operation.manifest_tokens()); + + quote! { + pub fn #manifest_fn_name() -> ras_permission_manifest::ServicePermissions { + ras_permission_manifest::ServicePermissions { + service_name: #service_name.to_string(), + transport: ras_permission_manifest::TransportKind::JsonRpc, + operations: vec![#(#manifest_operations),*], + } + } + + pub mod #permissions_mod_name { + #(#permission_consts)* + + pub mod operations { + #(#operation_consts)* + } + } + } +} + +fn collect_permissions(service_def: &ServiceDefinition) -> Vec { + let mut permissions = BTreeSet::new(); + for method in &service_def.methods { + if let AuthRequirement::WithPermissions(groups) = &method.auth { + for group in groups { + permissions.extend(group.iter().cloned()); + } + } + } + permissions.into_iter().collect() +} + +struct OperationEntry<'a> { + operation_id: String, + operation_name: String, + const_base: String, + wire_method: String, + auth: &'a AuthRequirement, + version: Option, + canonical_operation_id: Option, + is_protected: bool, +} + +impl OperationEntry<'_> { + fn manifest_tokens(&self) -> TokenStream { + let operation_id = &self.operation_id; + let operation_name = &self.operation_name; + let wire_method = &self.wire_method; + let auth = auth_tokens(self.auth); + let version = option_string_tokens(self.version.as_deref()); + let canonical_operation_id = option_string_tokens(self.canonical_operation_id.as_deref()); + + quote! { + ras_permission_manifest::OperationPermissions { + operation_id: #operation_id.to_string(), + operation_name: #operation_name.to_string(), + kind: ras_permission_manifest::OperationKind::JsonRpcMethod, + wire: ras_permission_manifest::WireTarget::JsonRpc { + method: #wire_method.to_string(), + }, + auth: #auth, + version: #version, + canonical_operation_id: #canonical_operation_id, + } + } + } +} + +fn operation_entries(service_def: &ServiceDefinition) -> Vec> { + let mut entries = Vec::new(); + for method in &service_def.methods { + let canonical_operation_id = operation_id(&service_def.service_name.to_string(), method); + let canonical_wire = method + .wire_name + .clone() + .unwrap_or_else(|| method.name.to_string()); + let is_protected = !matches!(method.auth, AuthRequirement::Unauthorized); + + entries.push(OperationEntry { + operation_id: canonical_operation_id.clone(), + operation_name: method.name.to_string(), + const_base: method.name.to_string(), + wire_method: canonical_wire, + auth: &method.auth, + version: method.version.clone(), + canonical_operation_id: None, + is_protected, + }); + + for version in &method.versions { + entries.push(OperationEntry { + operation_id: format!("{}@{}", canonical_operation_id, version.version), + operation_name: method.name.to_string(), + const_base: format!("{}_{}", method.name, version.version), + wire_method: version.wire_name.clone(), + auth: &method.auth, + version: Some(version.version.clone()), + canonical_operation_id: Some(canonical_operation_id.clone()), + is_protected, + }); + } + } + entries +} + +fn operation_id(service_name: &str, method: &MethodDefinition) -> String { + format!("{}.{}", service_name, method.name) +} + +fn auth_tokens(auth: &AuthRequirement) -> TokenStream { + match auth { + AuthRequirement::Unauthorized => { + quote! { ras_permission_manifest::AuthRequirementInfo::Public } + } + AuthRequirement::WithPermissions(groups) => { + if groups.is_empty() || groups.iter().any(Vec::is_empty) { + quote! { ras_permission_manifest::AuthRequirementInfo::Authenticated } + } else { + let group_tokens = groups.iter().map(|group| { + quote! { + ras_permission_manifest::PermissionGroupInfo { + all_of: vec![#(#group.to_string()),*], + } + } + }); + quote! { + ras_permission_manifest::AuthRequirementInfo::Permissions { + any_of: vec![#(#group_tokens),*], + } + } + } + } + } +} + +fn static_requirement_tokens(auth: &AuthRequirement) -> TokenStream { + match auth { + AuthRequirement::Unauthorized => { + quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } + } + AuthRequirement::WithPermissions(groups) => { + if groups.is_empty() || groups.iter().any(Vec::is_empty) { + quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } + } else { + let group_tokens = groups.iter().map(|group| quote! { &[#(#group),*] }); + quote! { ras_permission_manifest::StaticPermissionRequirement::new(&[#(#group_tokens),*]) } + } + } + } +} + +fn option_string_tokens(value: Option<&str>) -> TokenStream { + match value { + Some(value) => quote! { Some(#value.to_string()) }, + None => quote! { None }, + } +} + +fn unique_const_idents<'a>( + names: impl IntoIterator, +) -> BTreeMap { + let mut by_base: BTreeMap> = BTreeMap::new(); + for name in names { + by_base + .entry(sanitize_const_base(name)) + .or_default() + .push(name.to_string()); + } + + let mut idents = BTreeMap::new(); + for (base, mut names) in by_base { + names.sort(); + names.dedup(); + let has_collision = names.len() > 1; + for name in names { + let ident = if has_collision { + format!("{}_{}", base, stable_hash_hex(&name)) + } else { + base.clone() + }; + idents.insert(name, format_ident!("{}", ident)); + } + } + idents +} + +fn sanitize_const_base(value: &str) -> String { + let mut out = String::new(); + let mut last_was_underscore = false; + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_uppercase()); + last_was_underscore = false; + } else if !last_was_underscore { + out.push('_'); + last_was_underscore = true; + } + } + let out = out.trim_matches('_').to_string(); + let out = if out.is_empty() { + "PERMISSION".to_string() + } else { + out + }; + if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { + format!("PERMISSION_{}", out) + } else { + out + } +} + +fn stable_hash_hex(value: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{:08X}", hash as u32) +} diff --git a/crates/specs/ras-permission-manifest/Cargo.toml b/crates/specs/ras-permission-manifest/Cargo.toml new file mode 100644 index 0000000..24c6b69 --- /dev/null +++ b/crates/specs/ras-permission-manifest/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ras-permission-manifest" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Typed permission manifest data structures for Rust Agent Stack service definitions" +keywords = ["api", "auth", "permissions", "manifest"] +categories = ["api-bindings", "data-structures", "web-programming"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/specs/ras-permission-manifest/README.md b/crates/specs/ras-permission-manifest/README.md new file mode 100644 index 0000000..80fe6ab --- /dev/null +++ b/crates/specs/ras-permission-manifest/README.md @@ -0,0 +1,5 @@ +# ras-permission-manifest + +Typed permission manifest data structures for Rust Agent Stack service definitions. + +Service macros emit `ServicePermissions` values from their API definitions. Build scripts can combine those values into a deterministic `PermissionManifest` JSON artifact, while token issuing code can use generated permission constants with `PermissionSet`. diff --git a/crates/specs/ras-permission-manifest/src/lib.rs b/crates/specs/ras-permission-manifest/src/lib.rs new file mode 100644 index 0000000..5615f54 --- /dev/null +++ b/crates/specs/ras-permission-manifest/src/lib.rs @@ -0,0 +1,387 @@ +//! Permission manifest types for Rust Agent Stack service definitions. +//! +//! This crate is intentionally transport-free. Service macro crates generate +//! values of these types, and build scripts can serialize them as an audit or +//! tooling artifact. + +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeSet, HashSet}; +use std::path::Path; + +/// Current permission manifest schema version. +pub const SCHEMA_VERSION: u32 = 1; + +/// A combined permission manifest for one or more services. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionManifest { + pub schema_version: u32, + pub services: Vec, +} + +impl PermissionManifest { + /// Build a deterministic manifest from service-level permission metadata. + pub fn from_services(services: I) -> Self + where + I: IntoIterator, + { + let mut services: Vec<_> = services.into_iter().collect(); + for service in &mut services { + service.sort_operations(); + } + services.sort_by(|left, right| { + left.service_name + .cmp(&right.service_name) + .then_with(|| left.transport.cmp(&right.transport)) + }); + + Self { + schema_version: SCHEMA_VERSION, + services, + } + } + + /// Return every permission string referenced by the manifest. + pub fn permissions(&self) -> BTreeSet<&str> { + let mut permissions = BTreeSet::new(); + for service in &self.services { + for operation in &service.operations { + if let AuthRequirementInfo::Permissions { any_of } = &operation.auth { + for group in any_of { + for permission in &group.all_of { + permissions.insert(permission.as_str()); + } + } + } + } + } + permissions + } +} + +/// Write a permission manifest as pretty JSON. +pub fn write_manifest( + path: impl AsRef, + manifest: &PermissionManifest, +) -> std::io::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(manifest).map_err(std::io::Error::other)?; + std::fs::write(path, json) +} + +/// Permission metadata for one generated service definition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServicePermissions { + pub service_name: String, + pub transport: TransportKind, + pub operations: Vec, +} + +impl ServicePermissions { + pub fn sort_operations(&mut self) { + self.operations.sort_by(|left, right| { + left.operation_id + .cmp(&right.operation_id) + .then_with(|| left.operation_name.cmp(&right.operation_name)) + }); + } +} + +/// Transport family for a generated service. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportKind { + Rest, + JsonRpc, + File, + JsonRpcBidirectional, +} + +/// Operation kind within a service transport. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperationKind { + RestEndpoint, + JsonRpcMethod, + FileUpload, + FileDownload, + BidirectionalClientToServer, + BidirectionalServerToClientCall, +} + +/// Callable wire target for an operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WireTarget { + Rest { method: String, path: String }, + JsonRpc { method: String }, + File { method: String, path: String }, + BidirectionalJsonRpc { direction: String, method: String }, +} + +/// Permission metadata for one callable operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperationPermissions { + pub operation_id: String, + pub operation_name: String, + pub kind: OperationKind, + pub wire: WireTarget, + pub auth: AuthRequirementInfo, + pub version: Option, + pub canonical_operation_id: Option, +} + +/// Effective auth requirement for an operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AuthRequirementInfo { + Public, + Authenticated, + Permissions { any_of: Vec }, +} + +impl AuthRequirementInfo { + /// Build the effective auth requirement from permission groups. + /// + /// Groups are ORed together, and each group's permissions are ANDed. Any + /// empty group makes the operation authenticated-only. + pub fn from_permission_groups(groups: I) -> Self + where + I: IntoIterator, + G: IntoIterator, + P: Into, + { + let mut any_of = Vec::new(); + for group in groups { + let mut all_of: Vec = group.into_iter().map(Into::into).collect(); + all_of.sort(); + all_of.dedup(); + if all_of.is_empty() { + return Self::Authenticated; + } + any_of.push(PermissionGroupInfo { all_of }); + } + + if any_of.is_empty() { + Self::Authenticated + } else { + any_of.sort_by(|left, right| left.all_of.cmp(&right.all_of)); + any_of.dedup(); + Self::Permissions { any_of } + } + } +} + +/// One AND group inside a permission requirement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionGroupInfo { + pub all_of: Vec, +} + +/// A generated, typo-safe reference to a permission string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PermissionRef { + name: &'static str, +} + +impl PermissionRef { + pub const fn new(name: &'static str) -> Self { + Self { name } + } + + pub const fn as_str(self) -> &'static str { + self.name + } +} + +impl Serialize for PermissionRef { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.name) + } +} + +/// A generated static requirement for an operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StaticPermissionRequirement { + pub any_of: &'static [&'static [&'static str]], +} + +impl StaticPermissionRequirement { + pub const fn new(any_of: &'static [&'static [&'static str]]) -> Self { + Self { any_of } + } + + pub const fn authenticated_only() -> Self { + Self { any_of: &[] } + } + + pub const fn is_authenticated_only(self) -> bool { + self.any_of.is_empty() + } + + pub fn is_satisfied_by(self, permissions: &HashSet) -> bool { + self.any_of.is_empty() + || self.any_of.iter().any(|group| { + group + .iter() + .all(|permission| permissions.contains(*permission)) + }) + } + + pub fn first_group_permissions(self) -> impl Iterator { + self.any_of + .first() + .copied() + .unwrap_or(&[]) + .iter() + .copied() + .map(PermissionRef::new) + } +} + +/// Builder for token/session permission claims using generated constants. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PermissionSet { + permissions: BTreeSet<&'static str>, +} + +impl PermissionSet { + pub fn new() -> Self { + Self::default() + } + + pub fn with(mut self, permission: PermissionRef) -> Self { + self.permissions.insert(permission.as_str()); + self + } + + pub fn insert(&mut self, permission: PermissionRef) { + self.permissions.insert(permission.as_str()); + } + + pub fn extend_first_group(&mut self, requirement: StaticPermissionRequirement) { + self.permissions.extend( + requirement + .first_group_permissions() + .map(PermissionRef::as_str), + ); + } + + pub fn into_hash_set(self) -> HashSet { + self.permissions + .into_iter() + .map(ToOwned::to_owned) + .collect() + } +} + +impl FromIterator for PermissionSet { + fn from_iter>(iter: T) -> Self { + let mut set = Self::new(); + for permission in iter { + set.insert(permission); + } + set + } +} + +impl Extend for PermissionSet { + fn extend>(&mut self, iter: T) { + for permission in iter { + self.insert(permission); + } + } +} + +impl From for HashSet { + fn from(value: PermissionSet) -> Self { + value.into_hash_set() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permission_groups_preserve_or_and_semantics() { + let auth = AuthRequirementInfo::from_permission_groups([ + vec!["items:read", "items:list"], + vec!["admin"], + ]); + + assert_eq!( + auth, + AuthRequirementInfo::Permissions { + any_of: vec![ + PermissionGroupInfo { + all_of: vec!["admin".to_string()], + }, + PermissionGroupInfo { + all_of: vec!["items:list".to_string(), "items:read".to_string()], + }, + ], + } + ); + } + + #[test] + fn empty_permission_group_is_authenticated_only() { + let auth = AuthRequirementInfo::from_permission_groups([Vec::<&str>::new()]); + assert_eq!(auth, AuthRequirementInfo::Authenticated); + } + + #[test] + fn manifest_sorts_services_and_operations() { + let mut left = ServicePermissions { + service_name: "B".to_string(), + transport: TransportKind::Rest, + operations: vec![operation("z"), operation("a")], + }; + let right = ServicePermissions { + service_name: "A".to_string(), + transport: TransportKind::JsonRpc, + operations: vec![operation("m")], + }; + left.sort_operations(); + + let manifest = PermissionManifest::from_services([left, right]); + assert_eq!(manifest.services[0].service_name, "A"); + assert_eq!(manifest.services[1].operations[0].operation_id, "a"); + } + + #[test] + fn permission_set_builds_hash_set() { + const READ: PermissionRef = PermissionRef::new("items:read"); + const WRITE: PermissionRef = PermissionRef::new("items:write"); + + let permissions = PermissionSet::new() + .with(READ) + .with(WRITE) + .with(READ) + .into_hash_set(); + + assert_eq!(permissions.len(), 2); + assert!(permissions.contains("items:read")); + assert!(permissions.contains("items:write")); + } + + fn operation(operation_id: &str) -> OperationPermissions { + OperationPermissions { + operation_id: operation_id.to_string(), + operation_name: operation_id.to_string(), + kind: OperationKind::JsonRpcMethod, + wire: WireTarget::JsonRpc { + method: operation_id.to_string(), + }, + auth: AuthRequirementInfo::Public, + version: None, + canonical_operation_id: None, + } + } +} diff --git a/documentation/src/SUMMARY.md b/documentation/src/SUMMARY.md index 3ae8558..4eb36f7 100644 --- a/documentation/src/SUMMARY.md +++ b/documentation/src/SUMMARY.md @@ -24,5 +24,6 @@ # Integration Topics - [Generated Specs And Clients](generated-specs-and-clients.md) +- [Permission Manifests](permission-manifests.md) - [Identity And Sessions](identity-and-sessions.md) - [Observability](observability.md) diff --git a/documentation/src/auth-in-api-contract.md b/documentation/src/auth-in-api-contract.md index 8b64b34..f07ebd8 100644 --- a/documentation/src/auth-in-api-contract.md +++ b/documentation/src/auth-in-api-contract.md @@ -68,4 +68,6 @@ When OpenRPC or OpenAPI generation is enabled, protected operations include authentication metadata. REST and file services expose bearer auth security requirements in OpenAPI, and JSON-RPC methods expose `x-authentication`. Permission names are also emitted as extension metadata so explorer UIs and -client-generation workflows can show what a call requires. +client-generation workflows can show what a call requires. `x-permissions` +contains a flattened compatibility list, while `x-permission-groups` preserves +the real OR/AND grouping. diff --git a/documentation/src/generated-specs-and-clients.md b/documentation/src/generated-specs-and-clients.md index 74796e0..559ccfd 100644 --- a/documentation/src/generated-specs-and-clients.md +++ b/documentation/src/generated-specs-and-clients.md @@ -14,7 +14,8 @@ pub fn generate_userservice_openrpc_to_file() -> Result<(), std::io::Error>; ``` The document includes method names, request and response schemas, auth -extensions, permissions, and version metadata for versioned methods. +extensions, flattened `x-permissions`, grouped `x-permission-groups`, and +version metadata for versioned methods. ## OpenAPI @@ -27,16 +28,18 @@ pub fn generate_userservice_openapi_to_file() -> std::io::Result<()>; ``` REST operations include routes, HTTP methods, JSON schemas, bearer auth -requirements, and permission metadata. File-service operations also include +requirements, flattened `x-permissions`, and grouped `x-permission-groups`. +File-service operations also include multipart schemas, binary download responses, and `x-ras-file` metadata for upload limits, part policies, content types, and range support. ## Rust Clients -The shared API crate's `client` feature generates typed Rust clients. The -examples keep API definitions in separate API crates so server and browser -crates can depend on the same contract while enabling different API-crate -features. +Enabling a service macro crate's `client` feature emits typed Rust clients. +The examples keep API definitions in separate API crates and expose API-crate +`client` features that forward to the macro crates, so server and browser +crates can depend on the same contract while selecting different generated +surfaces. For browser targets, compile client crates with `--target wasm32-unknown-unknown` and enable only the API crate's client-side feature set. See: diff --git a/documentation/src/macros/bidirectional-jsonrpc-service.md b/documentation/src/macros/bidirectional-jsonrpc-service.md index 082fffc..22d4bd2 100644 --- a/documentation/src/macros/bidirectional-jsonrpc-service.md +++ b/documentation/src/macros/bidirectional-jsonrpc-service.md @@ -22,16 +22,19 @@ ras-jsonrpc-bidirectional-client = { version = "0.1.0", optional = true } [features] default = [] server = [ + "ras-jsonrpc-bidirectional-macro/server", "dep:ras-jsonrpc-bidirectional-server", ] client = [ + "ras-jsonrpc-bidirectional-macro/client", "dep:ras-jsonrpc-bidirectional-client", ] ``` -Define these features on the shared API crate. The WebSocket server depends on -that API crate with `features = ["server"]`; TUI, native, or browser clients -depend on it with `features = ["client"]`. +These API-crate features forward to the macro crate and enable the runtime +dependencies used by the generated surface. The WebSocket server depends on the +API crate with `features = ["server"]`; TUI, native, or browser clients depend +on it with `features = ["client"]`. If `server_to_client_calls` is used, the server feature also needs optional `tokio` and `uuid` dependencies because generated server-side client handles diff --git a/documentation/src/macros/file-service.md b/documentation/src/macros/file-service.md index 90b724d..c8eecd3 100644 --- a/documentation/src/macros/file-service.md +++ b/documentation/src/macros/file-service.md @@ -7,8 +7,9 @@ multipart fields, and stream bytes instead of buffering entire files. ## Dependencies And Features -Put the file service definition in a shared API crate and expose generated -transport code through API-crate features: +Put the file service definition in a shared API crate. If you want generated +transport code to stay optional, expose API-crate features that forward to the +macro crate features: ```toml [dependencies] @@ -32,6 +33,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul [features] default = [] server = [ + "ras-file-macro/server", "dep:ras-file-core", "dep:ras-auth-core", "dep:async-trait", @@ -39,11 +41,13 @@ server = [ "dep:schemars", "dep:serde_json", ] -client = ["dep:reqwest", "dep:tokio", "dep:tokio-util"] +client = ["ras-file-macro/client", "dep:reqwest", "dep:tokio", "dep:tokio-util"] ``` Server crates depend on the API crate with `features = ["server"]`. Native and browser clients depend on the same API crate with `features = ["client"]`. +Those API-crate features forward to `ras-file-macro/server` and +`ras-file-macro/client`; the macro emits only the selected generated surfaces. ## Define The Service diff --git a/documentation/src/macros/jsonrpc-service.md b/documentation/src/macros/jsonrpc-service.md index 8c7c373..9a6fd26 100644 --- a/documentation/src/macros/jsonrpc-service.md +++ b/documentation/src/macros/jsonrpc-service.md @@ -6,10 +6,10 @@ optional OpenRPC output. ## Dependencies And Features -Put the macro in the shared API definition crate and make `server` and -`client` features on that API crate. Server binaries then depend on -`my-api` with `features = ["server"]`; clients depend on the same API crate -with `features = ["client"]`. +Put the macro in the shared API definition crate. If you want server and client +outputs to stay optional, expose API-crate features that forward to the macro +crate features and enable the runtime dependencies those generated surfaces +refer to. ```toml [dependencies] @@ -27,10 +27,15 @@ reqwest = { version = "0.12", features = ["json"], optional = true } [features] default = [] -server = ["dep:ras-jsonrpc-core", "dep:axum", "dep:tokio"] -client = ["dep:reqwest"] +server = ["ras-jsonrpc-macro/server", "dep:ras-jsonrpc-core", "dep:axum", "dep:tokio"] +client = ["ras-jsonrpc-macro/client", "dep:reqwest"] ``` +Server binaries then depend on `my-api` with `features = ["server"]`; clients +depend on the same API crate with `features = ["client"]`. The generated code +itself is selected by the `ras-jsonrpc-macro` features, not by generated +consumer-crate cfg attributes. + ## Define The Service ```rust,ignore diff --git a/documentation/src/macros/rest-service.md b/documentation/src/macros/rest-service.md index 067d34f..2df3495 100644 --- a/documentation/src/macros/rest-service.md +++ b/documentation/src/macros/rest-service.md @@ -25,6 +25,7 @@ reqwest = { version = "0.12", features = ["json"], optional = true } [features] default = [] server = [ + "ras-rest-macro/server", "dep:ras-rest-core", "dep:ras-auth-core", "dep:async-trait", @@ -32,12 +33,19 @@ server = [ "dep:axum-extra", "dep:tokio", ] -client = ["dep:reqwest"] +client = ["ras-rest-macro/client", "dep:reqwest"] ``` -These features belong on the shared API definition crate. A backend depends on -that crate with `features = ["server"]`; a Rust client or WASM crate depends on -the same crate with `features = ["client"]`. +These API-crate features are forwarding gates. They enable the relevant macro +crate feature and the runtime dependencies that generated code refers to. The +macro emits server or client code only when the corresponding +`ras-rest-macro` feature is enabled; the generated code does not depend on a +consumer-crate `#[cfg(feature = "...")]` branch. + +A backend depends on the API crate with `features = ["server"]`; a Rust client +or WASM crate depends on the same crate with `features = ["client"]`. If one +crate should always expose both surfaces, enable `server` and `client` directly +on the `ras-rest-macro` dependency and make the runtime dependencies non-optional. ## Define The Service diff --git a/documentation/src/permission-manifests.md b/documentation/src/permission-manifests.md new file mode 100644 index 0000000..a255ac5 --- /dev/null +++ b/documentation/src/permission-manifests.md @@ -0,0 +1,121 @@ +# Permission Manifests + +The service macros can emit a typed permission manifest from the same auth +declarations used by the generated server. This is useful for audits, admin UI +tooling, test assertions, and token issuing code that should not repeat +permission strings by hand. + +## Enable The Feature + +Enable manifest generation on the macro crate. The generated API refers to +`ras-permission-manifest`, so add that crate beside the macro dependency: + +```toml +[dependencies] +ras-rest-macro = { version = "0.2.1", default-features = false, features = ["permissions"] } +ras-permission-manifest = "0.1.0" +``` + +For file services and JSON-RPC services, use the equivalent macro crate: + +```toml +ras-file-macro = { version = "0.1.0", default-features = false, features = ["permissions"] } +ras-jsonrpc-macro = { version = "0.2.0", default-features = false, features = ["permissions"] } +``` + +The `permissions` switch belongs to the macro crate. The macro emits the +manifest functions and constants only when that macro feature is enabled; the +generated code does not branch on a `permissions` feature in your API crate. + +If your API crate exposes optional server/client outputs, those features can +forward to the macro crate: + +```toml +[features] +server = ["ras-rest-macro/server", "dep:axum", "dep:ras-rest-core"] +client = ["ras-rest-macro/client", "dep:reqwest"] +``` + +Server build scripts then depend on the API crate feature that makes the +generated service/spec functions available: + +```toml +[build-dependencies] +workspace-api = { path = "../workspace-api", features = ["server"] } +ras-permission-manifest = "0.1.0" +``` + +## Generated API + +For a service named `UserService`, the macro emits: + +```rust,ignore +pub fn generate_userservice_permission_manifest() + -> ras_permission_manifest::ServicePermissions; + +pub mod userservice_permissions { + pub const ADMIN: ras_permission_manifest::PermissionRef; + pub const TASK_WRITE: ras_permission_manifest::PermissionRef; + + pub mod operations { + pub const POST_USERS: ras_permission_manifest::StaticPermissionRequirement; + } +} +``` + +Permission constants are generated from every permission string used by the +service. Operation constants are generated for protected operations and preserve +the same OR/AND grouping used by runtime checks. + +## Build-Time Artifact + +Aggregate service manifests explicitly from `build.rs`: + +```rust,ignore +fn main() { + let manifest = ras_permission_manifest::PermissionManifest::from_services([ + workspace_api::generate_userservice_permission_manifest(), + workspace_api::generate_documentservice_permission_manifest(), + ]); + + ras_permission_manifest::write_manifest( + "target/ras-permissions/workspace.json", + &manifest, + ) + .expect("write permission manifest"); +} +``` + +The JSON distinguishes public operations, authenticated-only operations, and +permission groups. For versioned compatibility endpoints, every callable wire +method or path appears in the manifest. + +## Token Issuing + +Use generated constants when constructing permission claims: + +```rust,ignore +use ras_permission_manifest::PermissionSet; +use workspace_api::userservice_permissions; + +let permissions = PermissionSet::new() + .with(userservice_permissions::TASK_WRITE) + .with(userservice_permissions::ADMIN) + .into_hash_set(); + +session_service.begin_session(user_id, permissions).await?; +``` + +You can also test whether a candidate token satisfies a generated operation +requirement: + +```rust,ignore +assert!( + userservice_permissions::operations::POST_USERS + .is_satisfied_by(&permissions) +); +``` + +The manifest does not replace the runtime auth model. JWT/session claims still +carry strings, but token issuing code can now import compile-checked constants +from the API contract instead of spelling those strings repeatedly. diff --git a/documentation/src/tutorial/create-the-api-crate.md b/documentation/src/tutorial/create-the-api-crate.md index 6774492..afb1269 100644 --- a/documentation/src/tutorial/create-the-api-crate.md +++ b/documentation/src/tutorial/create-the-api-crate.md @@ -5,7 +5,8 @@ connections, runtime configuration, or concrete auth logic. ## Cargo Features -Use API-crate features to select generated transport code: +Use API-crate features to forward macro-crate features and enable the runtime +dependencies referenced by generated transport code: ```toml [package] @@ -38,6 +39,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul [features] default = [] server = [ + "ras-rest-macro/server", + "ras-file-macro/server", + "ras-jsonrpc-bidirectional-macro/server", "dep:schemars", "dep:serde_json", "dep:async-trait", @@ -49,11 +53,20 @@ server = [ "dep:axum-extra", "dep:tokio", ] -client = ["dep:reqwest", "dep:tokio", "dep:tokio-util"] +client = [ + "ras-rest-macro/client", + "ras-file-macro/client", + "ras-jsonrpc-bidirectional-macro/client", + "dep:reqwest", + "dep:tokio", + "dep:tokio-util", +] ``` Server crates enable `workspace-api/server`. Rust or WASM clients enable -`workspace-api/client`. +`workspace-api/client`. The proc macro crate features decide which generated +code is emitted; the API-crate features are just a convenient way to select +those macro features from downstream crates. ## Source Layout @@ -181,4 +194,3 @@ jsonrpc_bidirectional_service!({ The API crate now describes the externally visible application boundary. The server crate can focus on persistence, auth, and business rules. - diff --git a/documentation/src/tutorial/index.md b/documentation/src/tutorial/index.md index d19b0a0..23bd93b 100644 --- a/documentation/src/tutorial/index.md +++ b/documentation/src/tutorial/index.md @@ -12,9 +12,9 @@ The example application is a small team workspace: - admins can perform wider maintenance operations. The important part is not the domain. The important part is the shape: the API -contract lives in a shared Rust crate, generated server code is enabled by the -server feature, generated client code is enabled by the client feature, and auth -requirements are declared beside the operation definitions. +contract lives in a shared Rust crate, API-crate `server` and `client` features +forward to the service macro features, and auth requirements are declared +beside the operation definitions. ## Target Architecture @@ -44,7 +44,8 @@ workspace-api = { path = "../workspace-api", default-features = false, features ``` This keeps generated transport code out of crates that do not need it, while -keeping request and response types shared. +keeping request and response types shared. The proc macro features decide what +code is emitted; the API-crate features are only the downstream selection point. ## What You Will Build @@ -63,4 +64,3 @@ attachments, and bidirectional JSON-RPC for live notifications. If your application is more command-oriented, the same structure works with [`jsonrpc_service!`](../macros/jsonrpc-service.md) instead of [`rest_service!`](../macros/rest-service.md). - diff --git a/examples/basic-jsonrpc/api/Cargo.toml b/examples/basic-jsonrpc/api/Cargo.toml index d3c1abf..c33811f 100644 --- a/examples/basic-jsonrpc/api/Cargo.toml +++ b/examples/basic-jsonrpc/api/Cargo.toml @@ -12,13 +12,14 @@ readme = "README.md" [features] default = [] -server = ["dep:axum", "dep:ras-jsonrpc-core"] -client = ["dep:reqwest"] +server = ["ras-jsonrpc-macro/server", "dep:axum", "dep:ras-jsonrpc-core"] +client = ["ras-jsonrpc-macro/client", "dep:reqwest"] [dependencies] -ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false } +ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false, features = ["permissions"] } ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2", optional = true } ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } +ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } serde = { workspace = true } serde_json = { workspace = true } schemars = { workspace = true } diff --git a/examples/basic-jsonrpc/service/Cargo.toml b/examples/basic-jsonrpc/service/Cargo.toml index d953764..dc8d306 100644 --- a/examples/basic-jsonrpc/service/Cargo.toml +++ b/examples/basic-jsonrpc/service/Cargo.toml @@ -13,7 +13,7 @@ readme = "README.md" [features] default = ["server"] server = [] -client = [] +client = ["basic-jsonrpc-api/client"] [dependencies] basic-jsonrpc-api = { path = "../api", version = "0.1.0", features = ["server"] } diff --git a/examples/bidirectional-chat/api/Cargo.toml b/examples/bidirectional-chat/api/Cargo.toml index 634b746..a873632 100644 --- a/examples/bidirectional-chat/api/Cargo.toml +++ b/examples/bidirectional-chat/api/Cargo.toml @@ -12,8 +12,17 @@ readme = "README.md" [features] default = [] -server = ["dep:ras-jsonrpc-bidirectional-server", "dep:axum"] -client = ["dep:ras-jsonrpc-bidirectional-client"] +server = [ + "ras-jsonrpc-bidirectional-macro/server", + "ras-rest-macro/server", + "dep:ras-jsonrpc-bidirectional-server", + "dep:axum", +] +client = [ + "ras-jsonrpc-bidirectional-macro/client", + "ras-rest-macro/client", + "dep:ras-jsonrpc-bidirectional-client", +] [dependencies] serde = { workspace = true } @@ -21,14 +30,15 @@ serde_json = { workspace = true } schemars = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } -ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro", version = "0.1.0", default-features = false } +ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro", version = "0.1.0", default-features = false, features = ["permissions"] } ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types", version = "0.1.0" } ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server", version = "0.1.0", optional = true } ras-jsonrpc-bidirectional-client = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client", version = "0.1.0", optional = true } +ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1" } ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } -ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1" } +ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false, features = ["permissions"] } reqwest = { workspace = true, features = ["json"] } tracing = { workspace = true } axum = { workspace = true, optional = true } diff --git a/examples/bidirectional-chat/api/src/lib.rs b/examples/bidirectional-chat/api/src/lib.rs index 7ad7537..ab6994a 100644 --- a/examples/bidirectional-chat/api/src/lib.rs +++ b/examples/bidirectional-chat/api/src/lib.rs @@ -369,3 +369,55 @@ mod tests { ); } } + +#[cfg(test)] +mod permission_manifest_tests { + use super::*; + use ras_permission_manifest::{ + AuthRequirementInfo, OperationKind, PermissionSet, TransportKind, WireTarget, + }; + + #[test] + fn generated_permission_manifest_documents_bidirectional_methods_only() { + let manifest = generate_chatservice_permission_manifest(); + + assert_eq!(manifest.service_name, "ChatService"); + assert_eq!(manifest.transport, TransportKind::JsonRpcBidirectional); + assert_eq!(manifest.operations.len(), 10); + + let kick_user = manifest + .operations + .iter() + .find(|operation| { + matches!( + &operation.wire, + WireTarget::BidirectionalJsonRpc { direction, method } + if direction == "client_to_server" && method == "kick_user" + ) + }) + .expect("kick_user operation"); + + assert_eq!(kick_user.kind, OperationKind::BidirectionalClientToServer); + assert_eq!( + kick_user.auth, + AuthRequirementInfo::Permissions { + any_of: vec![ras_permission_manifest::PermissionGroupInfo { + all_of: vec!["moderator".to_string()], + }], + } + ); + } + + #[test] + fn generated_permission_constants_can_feed_token_permissions() { + let permissions = PermissionSet::new() + .with(chatservice_permissions::MODERATOR) + .into_hash_set(); + + assert!(permissions.contains("moderator")); + assert!( + chatservice_permissions::operations::CLIENT_TO_SERVER_KICK_USER + .is_satisfied_by(&permissions) + ); + } +} diff --git a/examples/bidirectional-chat/server/Cargo.toml b/examples/bidirectional-chat/server/Cargo.toml index 248fa57..a13df33 100644 --- a/examples/bidirectional-chat/server/Cargo.toml +++ b/examples/bidirectional-chat/server/Cargo.toml @@ -47,3 +47,4 @@ axum-test = { workspace = true } [features] default = ["server"] server = [] +client = ["bidirectional-chat-api/client"] diff --git a/examples/bidirectional-chat/tui/Cargo.toml b/examples/bidirectional-chat/tui/Cargo.toml index de5dea8..aa2ddfb 100644 --- a/examples/bidirectional-chat/tui/Cargo.toml +++ b/examples/bidirectional-chat/tui/Cargo.toml @@ -41,3 +41,7 @@ chrono = { workspace = true } # Configuration dotenvy = { workspace = true } + +[features] +default = [] +server-api = ["bidirectional-chat-api/server"] diff --git a/examples/file-service-example/Cargo.toml b/examples/file-service-example/Cargo.toml index 772b879..d94a3a5 100644 --- a/examples/file-service-example/Cargo.toml +++ b/examples/file-service-example/Cargo.toml @@ -12,8 +12,8 @@ readme = "README.md" [features] default = ["server"] -server = [] -client = [] +server = ["ras-file-macro/server"] +client = ["ras-file-macro/client"] [dependencies] axum = { workspace = true } @@ -23,6 +23,7 @@ serde_json = { workspace = true } ras-file-macro = { path = "../../crates/rest/ras-file-macro", version = "0.1.0", default-features = false } ras-file-core = { path = "../../crates/rest/ras-file-core", version = "0.1.0" } ras-auth-core = { path = "../../crates/core/ras-auth-core", version = "0.1.0" } +ras-permission-manifest = { path = "../../crates/specs/ras-permission-manifest", version = "0.1.0" } thiserror = { workspace = true } async-trait = { workspace = true } tower-http = { workspace = true, features = ["fs", "trace"] } diff --git a/examples/file-service-wasm/file-service-api/Cargo.toml b/examples/file-service-wasm/file-service-api/Cargo.toml index 32cc64c..72d9672 100644 --- a/examples/file-service-wasm/file-service-api/Cargo.toml +++ b/examples/file-service-wasm/file-service-api/Cargo.toml @@ -14,9 +14,10 @@ readme = "README.md" crate-type = ["rlib"] [dependencies] -ras-file-macro = { path = "../../../crates/rest/ras-file-macro", version = "0.1.0", default-features = false } +ras-file-macro = { path = "../../../crates/rest/ras-file-macro", version = "0.1.0", default-features = false, features = ["permissions"] } ras-file-core = { path = "../../../crates/rest/ras-file-core", version = "0.1.0", optional = true } ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0", optional = true } +ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } serde = { workspace = true, features = ["derive"] } async-trait = { workspace = true, optional = true } thiserror = { workspace = true } @@ -43,6 +44,7 @@ serde_json = { workspace = true } [features] default = [] server = [ + "ras-file-macro/server", "dep:ras-file-core", "dep:ras-auth-core", "dep:async-trait", @@ -51,6 +53,7 @@ server = [ "dep:serde_json", ] client = [ + "ras-file-macro/client", "dep:reqwest", "dep:tokio", "dep:tokio-util", diff --git a/examples/file-service-wasm/file-service-api/src/lib.rs b/examples/file-service-wasm/file-service-api/src/lib.rs index bc4273c..5d888a8 100644 --- a/examples/file-service-wasm/file-service-api/src/lib.rs +++ b/examples/file-service-wasm/file-service-api/src/lib.rs @@ -181,6 +181,7 @@ mod tests { let profile_upload = &doc["paths"]["/upload_profile_picture"]["post"]; assert_eq!(profile_upload["security"][0]["bearerAuth"], json!([])); assert_eq!(profile_upload["x-permissions"], json!(["user"])); + assert_eq!(profile_upload["x-permission-groups"], json!([["user"]])); assert_eq!( profile_upload["requestBody"]["content"]["multipart/form-data"]["encoding"]["file"]["contentType"], json!("image/png, image/jpeg, image/webp") @@ -207,6 +208,7 @@ mod tests { let secure_download = &doc["paths"]["/download_secure/{file_id}"]["get"]; assert_eq!(secure_download["security"][0]["bearerAuth"], json!([])); assert_eq!(secure_download["x-permissions"], json!(["user"])); + assert_eq!(secure_download["x-permission-groups"], json!([["user"]])); } #[cfg(feature = "server")] @@ -230,3 +232,54 @@ mod tests { assert!(upload_response_schema["properties"]["size"].is_object()); } } + +#[cfg(test)] +mod permission_manifest_tests { + use super::*; + use ras_permission_manifest::{ + AuthRequirementInfo, OperationKind, PermissionSet, TransportKind, WireTarget, + }; + + #[test] + fn generated_permission_manifest_documents_file_operations() { + let manifest = generate_documentservice_permission_manifest(); + + assert_eq!(manifest.service_name, "DocumentService"); + assert_eq!(manifest.transport, TransportKind::File); + + let secure_download = manifest + .operations + .iter() + .find(|operation| { + matches!( + &operation.wire, + WireTarget::File { method, path } + if method == "GET" && path == "/api/documents/download_secure/{file_id}" + ) + }) + .expect("secure download operation"); + + assert_eq!(secure_download.kind, OperationKind::FileDownload); + assert_eq!( + secure_download.auth, + AuthRequirementInfo::Permissions { + any_of: vec![ras_permission_manifest::PermissionGroupInfo { + all_of: vec!["user".to_string()], + }], + } + ); + } + + #[test] + fn generated_permission_constants_can_feed_token_permissions() { + let permissions = PermissionSet::new() + .with(documentservice_permissions::USER) + .into_hash_set(); + + assert!(permissions.contains("user")); + assert!( + documentservice_permissions::operations::DOWNLOAD_SECURE_BY_FILE_ID + .is_satisfied_by(&permissions) + ); + } +} diff --git a/examples/file-service-wasm/file-service-backend/Cargo.toml b/examples/file-service-wasm/file-service-backend/Cargo.toml index 56857d1..f25f5ac 100644 --- a/examples/file-service-wasm/file-service-backend/Cargo.toml +++ b/examples/file-service-wasm/file-service-backend/Cargo.toml @@ -10,6 +10,10 @@ homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" +[features] +default = [] +client = ["file-service-api/client"] + [dependencies] # API crate with server feature file-service-api = { path = "../file-service-api", version = "0.1.0", features = ["server"] } @@ -40,6 +44,7 @@ dotenvy = { workspace = true } [build-dependencies] file-service-api = { path = "../file-service-api", version = "0.1.0", features = ["server"] } +ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } [dev-dependencies] tempfile = { workspace = true } diff --git a/examples/file-service-wasm/file-service-backend/build.rs b/examples/file-service-wasm/file-service-backend/build.rs index 562fadc..370214f 100644 --- a/examples/file-service-wasm/file-service-backend/build.rs +++ b/examples/file-service-wasm/file-service-backend/build.rs @@ -6,4 +6,14 @@ fn main() { Ok(_) => println!("Generated OpenAPI specification"), Err(e) => eprintln!("Failed to generate OpenAPI spec: {}", e), } + + let manifest = ras_permission_manifest::PermissionManifest::from_services([ + file_service_api::generate_documentservice_permission_manifest(), + ]); + if let Err(e) = ras_permission_manifest::write_manifest( + "target/ras-permissions/file-service-wasm.json", + &manifest, + ) { + eprintln!("Failed to generate permission manifest: {}", e); + } } diff --git a/examples/oauth2-demo/api/Cargo.toml b/examples/oauth2-demo/api/Cargo.toml index cc871cf..92c9b51 100644 --- a/examples/oauth2-demo/api/Cargo.toml +++ b/examples/oauth2-demo/api/Cargo.toml @@ -12,14 +12,15 @@ readme = "README.md" [features] default = [] -server = ["dep:axum", "dep:ras-jsonrpc-core"] -client = ["dep:reqwest"] +server = ["ras-jsonrpc-macro/server", "dep:axum", "dep:ras-jsonrpc-core"] +client = ["ras-jsonrpc-macro/client", "dep:reqwest"] [dependencies] # JSON-RPC infrastructure -ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false } +ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false, features = ["permissions"] } ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2", optional = true } ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } +ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } # Web framework and utilities axum = { workspace = true, optional = true } diff --git a/examples/oauth2-demo/api/src/lib.rs b/examples/oauth2-demo/api/src/lib.rs index 3c61120..14da006 100644 --- a/examples/oauth2-demo/api/src/lib.rs +++ b/examples/oauth2-demo/api/src/lib.rs @@ -345,6 +345,15 @@ mod tests { ("list_documents".to_string(), vec!["user:read".to_string()]), ]) ); + + let list_documents = methods + .iter() + .find(|method| method["name"] == "list_documents") + .expect("list_documents method"); + assert_eq!( + list_documents["x-permission-groups"], + json!([["user:read"]]) + ); } #[test] @@ -356,3 +365,46 @@ mod tests { assert_eq!(metadata["type"], json!("object")); } } + +#[cfg(test)] +mod permission_manifest_tests { + use super::*; + use ras_permission_manifest::{ + AuthRequirementInfo, OperationKind, PermissionSet, TransportKind, WireTarget, + }; + + #[test] + fn generated_permission_manifest_distinguishes_authenticated_only_jsonrpc() { + let manifest = generate_googleoauth2service_permission_manifest(); + + assert_eq!(manifest.service_name, "GoogleOAuth2Service"); + assert_eq!(manifest.transport, TransportKind::JsonRpc); + + let user_info = manifest + .operations + .iter() + .find(|operation| { + matches!( + &operation.wire, + WireTarget::JsonRpc { method } if method == "get_user_info" + ) + }) + .expect("get_user_info operation"); + + assert_eq!(user_info.kind, OperationKind::JsonRpcMethod); + assert_eq!(user_info.auth, AuthRequirementInfo::Authenticated); + } + + #[test] + fn generated_permission_constants_can_feed_token_permissions() { + let permissions = PermissionSet::new() + .with(googleoauth2service_permissions::USER_READ) + .into_hash_set(); + + assert!(permissions.contains("user:read")); + assert!( + googleoauth2service_permissions::operations::LIST_DOCUMENTS + .is_satisfied_by(&permissions) + ); + } +} diff --git a/examples/oauth2-demo/server/Cargo.toml b/examples/oauth2-demo/server/Cargo.toml index 9f3f71d..6c4991a 100644 --- a/examples/oauth2-demo/server/Cargo.toml +++ b/examples/oauth2-demo/server/Cargo.toml @@ -10,6 +10,10 @@ homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" +[features] +default = [] +client = ["oauth2-demo-api/client"] + [dependencies] oauth2-demo-api = { path = "../api", version = "0.1.0", features = ["server"] } # JSON-RPC infrastructure @@ -43,4 +47,5 @@ mime_guess = { workspace = true } [build-dependencies] oauth2-demo-api = { path = "../api", version = "0.1.0", features = ["server"] } +ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } serde_json = { workspace = true } diff --git a/examples/oauth2-demo/server/build.rs b/examples/oauth2-demo/server/build.rs index d337383..ade4b72 100644 --- a/examples/oauth2-demo/server/build.rs +++ b/examples/oauth2-demo/server/build.rs @@ -2,4 +2,10 @@ fn main() { println!("cargo:rerun-if-changed=../api/src/lib.rs"); oauth2_demo_api::generate_googleoauth2service_openrpc_to_file() .expect("failed to generate OAuth2 demo OpenRPC document"); + + let manifest = ras_permission_manifest::PermissionManifest::from_services([ + oauth2_demo_api::generate_googleoauth2service_permission_manifest(), + ]); + ras_permission_manifest::write_manifest("target/ras-permissions/oauth2-demo.json", &manifest) + .expect("failed to generate OAuth2 demo permission manifest"); } diff --git a/examples/rest-wasm-example/rest-api/Cargo.toml b/examples/rest-wasm-example/rest-api/Cargo.toml index eb8f7d7..8713d2f 100644 --- a/examples/rest-wasm-example/rest-api/Cargo.toml +++ b/examples/rest-wasm-example/rest-api/Cargo.toml @@ -14,8 +14,9 @@ readme = "README.md" crate-type = ["rlib"] [dependencies] -ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false } +ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false, features = ["permissions"] } ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0", optional = true } +ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1", optional = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -36,6 +37,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json"], opt [features] default = [] server = [ + "ras-rest-macro/server", "dep:ras-auth-core", "dep:ras-rest-core", "dep:async-trait", @@ -43,4 +45,4 @@ server = [ "dep:axum-extra", "dep:tokio", ] -client = ["dep:reqwest"] +client = ["ras-rest-macro/client", "dep:reqwest"] diff --git a/examples/rest-wasm-example/rest-api/src/lib.rs b/examples/rest-wasm-example/rest-api/src/lib.rs index f5d8e43..8abe204 100644 --- a/examples/rest-wasm-example/rest-api/src/lib.rs +++ b/examples/rest-wasm-example/rest-api/src/lib.rs @@ -223,6 +223,10 @@ mod tests { serde_json::json!([]) ); assert_eq!(create_user["x-permissions"], serde_json::json!(["admin"])); + assert_eq!( + create_user["x-permission-groups"], + serde_json::json!([["admin"]]) + ); let create_task = &doc["paths"]["/users/{user_id}/tasks"]["post"]; assert_eq!( @@ -230,6 +234,10 @@ mod tests { serde_json::json!([]) ); assert_eq!(create_task["x-permissions"], serde_json::json!(["user"])); + assert_eq!( + create_task["x-permission-groups"], + serde_json::json!([["user"]]) + ); assert_eq!( parameter(create_task, "user_id")["in"], serde_json::json!("path") @@ -281,3 +289,51 @@ mod tests { ); } } + +#[cfg(test)] +mod permission_manifest_tests { + use super::*; + use ras_permission_manifest::{ + AuthRequirementInfo, OperationKind, PermissionSet, TransportKind, WireTarget, + }; + + #[test] + fn generated_permission_manifest_documents_rest_auth() { + let manifest = generate_userservice_permission_manifest(); + + assert_eq!(manifest.service_name, "UserService"); + assert_eq!(manifest.transport, TransportKind::Rest); + + let create_user = manifest + .operations + .iter() + .find(|operation| { + matches!( + &operation.wire, + WireTarget::Rest { method, path } + if method == "POST" && path == "/api/v1/users" + ) + }) + .expect("create user operation"); + + assert_eq!(create_user.kind, OperationKind::RestEndpoint); + assert_eq!( + create_user.auth, + AuthRequirementInfo::Permissions { + any_of: vec![ras_permission_manifest::PermissionGroupInfo { + all_of: vec!["admin".to_string()], + }], + } + ); + } + + #[test] + fn generated_permission_constants_can_feed_token_permissions() { + let permissions = PermissionSet::new() + .with(userservice_permissions::ADMIN) + .into_hash_set(); + + assert!(permissions.contains("admin")); + assert!(userservice_permissions::operations::POST_USERS.is_satisfied_by(&permissions)); + } +} diff --git a/examples/rest-wasm-example/rest-backend/Cargo.toml b/examples/rest-wasm-example/rest-backend/Cargo.toml index 6174b4a..0903298 100644 --- a/examples/rest-wasm-example/rest-backend/Cargo.toml +++ b/examples/rest-wasm-example/rest-backend/Cargo.toml @@ -10,8 +10,13 @@ homepage = "https://github.com/JedimEmO/rust-api-stack" publish = false readme = "README.md" +[features] +default = [] +client = ["rest-api/client"] + [build-dependencies] rest-api = { path = "../rest-api", version = "0.1.0", features = ["server"] } +ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } [dependencies] rest-api = { path = "../rest-api", version = "0.1.0", features = ["server"] } diff --git a/examples/rest-wasm-example/rest-backend/build.rs b/examples/rest-wasm-example/rest-backend/build.rs index 11cd224..7ec7ac9 100644 --- a/examples/rest-wasm-example/rest-backend/build.rs +++ b/examples/rest-wasm-example/rest-backend/build.rs @@ -15,4 +15,17 @@ fn main() { // Don't fail the build, just warn } } + + let manifest = ras_permission_manifest::PermissionManifest::from_services([ + rest_api::generate_userservice_permission_manifest(), + ]); + if let Err(e) = ras_permission_manifest::write_manifest( + "target/ras-permissions/rest-wasm-example.json", + &manifest, + ) { + println!( + "cargo:warning=Failed to generate permission manifest: {}", + e + ); + } } diff --git a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml index 4875e12..acf1e66 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml +++ b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml @@ -20,8 +20,9 @@ anyhow = { workspace = true } axum = { workspace = true } ras-auth-core = { path = "../../../../crates/core/ras-auth-core", version = "0.1.0" } ras-jsonrpc-core = { path = "../../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } -ras-jsonrpc-macro = { path = "../../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0" } +ras-jsonrpc-macro = { path = "../../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false } ras-jsonrpc-types = { path = "../../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } +ras-permission-manifest = { path = "../../../../crates/specs/ras-permission-manifest", version = "0.1.0" } reqwest = { workspace = true } schemars = { workspace = true } serde = { workspace = true } diff --git a/tests/playwright/fixtures/rest-fixture/Cargo.toml b/tests/playwright/fixtures/rest-fixture/Cargo.toml index f33c269..59299e8 100644 --- a/tests/playwright/fixtures/rest-fixture/Cargo.toml +++ b/tests/playwright/fixtures/rest-fixture/Cargo.toml @@ -22,7 +22,8 @@ axum = { workspace = true } axum-extra = { workspace = true } ras-auth-core = { path = "../../../../crates/core/ras-auth-core", version = "0.1.0" } ras-rest-core = { path = "../../../../crates/rest/ras-rest-core", version = "0.1.1" } -ras-rest-macro = { path = "../../../../crates/rest/ras-rest-macro", version = "0.2.1" } +ras-rest-macro = { path = "../../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false } +ras-permission-manifest = { path = "../../../../crates/specs/ras-permission-manifest", version = "0.1.0" } reqwest = { workspace = true } schemars = { workspace = true } serde = { workspace = true }