diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa052f6..9a7a67a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -333,6 +333,27 @@ jobs: npm ci npm run build + - name: Install Playwright package + working-directory: tests/playwright + run: npm ci + + - name: Install Playwright browsers + working-directory: tests/playwright + run: npx playwright install --with-deps chromium + + - name: Run WASM UI browser tests + working-directory: tests/playwright + run: npm test -- --config wasm-ui.config.ts + + - name: Upload WASM UI test results + if: failure() + uses: actions/upload-artifact@v7.0.1 + with: + name: wasm-ui-test-results + path: tests/playwright/test-results + if-no-files-found: ignore + retention-days: 7 + coverage: name: Coverage report runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 34f2131..96a33c5 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ sketchpad/ # External security review input (not part of the published repo) SECURITY_FINDINGS.md + +# Specification emitter source modules are not generated documents. +!crates/rest/ras-rest-macro/src/openapi/ +!crates/rpc/ras-jsonrpc-macro/src/openrpc/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d37c3..155f7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed - 2026-09-05 (responsibility boundaries refactor) +- **`tokio-tungstenite` aligned with Axum** (`ras-jsonrpc-bidirectional-client`, `-server`, `-types`, `-macro`). The workspace pin moved from 0.26 to 0.29, the version Axum 0.8 already used, so the lockfile carries a single copy of `tungstenite` and `tokio-tungstenite`. No source changes were needed. +- **New `ras-api-explorer-assets` crate** (`crates/specs`). The browser API explorer that `ras-rest-macro` and `ras-jsonrpc-macro` embed now lives in its own dependency-free crate as standalone HTML, CSS and JavaScript files assembled at compile time into `TEMPLATE`, with the configuration placeholder exported as `CONFIG_PLACEHOLDER`. Previously the JSON-RPC macro read the template from the REST macro's source tree with a relative `include_str!`, which broke outside the workspace. Publish this crate before either macro. The served explorer differs from the previous one only in whitespace around the style and script wrapper tags. +- Large modules were split by responsibility across the macro, auth, identity, transport and bidirectional crates without changing public paths, generated code, or behavior; moved types are re-exported from their previous locations. Integration suites were regrouped into scenario modules under the same test targets, and the `xm_feedback_*` macro test targets were renamed to `http_service_contracts`. + ### Fixed - 2026-09-05 (audit remediation, fifth review pass) - **`ConnectionContext::subscribe` is the only subscription mutation, and it is checked** (`ras-jsonrpc-bidirectional-server`, breaking). The context now carries the service's `SubscriptionPolicy` (limits, shared accounting, manager) and `subscribe` enforces topic length and the per-connection cap under the connection's write guard, reserves a global slot atomically, and mirrors into the manager, returning `BidirectionalError` on refusal instead of `()`. `unsubscribe` releases the slot and the manager entry. The `info` field is no longer public, so there is no unchecked path: a handler subscribing from `on_connect`, `handle_request` or anywhere else is limited and counted exactly like a client-driven subscribe, and teardown releases only what was reserved, so the counter cannot underflow. The handler-level reconciliation (`sync_subscriptions`) and `WebSocketHandler::with_subscription_limits` / `with_subscription_accounting` are gone; attach the policy with `ConnectionContext::with_subscription_policy` (the service does this automatically). Regression test subscribes greedily from both `on_connect` and `handle_subscribe` and asserts exactly the cap was accepted and the counter returns to zero. diff --git a/Cargo.lock b/Cargo.lock index 854cf4f..86e38eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,7 +127,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.29.0", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -1053,7 +1053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1770,7 +1770,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2001,7 +2001,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2576,7 +2576,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2685,6 +2685,10 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ras-api-explorer-assets" +version = "0.1.0" + [[package]] name = "ras-auth-core" version = "0.3.0" @@ -2825,7 +2829,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite", "tracing", "tracing-subscriber", "url", @@ -2859,7 +2863,7 @@ dependencies = [ "syn", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite", "url", "uuid", ] @@ -2881,7 +2885,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite", "tracing", ] @@ -2898,7 +2902,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite", "tracing", "uuid", ] @@ -2929,6 +2933,7 @@ dependencies = [ "proc-macro2", "quote", "rand 0.8.6", + "ras-api-explorer-assets", "ras-auth-core", "ras-identity-session", "ras-jsonrpc-core", @@ -2956,7 +2961,7 @@ name = "ras-observability-core" version = "0.2.0" dependencies = [ "async-trait", - "axum", + "http", "ras-auth-core", "serde", "serde_json", @@ -3027,6 +3032,7 @@ dependencies = [ "proc-macro2", "quote", "rand 0.8.6", + "ras-api-explorer-assets", "ras-auth-core", "ras-identity-session", "ras-jsonrpc-core", @@ -3331,7 +3337,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3344,7 +3350,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3739,7 +3745,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3915,18 +3921,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.26.2", -] - [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -3936,7 +3930,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.29.0", + "tungstenite", ] [[package]] @@ -4117,23 +4111,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.29.0" @@ -4257,12 +4234,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4505,7 +4476,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4582,15 +4553,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 9480738..b261d8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ sha2 = "0.10" subtle = "2.6" tempfile = "3.13" thiserror = "2.0" -tokio-tungstenite = "0.26" +tokio-tungstenite = "0.29" tower-http = "0.6" tracing = "0.1" url = "2.5" diff --git a/README.md b/README.md index 4c0400f..4ee6d50 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,10 @@ crates/ │ └── ras-identity-session # JWT session management ├── observability/ # Monitoring and metrics │ └── ras-observability-otel # OpenTelemetry implementation -├── specs/ # Specification types -│ └── ras-openrpc-types # OpenRPC 1.3.2 spec types +├── specs/ # Specifications and their shared assets +│ ├── ras-api-explorer-assets # Embedded API explorer shared by the REST and JSON-RPC macros +│ ├── ras-openrpc-types # OpenRPC 1.3.2 spec types +│ └── ras-permission-manifest # Permission manifest types examples/ # Example applications ├── basic-jsonrpc/ # JSON-RPC service demo ├── bidirectional-chat/ # Real-time chat system diff --git a/crates/core/ras-auth-core/src/transport.rs b/crates/core/ras-auth-core/src/transport.rs deleted file mode 100644 index 92bb7bb..0000000 --- a/crates/core/ras-auth-core/src/transport.rs +++ /dev/null @@ -1,1305 +0,0 @@ -//! 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 subtle::ConstantTimeEq; -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"; - -/// Header names that provide no CSRF protection because a browser either sends -/// them automatically cross-origin (CORS-safelisted request headers) or -/// populates them itself (forbidden headers a page cannot control). A CSRF -/// header must be a custom header, since only a custom header forces a CORS -/// preflight that a cross-site attacker cannot satisfy. -const CSRF_UNSAFE_HEADER_NAMES: &[&str] = &[ - // CORS-safelisted request headers — sent cross-origin without a preflight. - "accept", - "accept-language", - "content-language", - "content-type", - // Browser-controlled / forbidden headers — auto-sent, not page-settable. - "cookie", - "origin", - "referer", - "host", - "user-agent", - "content-length", - "connection", - "accept-encoding", - "date", -]; - -/// 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 a single, static, process-wide value. - /// - /// **Dangerous.** A static value is not bound to a session: any attacker - /// who learns it once (from a leaked bundle, a shared client, or a single - /// captured request) can forge unsafe cookie-authenticated requests for - /// every user until the value is rotated. This disables the double-submit - /// cookie check. Prefer [`Self::default`] for browser sessions. - pub fn dangerous_static_value(mut self, expected_value: impl Into) -> Self { - self.expected_value = Some(expected_value.into()); - self.cookie_name = None; - self - } - - /// Deprecated alias for [`Self::dangerous_static_value`]. - #[deprecated( - since = "0.3.0", - note = "renamed to `dangerous_static_value`; a static CSRF value is not \ - bound to a session and is a weak CSRF defense" - )] - pub fn with_expected_value(self, expected_value: impl Into) -> Self { - self.dangerous_static_value(expected_value) - } - - /// 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. - /// - /// **Dangerous.** This mode relies entirely on the browser refusing to send - /// a custom header cross-origin without a successful CORS preflight. It is - /// only sound behind a restrictive credentialed CORS policy and is not a - /// complete CSRF defense by itself. Prefer [`Self::default`] for browser - /// sessions. - pub fn dangerous_header_presence_only(header_name: HeaderName) -> Self { - Self { - header_name, - expected_value: None, - cookie_name: None, - } - } - - /// Deprecated alias for [`Self::dangerous_header_presence_only`]. - #[deprecated( - since = "0.3.0", - note = "renamed to `dangerous_header_presence_only`; presence-only CSRF \ - depends on restrictive CORS and is a weak CSRF defense" - )] - pub fn header_presence_only(header_name: HeaderName) -> Self { - Self::dangerous_header_presence_only(header_name) - } - - /// Whether this configuration uses one of the weak, opt-in modes - /// ([`Self::dangerous_static_value`] or - /// [`Self::dangerous_header_presence_only`]) rather than the default - /// double-submit cookie check. - /// - /// Returns the mode name for logging, or `None` for the double-submit mode. - pub fn dangerous_mode(&self) -> Option<&'static str> { - match (&self.expected_value, &self.cookie_name) { - (Some(_), _) => Some("static_value"), - (None, None) => Some("header_presence_only"), - (None, Some(_)) => None, - } - } - - /// Emit a `warn!` if this CSRF config is in a weak mode. Called from the - /// [`AuthTransportConfig`] builders (once per construction) and, as a - /// fallback for struct-literal construction, once per process from - /// [`AuthTransportConfig::validate`]. - fn warn_if_dangerous(&self) { - if let Some(mode) = self.dangerous_mode() { - tracing::warn!( - csrf_mode = mode, - csrf_header = %self.header_name, - "cookie auth is configured with a weak CSRF mode \ - (`CsrfConfig::dangerous_*`); this is not a complete CSRF defense. \ - Prefer the default double-submit cookie mode for browser sessions" - ); - } - } - - /// 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> { - // A CORS-safelisted or browser-controlled header name provides zero CSRF - // protection (it is sent automatically cross-origin), so reject it — - // otherwise `dangerous_header_presence_only(HeaderName::from_static("accept"))` - // would produce a config that passes validation but never blocks a - // forged request. - let header = self.header_name.as_str(); - if CSRF_UNSAFE_HEADER_NAMES - .iter() - .any(|name| header.eq_ignore_ascii_case(name)) - { - return Err(AuthTransportError::InvalidCsrfConfig(format!( - "CSRF header `{header}` is CORS-safelisted or browser-controlled \ - and provides no protection; use a custom header name (e.g. \ - `x-csrf-token`)" - ))); - } - - 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 - && !ct_eq_str(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() || !ct_eq_str(&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. - /// - /// Cookie credentials are vulnerable to CSRF on unsafe methods, so this also - /// installs a default double-submit [`CsrfConfig`] when none is configured - /// yet. Override it with [`Self::with_csrf`] if you need a different policy; - /// there is intentionally no builder path to cookie auth without CSRF. - pub fn with_cookie(mut self, cookie: AuthCookieConfig) -> Self { - self.cookie = Some(cookie); - if self.csrf.is_none() { - self.csrf = Some(CsrfConfig::default()); - } - self.warn_if_weak_csrf(); - self - } - - /// Enable CSRF protection for cookie-authenticated unsafe requests. - /// - /// Passing a `CsrfConfig::dangerous_*` mode together with cookie auth logs - /// a `warn!` at construction time. - pub fn with_csrf(mut self, csrf: CsrfConfig) -> Self { - self.csrf = Some(csrf); - self.warn_if_weak_csrf(); - self - } - - /// Log a warning when cookie auth is paired with a weak CSRF mode. - fn warn_if_weak_csrf(&self) { - if self.cookie.is_some() - && let Some(csrf) = &self.csrf - { - csrf.warn_if_dangerous(); - } - } - - /// 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(), - )); - } - - // Cookie credentials are automatically attached by the browser, so - // cookie auth without a CSRF guard lets any cross-site request act as - // the victim on unsafe methods. `with_cookie` installs a default CSRF - // config; a struct literal that clears it must fail closed here. - if self.cookie.is_some() && self.csrf.is_none() { - return Err(AuthTransportError::InvalidAuthTransportConfig( - "cookie auth requires a CSRF configuration; use with_cookie (which sets a \ - default double-submit CsrfConfig) or with_csrf" - .to_string(), - )); - } - - if let Some(cookie) = &self.cookie { - cookie.validate()?; - } - - if let Some(csrf) = &self.csrf { - csrf.validate()?; - } - - // `validate` runs on every request, so the weak-mode warning is - // rate-limited here to once per distinct weak config per process. The - // builders (`with_cookie`, `with_csrf`) warn unconditionally at - // construction time; this is the fallback for struct-literal configs. - if self.cookie.is_some() - && let Some(csrf) = &self.csrf - && let Some(mode) = csrf.dangerous_mode() - { - static WEAK_CSRF_WARNED: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - let key = (csrf.header_name.as_str().to_string(), mode); - let mut warned = WEAK_CSRF_WARNED - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !warned.contains(&key) { - warned.push(key); - csrf.warn_if_dangerous(); - } - } - - 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]")); - } -} - -/// Constant-time string comparison for CSRF tokens. -/// -/// Length is allowed to leak (subtle short-circuits on differing lengths), but -/// equal-length values are compared without an input-dependent early return. -fn ct_eq_str(a: &str, b: &str) -> bool { - a.as_bytes().ct_eq(b.as_bytes()).into() -} - -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().dangerous_static_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 with_cookie_installs_default_csrf_and_validates() { - let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); - - assert!(config.csrf.is_some()); - assert!(config.validate().is_ok()); - } - - #[test] - fn csrf_config_rejects_cors_safelisted_header_names() { - // A safelisted / browser-controlled header name provides no CSRF - // protection and must fail validation even though it is "present". - for name in [ - "accept", - "content-type", - "Accept-Language", - "cookie", - "origin", - ] { - let csrf = CsrfConfig::dangerous_header_presence_only( - HeaderName::from_bytes(name.as_bytes()).unwrap(), - ); - let error = csrf.validate().expect_err(name); - assert!( - matches!(error, AuthTransportError::InvalidCsrfConfig(_)), - "{name} should be rejected" - ); - } - - // A genuinely custom header (forces a CORS preflight) is accepted. - let ok = - CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")); - assert!(ok.validate().is_ok()); - } - - #[test] - fn cookie_without_csrf_fails_validate() { - let config = AuthTransportConfig { - bearer: true, - cookie: Some(AuthCookieConfig::default()), - csrf: None, - }; - - let error = config.validate().unwrap_err(); - - assert!(matches!( - error, - AuthTransportError::InvalidAuthTransportConfig(_) - )); - } - - #[test] - fn with_cookie_default_still_requires_csrf_header_on_unsafe_cookie_request() { - let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); - let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); - - // No CSRF header present -> unsafe cookie request is rejected. - assert_eq!( - validate_csrf_for_credential("POST", &HeaderMap::new(), &cookie, &config).unwrap_err(), - AuthTransportError::CsrfValidationFailed - ); - - // Bearer credentials stay exempt even on unsafe methods. - let bearer = AuthCredential::new("bearer-token", AuthTokenSource::Bearer); - assert!(validate_csrf_for_credential("POST", &HeaderMap::new(), &bearer, &config).is_ok()); - - // GET cookie requests stay exempt. - assert!(validate_csrf_for_credential("GET", &HeaderMap::new(), &cookie, &config).is_ok()); - - // Valid double-submit header + cookie passes. - let headers = headers(&[ - (DEFAULT_CSRF_HEADER, "csrf-token"), - ("cookie", "__Host-ras-csrf=csrf-token"), - ]); - assert!(validate_csrf_for_credential("POST", &headers, &cookie, &config).is_ok()); - } - - #[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]") - ); - } - - /// Minimal `tracing` subscriber that records the messages of `WARN` events. - /// Kept dependency-free (no `tracing-subscriber`) since it only needs to - /// capture a handful of events for the A1 regression tests. - struct WarnCapture(std::sync::Mutex>); - - impl tracing::Subscriber for WarnCapture { - fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { - *metadata.level() <= tracing::Level::WARN - } - fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { - tracing::span::Id::from_u64(1) - } - fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} - fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} - fn event(&self, event: &tracing::Event<'_>) { - struct Msg(String); - impl tracing::field::Visit for Msg { - fn record_debug( - &mut self, - field: &tracing::field::Field, - value: &dyn std::fmt::Debug, - ) { - self.0.push_str(&format!("{}={:?} ", field.name(), value)); - } - } - let mut msg = Msg(String::new()); - event.record(&mut msg); - self.0.lock().unwrap().push(msg.0); - } - fn enter(&self, _: &tracing::span::Id) {} - fn exit(&self, _: &tracing::span::Id) {} - } - - fn capture_warnings(f: impl FnOnce()) -> Vec { - let capture = std::sync::Arc::new(WarnCapture(std::sync::Mutex::new(Vec::new()))); - tracing::subscriber::with_default(capture.clone(), f); - capture.0.lock().unwrap().clone() - } - - #[test] - fn a1_dangerous_modes_are_reported_and_deprecated_aliases_still_work() { - let presence = - CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")); - assert_eq!(presence.dangerous_mode(), Some("header_presence_only")); - - let static_value = CsrfConfig::default().dangerous_static_value("shared-secret"); - assert_eq!(static_value.dangerous_mode(), Some("static_value")); - - assert_eq!(CsrfConfig::default().dangerous_mode(), None); - assert_eq!( - CsrfConfig::default() - .with_cookie_name("__Host-other") - .dangerous_mode(), - None - ); - - // The deprecated names remain as thin wrappers for one release. - #[allow(deprecated)] - let legacy_presence = - CsrfConfig::header_presence_only(HeaderName::from_static("x-csrf-token")); - assert_eq!(legacy_presence, presence); - #[allow(deprecated)] - let legacy_static = CsrfConfig::default().with_expected_value("shared-secret"); - assert_eq!(legacy_static, static_value); - } - - #[test] - fn a1_cookie_auth_with_weak_csrf_mode_warns_at_construction() { - let warnings = capture_warnings(|| { - let _ = AuthTransportConfig::default() - .with_cookie(AuthCookieConfig::default()) - .with_csrf(CsrfConfig::dangerous_header_presence_only( - HeaderName::from_static("x-csrf-token"), - )); - }); - assert_eq!(warnings.len(), 1, "{warnings:?}"); - assert!(warnings[0].contains("csrf_mode=\"header_presence_only\"")); - assert!(warnings[0].contains("weak CSRF mode")); - - // Ordering does not matter: csrf first, then cookie. - let warnings = capture_warnings(|| { - let _ = AuthTransportConfig::default() - .with_csrf(CsrfConfig::default().dangerous_static_value("shared-secret")) - .with_cookie(AuthCookieConfig::default()); - }); - assert_eq!(warnings.len(), 1, "{warnings:?}"); - assert!(warnings[0].contains("csrf_mode=\"static_value\"")); - - // Weak CSRF without cookie auth is irrelevant (bearer-only) — no warning. - let warnings = capture_warnings(|| { - let _ = AuthTransportConfig::default().with_csrf( - CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")), - ); - }); - assert!(warnings.is_empty(), "{warnings:?}"); - - // The default double-submit mode never warns. - let warnings = capture_warnings(|| { - let _ = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); - }); - assert!(warnings.is_empty(), "{warnings:?}"); - } - - #[test] - fn a1_struct_literal_weak_csrf_warns_from_validate_once() { - // Struct-literal construction bypasses the builders, so `validate` - // warns as a fallback — but only once per distinct weak config per - // process, since it runs on every request. A header name unique to - // this test keeps it independent of test ordering. - let config = AuthTransportConfig { - bearer: true, - cookie: Some(AuthCookieConfig::default()), - csrf: Some(CsrfConfig::dangerous_header_presence_only( - HeaderName::from_static("x-a1-struct-literal-csrf"), - )), - }; - let warnings = capture_warnings(|| { - config.validate().unwrap(); - config.validate().unwrap(); - config.validate().unwrap(); - }); - assert_eq!(warnings.len(), 1, "{warnings:?}"); - assert!(warnings[0].contains("csrf_mode=\"header_presence_only\"")); - } -} diff --git a/crates/core/ras-auth-core/src/transport/cookie.rs b/crates/core/ras-auth-core/src/transport/cookie.rs new file mode 100644 index 0000000..5f7cc4d --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/cookie.rs @@ -0,0 +1,279 @@ +use super::AuthTransportError; +use ::cookie::{ + Cookie, SameSite, + time::{Duration, OffsetDateTime}, +}; +use http::{ + HeaderMap, HeaderValue, + header::{COOKIE, HeaderName, SET_COOKIE}, +}; +const DEFAULT_COOKIE_NAME: &str = "__Host-ras-session"; + +/// 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())) +} + +/// Header name used by cookie helper return values. +pub fn set_cookie_header_name() -> HeaderName { + SET_COOKIE +} + +pub(super) 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) +} diff --git a/crates/core/ras-auth-core/src/transport/credential.rs b/crates/core/ras-auth-core/src/transport/credential.rs new file mode 100644 index 0000000..4d53f43 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/credential.rs @@ -0,0 +1,72 @@ +use super::cookie::extract_cookie; +use super::{AuthTransportConfig, AuthTransportError}; +use http::{HeaderMap, header::AUTHORIZATION}; + +/// 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 + } +} + +/// 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) +} diff --git a/crates/core/ras-auth-core/src/transport/csrf.rs b/crates/core/ras-auth-core/src/transport/csrf.rs new file mode 100644 index 0000000..d5d92ef --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/csrf.rs @@ -0,0 +1,289 @@ +use super::cookie::extract_cookie; +use super::{ + AuthCookieConfig, AuthCredential, AuthTokenSource, AuthTransportConfig, AuthTransportError, +}; +use http::{HeaderMap, HeaderValue, header::HeaderName}; +use subtle::ConstantTimeEq; + +const DEFAULT_CSRF_COOKIE_NAME: &str = "__Host-ras-csrf"; +pub(super) const DEFAULT_CSRF_HEADER: &str = "x-ras-csrf"; + +/// Header names that provide no CSRF protection because a browser either sends +/// them automatically cross-origin (CORS-safelisted request headers) or +/// populates them itself (forbidden headers a page cannot control). A CSRF +/// header must be a custom header, since only a custom header forces a CORS +/// preflight that a cross-site attacker cannot satisfy. +const CSRF_UNSAFE_HEADER_NAMES: &[&str] = &[ + // CORS-safelisted request headers — sent cross-origin without a preflight. + "accept", + "accept-language", + "content-language", + "content-type", + // Browser-controlled / forbidden headers — auto-sent, not page-settable. + "cookie", + "origin", + "referer", + "host", + "user-agent", + "content-length", + "connection", + "accept-encoding", + "date", +]; + +/// 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 a single, static, process-wide value. + /// + /// **Dangerous.** A static value is not bound to a session: any attacker + /// who learns it once (from a leaked bundle, a shared client, or a single + /// captured request) can forge unsafe cookie-authenticated requests for + /// every user until the value is rotated. This disables the double-submit + /// cookie check. Prefer [`Self::default`] for browser sessions. + pub fn dangerous_static_value(mut self, expected_value: impl Into) -> Self { + self.expected_value = Some(expected_value.into()); + self.cookie_name = None; + self + } + + /// Deprecated alias for [`Self::dangerous_static_value`]. + #[deprecated( + since = "0.3.0", + note = "renamed to `dangerous_static_value`; a static CSRF value is not \ + bound to a session and is a weak CSRF defense" + )] + pub fn with_expected_value(self, expected_value: impl Into) -> Self { + self.dangerous_static_value(expected_value) + } + + /// 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. + /// + /// **Dangerous.** This mode relies entirely on the browser refusing to send + /// a custom header cross-origin without a successful CORS preflight. It is + /// only sound behind a restrictive credentialed CORS policy and is not a + /// complete CSRF defense by itself. Prefer [`Self::default`] for browser + /// sessions. + pub fn dangerous_header_presence_only(header_name: HeaderName) -> Self { + Self { + header_name, + expected_value: None, + cookie_name: None, + } + } + + /// Deprecated alias for [`Self::dangerous_header_presence_only`]. + #[deprecated( + since = "0.3.0", + note = "renamed to `dangerous_header_presence_only`; presence-only CSRF \ + depends on restrictive CORS and is a weak CSRF defense" + )] + pub fn header_presence_only(header_name: HeaderName) -> Self { + Self::dangerous_header_presence_only(header_name) + } + + /// Whether this configuration uses one of the weak, opt-in modes + /// ([`Self::dangerous_static_value`] or + /// [`Self::dangerous_header_presence_only`]) rather than the default + /// double-submit cookie check. + /// + /// Returns the mode name for logging, or `None` for the double-submit mode. + pub fn dangerous_mode(&self) -> Option<&'static str> { + match (&self.expected_value, &self.cookie_name) { + (Some(_), _) => Some("static_value"), + (None, None) => Some("header_presence_only"), + (None, Some(_)) => None, + } + } + + /// Emit a `warn!` if this CSRF config is in a weak mode. Called from the + /// [`AuthTransportConfig`] builders (once per construction) and, as a + /// fallback for struct-literal construction, once per process from + /// [`AuthTransportConfig::validate`]. + pub(super) fn warn_if_dangerous(&self) { + if let Some(mode) = self.dangerous_mode() { + tracing::warn!( + csrf_mode = mode, + csrf_header = %self.header_name, + "cookie auth is configured with a weak CSRF mode \ + (`CsrfConfig::dangerous_*`); this is not a complete CSRF defense. \ + Prefer the default double-submit cookie mode for browser sessions" + ); + } + } + + /// 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> { + // A CORS-safelisted or browser-controlled header name provides zero CSRF + // protection (it is sent automatically cross-origin), so reject it — + // otherwise `dangerous_header_presence_only(HeaderName::from_static("accept"))` + // would produce a config that passes validation but never blocks a + // forged request. + let header = self.header_name.as_str(); + if CSRF_UNSAFE_HEADER_NAMES + .iter() + .any(|name| header.eq_ignore_ascii_case(name)) + { + return Err(AuthTransportError::InvalidCsrfConfig(format!( + "CSRF header `{header}` is CORS-safelisted or browser-controlled \ + and provides no protection; use a custom header name (e.g. \ + `x-csrf-token`)" + ))); + } + + 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 + && !ct_eq_str(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() || !ct_eq_str(&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) + } +} + +/// 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(()), + } +} + +/// Constant-time string comparison for CSRF tokens. +/// +/// Length is allowed to leak (subtle short-circuits on differing lengths), but +/// equal-length values are compared without an input-dependent early return. +fn ct_eq_str(a: &str, b: &str) -> bool { + a.as_bytes().ct_eq(b.as_bytes()).into() +} + +fn is_unsafe_method(method: &str) -> bool { + matches!( + method.to_ascii_uppercase().as_str(), + "POST" | "PUT" | "PATCH" | "DELETE" + ) +} diff --git a/crates/core/ras-auth-core/src/transport/mod.rs b/crates/core/ras-auth-core/src/transport/mod.rs new file mode 100644 index 0000000..79142f1 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/mod.rs @@ -0,0 +1,164 @@ +//! HTTP credential transport helpers for bearer and cookie-based sessions. +mod cookie; +mod credential; +mod csrf; +mod redaction; + +pub use cookie::{AuthCookieConfig, CookieSameSite, set_cookie_header_name}; +pub use credential::{AuthCredential, AuthTokenSource, extract_auth_credential}; +pub use csrf::{CsrfConfig, validate_csrf_for_credential}; +pub use redaction::{redact_sensitive_headers, redact_sensitive_headers_for_auth_transport}; +use thiserror::Error; + +/// 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), +} + +/// 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. + /// + /// Cookie credentials are vulnerable to CSRF on unsafe methods, so this also + /// installs a default double-submit [`CsrfConfig`] when none is configured + /// yet. Override it with [`Self::with_csrf`] if you need a different policy; + /// there is intentionally no builder path to cookie auth without CSRF. + pub fn with_cookie(mut self, cookie: AuthCookieConfig) -> Self { + self.cookie = Some(cookie); + if self.csrf.is_none() { + self.csrf = Some(CsrfConfig::default()); + } + self.warn_if_weak_csrf(); + self + } + + /// Enable CSRF protection for cookie-authenticated unsafe requests. + /// + /// Passing a `CsrfConfig::dangerous_*` mode together with cookie auth logs + /// a `warn!` at construction time. + pub fn with_csrf(mut self, csrf: CsrfConfig) -> Self { + self.csrf = Some(csrf); + self.warn_if_weak_csrf(); + self + } + + /// Log a warning when cookie auth is paired with a weak CSRF mode. + fn warn_if_weak_csrf(&self) { + if self.cookie.is_some() + && let Some(csrf) = &self.csrf + { + csrf.warn_if_dangerous(); + } + } + + /// 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(), + )); + } + + // Cookie credentials are automatically attached by the browser, so + // cookie auth without a CSRF guard lets any cross-site request act as + // the victim on unsafe methods. `with_cookie` installs a default CSRF + // config; a struct literal that clears it must fail closed here. + if self.cookie.is_some() && self.csrf.is_none() { + return Err(AuthTransportError::InvalidAuthTransportConfig( + "cookie auth requires a CSRF configuration; use with_cookie (which sets a \ + default double-submit CsrfConfig) or with_csrf" + .to_string(), + )); + } + + if let Some(cookie) = &self.cookie { + cookie.validate()?; + } + + if let Some(csrf) = &self.csrf { + csrf.validate()?; + } + + // `validate` runs on every request, so the weak-mode warning is + // rate-limited here to once per distinct weak config per process. The + // builders (`with_cookie`, `with_csrf`) warn unconditionally at + // construction time; this is the fallback for struct-literal configs. + if self.cookie.is_some() + && let Some(csrf) = &self.csrf + && let Some(mode) = csrf.dangerous_mode() + { + static WEAK_CSRF_WARNED: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + let key = (csrf.header_name.as_str().to_string(), mode); + let mut warned = WEAK_CSRF_WARNED + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !warned.contains(&key) { + warned.push(key); + csrf.warn_if_dangerous(); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/ras-auth-core/src/transport/redaction.rs b/crates/core/ras-auth-core/src/transport/redaction.rs new file mode 100644 index 0000000..709958b --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/redaction.rs @@ -0,0 +1,52 @@ +use super::AuthTransportConfig; +use super::csrf::DEFAULT_CSRF_HEADER; +use http::{ + HeaderMap, HeaderValue, + header::{AUTHORIZATION, COOKIE, 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]")); + } +} diff --git a/crates/core/ras-auth-core/src/transport/tests/config.rs b/crates/core/ras-auth-core/src/transport/tests/config.rs new file mode 100644 index 0000000..c585b08 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/config.rs @@ -0,0 +1,148 @@ +use super::*; + +#[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 with_cookie_installs_default_csrf_and_validates() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + + assert!(config.csrf.is_some()); + assert!(config.validate().is_ok()); +} + +#[test] +fn cookie_without_csrf_fails_validate() { + let config = AuthTransportConfig { + bearer: true, + cookie: Some(AuthCookieConfig::default()), + csrf: None, + }; + + let error = config.validate().unwrap_err(); + + assert!(matches!( + error, + AuthTransportError::InvalidAuthTransportConfig(_) + )); +} + +#[test] +fn with_cookie_default_still_requires_csrf_header_on_unsafe_cookie_request() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); + + // No CSRF header present -> unsafe cookie request is rejected. + assert_eq!( + validate_csrf_for_credential("POST", &HeaderMap::new(), &cookie, &config).unwrap_err(), + AuthTransportError::CsrfValidationFailed + ); + + // Bearer credentials stay exempt even on unsafe methods. + let bearer = AuthCredential::new("bearer-token", AuthTokenSource::Bearer); + assert!(validate_csrf_for_credential("POST", &HeaderMap::new(), &bearer, &config).is_ok()); + + // GET cookie requests stay exempt. + assert!(validate_csrf_for_credential("GET", &HeaderMap::new(), &cookie, &config).is_ok()); + + // Valid double-submit header + cookie passes. + let headers = headers(&[ + (DEFAULT_CSRF_HEADER, "csrf-token"), + ("cookie", "__Host-ras-csrf=csrf-token"), + ]); + assert!(validate_csrf_for_credential("POST", &headers, &cookie, &config).is_ok()); +} + +#[test] +fn a1_dangerous_modes_are_reported_and_deprecated_aliases_still_work() { + let presence = + CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")); + assert_eq!(presence.dangerous_mode(), Some("header_presence_only")); + + let static_value = CsrfConfig::default().dangerous_static_value("shared-secret"); + assert_eq!(static_value.dangerous_mode(), Some("static_value")); + + assert_eq!(CsrfConfig::default().dangerous_mode(), None); + assert_eq!( + CsrfConfig::default() + .with_cookie_name("__Host-other") + .dangerous_mode(), + None + ); + + // The deprecated names remain as thin wrappers for one release. + #[allow(deprecated)] + let legacy_presence = CsrfConfig::header_presence_only(HeaderName::from_static("x-csrf-token")); + assert_eq!(legacy_presence, presence); + #[allow(deprecated)] + let legacy_static = CsrfConfig::default().with_expected_value("shared-secret"); + assert_eq!(legacy_static, static_value); +} + +#[test] +fn a1_cookie_auth_with_weak_csrf_mode_warns_at_construction() { + let warnings = capture_warnings(|| { + let _ = AuthTransportConfig::default() + .with_cookie(AuthCookieConfig::default()) + .with_csrf(CsrfConfig::dangerous_header_presence_only( + HeaderName::from_static("x-csrf-token"), + )); + }); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("csrf_mode=\"header_presence_only\"")); + assert!(warnings[0].contains("weak CSRF mode")); + + // Ordering does not matter: csrf first, then cookie. + let warnings = capture_warnings(|| { + let _ = AuthTransportConfig::default() + .with_csrf(CsrfConfig::default().dangerous_static_value("shared-secret")) + .with_cookie(AuthCookieConfig::default()); + }); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("csrf_mode=\"static_value\"")); + + // Weak CSRF without cookie auth is irrelevant (bearer-only) — no warning. + let warnings = capture_warnings(|| { + let _ = AuthTransportConfig::default().with_csrf( + CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")), + ); + }); + assert!(warnings.is_empty(), "{warnings:?}"); + + // The default double-submit mode never warns. + let warnings = capture_warnings(|| { + let _ = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + }); + assert!(warnings.is_empty(), "{warnings:?}"); +} + +#[test] +fn a1_struct_literal_weak_csrf_warns_from_validate_once() { + // Struct-literal construction bypasses the builders, so `validate` + // warns as a fallback — but only once per distinct weak config per + // process, since it runs on every request. A header name unique to + // this test keeps it independent of test ordering. + let config = AuthTransportConfig { + bearer: true, + cookie: Some(AuthCookieConfig::default()), + csrf: Some(CsrfConfig::dangerous_header_presence_only( + HeaderName::from_static("x-a1-struct-literal-csrf"), + )), + }; + let warnings = capture_warnings(|| { + config.validate().unwrap(); + config.validate().unwrap(); + config.validate().unwrap(); + }); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("csrf_mode=\"header_presence_only\"")); +} diff --git a/crates/core/ras-auth-core/src/transport/tests/cookie.rs b/crates/core/ras-auth-core/src/transport/tests/cookie.rs new file mode 100644 index 0000000..0b6bd5c --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/cookie.rs @@ -0,0 +1,74 @@ +use super::*; + +#[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 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=/")); +} diff --git a/crates/core/ras-auth-core/src/transport/tests/credential.rs b/crates/core/ras-auth-core/src/transport/tests/credential.rs new file mode 100644 index 0000000..120e509 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/credential.rs @@ -0,0 +1,74 @@ +use super::*; + +#[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(_))); +} diff --git a/crates/core/ras-auth-core/src/transport/tests/csrf.rs b/crates/core/ras-auth-core/src/transport/tests/csrf.rs new file mode 100644 index 0000000..34285b2 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/csrf.rs @@ -0,0 +1,83 @@ +use super::*; + +#[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().dangerous_static_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 csrf_config_rejects_cors_safelisted_header_names() { + // A safelisted / browser-controlled header name provides no CSRF + // protection and must fail validation even though it is "present". + for name in [ + "accept", + "content-type", + "Accept-Language", + "cookie", + "origin", + ] { + let csrf = CsrfConfig::dangerous_header_presence_only( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + ); + let error = csrf.validate().expect_err(name); + assert!( + matches!(error, AuthTransportError::InvalidCsrfConfig(_)), + "{name} should be rejected" + ); + } + + // A genuinely custom header (forces a CORS preflight) is accepted. + let ok = CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")); + assert!(ok.validate().is_ok()); +} diff --git a/crates/core/ras-auth-core/src/transport/tests/mod.rs b/crates/core/ras-auth-core/src/transport/tests/mod.rs new file mode 100644 index 0000000..e886913 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/mod.rs @@ -0,0 +1,55 @@ +use super::csrf::DEFAULT_CSRF_HEADER; +use super::*; +use http::{HeaderMap, HeaderValue, header::HeaderName}; + +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 +} + +/// Minimal `tracing` subscriber that records the messages of `WARN` events. +/// Kept dependency-free (no `tracing-subscriber`) since it only needs to +/// capture a handful of events for the A1 regression tests. +struct WarnCapture(std::sync::Mutex>); + +impl tracing::Subscriber for WarnCapture { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + *metadata.level() <= tracing::Level::WARN + } + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + struct Msg(String); + impl tracing::field::Visit for Msg { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.push_str(&format!("{}={:?} ", field.name(), value)); + } + } + let mut msg = Msg(String::new()); + event.record(&mut msg); + self.0.lock().unwrap().push(msg.0); + } + fn enter(&self, _: &tracing::span::Id) {} + fn exit(&self, _: &tracing::span::Id) {} +} + +fn capture_warnings(f: impl FnOnce()) -> Vec { + let capture = std::sync::Arc::new(WarnCapture(std::sync::Mutex::new(Vec::new()))); + tracing::subscriber::with_default(capture.clone(), f); + capture.0.lock().unwrap().clone() +} + +mod config; +mod cookie; +mod credential; +mod csrf; +mod redaction; diff --git a/crates/core/ras-auth-core/src/transport/tests/redaction.rs b/crates/core/ras-auth-core/src/transport/tests/redaction.rs new file mode 100644 index 0000000..d7a82cc --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/redaction.rs @@ -0,0 +1,41 @@ +use super::*; + +#[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/core/ras-observability-core/Cargo.toml b/crates/core/ras-observability-core/Cargo.toml index e4d241b..dd46338 100644 --- a/crates/core/ras-observability-core/Cargo.toml +++ b/crates/core/ras-observability-core/Cargo.toml @@ -13,7 +13,7 @@ readme = "README.md" ras-auth-core = { path = "../ras-auth-core", version = "0.3.0" } async-trait = { workspace = true } serde = { workspace = true } -axum = { workspace = true } +http = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full", "macros", "rt-multi-thread"] } diff --git a/crates/core/ras-observability-core/src/lib.rs b/crates/core/ras-observability-core/src/lib.rs index bb34049..cdb1dbe 100644 --- a/crates/core/ras-observability-core/src/lib.rs +++ b/crates/core/ras-observability-core/src/lib.rs @@ -4,7 +4,7 @@ //! usage tracking, and observability across REST and JSON-RPC services. use async_trait::async_trait; -use axum::http::HeaderMap; +use http::HeaderMap; use ras_auth_core::AuthenticatedUser; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/crates/core/ras-transport-core/src/lib.rs b/crates/core/ras-transport-core/src/lib.rs index cbe8aa6..0f6abe9 100644 --- a/crates/core/ras-transport-core/src/lib.rs +++ b/crates/core/ras-transport-core/src/lib.rs @@ -17,7 +17,6 @@ use std::pin::Pin; use bytes::Bytes; use futures_core::Stream; -use serde::Serialize; use serde::de::DeserializeOwned; pub mod error; @@ -104,338 +103,13 @@ pub trait HttpTransport: TransportThreadBounds { -> Result; } -/// Convert a query value into decoded `(key, value)` pairs for form encoding. -/// -/// Sequences produce repeated keys, enum variants honor `#[serde(rename)]`, -/// and `Option::None` produces no pairs. Encode with [`serialize_query_pairs`]. -pub fn serialize_query_value( - key: &str, - value: &T, -) -> Result, TransportError> { - let mut collector = QueryValueCollector { values: Vec::new() }; - value - .serialize(&mut collector) - .map_err(|e| TransportError::Serialize(serde::ser::Error::custom(e.to_string())))?; - Ok(collector - .values - .into_iter() - .map(|v| (key.to_string(), v)) - .collect()) -} - -/// Serialize several `(key, value)` query parameters and join them into a -/// single query string (without a leading `?`). Empty result if no pairs. -/// -/// Uses `application/x-www-form-urlencoded` encoding: `*` stays raw, `~` -/// becomes `%7E`, and space becomes `+`. Pair order, including repeated keys, -/// is preserved. -/// -/// Returns [`TransportError::Serialize`] on encoding failure rather than -/// silently yielding an empty string — generated clients append the result -/// after a `?`/`&` separator, so a swallowed failure would send a different -/// (unfiltered) query than the caller asked for. -pub fn serialize_query_pairs(pairs: &[(String, String)]) -> Result { - serde_urlencoded::to_string(pairs) - .map_err(|e| TransportError::Serialize(serde::ser::Error::custom(e.to_string()))) -} +mod path; +mod query; +pub use path::encode_path_segment; +pub use query::{serialize_query_pairs, serialize_query_value}; /// Deserialize JSON bytes into `T`, mapping failures to /// [`TransportError::Deserialize`]. pub fn deserialize_json(bytes: &[u8]) -> Result { serde_json::from_slice(bytes).map_err(TransportError::Deserialize) } - -/// Percent-encode a value for safe interpolation into a single URL **path -/// segment**. -/// -/// Generated clients substitute path parameters into a URL template by string -/// replacement (`/items/{id}` -> `/items/`). Without encoding, a value -/// containing `/`, `?`, `#`, `%`, or control bytes could break out of its -/// segment and alter the request's path, query, or fragment (e.g. an `id` of -/// `../admin` or `x?role=admin`). Only RFC 3986 `unreserved` characters -/// (`ALPHA`/`DIGIT`/`-`/`.`/`_`/`~`) pass through unescaped; every other byte is -/// `%XX`-encoded. The result is what servers (e.g. axum's `Path` extractor) -/// percent-decode back to the original value. -pub fn encode_path_segment(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - for &b in value.as_bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { - out.push(b as char); - } - _ => { - out.push('%'); - out.push(hex_digit(b >> 4)); - out.push(hex_digit(b & 0x0f)); - } - } - } - out -} - -/// Upper-case hex digit for a nibble (0..=15). -fn hex_digit(nibble: u8) -> char { - match nibble { - 0..=9 => (b'0' + nibble) as char, - _ => (b'A' + (nibble - 10)) as char, - } -} - -/// Preserve form-serializer scalar formatting and enum renames while returning -/// a decoded value for [`serialize_query_pairs`]. -fn encode_scalar(value: &T) -> Result { - use serde::ser::Error as _; - // serde_urlencoded serializes a sequence of (key, value) tuples. - let encoded = serde_urlencoded::to_string([("v", value)]) - .map_err(|e| serde_json::Error::custom(e.to_string()))?; - // `encoded` looks like "v="; strip the "v=" prefix and decode. - let raw = encoded.strip_prefix("v=").unwrap_or(&encoded); - Ok(percent_decode(raw)) -} - -/// A serde `Serializer` that collects scalar query values, expanding sequences -/// into multiple values and treating `Option::None`/unit as empty. -struct QueryValueCollector { - values: Vec, -} - -type QueryResult = Result<(), serde_json::Error>; - -macro_rules! collect_scalar { - ($method:ident, $ty:ty) => { - fn $method(self, v: $ty) -> QueryResult { - self.values.push(encode_scalar(&v)?); - Ok(()) - } - }; -} - -impl serde::Serializer for &mut QueryValueCollector { - type Ok = (); - type Error = serde_json::Error; - type SerializeSeq = Self; - type SerializeTuple = Self; - type SerializeTupleStruct = Self; - type SerializeTupleVariant = serde::ser::Impossible<(), serde_json::Error>; - type SerializeMap = serde::ser::Impossible<(), serde_json::Error>; - type SerializeStruct = serde::ser::Impossible<(), serde_json::Error>; - type SerializeStructVariant = serde::ser::Impossible<(), serde_json::Error>; - - collect_scalar!(serialize_bool, bool); - collect_scalar!(serialize_i8, i8); - collect_scalar!(serialize_i16, i16); - collect_scalar!(serialize_i32, i32); - collect_scalar!(serialize_i64, i64); - collect_scalar!(serialize_u8, u8); - collect_scalar!(serialize_u16, u16); - collect_scalar!(serialize_u32, u32); - collect_scalar!(serialize_u64, u64); - collect_scalar!(serialize_f32, f32); - collect_scalar!(serialize_f64, f64); - collect_scalar!(serialize_char, char); - - fn serialize_str(self, v: &str) -> QueryResult { - self.values.push(v.to_string()); - Ok(()) - } - - fn serialize_bytes(self, _v: &[u8]) -> QueryResult { - use serde::ser::Error as _; - Err(serde_json::Error::custom( - "bytes are not a valid query value", - )) - } - - fn serialize_none(self) -> QueryResult { - Ok(()) - } - - fn serialize_some(self, value: &T) -> QueryResult - where - T: ?Sized + Serialize, - { - value.serialize(self) - } - - fn serialize_unit(self) -> QueryResult { - Ok(()) - } - - fn serialize_unit_struct(self, _name: &'static str) -> QueryResult { - Ok(()) - } - - fn serialize_unit_variant( - self, - _name: &'static str, - _variant_index: u32, - variant: &'static str, - ) -> QueryResult { - // Honor `#[serde(rename = ...)]`: `variant` is already the renamed form. - self.values.push(variant.to_string()); - Ok(()) - } - - fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> QueryResult - where - T: ?Sized + Serialize, - { - value.serialize(self) - } - - fn serialize_newtype_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - value: &T, - ) -> QueryResult - where - T: ?Sized + Serialize, - { - value.serialize(self) - } - - fn serialize_seq(self, _len: Option) -> Result { - Ok(self) - } - - fn serialize_tuple(self, _len: usize) -> Result { - Ok(self) - } - - fn serialize_tuple_struct( - self, - _name: &'static str, - _len: usize, - ) -> Result { - Ok(self) - } - - fn serialize_tuple_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - _len: usize, - ) -> Result { - use serde::ser::Error as _; - Err(serde_json::Error::custom( - "tuple variants are not valid query values", - )) - } - - fn serialize_map(self, _len: Option) -> Result { - use serde::ser::Error as _; - Err(serde_json::Error::custom("maps are not valid query values")) - } - - fn serialize_struct( - self, - _name: &'static str, - _len: usize, - ) -> Result { - use serde::ser::Error as _; - Err(serde_json::Error::custom( - "structs are not valid query values", - )) - } - - fn serialize_struct_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - _len: usize, - ) -> Result { - use serde::ser::Error as _; - Err(serde_json::Error::custom( - "struct variants are not valid query values", - )) - } -} - -impl serde::ser::SerializeSeq for &mut QueryValueCollector { - type Ok = (); - type Error = serde_json::Error; - fn serialize_element(&mut self, value: &T) -> QueryResult - where - T: ?Sized + Serialize, - { - value.serialize(&mut **self) - } - fn end(self) -> QueryResult { - Ok(()) - } -} - -impl serde::ser::SerializeTuple for &mut QueryValueCollector { - type Ok = (); - type Error = serde_json::Error; - fn serialize_element(&mut self, value: &T) -> QueryResult - where - T: ?Sized + Serialize, - { - value.serialize(&mut **self) - } - fn end(self) -> QueryResult { - Ok(()) - } -} - -impl serde::ser::SerializeTupleStruct for &mut QueryValueCollector { - type Ok = (); - type Error = serde_json::Error; - fn serialize_field(&mut self, value: &T) -> QueryResult - where - T: ?Sized + Serialize, - { - value.serialize(&mut **self) - } - fn end(self) -> QueryResult { - Ok(()) - } -} - -/// Decode `application/x-www-form-urlencoded` text (`+` -> space, `%XX`). -fn percent_decode(s: &str) -> String { - let bytes = s.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'+' => { - out.push(b' '); - i += 1; - } - b'%' if i + 2 < bytes.len() => { - let hi = hex_val(bytes[i + 1]); - let lo = hex_val(bytes[i + 2]); - match (hi, lo) { - (Some(h), Some(l)) => { - out.push((h << 4) | l); - i += 3; - } - _ => { - out.push(bytes[i]); - i += 1; - } - } - } - b => { - out.push(b); - i += 1; - } - } - } - String::from_utf8_lossy(&out).into_owned() -} - -fn hex_val(b: u8) -> Option { - match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(b - b'a' + 10), - b'A'..=b'F' => Some(b - b'A' + 10), - _ => None, - } -} diff --git a/crates/core/ras-transport-core/src/path.rs b/crates/core/ras-transport-core/src/path.rs new file mode 100644 index 0000000..f78f36d --- /dev/null +++ b/crates/core/ras-transport-core/src/path.rs @@ -0,0 +1,35 @@ +/// Percent-encode a value for safe interpolation into a single URL **path +/// segment**. +/// +/// Generated clients substitute path parameters into a URL template by string +/// replacement (`/items/{id}` -> `/items/`). Without encoding, a value +/// containing `/`, `?`, `#`, `%`, or control bytes could break out of its +/// segment and alter the request's path, query, or fragment (e.g. an `id` of +/// `../admin` or `x?role=admin`). Only RFC 3986 `unreserved` characters +/// (`ALPHA`/`DIGIT`/`-`/`.`/`_`/`~`) pass through unescaped; every other byte is +/// `%XX`-encoded. The result is what servers (e.g. axum's `Path` extractor) +/// percent-decode back to the original value. +pub fn encode_path_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for &b in value.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push(hex_digit(b >> 4)); + out.push(hex_digit(b & 0x0f)); + } + } + } + out +} + +/// Upper-case hex digit for a nibble (0..=15). +fn hex_digit(nibble: u8) -> char { + match nibble { + 0..=9 => (b'0' + nibble) as char, + _ => (b'A' + (nibble - 10)) as char, + } +} diff --git a/crates/core/ras-transport-core/src/query.rs b/crates/core/ras-transport-core/src/query.rs new file mode 100644 index 0000000..4844bf3 --- /dev/null +++ b/crates/core/ras-transport-core/src/query.rs @@ -0,0 +1,296 @@ +use crate::TransportError; +use serde::Serialize; + +/// Convert a query value into decoded `(key, value)` pairs for form encoding. +/// +/// Sequences produce repeated keys, enum variants honor `#[serde(rename)]`, +/// and `Option::None` produces no pairs. Encode with [`serialize_query_pairs`]. +pub fn serialize_query_value( + key: &str, + value: &T, +) -> Result, TransportError> { + let mut collector = QueryValueCollector { values: Vec::new() }; + value + .serialize(&mut collector) + .map_err(|e| TransportError::Serialize(serde::ser::Error::custom(e.to_string())))?; + Ok(collector + .values + .into_iter() + .map(|v| (key.to_string(), v)) + .collect()) +} + +/// Serialize several `(key, value)` query parameters and join them into a +/// single query string (without a leading `?`). Empty result if no pairs. +/// +/// Uses `application/x-www-form-urlencoded` encoding: `*` stays raw, `~` +/// becomes `%7E`, and space becomes `+`. Pair order, including repeated keys, +/// is preserved. +/// +/// Returns [`TransportError::Serialize`] on encoding failure rather than +/// silently yielding an empty string — generated clients append the result +/// after a `?`/`&` separator, so a swallowed failure would send a different +/// (unfiltered) query than the caller asked for. +pub fn serialize_query_pairs(pairs: &[(String, String)]) -> Result { + serde_urlencoded::to_string(pairs) + .map_err(|e| TransportError::Serialize(serde::ser::Error::custom(e.to_string()))) +} + +/// Preserve form-serializer scalar formatting and enum renames while returning +/// a decoded value for [`serialize_query_pairs`]. +fn encode_scalar(value: &T) -> Result { + use serde::ser::Error as _; + // serde_urlencoded serializes a sequence of (key, value) tuples. + let encoded = serde_urlencoded::to_string([("v", value)]) + .map_err(|e| serde_json::Error::custom(e.to_string()))?; + // `encoded` looks like "v="; strip the "v=" prefix and decode. + let raw = encoded.strip_prefix("v=").unwrap_or(&encoded); + Ok(percent_decode(raw)) +} + +/// A serde `Serializer` that collects scalar query values, expanding sequences +/// into multiple values and treating `Option::None`/unit as empty. +struct QueryValueCollector { + values: Vec, +} + +type QueryResult = Result<(), serde_json::Error>; + +macro_rules! collect_scalar { + ($method:ident, $ty:ty) => { + fn $method(self, v: $ty) -> QueryResult { + self.values.push(encode_scalar(&v)?); + Ok(()) + } + }; +} + +impl serde::Serializer for &mut QueryValueCollector { + type Ok = (); + type Error = serde_json::Error; + type SerializeSeq = Self; + type SerializeTuple = Self; + type SerializeTupleStruct = Self; + type SerializeTupleVariant = serde::ser::Impossible<(), serde_json::Error>; + type SerializeMap = serde::ser::Impossible<(), serde_json::Error>; + type SerializeStruct = serde::ser::Impossible<(), serde_json::Error>; + type SerializeStructVariant = serde::ser::Impossible<(), serde_json::Error>; + + collect_scalar!(serialize_bool, bool); + collect_scalar!(serialize_i8, i8); + collect_scalar!(serialize_i16, i16); + collect_scalar!(serialize_i32, i32); + collect_scalar!(serialize_i64, i64); + collect_scalar!(serialize_u8, u8); + collect_scalar!(serialize_u16, u16); + collect_scalar!(serialize_u32, u32); + collect_scalar!(serialize_u64, u64); + collect_scalar!(serialize_f32, f32); + collect_scalar!(serialize_f64, f64); + collect_scalar!(serialize_char, char); + + fn serialize_str(self, v: &str) -> QueryResult { + self.values.push(v.to_string()); + Ok(()) + } + + fn serialize_bytes(self, _v: &[u8]) -> QueryResult { + use serde::ser::Error as _; + Err(serde_json::Error::custom( + "bytes are not a valid query value", + )) + } + + fn serialize_none(self) -> QueryResult { + Ok(()) + } + + fn serialize_some(self, value: &T) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(self) + } + + fn serialize_unit(self) -> QueryResult { + Ok(()) + } + + fn serialize_unit_struct(self, _name: &'static str) -> QueryResult { + Ok(()) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> QueryResult { + // Honor `#[serde(rename = ...)]`: `variant` is already the renamed form. + self.values.push(variant.to_string()); + Ok(()) + } + + fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + value: &T, + ) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(self) + } + + fn serialize_seq(self, _len: Option) -> Result { + Ok(self) + } + + fn serialize_tuple(self, _len: usize) -> Result { + Ok(self) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Ok(self) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + use serde::ser::Error as _; + Err(serde_json::Error::custom( + "tuple variants are not valid query values", + )) + } + + fn serialize_map(self, _len: Option) -> Result { + use serde::ser::Error as _; + Err(serde_json::Error::custom("maps are not valid query values")) + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + use serde::ser::Error as _; + Err(serde_json::Error::custom( + "structs are not valid query values", + )) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + use serde::ser::Error as _; + Err(serde_json::Error::custom( + "struct variants are not valid query values", + )) + } +} + +impl serde::ser::SerializeSeq for &mut QueryValueCollector { + type Ok = (); + type Error = serde_json::Error; + fn serialize_element(&mut self, value: &T) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(&mut **self) + } + fn end(self) -> QueryResult { + Ok(()) + } +} + +impl serde::ser::SerializeTuple for &mut QueryValueCollector { + type Ok = (); + type Error = serde_json::Error; + fn serialize_element(&mut self, value: &T) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(&mut **self) + } + fn end(self) -> QueryResult { + Ok(()) + } +} + +impl serde::ser::SerializeTupleStruct for &mut QueryValueCollector { + type Ok = (); + type Error = serde_json::Error; + fn serialize_field(&mut self, value: &T) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(&mut **self) + } + fn end(self) -> QueryResult { + Ok(()) + } +} + +/// Decode `application/x-www-form-urlencoded` text (`+` -> space, `%XX`). +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < bytes.len() => { + let hi = hex_val(bytes[i + 1]); + let lo = hex_val(bytes[i + 2]); + match (hi, lo) { + (Some(h), Some(l)) => { + out.push((h << 4) | l); + i += 3; + } + _ => { + out.push(bytes[i]); + i += 1; + } + } + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} diff --git a/crates/identity/ras-identity-local/src/lib.rs b/crates/identity/ras-identity-local/src/lib.rs index 8dbd7dd..5bfdac5 100644 --- a/crates/identity/ras-identity-local/src/lib.rs +++ b/crates/identity/ras-identity-local/src/lib.rs @@ -251,617 +251,4 @@ impl IdentityProvider for LocalUserProvider { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn debug_redacts_password_hash() { - let user = LocalUser { - username: "alice".to_string(), - password_hash: "$argon2id$v=19$m=19456,t=2,p=1$secretsecret$hashhashhash".to_string(), - email: None, - display_name: None, - metadata: None, - }; - let debug = format!("{user:?}"); - assert!(!debug.contains("hashhashhash")); - assert!(!debug.contains("$argon2id$")); - assert!(debug.contains("[REDACTED]")); - assert!(debug.contains("alice")); - } - - #[test] - fn i1a_local_user_serialize_omits_password_hash() { - let user = LocalUser { - username: "alice".to_string(), - password_hash: "$argon2id$v=19$m=19456,t=2,p=1$secretsecret$hashhashhash".to_string(), - email: Some("alice@example.com".to_string()), - display_name: None, - metadata: None, - }; - let json = serde_json::to_value(&user).unwrap(); - assert!(json.get("password_hash").is_none()); - assert!(!json.to_string().contains("hashhashhash")); - assert_eq!(json["username"], "alice"); - - // Deserialize still requires the hash. - let full = serde_json::json!({ - "username": "alice", - "password_hash": "$argon2id$x", - "email": null, - "display_name": null, - "metadata": null - }); - let parsed: LocalUser = serde_json::from_value(full).unwrap(); - assert_eq!(parsed.password_hash, "$argon2id$x"); - assert!(serde_json::from_value::(json).is_err()); - } - - #[test] - fn i2_login_payload_debug_redacts_password() { - let payload = LocalAuthPayload { - username: "alice".to_string(), - password: "hunter2-super-secret".to_string(), - }; - let debug = format!("{payload:?}"); - assert!(!debug.contains("hunter2")); - assert!(debug.contains("[REDACTED]")); - assert!(debug.contains("alice")); - } - - #[tokio::test] - async fn i4_oversized_password_rejected_before_hashing() { - let provider = setup_test_provider().await; - - let too_long = "x".repeat(MAX_PASSWORD_BYTES + 1); - let result = provider - .add_user("bob".to_string(), too_long.clone(), None, None) - .await; - assert!(matches!( - result, - Err(LocalUserError::PasswordTooLong { max_bytes }) if max_bytes == MAX_PASSWORD_BYTES - )); - - let result = provider - .verify(serde_json::json!({ "username": "testuser", "password": too_long })) - .await; - assert!(matches!(result, Err(IdentityError::InvalidCredentials))); - - // Exactly at the limit is still accepted. - let at_limit = "y".repeat(MAX_PASSWORD_BYTES); - provider - .add_user("carol".to_string(), at_limit.clone(), None, None) - .await - .unwrap(); - assert!( - provider - .verify(serde_json::json!({ "username": "carol", "password": at_limit })) - .await - .is_ok() - ); - } - - async fn setup_test_provider() -> LocalUserProvider { - let provider = LocalUserProvider::new(); - - // Add test users - provider - .add_user( - "testuser".to_string(), - "password123".to_string(), - Some("test@example.com".to_string()), - Some("Test User".to_string()), - ) - .await - .unwrap(); - - provider - .add_user( - "alice".to_string(), - "supersecret".to_string(), - Some("alice@example.com".to_string()), - Some("Alice Smith".to_string()), - ) - .await - .unwrap(); - - provider - } - - #[tokio::test] - async fn test_basic_authentication_success() { - let provider = setup_test_provider().await; - - let auth_payload = serde_json::json!({ - "username": "testuser", - "password": "password123" - }); - - let identity = provider.verify(auth_payload).await.unwrap(); - - assert_eq!(identity.subject, "testuser"); - assert_eq!(identity.email.as_deref(), Some("test@example.com")); - assert_eq!(identity.display_name.as_deref(), Some("Test User")); - 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; - - let bad_payload = serde_json::json!({ - "username": "testuser", - "password": "wrongpassword" - }); - - let result = provider.verify(bad_payload).await; - assert!(result.is_err()); - - match result.unwrap_err() { - IdentityError::InvalidCredentials => {} // Expected - other => panic!("Expected InvalidCredentials, got: {:?}", other), - } - } - - #[tokio::test] - async fn test_username_enumeration_prevention() { - let provider = setup_test_provider().await; - - // Test with non-existent username - let nonexistent_user_payload = serde_json::json!({ - "username": "nonexistentuser", - "password": "anypassword" - }); - - // Test with existing username but wrong password - let wrong_password_payload = serde_json::json!({ - "username": "testuser", - "password": "wrongpassword" - }); - - let result1 = provider.verify(nonexistent_user_payload).await; - let result2 = provider.verify(wrong_password_payload).await; - - // Both should fail with the same error type - assert!(result1.is_err()); - assert!(result2.is_err()); - - let err1 = result1.unwrap_err(); - let err2 = result2.unwrap_err(); - - // Both should be InvalidCredentials errors - assert!(matches!(err1, IdentityError::InvalidCredentials)); - assert!(matches!(err2, IdentityError::InvalidCredentials)); - - // Error messages should be identical - assert_eq!(err1.to_string(), err2.to_string()); - } - - #[cfg(feature = "timing-tests")] - #[tokio::test] - #[ignore = "timing-sensitive statistical check; run explicitly on a quiet machine"] - async fn test_timing_attack_resistance() { - use std::time::{Duration, Instant}; - - let provider = setup_test_provider().await; - - const NUM_ATTEMPTS: usize = 10; - let mut nonexistent_times = Vec::new(); - let mut wrong_password_times = Vec::new(); - - // Measure timing for non-existent users - for i in 0..NUM_ATTEMPTS { - let payload = serde_json::json!({ - "username": format!("nonexistentuser{}", i), - "password": "anypassword" - }); - - let start = Instant::now(); - let _ = provider.verify(payload).await; - let duration = start.elapsed(); - nonexistent_times.push(duration); - } - - // Measure timing for wrong passwords with existing users - for i in 0..NUM_ATTEMPTS { - let payload = serde_json::json!({ - "username": "testuser", - "password": format!("wrongpassword{}", i) - }); - - let start = Instant::now(); - let _ = provider.verify(payload).await; - let duration = start.elapsed(); - wrong_password_times.push(duration); - } - - // Calculate average times - let avg_nonexistent = nonexistent_times.iter().sum::() / NUM_ATTEMPTS as u32; - let avg_wrong_password = - wrong_password_times.iter().sum::() / NUM_ATTEMPTS as u32; - - // The difference should be small (less than 10ms typically for Argon2) - 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); - println!("Time difference: {:?}", time_diff); - - // Assert that timing difference is reasonable (less than 50ms) - // This is generous but accounts for system variance - assert!( - time_diff < Duration::from_millis(50), - "Timing difference too large: {:?}. This could enable timing attacks.", - time_diff - ); - } - - #[cfg(feature = "timing-tests")] - #[tokio::test] - async fn test_brute_force_simulation() { - let provider = setup_test_provider().await; - - const ATTACK_ATTEMPTS: usize = 50; - let mut consecutive_failures = 0; - let mut error_consistency = true; - - // Simulate brute force attack on known username - for i in 0..ATTACK_ATTEMPTS { - let payload = serde_json::json!({ - "username": "testuser", - "password": format!("bruteforce_attempt_{}", i) - }); - - let result = provider.verify(payload).await; - - if let Err(error) = result { - consecutive_failures += 1; - - // Ensure all failures are consistent - if !matches!(error, IdentityError::InvalidCredentials) { - error_consistency = false; - } - } else { - // Should not succeed with random passwords - panic!("Brute force attempt unexpectedly succeeded"); - } - } - - assert_eq!(consecutive_failures, ATTACK_ATTEMPTS); - assert!( - error_consistency, - "Error types were not consistent across brute force attempts" - ); - } - - #[tokio::test] - async fn test_malformed_payload_handling() { - let provider = setup_test_provider().await; - - // Test with missing username - let missing_username = serde_json::json!({ - "password": "password123" - }); - - let result = provider.verify(missing_username).await; - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), IdentityError::InvalidPayload)); - - // Test with missing password - let missing_password = serde_json::json!({ - "username": "testuser" - }); - - let result = provider.verify(missing_password).await; - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), IdentityError::InvalidPayload)); - - // Test with wrong field names - let wrong_fields = serde_json::json!({ - "user": "testuser", - "pass": "password123" - }); - - let result = provider.verify(wrong_fields).await; - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), IdentityError::InvalidPayload)); - - // Test with completely invalid JSON structure - let invalid_structure = serde_json::json!("just a string"); - - let result = provider.verify(invalid_structure).await; - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), IdentityError::InvalidPayload)); - } - - #[tokio::test] - async fn test_empty_credentials() { - let provider = setup_test_provider().await; - - // Test with empty username - let empty_username = serde_json::json!({ - "username": "", - "password": "password123" - }); - - let result = provider.verify(empty_username).await; - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - IdentityError::InvalidCredentials - )); - - // Test with empty password - let empty_password = serde_json::json!({ - "username": "testuser", - "password": "" - }); - - let result = provider.verify(empty_password).await; - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - IdentityError::InvalidCredentials - )); - - // Test with both empty - let both_empty = serde_json::json!({ - "username": "", - "password": "" - }); - - let result = provider.verify(both_empty).await; - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - IdentityError::InvalidCredentials - )); - } - - #[tokio::test] - async fn test_special_characters_in_credentials() { - let provider = LocalUserProvider::new(); - - // Add user with special characters in username and password - provider - .add_user( - "user@domain.com".to_string(), - "p@ssw0rd!#$%".to_string(), - None, - None, - ) - .await - .unwrap(); - - // Test successful authentication with special characters - let payload = serde_json::json!({ - "username": "user@domain.com", - "password": "p@ssw0rd!#$%" - }); - - let result = provider.verify(payload).await; - assert!(result.is_ok()); - - // Test with SQL injection-like patterns (should be safely handled) - let sql_injection_attempt = serde_json::json!({ - "username": "user@domain.com'; DROP TABLE users; --", - "password": "p@ssw0rd!#$%" - }); - - let result = provider.verify(sql_injection_attempt).await; - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - IdentityError::InvalidCredentials - )); - } - - #[tokio::test] - async fn test_very_long_credentials() { - let provider = setup_test_provider().await; - - // Test with extremely long username - let long_username = "a".repeat(10000); - let long_username_payload = serde_json::json!({ - "username": long_username, - "password": "password123" - }); - - let result = provider.verify(long_username_payload).await; - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - IdentityError::InvalidCredentials - )); - - // Test with extremely long password - let long_password = "b".repeat(10000); - let long_password_payload = serde_json::json!({ - "username": "testuser", - "password": long_password - }); - - let result = provider.verify(long_password_payload).await; - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - IdentityError::InvalidCredentials - )); - } - - #[tokio::test] - async fn test_concurrent_authentication_attempts() { - let provider = setup_test_provider().await; - let provider = Arc::new(provider); - - const CONCURRENT_ATTEMPTS: usize = 20; - let mut handles = Vec::new(); - - // Launch concurrent authentication attempts - for i in 0..CONCURRENT_ATTEMPTS { - let provider_clone = Arc::clone(&provider); - let handle = tokio::spawn(async move { - let payload = if i % 2 == 0 { - // Half valid, half invalid - serde_json::json!({ - "username": "testuser", - "password": "password123" - }) - } else { - serde_json::json!({ - "username": "testuser", - "password": format!("wrong_password_{}", i) - }) - }; - - provider_clone.verify(payload).await - }); - handles.push(handle); - } - - // Collect results - let mut successful_auths = 0; - let mut failed_auths = 0; - - for handle in handles { - let result = handle.await.unwrap(); - match result { - Ok(_) => successful_auths += 1, - Err(IdentityError::InvalidCredentials) => failed_auths += 1, - Err(other) => panic!("Unexpected error: {:?}", other), - } - } - - // 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); - } -} +mod tests; diff --git a/crates/identity/ras-identity-local/src/tests.rs b/crates/identity/ras-identity-local/src/tests.rs new file mode 100644 index 0000000..df1ac7b --- /dev/null +++ b/crates/identity/ras-identity-local/src/tests.rs @@ -0,0 +1,611 @@ +use super::*; + +#[test] +fn debug_redacts_password_hash() { + let user = LocalUser { + username: "alice".to_string(), + password_hash: "$argon2id$v=19$m=19456,t=2,p=1$secretsecret$hashhashhash".to_string(), + email: None, + display_name: None, + metadata: None, + }; + let debug = format!("{user:?}"); + assert!(!debug.contains("hashhashhash")); + assert!(!debug.contains("$argon2id$")); + assert!(debug.contains("[REDACTED]")); + assert!(debug.contains("alice")); +} + +#[test] +fn i1a_local_user_serialize_omits_password_hash() { + let user = LocalUser { + username: "alice".to_string(), + password_hash: "$argon2id$v=19$m=19456,t=2,p=1$secretsecret$hashhashhash".to_string(), + email: Some("alice@example.com".to_string()), + display_name: None, + metadata: None, + }; + let json = serde_json::to_value(&user).unwrap(); + assert!(json.get("password_hash").is_none()); + assert!(!json.to_string().contains("hashhashhash")); + assert_eq!(json["username"], "alice"); + + // Deserialize still requires the hash. + let full = serde_json::json!({ + "username": "alice", + "password_hash": "$argon2id$x", + "email": null, + "display_name": null, + "metadata": null + }); + let parsed: LocalUser = serde_json::from_value(full).unwrap(); + assert_eq!(parsed.password_hash, "$argon2id$x"); + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn i2_login_payload_debug_redacts_password() { + let payload = LocalAuthPayload { + username: "alice".to_string(), + password: "hunter2-super-secret".to_string(), + }; + let debug = format!("{payload:?}"); + assert!(!debug.contains("hunter2")); + assert!(debug.contains("[REDACTED]")); + assert!(debug.contains("alice")); +} + +#[tokio::test] +async fn i4_oversized_password_rejected_before_hashing() { + let provider = setup_test_provider().await; + + let too_long = "x".repeat(MAX_PASSWORD_BYTES + 1); + let result = provider + .add_user("bob".to_string(), too_long.clone(), None, None) + .await; + assert!(matches!( + result, + Err(LocalUserError::PasswordTooLong { max_bytes }) if max_bytes == MAX_PASSWORD_BYTES + )); + + let result = provider + .verify(serde_json::json!({ "username": "testuser", "password": too_long })) + .await; + assert!(matches!(result, Err(IdentityError::InvalidCredentials))); + + // Exactly at the limit is still accepted. + let at_limit = "y".repeat(MAX_PASSWORD_BYTES); + provider + .add_user("carol".to_string(), at_limit.clone(), None, None) + .await + .unwrap(); + assert!( + provider + .verify(serde_json::json!({ "username": "carol", "password": at_limit })) + .await + .is_ok() + ); +} + +async fn setup_test_provider() -> LocalUserProvider { + let provider = LocalUserProvider::new(); + + // Add test users + provider + .add_user( + "testuser".to_string(), + "password123".to_string(), + Some("test@example.com".to_string()), + Some("Test User".to_string()), + ) + .await + .unwrap(); + + provider + .add_user( + "alice".to_string(), + "supersecret".to_string(), + Some("alice@example.com".to_string()), + Some("Alice Smith".to_string()), + ) + .await + .unwrap(); + + provider +} + +#[tokio::test] +async fn test_basic_authentication_success() { + let provider = setup_test_provider().await; + + let auth_payload = serde_json::json!({ + "username": "testuser", + "password": "password123" + }); + + let identity = provider.verify(auth_payload).await.unwrap(); + + assert_eq!(identity.subject, "testuser"); + assert_eq!(identity.email.as_deref(), Some("test@example.com")); + assert_eq!(identity.display_name.as_deref(), Some("Test User")); + 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; + + let bad_payload = serde_json::json!({ + "username": "testuser", + "password": "wrongpassword" + }); + + let result = provider.verify(bad_payload).await; + assert!(result.is_err()); + + match result.unwrap_err() { + IdentityError::InvalidCredentials => {} // Expected + other => panic!("Expected InvalidCredentials, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_username_enumeration_prevention() { + let provider = setup_test_provider().await; + + // Test with non-existent username + let nonexistent_user_payload = serde_json::json!({ + "username": "nonexistentuser", + "password": "anypassword" + }); + + // Test with existing username but wrong password + let wrong_password_payload = serde_json::json!({ + "username": "testuser", + "password": "wrongpassword" + }); + + let result1 = provider.verify(nonexistent_user_payload).await; + let result2 = provider.verify(wrong_password_payload).await; + + // Both should fail with the same error type + assert!(result1.is_err()); + assert!(result2.is_err()); + + let err1 = result1.unwrap_err(); + let err2 = result2.unwrap_err(); + + // Both should be InvalidCredentials errors + assert!(matches!(err1, IdentityError::InvalidCredentials)); + assert!(matches!(err2, IdentityError::InvalidCredentials)); + + // Error messages should be identical + assert_eq!(err1.to_string(), err2.to_string()); +} + +#[cfg(feature = "timing-tests")] +#[tokio::test] +#[ignore = "timing-sensitive statistical check; run explicitly on a quiet machine"] +async fn test_timing_attack_resistance() { + use std::time::{Duration, Instant}; + + let provider = setup_test_provider().await; + + const NUM_ATTEMPTS: usize = 10; + let mut nonexistent_times = Vec::new(); + let mut wrong_password_times = Vec::new(); + + // Measure timing for non-existent users + for i in 0..NUM_ATTEMPTS { + let payload = serde_json::json!({ + "username": format!("nonexistentuser{}", i), + "password": "anypassword" + }); + + let start = Instant::now(); + let _ = provider.verify(payload).await; + let duration = start.elapsed(); + nonexistent_times.push(duration); + } + + // Measure timing for wrong passwords with existing users + for i in 0..NUM_ATTEMPTS { + let payload = serde_json::json!({ + "username": "testuser", + "password": format!("wrongpassword{}", i) + }); + + let start = Instant::now(); + let _ = provider.verify(payload).await; + let duration = start.elapsed(); + wrong_password_times.push(duration); + } + + // Calculate average times + let avg_nonexistent = nonexistent_times.iter().sum::() / NUM_ATTEMPTS as u32; + let avg_wrong_password = wrong_password_times.iter().sum::() / NUM_ATTEMPTS as u32; + + // The difference should be small (less than 10ms typically for Argon2) + 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); + println!("Time difference: {:?}", time_diff); + + // Assert that timing difference is reasonable (less than 50ms) + // This is generous but accounts for system variance + assert!( + time_diff < Duration::from_millis(50), + "Timing difference too large: {:?}. This could enable timing attacks.", + time_diff + ); +} + +#[cfg(feature = "timing-tests")] +#[tokio::test] +async fn test_brute_force_simulation() { + let provider = setup_test_provider().await; + + const ATTACK_ATTEMPTS: usize = 50; + let mut consecutive_failures = 0; + let mut error_consistency = true; + + // Simulate brute force attack on known username + for i in 0..ATTACK_ATTEMPTS { + let payload = serde_json::json!({ + "username": "testuser", + "password": format!("bruteforce_attempt_{}", i) + }); + + let result = provider.verify(payload).await; + + if let Err(error) = result { + consecutive_failures += 1; + + // Ensure all failures are consistent + if !matches!(error, IdentityError::InvalidCredentials) { + error_consistency = false; + } + } else { + // Should not succeed with random passwords + panic!("Brute force attempt unexpectedly succeeded"); + } + } + + assert_eq!(consecutive_failures, ATTACK_ATTEMPTS); + assert!( + error_consistency, + "Error types were not consistent across brute force attempts" + ); +} + +#[tokio::test] +async fn test_malformed_payload_handling() { + let provider = setup_test_provider().await; + + // Test with missing username + let missing_username = serde_json::json!({ + "password": "password123" + }); + + let result = provider.verify(missing_username).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), IdentityError::InvalidPayload)); + + // Test with missing password + let missing_password = serde_json::json!({ + "username": "testuser" + }); + + let result = provider.verify(missing_password).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), IdentityError::InvalidPayload)); + + // Test with wrong field names + let wrong_fields = serde_json::json!({ + "user": "testuser", + "pass": "password123" + }); + + let result = provider.verify(wrong_fields).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), IdentityError::InvalidPayload)); + + // Test with completely invalid JSON structure + let invalid_structure = serde_json::json!("just a string"); + + let result = provider.verify(invalid_structure).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), IdentityError::InvalidPayload)); +} + +#[tokio::test] +async fn test_empty_credentials() { + let provider = setup_test_provider().await; + + // Test with empty username + let empty_username = serde_json::json!({ + "username": "", + "password": "password123" + }); + + let result = provider.verify(empty_username).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + IdentityError::InvalidCredentials + )); + + // Test with empty password + let empty_password = serde_json::json!({ + "username": "testuser", + "password": "" + }); + + let result = provider.verify(empty_password).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + IdentityError::InvalidCredentials + )); + + // Test with both empty + let both_empty = serde_json::json!({ + "username": "", + "password": "" + }); + + let result = provider.verify(both_empty).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + IdentityError::InvalidCredentials + )); +} + +#[tokio::test] +async fn test_special_characters_in_credentials() { + let provider = LocalUserProvider::new(); + + // Add user with special characters in username and password + provider + .add_user( + "user@domain.com".to_string(), + "p@ssw0rd!#$%".to_string(), + None, + None, + ) + .await + .unwrap(); + + // Test successful authentication with special characters + let payload = serde_json::json!({ + "username": "user@domain.com", + "password": "p@ssw0rd!#$%" + }); + + let result = provider.verify(payload).await; + assert!(result.is_ok()); + + // Test with SQL injection-like patterns (should be safely handled) + let sql_injection_attempt = serde_json::json!({ + "username": "user@domain.com'; DROP TABLE users; --", + "password": "p@ssw0rd!#$%" + }); + + let result = provider.verify(sql_injection_attempt).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + IdentityError::InvalidCredentials + )); +} + +#[tokio::test] +async fn test_very_long_credentials() { + let provider = setup_test_provider().await; + + // Test with extremely long username + let long_username = "a".repeat(10000); + let long_username_payload = serde_json::json!({ + "username": long_username, + "password": "password123" + }); + + let result = provider.verify(long_username_payload).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + IdentityError::InvalidCredentials + )); + + // Test with extremely long password + let long_password = "b".repeat(10000); + let long_password_payload = serde_json::json!({ + "username": "testuser", + "password": long_password + }); + + let result = provider.verify(long_password_payload).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + IdentityError::InvalidCredentials + )); +} + +#[tokio::test] +async fn test_concurrent_authentication_attempts() { + let provider = setup_test_provider().await; + let provider = Arc::new(provider); + + const CONCURRENT_ATTEMPTS: usize = 20; + let mut handles = Vec::new(); + + // Launch concurrent authentication attempts + for i in 0..CONCURRENT_ATTEMPTS { + let provider_clone = Arc::clone(&provider); + let handle = tokio::spawn(async move { + let payload = if i % 2 == 0 { + // Half valid, half invalid + serde_json::json!({ + "username": "testuser", + "password": "password123" + }) + } else { + serde_json::json!({ + "username": "testuser", + "password": format!("wrong_password_{}", i) + }) + }; + + provider_clone.verify(payload).await + }); + handles.push(handle); + } + + // Collect results + let mut successful_auths = 0; + let mut failed_auths = 0; + + for handle in handles { + let result = handle.await.unwrap(); + match result { + Ok(_) => successful_auths += 1, + Err(IdentityError::InvalidCredentials) => failed_auths += 1, + Err(other) => panic!("Unexpected error: {:?}", other), + } + } + + // 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/src/client.rs b/crates/identity/ras-identity-oauth2/src/client.rs deleted file mode 100644 index e5d43ec..0000000 --- a/crates/identity/ras-identity-oauth2/src/client.rs +++ /dev/null @@ -1,1280 +0,0 @@ -//! OAuth2 client implementation with PKCE support. - -use crate::config::OAuth2ProviderConfig; -use crate::error::{OAuth2Error, OAuth2Result}; -use crate::state::{OAuth2State, OAuth2StateStore}; -use crate::types::{AuthorizationResponse, TokenResponse, UserInfoResponse}; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use rand::{Rng, thread_rng}; -use reqwest::Client; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; -use subtle::ConstantTimeEq; -use tracing::{debug, error, info, warn}; -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 - .map_err(log_upstream_error)?; - - if !response.status().is_success() { - // Never log or propagate the raw provider response body — it can - // contain tokens or other sensitive material. Status only. - let status = response.status(); - error!("Token exchange failed with status {}", status); - return Err(OAuth2Error::TokenExchangeFailed(format!( - "token endpoint returned status {status}" - ))); - } - - let token_response: TokenResponse = response.json().await.map_err(|e| { - // reqwest decode errors embed the request URL; keep that in the log only (I6). - warn!(error = %e, "token endpoint returned an undecodable response"); - OAuth2Error::InvalidTokenResponse("undecodable token response".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 - .map_err(log_upstream_error)?; - - if !response.status().is_success() { - // Status only; the raw body may echo the bearer token. - let status = response.status(); - error!("User info request failed with status {}", status); - return Err(OAuth2Error::UserInfoFailed(format!( - "userinfo endpoint returned status {status}" - ))); - } - - let user_info: UserInfoResponse = response.json().await.map_err(|e| { - warn!(error = %e, "userinfo endpoint returned an undecodable response"); - OAuth2Error::InvalidUserInfoResponse("undecodable userinfo response".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 { - pub code_verifier: String, - pub code_challenge: String, - pub code_challenge_method: String, -} - -impl Default for PkceChallenge { - fn default() -> Self { - Self::new() - } -} - -impl PkceChallenge { - /// Generate a new PKCE challenge - pub fn new() -> Self { - let code_verifier = Self::generate_code_verifier(); - let code_challenge = Self::generate_code_challenge(&code_verifier); - - Self { - code_verifier, - code_challenge, - code_challenge_method: "S256".to_string(), - } - } - - fn generate_code_verifier() -> String { - let mut rng = thread_rng(); - let bytes: Vec = (0..64).map(|_| rng.r#gen::()).collect(); - URL_SAFE_NO_PAD.encode(bytes) - } - - fn generate_code_challenge(verifier: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(verifier.as_bytes()); - let result = hasher.finalize(); - URL_SAFE_NO_PAD.encode(result) - } -} - -/// Reserved OAuth/OIDC query parameters that the library sets itself. Neither -/// `provider_config.auth_params` nor caller-supplied `additional_params` may -/// override them — many providers honour the last occurrence of a duplicated -/// query parameter, so an injected second `redirect_uri` / `state` / PKCE value -/// would be an authorization-code-theft or CSRF vector. -const RESERVED_AUTH_PARAMS: &[&str] = &[ - "response_type", - "client_id", - "client_secret", - "redirect_uri", - "state", - "nonce", - "scope", - "code_challenge", - "code_challenge_method", - "grant_type", - "code", - "code_verifier", - // OIDC request objects (Core §6): parameters inside a `request` / - // `request_uri` JWT take precedence over the query parameters we set, so - // permitting them would re-establish the exact override primitive this - // denylist removes (e.g. silently dropping PKCE or overriding state/nonce). - "request", - "request_uri", - // Response delivery / audience controls a caller must not influence. - "response_mode", - "resource", - "audience", - "id_token_hint", -]; - -/// Reject any key that collides (case-insensitively) with a reserved parameter. -fn reject_reserved_params<'a, I>(keys: I, source: &str) -> OAuth2Result<()> -where - I: IntoIterator, -{ - for key in keys { - if RESERVED_AUTH_PARAMS - .iter() - .any(|reserved| reserved.eq_ignore_ascii_case(key)) - { - return Err(OAuth2Error::InvalidAuthorizationParam(format!( - "{source} may not set the reserved parameter `{key}`" - ))); - } - } - Ok(()) -} - -/// OAuth2 client for handling authorization flows -#[derive(Clone)] -pub struct OAuth2Client { - http_transport: Arc, - state_store: Arc, - state_ttl_seconds: u64, -} - -impl OAuth2Client { - /// Create a client with bounded HTTP timeouts. - /// - /// # Panics - /// Panics if the HTTP client cannot be built. Use [`Self::try_new`] to - /// handle construction errors. - pub fn new( - state_store: Arc, - state_ttl_seconds: u64, - http_timeout_seconds: u64, - ) -> Self { - Self::try_new(state_store, state_ttl_seconds, http_timeout_seconds) - .expect("failed to build OAuth2 HTTP client") - } - - 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()?; - - 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_transport, - state_store, - state_ttl_seconds, - } - } - - #[cfg(test)] - pub fn state_store(&self) -> &Arc { - &self.state_store - } - - /// Generate authorization URL for a provider - pub async fn generate_authorization_url( - &self, - provider_config: &OAuth2ProviderConfig, - additional_params: HashMap, - ) -> OAuth2Result<(String, String)> { - self.generate_authorization_url_bound(provider_config, additional_params, None) - .await - } - - /// Generate an authorization URL bound to the initiating browser session. - /// - /// `binding` should be an unguessable value the integrator can recover on - /// callback (e.g. a random cookie value); the callback must then present - /// the identical value, preventing login CSRF where an attacker tricks a - /// victim into completing the attacker's flow. - pub async fn generate_authorization_url_bound( - &self, - provider_config: &OAuth2ProviderConfig, - additional_params: HashMap, - binding: Option, - ) -> OAuth2Result<(String, String)> { - // Reject reserved-parameter overrides before doing any work. - reject_reserved_params(provider_config.auth_params.keys(), "provider auth_params")?; - reject_reserved_params(additional_params.keys(), "additional_params")?; - - let mut url = Url::parse(&provider_config.authorization_endpoint)?; - - // Generate PKCE if enabled - let pkce = if provider_config.use_pkce { - Some(PkceChallenge::new()) - } else { - None - }; - - // OIDC nonce: echoed back inside the id_token and verified on - // callback, binding the token to this authorization request. - let nonce = uuid::Uuid::new_v4().to_string(); - - // Create and store state - let state = OAuth2State::new( - provider_config.provider_id.clone(), - provider_config.redirect_uri.clone(), - pkce.as_ref().map(|p| p.code_verifier.clone()), - self.state_ttl_seconds, - ) - .with_nonce(nonce.clone()) - .with_binding(binding); - - let state_param = state.state.clone(); - self.state_store.store(state).await?; - - // Build query parameters - let mut params = url.query_pairs_mut(); - params.append_pair("response_type", "code"); - params.append_pair("client_id", &provider_config.client_id); - params.append_pair("redirect_uri", &provider_config.redirect_uri); - params.append_pair("state", &state_param); - params.append_pair("nonce", &nonce); - - // Add scopes - if !provider_config.scopes.is_empty() { - params.append_pair("scope", &provider_config.scopes.join(" ")); - } - - // Add PKCE parameters - if let Some(pkce) = &pkce { - params.append_pair("code_challenge", &pkce.code_challenge); - params.append_pair("code_challenge_method", &pkce.code_challenge_method); - } - - // Add provider-specific parameters - for (key, value) in &provider_config.auth_params { - params.append_pair(key, value); - } - - // Add additional parameters from the request - for (key, value) in &additional_params { - params.append_pair(key, value); - } - - drop(params); - - let auth_url = url.to_string(); - debug!( - "Generated authorization URL for provider {}", - provider_config.provider_id - ); - - Ok((auth_url, state_param)) - } - - /// Handle OAuth2 callback and exchange code for tokens - pub async fn handle_callback( - &self, - provider_config: &OAuth2ProviderConfig, - callback_response: AuthorizationResponse, - ) -> OAuth2Result { - // Verify state - let state = self.state_store.retrieve(&callback_response.state).await?; - - if state.provider_id != provider_config.provider_id { - return Err(OAuth2Error::InvalidState); - } - - // When the flow was bound to a browser session, the callback must - // present the identical binding value (login-CSRF guard). Compared in - // constant time so the binding cannot be recovered byte-by-byte (I7). - if let Some(expected) = &state.binding - && !binding_matches(expected, callback_response.binding.as_deref()) - { - return Err(OAuth2Error::InvalidState); - } - - // Check for errors in callback. Only the standardized error code is - // surfaced; the free-text description stays in the server log (I5). - if let Some(error) = &callback_response.error { - warn!( - provider = %provider_config.provider_id, - error = %error, - error_description = callback_response.error_description.as_deref().unwrap_or(""), - "OAuth2 provider returned an error on callback" - ); - return Err(OAuth2Error::ProviderDenied { - error: error.clone(), - }); - } - - let Some(code) = callback_response.code.as_deref() else { - return Err(OAuth2Error::InvalidCallback); - }; - - // Exchange authorization code for tokens - let token_response = self - .exchange_code(provider_config, code, state.code_verifier.as_deref()) - .await?; - - // Validate id_token claims when the provider returned one. The token - // arrived directly from the token endpoint over TLS, which OIDC Core - // §3.1.3.7 permits in place of signature validation for the code - // flow — but iss / aud / exp / nonce are still mandatory checks. - if let Some(id_token) = &token_response.id_token { - validate_id_token_claims(provider_config, id_token, state.nonce.as_deref())?; - } - - Ok(token_response) - } - - /// Exchange authorization code for tokens - async fn exchange_code( - &self, - provider_config: &OAuth2ProviderConfig, - code: &str, - code_verifier: Option<&str>, - ) -> OAuth2Result { - let mut params = HashMap::new(); - 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".to_string(), verifier.to_string()); - } - - self.http_transport - .exchange_code(&provider_config.token_endpoint, ¶ms) - .await - } - - /// Get user info using access token - pub async fn get_user_info( - &self, - provider_config: &OAuth2ProviderConfig, - access_token: &str, - ) -> OAuth2Result { - let userinfo_endpoint = provider_config.userinfo_endpoint.as_ref().ok_or_else(|| { - OAuth2Error::ConfigError("User info endpoint not configured".to_string()) - })?; - - self.http_transport - .get_user_info(userinfo_endpoint, access_token) - .await - } -} - -/// Claims checked on an id_token returned by the token endpoint. -#[derive(serde::Deserialize)] -struct IdTokenClaims { - iss: Option, - sub: Option, - aud: Option, - /// Authorized party — required to equal `client_id` when `aud` has multiple - /// entries (OIDC Core §3.1.3.7 / §2). - azp: Option, - exp: Option, - nonce: Option, -} - -/// Subject (`sub`) claim of an id_token, used to bind it to the userinfo -/// response so a confused-deputy userinfo cannot change the account. -pub(crate) fn id_token_subject(id_token: &str) -> OAuth2Result> { - Ok(decode_id_token_claims(id_token)?.sub) -} - -fn decode_id_token_claims(id_token: &str) -> OAuth2Result { - let payload = id_token - .split('.') - .nth(1) - .ok_or_else(|| OAuth2Error::InvalidIdToken("malformed JWT".to_string()))?; - let bytes = URL_SAFE_NO_PAD - .decode(payload) - .map_err(|_| OAuth2Error::InvalidIdToken("invalid base64 payload".to_string()))?; - serde_json::from_slice(&bytes) - .map_err(|_| OAuth2Error::InvalidIdToken("invalid JSON payload".to_string())) -} - -/// Validate the id_token issuer, audience, expiry, subject, and expected nonce. -/// Accepting an id_token requires a configured provider issuer. -/// -/// The signature is not verified: the token was received directly from the -/// token endpoint over TLS, which OIDC Core §3.1.3.7 permits as a substitute -/// for signature validation in the authorization-code flow. -/// Log a transport-level failure at `warn` (the `reqwest::Error` carries the -/// request URL) and hand back the fixed-message error variant (I6). -fn log_upstream_error(error: reqwest::Error) -> OAuth2Error { - warn!(error = %error, "upstream OAuth2 request failed"); - OAuth2Error::HttpError(error) -} - -/// Constant-time comparison of the stored session binding against the value -/// presented on callback (I7). A missing callback value never matches. -fn binding_matches(expected: &str, presented: Option<&str>) -> bool { - match presented { - Some(presented) => { - // `ct_eq` on slices short-circuits on length, but the length of the - // binding is not secret (it is a UUID or caller-chosen value). - expected.as_bytes().ct_eq(presented.as_bytes()).into() - } - None => false, - } -} - -pub(crate) fn validate_id_token_claims( - provider_config: &OAuth2ProviderConfig, - id_token: &str, - expected_nonce: Option<&str>, -) -> OAuth2Result<()> { - let claims = decode_id_token_claims(id_token)?; - - // Issuer is fail-closed: an id_token whose issuer is unverified cannot be - // trusted to identify the account, so accepting one without a configured - // `issuer` is refused rather than silently skipped. - let Some(expected_issuer) = &provider_config.issuer else { - return Err(OAuth2Error::InvalidIdToken( - "provider `issuer` must be configured to accept id_tokens".to_string(), - )); - }; - if claims.iss.as_deref() != Some(expected_issuer.as_str()) { - return Err(OAuth2Error::InvalidIdToken(format!( - "issuer mismatch: expected {expected_issuer}" - ))); - } - - let client_id = provider_config.client_id.as_str(); - let audience_matches = match &claims.aud { - Some(serde_json::Value::String(aud)) => aud == client_id, - Some(serde_json::Value::Array(auds)) => { - let contains = auds.iter().any(|aud| aud.as_str() == Some(client_id)); - if !contains { - false - } else if auds.len() > 1 { - // Multiple audiences: `azp` must be present and equal client_id. - claims.azp.as_deref() == Some(client_id) - } else { - true - } - } - _ => false, - }; - if !audience_matches { - return Err(OAuth2Error::InvalidIdToken( - "audience does not include this client (or azp mismatch for multi-audience token)" - .to_string(), - )); - } - - match claims.exp { - Some(exp) if exp > chrono::Utc::now().timestamp() => {} - _ => { - return Err(OAuth2Error::InvalidIdToken( - "token expired or missing exp".to_string(), - )); - } - } - - if let Some(expected) = expected_nonce - && claims.nonce.as_deref() != Some(expected) - { - return Err(OAuth2Error::InvalidIdToken("nonce mismatch".to_string())); - } - - // `sub` is REQUIRED by OIDC Core §2. Refuse an id_token without it so the - // userinfo <-> id_token subject binding cannot silently no-op on a - // token that carries no subject. - match claims.sub.as_deref() { - Some(sub) if !sub.trim().is_empty() => {} - _ => { - return Err(OAuth2Error::InvalidIdToken( - "id_token is missing the required `sub` claim".to_string(), - )); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - 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()), - issuer: None, - 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, - metadata_claims: Vec::new(), - allow_insecure_endpoints: false, - } - } - - fn client_with_transport( - state_store: Arc, - transport: Arc, - ) -> OAuth2Client { - OAuth2Client::with_http_transport(state_store, 600, transport) - } - - #[test] - fn test_pkce_generation() { - let pkce1 = PkceChallenge::new(); - let pkce2 = PkceChallenge::new(); - - // Verifiers should be different - assert_ne!(pkce1.code_verifier, pkce2.code_verifier); - - // Challenges should be different - assert_ne!(pkce1.code_challenge, pkce2.code_challenge); - - // Method should be S256 - assert_eq!(pkce1.code_challenge_method, "S256"); - - // Verify the challenge is correctly generated - let expected_challenge = PkceChallenge::generate_code_challenge(&pkce1.code_verifier); - assert_eq!(pkce1.code_challenge, expected_challenge); - } - - #[tokio::test] - async fn test_authorization_url_generation() { - let state_store = Arc::new(InMemoryStateStore::new()); - let client = OAuth2Client::new(state_store, 600, 30); - - let provider_config = provider_config(); - - let (auth_url, state) = client - .generate_authorization_url(&provider_config, HashMap::new()) - .await - .unwrap(); - - // Verify URL structure - let url = Url::parse(&auth_url).unwrap(); - assert_eq!(url.host_str(), Some("example.com")); - assert_eq!(url.path(), "/auth"); - - // Verify query parameters - let params: HashMap<_, _> = url.query_pairs().collect(); - assert_eq!(params.get("response_type"), Some(&"code".into())); - assert_eq!(params.get("client_id"), Some(&"test_client_id".into())); - assert_eq!( - params.get("redirect_uri"), - Some(&"http://localhost:3000/callback".into()) - ); - assert_eq!(params.get("state"), Some(&state.into())); - assert_eq!(params.get("scope"), Some(&"openid email".into())); - 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: Some("auth-code".to_string()), - state, - error: None, - error_description: None, - binding: None, - }, - ) - .await - .expect_err("provider mismatch should reject callback"); - - assert!(matches!(error, OAuth2Error::InvalidState)); - assert!(transport.token_requests().is_empty()); - } - - #[tokio::test] - async fn i5_handle_callback_maps_provider_error_to_fixed_variant_without_description() { - 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(); - - // A legitimate denial carries no code at all. - let error = client - .handle_callback( - &provider_config, - AuthorizationResponse { - code: None, - state, - error: Some("access_denied".to_string()), - error_description: Some("user denied consent - - - diff --git a/crates/rest/ras-rest-macro/src/ast.rs b/crates/rest/ras-rest-macro/src/ast.rs new file mode 100644 index 0000000..ec4fc95 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/ast.rs @@ -0,0 +1,129 @@ +//! Parsed REST service contract shared by the parser and emitters. + +use crate::static_hosting; +use syn::{Ident, Type}; + +#[derive(Debug)] +pub(crate) struct ServiceDefinition { + pub(crate) service_name: Ident, + pub(crate) base_path: String, + pub(crate) openapi: Option, + pub(crate) static_hosting: static_hosting::StaticHostingConfig, + pub(crate) body_limit: Option, + pub(crate) feature_gated: bool, + /// Require an `application/json` request `Content-Type` on every endpoint + /// that declares a body. Defaults to `true`. Set `require_json_content_type: + /// false` to opt out (e.g. for clients that cannot set the header). + pub(crate) require_json_content_type: bool, + /// Gate the generated docs page and `openapi.json` behind authentication + /// (any authenticated user). Defaults to `false` — docs are public when + /// `serve_docs` is enabled, matching conventional API-explorer behavior. + pub(crate) docs_require_auth: bool, + pub(crate) endpoints: Vec, +} + +/// Default maximum JSON body size in bytes (matches axum's default). +pub(crate) const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; + +#[derive(Debug)] +pub(crate) enum OpenApiConfig { + Enabled, + WithPath(String), +} + +#[derive(Debug)] +pub(crate) struct EndpointDefinition { + pub(crate) docs: Option, + pub(crate) method: HttpMethod, + pub(crate) auth: AuthRequirement, + pub(crate) path: String, + pub(crate) path_params: Vec, + pub(crate) query_params: Vec, + pub(crate) request_type: Option, + pub(crate) response_type: Type, + pub(crate) handler_name: Ident, + pub(crate) version: Option, + pub(crate) versions: Vec, + /// Per-endpoint request body size cap (bytes). Overrides the service-level + /// `body_limit` for this endpoint when set. + pub(crate) body_limit: Option, + /// When `true`, the handler receives the request `HeaderMap` as an extra + /// parameter (immediately after the caller/user, before path params). + pub(crate) with_headers: bool, +} + +#[derive(Debug)] +pub(crate) struct EndpointVersionDefinition { + pub(crate) version: String, + pub(crate) path: String, + pub(crate) path_params: Vec, + pub(crate) query_params: Vec, + pub(crate) request_type: Option, + pub(crate) response_type: Type, + pub(crate) migration_type: Type, +} + +#[derive(Debug)] +pub(crate) struct DocComment { + pub(crate) summary: String, + pub(crate) description: String, +} + +impl DocComment { + pub(crate) fn from_lines(lines: Vec) -> Option { + let lines: Vec = lines + .into_iter() + .map(|line| line.trim().to_string()) + .collect(); + let start = lines.iter().position(|line| !line.is_empty())?; + let end = lines.iter().rposition(|line| !line.is_empty())?; + let lines = &lines[start..=end]; + + Some(Self { + summary: lines[0].clone(), + description: lines.join("\n"), + }) + } +} + +#[derive(Debug, Clone)] +pub(crate) enum HttpMethod { + Get, + Post, + Put, + Delete, + Patch, +} + +impl HttpMethod { + pub(crate) fn as_str(&self) -> &'static str { + match self { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + HttpMethod::Put => "PUT", + HttpMethod::Delete => "DELETE", + HttpMethod::Patch => "PATCH", + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct PathParam { + pub(crate) name: Ident, + pub(crate) param_type: Type, +} + +#[derive(Debug, Clone)] +pub(crate) struct QueryParam { + pub(crate) name: Ident, + pub(crate) param_type: Type, +} + +#[derive(Debug)] +pub(crate) enum AuthRequirement { + Unauthorized, + /// Public route that opportunistically identifies its caller. Never rejected + /// for auth reasons; the handler receives a `ras_auth_core::Caller`. + OptionalAuth, + WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups +} diff --git a/crates/rest/ras-rest-macro/src/expand.rs b/crates/rest/ras-rest-macro/src/expand.rs new file mode 100644 index 0000000..64ec279 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/expand.rs @@ -0,0 +1,94 @@ +//! Assemble protocol, server, client, and specification expansions. + +use crate::{ast::*, openapi, permissions, static_hosting}; +use quote::{format_ident, quote}; + +pub(crate) fn generate_service_code( + service_def: ServiceDefinition, +) -> syn::Result { + let service_name = &service_def.service_name; + let service_name_lower = service_name.to_string().to_lowercase(); + let server_mod = format_ident!("__ras_rest_{}_server", service_name_lower); + let client_mod = format_ident!("__ras_rest_{}_client", service_name_lower); + + let (openapi_code, schema_checks) = if let Some(openapi_config) = &service_def.openapi { + ( + openapi::generate_openapi_code(&service_def, openapi_config), + openapi::generate_schema_impl_checks(&service_def), + ) + } else { + (quote! {}, quote! {}) + }; + + let static_hosting_code = if service_def.static_hosting.serve_docs { + static_hosting::generate_static_hosting_code(&service_def, &service_def.static_hosting) + } else { + quote! {} + }; + + let client_impl = crate::client::generate_client_code(&service_def); + let permissions_code = if cfg!(feature = "permissions") { + permissions::generate_permissions_code(&service_def) + } else { + quote! {} + }; + + // `cfg!(feature = ...)` below evaluates the MACRO crate's features, which + // Cargo unifies across the whole workspace — one crate enabling `client` + // forces client codegen into every consumer's expansion. With + // `feature_gated: true` the generated code is instead wrapped in + // `#[cfg(feature = ...)]` attributes that resolve against the CONSUMER + // crate's own `server`/`client` features, immune to unification. + let feature_gated = service_def.feature_gated; + let cfg_server = if feature_gated { + quote! { #[cfg(feature = "server")] } + } else { + quote! {} + }; + let cfg_client = if feature_gated { + quote! { #[cfg(feature = "client")] } + } else { + quote! {} + }; + + let server_code = if feature_gated || cfg!(feature = "server") { + let server_impl = crate::server::generate_server_code( + &service_def, + schema_checks, + openapi_code, + static_hosting_code, + ); + quote! { + #cfg_server + mod #server_mod { use super::*; #server_impl } + #cfg_server + pub use #server_mod::*; + } + } else { + quote! {} + }; + + let client_code = if feature_gated || cfg!(feature = "client") { + quote! { + #cfg_client + mod #client_mod { + use super::*; + + #client_impl + } + + #cfg_client + pub use #client_mod::*; + } + } else { + quote! {} + }; + + let output = quote! { + #permissions_code + #server_code + #client_code + }; + + Ok(output) +} diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index b190874..902df2b 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -1,6 +1,11 @@ +use ast::*; use proc_macro::TokenStream; -use quote::{format_ident, quote}; -use syn::{Ident, LitStr, Token, Type, parse::Parse, parse_macro_input}; +use syn::parse_macro_input; + +mod ast; +mod expand; +mod parser; +mod server; mod client; mod openapi; @@ -132,2179 +137,8 @@ mod static_hosting; pub fn rest_service(input: TokenStream) -> TokenStream { let service_definition = parse_macro_input!(input as ServiceDefinition); - match generate_service_code(service_definition) { + match expand::generate_service_code(service_definition) { Ok(tokens) => tokens.into(), Err(err) => err.to_compile_error().into(), } } - -#[derive(Debug)] -struct ServiceDefinition { - service_name: Ident, - base_path: String, - openapi: Option, - static_hosting: static_hosting::StaticHostingConfig, - body_limit: Option, - feature_gated: bool, - /// Require an `application/json` request `Content-Type` on every endpoint - /// that declares a body. Defaults to `true`. Set `require_json_content_type: - /// false` to opt out (e.g. for clients that cannot set the header). - require_json_content_type: bool, - /// Gate the generated docs page and `openapi.json` behind authentication - /// (any authenticated user). Defaults to `false` — docs are public when - /// `serve_docs` is enabled, matching conventional API-explorer behavior. - docs_require_auth: bool, - endpoints: Vec, -} - -/// Default maximum JSON body size in bytes (matches axum's default). -const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; - -#[derive(Debug)] -enum OpenApiConfig { - Enabled, - WithPath(String), -} - -#[derive(Debug)] -struct EndpointDefinition { - docs: Option, - method: HttpMethod, - auth: AuthRequirement, - path: String, - path_params: Vec, - query_params: Vec, - request_type: Option, - response_type: Type, - handler_name: Ident, - version: Option, - versions: Vec, - /// Per-endpoint request body size cap (bytes). Overrides the service-level - /// `body_limit` for this endpoint when set. - body_limit: Option, - /// When `true`, the handler receives the request `HeaderMap` as an extra - /// parameter (immediately after the caller/user, before path params). - with_headers: bool, -} - -#[derive(Debug)] -struct EndpointVersionDefinition { - version: String, - path: String, - path_params: Vec, - query_params: Vec, - request_type: Option, - response_type: Type, - migration_type: Type, -} - -#[derive(Debug)] -struct DocComment { - summary: String, - description: String, -} - -impl DocComment { - fn from_lines(lines: Vec) -> Option { - let lines: Vec = lines - .into_iter() - .map(|line| line.trim().to_string()) - .collect(); - let start = lines.iter().position(|line| !line.is_empty())?; - let end = lines.iter().rposition(|line| !line.is_empty())?; - let lines = &lines[start..=end]; - - Some(Self { - summary: lines[0].clone(), - description: lines.join("\n"), - }) - } -} - -#[derive(Debug, Clone)] -enum HttpMethod { - Get, - Post, - Put, - Delete, - Patch, -} - -impl HttpMethod { - fn as_axum_method(&self) -> proc_macro2::TokenStream { - match self { - HttpMethod::Get => quote! { axum::routing::get }, - HttpMethod::Post => quote! { axum::routing::post }, - HttpMethod::Put => quote! { axum::routing::put }, - HttpMethod::Delete => quote! { axum::routing::delete }, - HttpMethod::Patch => quote! { axum::routing::patch }, - } - } - - fn as_str(&self) -> &'static str { - match self { - HttpMethod::Get => "GET", - HttpMethod::Post => "POST", - HttpMethod::Put => "PUT", - HttpMethod::Delete => "DELETE", - HttpMethod::Patch => "PATCH", - } - } -} - -#[derive(Debug, Clone)] -struct PathParam { - name: Ident, - param_type: Type, -} - -#[derive(Debug, Clone)] -struct QueryParam { - name: Ident, - param_type: Type, -} - -#[derive(Debug)] -enum AuthRequirement { - Unauthorized, - /// Public route that opportunistically identifies its caller. Never rejected - /// for auth reasons; the handler receives a `ras_auth_core::Caller`. - OptionalAuth, - WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups -} - -const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`"; - -fn parse_label(input: syn::parse::ParseStream) -> syn::Result { - if input.peek(LitStr) { - Ok(input.parse::()?.value()) - } else { - Ok(input.parse::()?.to_string()) - } -} - -fn parse_doc_comment_attrs( - attrs: Vec, - entry_kind: &str, -) -> syn::Result> { - let lines = attrs - .into_iter() - .map(|attr| parse_doc_comment_attr(attr, entry_kind)) - .collect::>>()?; - - Ok(DocComment::from_lines(lines)) -} - -fn parse_doc_comment_attr(attr: syn::Attribute, entry_kind: &str) -> syn::Result { - if !attr.path().is_ident("doc") { - return Err(syn::Error::new_spanned( - attr, - format!("Only doc comments (`/// ...`) are supported before {entry_kind} definitions"), - )); - } - - if let syn::Meta::NameValue(name_value) = &attr.meta - && let syn::Expr::Lit(expr_lit) = &name_value.value - && let syn::Lit::Str(doc_line) = &expr_lit.lit - { - return Ok(doc_line.value()); - } - - Err(syn::Error::new_spanned(attr, DOC_COMMENT_EXPECTED)) -} - -impl Parse for ServiceDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let content; - syn::braced!(content in input); - - let _ = content.parse::()?; // "service_name" - let _ = content.parse::()?; - let service_name = content.parse::()?; - let _ = content.parse::()?; - - let _ = content.parse::()?; // "base_path" - let _ = content.parse::()?; - let base_path_lit = content.parse::()?; - let base_path = base_path_lit.value(); - let _ = content.parse::()?; - - let mut openapi = None; - let mut static_hosting = static_hosting::StaticHostingConfig::default(); - let mut body_limit = None; - let mut feature_gated = false; - let mut require_json_content_type = true; - let mut docs_require_auth = false; - - while content.peek(Ident) { - let field_name = content.fork().parse::()?; - - if field_name == "openapi" { - let _ = content.parse::()?; // "openapi" - let _ = content.parse::()?; - - if content.peek(syn::LitBool) { - let enabled = content.parse::()?; - if enabled.value() { - openapi = Some(OpenApiConfig::Enabled); - } - } else if content.peek(syn::token::Brace) { - let openapi_content; - syn::braced!(openapi_content in content); - - let _ = openapi_content.parse::()?; // "output" - let _ = openapi_content.parse::()?; - let path = openapi_content.parse::()?; - openapi = Some(OpenApiConfig::WithPath(path.value())); - } - - let _ = content.parse::()?; - } else if field_name == "serve_docs" { - let _ = content.parse::()?; // "serve_docs" - let _ = content.parse::()?; - let enabled = content.parse::()?; - static_hosting.serve_docs = enabled.value(); - let _ = content.parse::()?; - } else if field_name == "docs_path" { - let _ = content.parse::()?; // "docs_path" - let _ = content.parse::()?; - let path = content.parse::()?; - static_hosting.docs_path = path.value(); - let _ = content.parse::()?; - } else if field_name == "ui_theme" { - let _ = content.parse::()?; // "ui_theme" - let _ = content.parse::()?; - let theme = content.parse::()?; - static_hosting.ui_theme = theme.value(); - let _ = content.parse::()?; - } else if field_name == "body_limit" { - let _ = content.parse::()?; // "body_limit" - let _ = content.parse::()?; - let limit = content.parse::()?; - body_limit = Some(limit.base10_parse::()?); - let _ = content.parse::()?; - } else if field_name == "feature_gated" { - let _ = content.parse::()?; // "feature_gated" - let _ = content.parse::()?; - let enabled = content.parse::()?; - feature_gated = enabled.value(); - let _ = content.parse::()?; - } else if field_name == "require_json_content_type" { - let _ = content.parse::()?; // "require_json_content_type" - let _ = content.parse::()?; - let enabled = content.parse::()?; - require_json_content_type = enabled.value(); - let _ = content.parse::()?; - } else if field_name == "docs_require_auth" { - let _ = content.parse::()?; // "docs_require_auth" - let _ = content.parse::()?; - let enabled = content.parse::()?; - docs_require_auth = enabled.value(); - let _ = content.parse::()?; - } else if field_name == "endpoints" { - break; // Start parsing endpoints - } else { - return Err(syn::Error::new( - field_name.span(), - format!("Unknown field: {}", field_name), - )); - } - } - - let _ = content.parse::()?; // "endpoints" - let _ = content.parse::()?; - - let endpoints_content; - syn::bracketed!(endpoints_content in content); - - let mut endpoints = Vec::new(); - while !endpoints_content.is_empty() { - let endpoint = endpoints_content.parse::()?; - endpoints.push(endpoint); - - if endpoints_content.peek(Token![,]) { - let _ = endpoints_content.parse::()?; - } - } - - Ok(ServiceDefinition { - service_name, - base_path, - openapi, - static_hosting, - body_limit, - feature_gated, - require_json_content_type, - docs_require_auth, - endpoints, - }) - } -} - -fn parse_endpoint_path( - input: syn::parse::ParseStream, -) -> syn::Result<(String, Vec, Vec)> { - let mut path_segments = Vec::new(); - let mut path_params = Vec::new(); - let mut handler_name_parts = Vec::new(); - - let first_segment = input.parse::()?; - path_segments.push(first_segment.to_string()); - handler_name_parts.push(first_segment.to_string()); - - while input.peek(Token![/]) { - let _ = input.parse::()?; - - if input.peek(syn::token::Brace) { - let param_content; - syn::braced!(param_content in input); - - let param_name = param_content.parse::()?; - let _ = param_content.parse::()?; - let param_type = param_content.parse::()?; - - path_segments.push(format!("{{{}}}", param_name)); - path_params.push(PathParam { - name: param_name.clone(), - param_type, - }); - handler_name_parts.push(format!("by_{}", param_name)); - } else { - let segment = input.parse::()?; - path_segments.push(segment.to_string()); - handler_name_parts.push(segment.to_string()); - } - } - - Ok(( - format!("/{}", path_segments.join("/")), - path_params, - handler_name_parts, - )) -} - -fn parse_query_params(input: syn::parse::ParseStream) -> syn::Result> { - let mut query_params = Vec::new(); - - if input.is_empty() { - return Ok(query_params); - } - - let param_name = input.parse::()?; - let _ = input.parse::()?; - let param_type = input.parse::()?; - query_params.push(QueryParam { - name: param_name, - param_type, - }); - - while input.peek(Token![&]) || input.peek(Token![,]) { - if input.peek(Token![&]) { - let _ = input.parse::()?; - } else { - let _ = input.parse::()?; - } - - if input.is_empty() { - break; - } - - let param_name = input.parse::()?; - let _ = input.parse::()?; - let param_type = input.parse::()?; - query_params.push(QueryParam { - name: param_name, - param_type, - }); - } - - Ok(query_params) -} - -impl Parse for EndpointDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let docs = parse_doc_comment_attrs(input.call(syn::Attribute::parse_outer)?, "endpoint")?; - - let method_ident = input.parse::()?; - let method = match method_ident.to_string().as_str() { - "GET" => HttpMethod::Get, - "POST" => HttpMethod::Post, - "PUT" => HttpMethod::Put, - "DELETE" => HttpMethod::Delete, - "PATCH" => HttpMethod::Patch, - _ => { - return Err(syn::Error::new( - method_ident.span(), - "Expected GET, POST, PUT, DELETE, or PATCH", - )); - } - }; - - let auth = if input.peek(syn::Ident) { - let auth_ident = input.parse::()?; - match auth_ident.to_string().as_str() { - "UNAUTHORIZED" => AuthRequirement::Unauthorized, - "OPTIONAL_AUTH" => AuthRequirement::OptionalAuth, - "WITH_PERMISSIONS" => { - let perms_content; - syn::parenthesized!(perms_content in input); - - let mut permission_groups = Vec::new(); - - let first_group_content; - syn::bracketed!(first_group_content in perms_content); - - let mut first_group = Vec::new(); - while !first_group_content.is_empty() { - let perm = first_group_content.parse::()?; - first_group.push(perm.value()); - - if first_group_content.peek(Token![,]) { - let _ = first_group_content.parse::()?; - } - } - permission_groups.push(first_group); - - while perms_content.peek(Token![|]) { - let _ = perms_content.parse::()?; - - let group_content; - syn::bracketed!(group_content in perms_content); - - let mut group = Vec::new(); - while !group_content.is_empty() { - let perm = group_content.parse::()?; - group.push(perm.value()); - - if group_content.peek(Token![,]) { - let _ = group_content.parse::()?; - } - } - permission_groups.push(group); - } - - if permission_groups.len() > 1 - && permission_groups.iter().any(|group| group.is_empty()) - { - return Err(syn::Error::new( - auth_ident.span(), - "an empty permission group is only valid as the entire requirement \ - (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ - empty group with non-empty groups would silently grant access to any \ - authenticated user", - )); - } - - AuthRequirement::WithPermissions(permission_groups) - } - _ => { - return Err(syn::Error::new( - auth_ident.span(), - "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", - )); - } - } - } else { - return Err(syn::Error::new( - input.span(), - "Expected authentication requirement", - )); - }; - - let (path, path_params, handler_name_parts) = parse_endpoint_path(input)?; - - let mut query_params = Vec::new(); - if input.peek(Token![?]) { - let _ = input.parse::()?; - query_params = parse_query_params(input)?; - } - - let method_str = method.as_str().to_lowercase(); - let path_str = handler_name_parts.join("_"); - let handler_name = syn::parse_str::(&format!("{}_{}", method_str, path_str))?; - - let request_type = if input.peek(syn::token::Paren) { - let request_content; - syn::parenthesized!(request_content in input); - if !request_content.is_empty() { - Some(request_content.parse::()?) - } else { - None - } - } else { - None - }; - - let _ = input.parse::]>()?; - let response_type = input.parse::()?; - - let mut version = None; - let mut versions = Vec::new(); - let mut body_limit = None; - let mut with_headers = false; - - if input.peek(syn::token::Brace) { - let content; - syn::braced!(content in input); - - while !content.is_empty() { - let field_name = content.parse::()?; - let _ = content.parse::()?; - - match field_name.to_string().as_str() { - "version" => { - version = Some(parse_label(&content)?); - } - "versions" => { - let versions_content; - syn::bracketed!(versions_content in content); - - while !versions_content.is_empty() { - versions.push(versions_content.parse::()?); - - if versions_content.peek(Token![,]) { - let _ = versions_content.parse::()?; - } - } - } - "body_limit" => { - let limit = content.parse::()?; - body_limit = Some(limit.base10_parse::()?); - } - "headers" => { - with_headers = content.parse::()?.value(); - } - _ => { - return Err(syn::Error::new( - field_name.span(), - "Expected version, versions, body_limit, or headers", - )); - } - } - - if content.peek(Token![,]) { - let _ = content.parse::()?; - } - } - } - - Ok(EndpointDefinition { - docs, - method, - auth, - path, - path_params, - query_params, - request_type, - response_type, - handler_name, - version, - versions, - body_limit, - with_headers, - }) - } -} - -impl Parse for EndpointVersionDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let version = parse_label(input)?; - - let content; - syn::braced!(content in input); - - let mut path = None; - let mut path_params = Vec::new(); - let mut query_params = Vec::new(); - let mut request_type = None; - let mut response_type = None; - let mut migration_type = None; - - while !content.is_empty() { - let field_name = content.parse::()?; - let _ = content.parse::()?; - - match field_name.to_string().as_str() { - "path" => { - let (parsed_path, parsed_path_params, _) = parse_endpoint_path(&content)?; - path = Some(parsed_path); - path_params = parsed_path_params; - } - "query" => { - let query_content; - syn::bracketed!(query_content in content); - query_params = parse_query_params(&query_content)?; - } - "body" | "request" => { - let parsed_type = content.parse::()?; - if quote!(#parsed_type).to_string() != "()" { - request_type = Some(parsed_type); - } - } - "response" => { - response_type = Some(content.parse::()?); - } - "migration" => { - migration_type = Some(content.parse::()?); - } - _ => { - return Err(syn::Error::new( - field_name.span(), - "Expected path, query, body, request, response, or migration", - )); - } - } - - if content.peek(Token![,]) { - let _ = content.parse::()?; - } - } - - Ok(Self { - version, - path: path - .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing path"))?, - path_params, - query_params, - request_type, - response_type: response_type.ok_or_else(|| { - syn::Error::new(input.span(), "Version entry is missing response") - })?, - migration_type: migration_type.ok_or_else(|| { - syn::Error::new(input.span(), "Version entry is missing migration") - })?, - }) - } -} - -fn generate_service_code(service_def: ServiceDefinition) -> syn::Result { - let service_name = &service_def.service_name; - let service_trait_name = quote::format_ident!("{}Trait", service_name); - let builder_name = quote::format_ident!("{}Builder", service_name); - let base_path = &service_def.base_path; - let service_name_lower = service_name.to_string().to_lowercase(); - let server_mod = format_ident!("__ras_rest_{}_server", service_name_lower); - let client_mod = format_ident!("__ras_rest_{}_client", service_name_lower); - - let (openapi_code, schema_checks) = if let Some(openapi_config) = &service_def.openapi { - ( - openapi::generate_openapi_code(&service_def, openapi_config), - openapi::generate_schema_impl_checks(&service_def), - ) - } else { - (quote! {}, quote! {}) - }; - - let static_hosting_code = if service_def.static_hosting.serve_docs { - static_hosting::generate_static_hosting_code(&service_def, &service_def.static_hosting) - } else { - quote! {} - }; - - let client_impl = crate::client::generate_client_code(&service_def); - let permissions_code = if cfg!(feature = "permissions") { - permissions::generate_permissions_code(&service_def) - } else { - quote! {} - }; - - let trait_methods = service_def.endpoints.iter().map(|endpoint| { - let handler_name = &endpoint.handler_name; - let response_type = &endpoint.response_type; - - let mut params = Vec::new(); - match &endpoint.auth { - AuthRequirement::Unauthorized => {} - AuthRequirement::OptionalAuth => { - params.push(quote! { caller: ras_auth_core::Caller }); - } - AuthRequirement::WithPermissions(_) => { - params.push(quote! { user: &ras_auth_core::AuthenticatedUser }); - } - } - - // Opt-in request headers (immediately after the caller/user) - if endpoint.with_headers { - params.push(quote! { headers: axum::http::HeaderMap }); - } - - for path_param in &endpoint.path_params { - let param_name = &path_param.name; - let param_type = &path_param.param_type; - params.push(quote! { #param_name: #param_type }); - } - - for query_param in &endpoint.query_params { - let param_name = &query_param.name; - let param_type = &query_param.param_type; - params.push(quote! { #param_name: #param_type }); - } - - if let Some(request_type) = &endpoint.request_type { - params.push(quote! { request: #request_type }); - } - - quote! { - async fn #handler_name(&self, #(#params),*) -> ras_rest_core::RestResult<#response_type>; - } - }); - - let request_part_structs = generate_rest_request_part_structs(&service_def); - - let mut query_structs: Vec = Vec::new(); - let mut route_registrations: Vec = Vec::new(); - let mut route_idx = 0usize; - - for endpoint in &service_def.endpoints { - let query_struct_name = quote::format_ident!("QueryParams{}", route_idx); - query_structs.push(generate_query_struct( - &query_struct_name, - &endpoint.query_params, - )); - route_registrations.push(generate_canonical_route_registration( - endpoint, - &query_struct_name, - service_def.require_json_content_type, - )); - route_idx += 1; - - for version in &endpoint.versions { - let query_struct_name = quote::format_ident!("QueryParams{}", route_idx); - query_structs.push(generate_query_struct( - &query_struct_name, - &version.query_params, - )); - route_registrations.push(generate_legacy_route_registration( - &service_def.service_name, - endpoint, - version, - &query_struct_name, - service_def.require_json_content_type, - )); - route_idx += 1; - } - } - - let static_routes = if service_def.static_hosting.serve_docs { - static_hosting::generate_static_routes(&service_def, &service_def.static_hosting) - } else { - quote! {} - }; - - let body_limit = service_def.body_limit.unwrap_or(DEFAULT_BODY_LIMIT); - - // Startup assertion: a service with any WITH_PERMISSIONS route needs an auth - // provider, otherwise every such route returns a runtime 500 (NoAuthProvider) - // on first request. Catch the misconfiguration at build() instead. - let any_route_requires_auth = service_def - .endpoints - .iter() - .any(|endpoint| matches!(endpoint.auth, AuthRequirement::WithPermissions(_))) - || (service_def.static_hosting.serve_docs && service_def.docs_require_auth); - let service_name_str = service_name.to_string(); - let provider_assertion = if any_route_requires_auth { - quote! { - if self.auth_provider.is_none() { - panic!(concat!( - "REST service `", - #service_name_str, - "` has endpoints requiring authorization (WITH_PERMISSIONS) but no ", - "auth_provider was configured; call .auth_provider(...) before build()" - )); - } - } - } else { - quote! {} - }; - - // `cfg!(feature = ...)` below evaluates the MACRO crate's features, which - // Cargo unifies across the whole workspace — one crate enabling `client` - // forces client codegen into every consumer's expansion. With - // `feature_gated: true` the generated code is instead wrapped in - // `#[cfg(feature = ...)]` attributes that resolve against the CONSUMER - // crate's own `server`/`client` features, immune to unification. - let feature_gated = service_def.feature_gated; - let cfg_server = if feature_gated { - quote! { #[cfg(feature = "server")] } - } else { - quote! {} - }; - let cfg_client = if feature_gated { - quote! { #[cfg(feature = "client")] } - } else { - quote! {} - }; - - let server_code = if feature_gated || cfg!(feature = "server") { - quote! { - #cfg_server - mod #server_mod { - use super::*; - - /// Maximum accepted JSON body size in bytes - #[allow(dead_code)] - const __RAS_BODY_LIMIT: usize = #body_limit; - - /// Map a shared authorization failure to this service's JSON error shape - #[allow(dead_code)] - fn __ras_authorize_error_response(error: ras_auth_core::AuthorizeError) -> axum::response::Response { - use axum::response::IntoResponse; - let (status, message) = match &error { - ras_auth_core::AuthorizeError::MissingCredential => ( - axum::http::StatusCode::UNAUTHORIZED, - "Missing or invalid Authorization header", - ), - ras_auth_core::AuthorizeError::CsrfValidationFailed => ( - axum::http::StatusCode::FORBIDDEN, - "CSRF validation failed", - ), - ras_auth_core::AuthorizeError::AuthenticationFailed(_) => ( - axum::http::StatusCode::UNAUTHORIZED, - "Authentication failed", - ), - ras_auth_core::AuthorizeError::NoAuthProvider => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "No auth provider configured", - ), - ras_auth_core::AuthorizeError::InsufficientPermissions(_) => ( - axum::http::StatusCode::FORBIDDEN, - "Insufficient permissions", - ), - }; - // Rejections are otherwise invisible to the usage/duration trackers, - // which only run on the post-auth happy path; log them here so a - // client hammering an endpoint with a bad credential is observable. - // The `error` detail is logged server-side only; the client gets the - // generic `message`. - ras_rest_core::tracing::warn!( - status = status.as_u16(), - error = ?error, - "request rejected during authorization" - ); - (status, axum::Json(serde_json::json!({ "error": message }))).into_response() - } - - /// Build a success response, omitting the JSON body for status codes that - /// must not carry one (`204 No Content`, `205 Reset Content`, - /// `304 Not Modified`). Without this, `RestResponse::no_content()` would - /// emit a `204` with a serialized `null` body and - /// `Content-Type: application/json`, which violates RFC 9110 and is - /// rejected by some proxies and clients. - #[allow(dead_code)] - fn __ras_success_response( - status: axum::http::StatusCode, - body: T, - ) -> axum::response::Response { - use axum::response::IntoResponse; - if status == axum::http::StatusCode::NO_CONTENT - || status == axum::http::StatusCode::RESET_CONTENT - || status == axum::http::StatusCode::NOT_MODIFIED - { - status.into_response() - } else { - (status, axum::Json(body)).into_response() - } - } - - /// Generated service trait - #[async_trait::async_trait] - #[allow(private_interfaces, private_bounds)] - pub trait #service_trait_name: Send + Sync + 'static { - #(#trait_methods)* - } - - /// Generated builder for the REST service - pub struct #builder_name { - 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>>, - } - - const _: () = { - #schema_checks - }; - - #openapi_code - - #static_hosting_code - - use self::query_params::*; - - mod query_params { - #[allow(unused_imports)] - use super::*; - - #(#query_structs)* - } - - #request_part_structs - - impl #builder_name { - /// Create a new builder with the service implementation - pub fn new(service: T) -> Self { - Self { - service: std::sync::Arc::new(service), - auth_provider: None, - auth_transport: ras_auth_core::AuthTransportConfig::default(), - with_usage_tracker: None, - with_method_duration_tracker: None, - } - } - - /// Set the auth provider - pub fn auth_provider(mut self, provider: A) -> Self { - self.auth_provider = Some(std::sync::Arc::new(provider)); - self - } - - /// Enable cookie authentication alongside bearer tokens. - /// - /// Installs a default double-submit CSRF config when none is set, - /// because cookie credentials are CSRF-exploitable on unsafe methods. - /// Override with `csrf_protection`. - pub fn auth_cookie(mut self, cookie: ras_auth_core::AuthCookieConfig) -> Self { - self.auth_transport.cookie = Some(cookie); - if self.auth_transport.csrf.is_none() { - self.auth_transport.csrf = Some(ras_auth_core::CsrfConfig::default()); - } - 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 - where - F: Fn(&axum::http::HeaderMap, Option<&ras_auth_core::AuthenticatedUser>, &str, &str) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.with_usage_tracker = Some(std::sync::Arc::new(move |headers, user, method, path| { - Box::pin(tracker(headers, user, method, path)) - })); - self - } - - /// Set the method duration tracker - called after each request completes - /// The tracker receives the HTTP method, path, authenticated user (if any), and execution duration - pub fn with_method_duration_tracker(mut self, tracker: F) -> Self - where - F: Fn(&str, &str, Option<&ras_auth_core::AuthenticatedUser>, std::time::Duration) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.with_method_duration_tracker = Some(std::sync::Arc::new(move |method, path, user, duration| { - Box::pin(tracker(method, path, user, duration)) - })); - self - } - - /// Build the axum router for the REST service - pub fn build(self) -> axum::Router { - self.auth_transport - .validate() - .expect("invalid auth transport configuration"); - - #provider_assertion - - let mut router = axum::Router::new(); - - #(#route_registrations)* - - #static_routes - - if #base_path.is_empty() || #base_path == "/" { - router - } else { - axum::Router::new().nest(#base_path, router) - } - } - } - - } - - #cfg_server - pub use #server_mod::*; - } - } else { - quote! {} - }; - - let client_code = if feature_gated || cfg!(feature = "client") { - quote! { - #cfg_client - mod #client_mod { - use super::*; - - #client_impl - } - - #cfg_client - pub use #client_mod::*; - } - } else { - quote! {} - }; - - let output = quote! { - #permissions_code - #server_code - #client_code - }; - - Ok(output) -} - -fn rest_permission_groups_code(auth: &AuthRequirement) -> proc_macro2::TokenStream { - let permission_groups = match auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), - AuthRequirement::WithPermissions(groups) => groups.clone(), - }; - - if permission_groups.is_empty() { - quote! { Vec::>::new() } - } else { - let groups = permission_groups.iter().map(|group| { - let perms = group.iter(); - quote! { vec![#(#perms.to_string()),*] } - }); - quote! { vec![#(#groups),*] as Vec> } - } -} - -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() { - "Version".to_string() - } else if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { - format!("V{out}") - } else { - out - } -} - -fn rest_request_part_idents( - service_name: &Ident, - handler_name: &Ident, - version: &str, -) -> (Ident, Ident, Ident) { - let service = service_name.to_string(); - let handler = pascal_ident_segment(&handler_name.to_string()); - let version = pascal_ident_segment(version); - let request_ident = quote::format_ident!("{}{}{}Request", service, handler, version); - let path_ident = quote::format_ident!("{}{}{}Path", service, handler, version); - let query_ident = quote::format_ident!("{}{}{}Query", service, handler, version); - (request_ident, path_ident, query_ident) -} - -fn rest_body_type_tokens(request_type: Option<&Type>) -> proc_macro2::TokenStream { - match request_type { - Some(request_type) => quote! { #request_type }, - None => quote! { () }, - } -} - -fn generate_rest_request_part_structs(service_def: &ServiceDefinition) -> proc_macro2::TokenStream { - let structs = service_def.endpoints.iter().flat_map(|endpoint| { - if endpoint.versions.is_empty() { - return Vec::new(); - } - - let canonical_version = endpoint.version.as_deref().unwrap_or("current"); - let mut structs = vec![generate_rest_request_part_struct( - &service_def.service_name, - &endpoint.handler_name, - canonical_version, - &endpoint.path_params, - &endpoint.query_params, - endpoint.request_type.as_ref(), - )]; - - structs.extend(endpoint.versions.iter().map(|version| { - generate_rest_request_part_struct( - &service_def.service_name, - &endpoint.handler_name, - &version.version, - &version.path_params, - &version.query_params, - version.request_type.as_ref(), - ) - })); - - structs - }); - - quote! { - #(#structs)* - } -} - -fn generate_rest_request_part_struct( - service_name: &Ident, - handler_name: &Ident, - version: &str, - path_params: &[PathParam], - query_params: &[QueryParam], - request_type: Option<&Type>, -) -> proc_macro2::TokenStream { - let (request_ident, path_ident, query_ident) = - rest_request_part_idents(service_name, handler_name, version); - let path_fields = path_params.iter().map(|param| { - let name = ¶m.name; - let param_type = ¶m.param_type; - quote! { pub #name: #param_type } - }); - let query_fields = query_params.iter().map(|param| { - let name = ¶m.name; - let param_type = ¶m.param_type; - quote! { pub #name: #param_type } - }); - let body_type = rest_body_type_tokens(request_type); - - quote! { - pub struct #path_ident { - #(#path_fields),* - } - - pub struct #query_ident { - #(#query_fields),* - } - - pub struct #request_ident { - pub path: #path_ident, - pub query: #query_ident, - pub body: #body_type, - } - } -} - -fn generate_rest_parts_init( - service_name: &Ident, - handler_name: &Ident, - version: &str, - path_params: &[PathParam], - query_params: &[QueryParam], - request_type: Option<&Type>, -) -> proc_macro2::TokenStream { - let (request_ident, path_ident, query_ident) = - rest_request_part_idents(service_name, handler_name, version); - - let path_values = 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 } - } - }); - - let query_values = query_params.iter().map(|param| { - let name = ¶m.name; - quote! { #name: query_params.#name } - }); - - let body_value = if request_type.is_some() { - quote! { body } - } else { - quote! { () } - }; - - quote! { - #request_ident { - path: #path_ident { - #(#path_values),* - }, - query: #query_ident { - #(#query_values),* - }, - body: #body_value, - } - } -} - -fn rest_canonical_args_from_parts( - endpoint: &EndpointDefinition, - parts_ident: &Ident, -) -> Vec { - let mut args = Vec::new(); - - for path_param in &endpoint.path_params { - let name = &path_param.name; - args.push(quote! { #parts_ident.path.#name }); - } - - for query_param in &endpoint.query_params { - let name = &query_param.name; - args.push(quote! { #parts_ident.query.#name }); - } - - if endpoint.request_type.is_some() { - args.push(quote! { #parts_ident.body }); - } - - args -} - -fn generate_query_struct( - struct_name: &Ident, - query_params: &[QueryParam], -) -> proc_macro2::TokenStream { - if query_params.is_empty() { - return quote! {}; - } - - let fields = query_params.iter().map(|param| { - let name = ¶m.name; - let param_type = ¶m.param_type; - quote! { pub #name: #param_type } - }); - - quote! { - #[derive(serde::Deserialize)] - pub(super) struct #struct_name { - #(#fields),* - } - } -} - -fn generate_canonical_route_registration( - endpoint: &EndpointDefinition, - query_struct_name: &Ident, - require_json: bool, -) -> proc_macro2::TokenStream { - let method_routing = endpoint.method.as_axum_method(); - let path = &endpoint.path; - let handler_name = &endpoint.handler_name; - let method_str = endpoint.method.as_str(); - let AxumHandlerParts { - extractors: axum_handler, - prelude: extractor_prelude, - } = generate_axum_handler( - &endpoint.path_params, - &endpoint.query_params, - endpoint.request_type.as_ref(), - query_struct_name, - method_str, - path, - ); - let handler_body = - generate_handler_body(endpoint, handler_name, method_str, path, require_json); - let permission_groups_code = rest_permission_groups_code(&endpoint.auth); - - quote! { - { - 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(); - - router = router.route(#path, #method_routing({ - 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(); - - async move { - #extractor_prelude - #handler_body - } - } - })); - } - } -} - -fn generate_legacy_route_registration( - service_name: &Ident, - endpoint: &EndpointDefinition, - version: &EndpointVersionDefinition, - query_struct_name: &Ident, - require_json: bool, -) -> proc_macro2::TokenStream { - let method_routing = endpoint.method.as_axum_method(); - let path = &version.path; - let AxumHandlerParts { - extractors: axum_handler, - prelude: extractor_prelude, - } = generate_axum_handler( - &version.path_params, - &version.query_params, - version.request_type.as_ref(), - query_struct_name, - endpoint.method.as_str(), - path, - ); - let handler_body = generate_legacy_handler_body(service_name, endpoint, version, require_json); - let permission_groups_code = rest_permission_groups_code(&endpoint.auth); - - quote! { - { - 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(); - - router = router.route(#path, #method_routing({ - 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(); - - async move { - #extractor_prelude - #handler_body - } - } - })); - } - } -} - -fn generate_legacy_handler_body( - service_name: &Ident, - endpoint: &EndpointDefinition, - version: &EndpointVersionDefinition, - require_json: bool, -) -> proc_macro2::TokenStream { - let handler_name = &endpoint.handler_name; - let method = endpoint.method.as_str(); - let path = &version.path; - let body_limit_tokens = effective_body_limit_tokens(endpoint); - let migration_type = &version.migration_type; - let canonical_response_type = &endpoint.response_type; - let legacy_response_type = &version.response_type; - let canonical_version = endpoint.version.as_deref().unwrap_or("current"); - let (canonical_request_ident, _, _) = - rest_request_part_idents(service_name, handler_name, canonical_version); - let (legacy_request_ident, _, _) = - rest_request_part_idents(service_name, handler_name, &version.version); - let legacy_parts_init = generate_rest_parts_init( - service_name, - handler_name, - &version.version, - &version.path_params, - &version.query_params, - version.request_type.as_ref(), - ); - let canonical_parts_ident = quote::format_ident!("canonical_parts"); - let mut canonical_args = rest_canonical_args_from_parts(endpoint, &canonical_parts_ident); - - // Opt-in request headers, inserted before the auth arg is prepended below so - // the final order is [caller/user?, headers, path.., query.., body?]. - if endpoint.with_headers { - canonical_args.insert(0, quote! { headers.clone() }); - } - - let json_handling = if version.request_type.is_some() { - generate_body_extraction(require_json, &body_limit_tokens, method, path) - } else { - quote! {} - }; - - match &endpoint.auth { - AuthRequirement::Unauthorized => quote! { - #json_handling - - if let Some(tracker) = &with_usage_tracker { - 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; - let #canonical_parts_ident: #canonical_request_ident = - match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { - Ok(parts) => parts, - Err(e) => { - use axum::response::IntoResponse; - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": e.to_string() - })) - ).into_response(); - }, - }; - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#canonical_args),*).await { - Ok(rest_response) => { - use axum::response::IntoResponse; - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - let body: #legacy_response_type = - match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { - Ok(body) => body, - Err(e) => { - ras_rest_core::tracing::error!(error = %e, "Response migration failed"); - return ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - axum::Json(serde_json::json!({ - "error": "Internal server error" - })) - ).into_response(); - }, - }; - __ras_success_response(status_code, body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, None, duration).await; - } - - result - }, - AuthRequirement::OptionalAuth => { - canonical_args.insert(0, quote! { caller }); - - quote! { - // Best-effort authentication for an OPTIONAL_AUTH route — never - // rejected: resolves to Caller::Anonymous for a missing/invalid - // credential, Caller::Authenticated for a valid one. - let caller = ras_auth_core::resolve_caller( - #method, - &headers, - &auth_transport, - auth_provider.as_deref(), - ).await; - // Snapshot the user for tracking; `caller` is moved into the handler. - let __ras_caller_user = caller.authenticated().cloned(); - - #json_handling - - if let Some(tracker) = &with_usage_tracker { - let tracker_headers = - ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); - tracker(&tracker_headers, __ras_caller_user.as_ref(), #method, #path).await; - } - - let legacy_parts: #legacy_request_ident = #legacy_parts_init; - let #canonical_parts_ident: #canonical_request_ident = - match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { - Ok(parts) => parts, - Err(e) => { - use axum::response::IntoResponse; - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": e.to_string() - })) - ).into_response(); - }, - }; - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#canonical_args),*).await { - Ok(rest_response) => { - use axum::response::IntoResponse; - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - let body: #legacy_response_type = - match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { - Ok(body) => body, - Err(e) => { - ras_rest_core::tracing::error!(error = %e, "Response migration failed"); - return ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - axum::Json(serde_json::json!({ - "error": "Internal server error" - })) - ).into_response(); - }, - }; - __ras_success_response(status_code, body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, __ras_caller_user.as_ref(), duration).await; - } - - result - } - } - AuthRequirement::WithPermissions(_) => { - canonical_args.insert(0, quote! { &user }); - - quote! { - // Authenticate and authorize: credential → CSRF → authenticate - // → OR-of-AND permission groups (shared ras-auth-core pipeline) - let user = match ras_auth_core::authorize_request( - #method, - &headers, - &auth_transport, - auth_provider.as_deref(), - &required_permission_groups, - ).await { - Ok(user) => user, - Err(error) => return __ras_authorize_error_response(error), - }; - - // Read and parse the body only after auth has succeeded - #json_handling - - if let Some(tracker) = &with_usage_tracker { - 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; - let #canonical_parts_ident: #canonical_request_ident = - match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { - Ok(parts) => parts, - Err(e) => { - use axum::response::IntoResponse; - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": e.to_string() - })) - ).into_response(); - }, - }; - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#canonical_args),*).await { - Ok(rest_response) => { - use axum::response::IntoResponse; - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - let body: #legacy_response_type = - match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { - Ok(body) => body, - Err(e) => { - ras_rest_core::tracing::error!(error = %e, "Response migration failed"); - return ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - axum::Json(serde_json::json!({ - "error": "Internal server error" - })) - ).into_response(); - }, - }; - __ras_success_response(status_code, body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, Some(&user), duration).await; - } - - result - } - } - } -} - -/// Generated handler signature plus the prelude that unwraps fallible -/// extractors inside the handler body. -struct AxumHandlerParts { - /// Closure parameter list (`headers`, path/query extractors, raw request). - extractors: proc_macro2::TokenStream, - /// Emitted at the top of the handler body. Path and query extractors are - /// taken as `Result<_, Rejection>` so the axum default rejection body — - /// which echoes the offending value verbatim, e.g. - /// ``Invalid URL: Cannot parse `abc` to a `i32` `` — is never sent to the - /// client. The detail is logged at `warn` and a fixed message is returned. - prelude: proc_macro2::TokenStream, -} - -fn generate_axum_handler( - path_params: &[PathParam], - query_params: &[QueryParam], - request_type: Option<&Type>, - query_struct_name: &Ident, - method: &str, - path: &str, -) -> AxumHandlerParts { - let mut extractors = Vec::new(); - let mut prelude = Vec::new(); - - extractors.push(quote! { headers: axum::http::HeaderMap }); - - if !path_params.is_empty() { - let path_param_types = path_params.iter().map(|param| ¶m.param_type); - let path_ty = if path_params.len() == 1 { - quote! { axum::extract::Path<#(#path_param_types)*> } - } else { - quote! { axum::extract::Path<(#(#path_param_types),*)> } - }; - extractors.push(quote! { - __ras_path: Result<#path_ty, axum::extract::rejection::PathRejection> - }); - prelude.push(quote! { - let axum::extract::Path(path_params) = match __ras_path { - Ok(path) => path, - Err(__ras_rejection) => { - use axum::response::IntoResponse; - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - status = __ras_rejection.status().as_u16(), - detail = %ras_rest_core::sanitize_log_detail(&__ras_rejection.body_text()), - "rejected request: invalid path parameters" - ); - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ "error": "Invalid path parameters" })) - ).into_response(); - } - }; - }); - } - - if !query_params.is_empty() { - extractors.push(quote! { - __ras_query: Result< - ::axum_extra::extract::Query, - ::axum_extra::extract::QueryRejection, - > - }); - prelude.push(quote! { - let ::axum_extra::extract::Query(query_params) = match __ras_query { - Ok(query) => query, - Err(__ras_rejection) => { - use axum::response::IntoResponse; - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - status = __ras_rejection.status().as_u16(), - detail = %ras_rest_core::sanitize_log_detail(&__ras_rejection.body_text()), - "rejected request: invalid query parameters" - ); - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ "error": "Invalid query parameters" })) - ).into_response(); - } - }; - }); - } - - // Take the raw request when a body is declared. The body is read and - // deserialized inside the handler AFTER auth/CSRF/permission checks, so - // unauthenticated clients cannot make the server buffer or parse payloads. - if request_type.is_some() { - extractors.push(quote! { request: axum::extract::Request }); - } - - AxumHandlerParts { - extractors: quote! { #(#extractors),* }, - prelude: quote! { #(#prelude)* }, - } -} - -/// Generated code that reads and JSON-deserializes the request body from the -/// raw `request` extractor, bounded by `limit`. -/// -/// For authenticated endpoints this must be emitted AFTER the -/// auth/CSRF/permission block so unauthenticated clients cannot make the -/// server buffer or parse payloads. -/// -/// Behavior: -/// * When `require_json` is set, a request whose `Content-Type` is not -/// `application/json` (ignoring parameters like `; charset=utf-8`) is rejected -/// with `415 Unsupported Media Type` before the body is read. Requiring -/// `application/json` also forces a CORS preflight for cross-origin requests, -/// which no CORS layer answers by default — defense-in-depth against -/// simple-request CSRF on cookie-authenticated endpoints. -/// * A declared `Content-Length` over `limit` is rejected with `413` up front so -/// a subsequent `to_bytes` error is unambiguously a read failure (`400`), -/// rather than the two being conflated as "too large". -/// * A malformed JSON body is logged (category + line/column, never the rejected -/// value) at `warn` before returning `400`, matching the handler-error logging -/// convention. -fn generate_body_extraction( - require_json: bool, - limit: &proc_macro2::TokenStream, - method: &str, - path: &str, -) -> proc_macro2::TokenStream { - let content_type_check = if require_json { - quote! { - { - let __ras_content_type_ok = headers - .get(axum::http::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| { - value - .split(';') - .next() - .unwrap_or("") - .trim() - .eq_ignore_ascii_case("application/json") - }) - .unwrap_or(false); - if !__ras_content_type_ok { - use axum::response::IntoResponse; - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - "rejected request: Content-Type is not application/json" - ); - return ( - axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, - axum::Json(serde_json::json!({ - "error": "Unsupported Media Type: expected application/json" - })) - ).into_response(); - } - } - } - } else { - quote! {} - }; - - quote! { - #content_type_check - - let body = { - // Reject an over-declared Content-Length up front so a 413 is - // unambiguous without reading the body. A chunked body with no - // declared length is still capped by `to_bytes`; that error is then - // classified below (over-limit -> 413, genuine read error -> 400). - if let Some(__ras_declared_len) = headers - .get(axum::http::header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - { - if __ras_declared_len > #limit { - use axum::response::IntoResponse; - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - declared_len = __ras_declared_len, - limit = #limit, - "rejected request: body exceeds limit" - ); - return ( - axum::http::StatusCode::PAYLOAD_TOO_LARGE, - axum::Json(serde_json::json!({ - "error": "Request body too large" - })) - ).into_response(); - } - } - - let body_bytes = match ::axum::body::to_bytes(request.into_body(), #limit).await { - Ok(bytes) => bytes, - Err(__ras_body_err) => { - use axum::response::IntoResponse; - // `to_bytes` fails for both an over-limit body and a genuine - // stream read error. axum wraps http_body_util's - // `LengthLimitError` (Display: "length limit exceeded") for - // the former; classify on it so a read failure is a 400 and - // only a real overflow is a 413. - let (__ras_status, __ras_client_msg) = - if __ras_body_err.to_string().contains("length limit exceeded") { - ( - axum::http::StatusCode::PAYLOAD_TOO_LARGE, - "Request body too large", - ) - } else { - ( - axum::http::StatusCode::BAD_REQUEST, - "Could not read request body", - ) - }; - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - status = __ras_status.as_u16(), - "rejected request: {}", - __ras_client_msg - ); - return ( - __ras_status, - axum::Json(serde_json::json!({ "error": __ras_client_msg })) - ).into_response(); - }, - }; - match serde_json::from_slice(&body_bytes) { - Ok(body) => body, - Err(__ras_json_err) => { - use axum::response::IntoResponse; - // Log the classification and location so the reason is - // recoverable server-side; never log the rejected value. - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - category = ?__ras_json_err.classify(), - line = __ras_json_err.line(), - column = __ras_json_err.column(), - "rejected request: malformed JSON body" - ); - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": "Invalid JSON" - })) - ).into_response(); - }, - } - }; - } -} - -/// The effective body-size limit expression for an endpoint: its per-endpoint -/// `body_limit` override when set, otherwise the service-level `__RAS_BODY_LIMIT`. -fn effective_body_limit_tokens(endpoint: &EndpointDefinition) -> proc_macro2::TokenStream { - match endpoint.body_limit { - Some(limit) => quote! { #limit }, - None => quote! { __RAS_BODY_LIMIT }, - } -} - -fn generate_handler_body( - endpoint: &EndpointDefinition, - handler_name: &Ident, - method: &str, - path: &str, - require_json: bool, -) -> proc_macro2::TokenStream { - let body_limit_tokens = effective_body_limit_tokens(endpoint); - match &endpoint.auth { - AuthRequirement::Unauthorized => { - let mut args = Vec::new(); - - // Opt-in request headers (before path params) - if endpoint.with_headers { - args.push(quote! { headers.clone() }); - } - - if endpoint.path_params.len() == 1 { - args.push(quote! { path_params }); - } else { - for (i, _) in endpoint.path_params.iter().enumerate() { - let idx = syn::Index::from(i); - args.push(quote! { path_params.#idx }); - } - } - - for query_param in &endpoint.query_params { - let param_name = &query_param.name; - args.push(quote! { query_params.#param_name }); - } - - let json_handling = if endpoint.request_type.is_some() { - args.push(quote! { body }); - generate_body_extraction(require_json, &body_limit_tokens, method, path) - } else { - quote! {} - }; - - quote! { - #json_handling - - if let Some(tracker) = &with_usage_tracker { - let tracker_headers = - ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); - tracker(&tracker_headers, None, #method, #path).await; - } - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#args),*).await { - Ok(rest_response) => { - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - __ras_success_response(status_code, rest_response.body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, None, duration).await; - } - - result - } - } - AuthRequirement::OptionalAuth => { - // Build argument list; the caller is passed by value as the first arg. - let mut args = vec![quote! { caller }]; - - // Opt-in request headers (after the caller, before path params) - if endpoint.with_headers { - args.push(quote! { headers.clone() }); - } - - if endpoint.path_params.len() == 1 { - args.push(quote! { path_params }); - } else { - for (i, _) in endpoint.path_params.iter().enumerate() { - let idx = syn::Index::from(i); - args.push(quote! { path_params.#idx }); - } - } - - for query_param in &endpoint.query_params { - let param_name = &query_param.name; - args.push(quote! { query_params.#param_name }); - } - - let json_handling = if endpoint.request_type.is_some() { - args.push(quote! { body }); - generate_body_extraction(require_json, &body_limit_tokens, method, path) - } else { - quote! {} - }; - - quote! { - // Best-effort authentication for an OPTIONAL_AUTH route: never - // rejected — Caller::Anonymous when no/invalid credential is - // present, Caller::Authenticated otherwise. - let caller = ras_auth_core::resolve_caller( - #method, - &headers, - &auth_transport, - auth_provider.as_deref(), - ).await; - // Snapshot the user for tracking; `caller` is moved into the handler. - let __ras_caller_user = caller.authenticated().cloned(); - - #json_handling - - if let Some(tracker) = &with_usage_tracker { - let tracker_headers = - ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); - tracker(&tracker_headers, __ras_caller_user.as_ref(), #method, #path).await; - } - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#args),*).await { - Ok(rest_response) => { - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - __ras_success_response(status_code, rest_response.body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, __ras_caller_user.as_ref(), duration).await; - } - - result - } - } - AuthRequirement::WithPermissions(_) => { - let mut args = vec![quote! { &user }]; - - // Opt-in request headers (after the user, before path params) - if endpoint.with_headers { - args.push(quote! { headers.clone() }); - } - - if endpoint.path_params.len() == 1 { - args.push(quote! { path_params }); - } else { - for (i, _) in endpoint.path_params.iter().enumerate() { - let idx = syn::Index::from(i); - args.push(quote! { path_params.#idx }); - } - } - - for query_param in &endpoint.query_params { - let param_name = &query_param.name; - args.push(quote! { query_params.#param_name }); - } - - let json_handling = if endpoint.request_type.is_some() { - args.push(quote! { body }); - generate_body_extraction(require_json, &body_limit_tokens, method, path) - } else { - quote! {} - }; - - quote! { - // Authenticate and authorize: credential → CSRF → authenticate - // → OR-of-AND permission groups (shared ras-auth-core pipeline) - let user = match ras_auth_core::authorize_request( - #method, - &headers, - &auth_transport, - auth_provider.as_deref(), - &required_permission_groups, - ).await { - Ok(user) => user, - Err(error) => return __ras_authorize_error_response(error), - }; - - // Read and parse the body only after auth has succeeded - #json_handling - - if let Some(tracker) = &with_usage_tracker { - let tracker_headers = - ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); - tracker(&tracker_headers, Some(&user), #method, #path).await; - } - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#args),*).await { - Ok(rest_response) => { - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - __ras_success_response(status_code, rest_response.body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, Some(&user), duration).await; - } - - result - } - } - } -} diff --git a/crates/rest/ras-rest-macro/src/openapi.rs b/crates/rest/ras-rest-macro/src/openapi.rs deleted file mode 100644 index 9575815..0000000 --- a/crates/rest/ras-rest-macro/src/openapi.rs +++ /dev/null @@ -1,775 +0,0 @@ -//! OpenAPI 3.0 document generation module -//! -//! This module provides functionality to generate OpenAPI 3.0 specification documents -//! from the rest_service macro definitions. - -use crate::{AuthRequirement, OpenApiConfig, ServiceDefinition}; -use proc_macro2::TokenStream; -use quote::quote; -use std::collections::HashMap; - -/// Generates OpenAPI document creation code -pub fn generate_openapi_code( - service_def: &ServiceDefinition, - config: &OpenApiConfig, -) -> TokenStream { - let service_name = &service_def.service_name; - let openapi_fn_name = quote::format_ident!( - "generate_{}_openapi", - service_name.to_string().to_lowercase() - ); - let openapi_to_file_fn_name = quote::format_ident!( - "generate_{}_openapi_to_file", - service_name.to_string().to_lowercase() - ); - let endpoint_info_struct_name = quote::format_ident!("{}OpenApiEndpointInfo", service_name); - - 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() - } - } - }; - - let mut unique_types = std::collections::HashMap::new(); - for endpoint in &service_def.endpoints { - if let Some(request_type) = &endpoint.request_type { - let request_type_str = quote!(#request_type).to_string(); - unique_types.insert(request_type_str, quote!(#request_type)); - } - - let response_type = &endpoint.response_type; - let response_type_str = quote!(#response_type).to_string(); - unique_types.insert(response_type_str, quote!(#response_type)); - - for path_param in &endpoint.path_params { - let param_type = &path_param.param_type; - let param_type_str = quote!(#param_type).to_string(); - unique_types.insert(param_type_str, quote!(#param_type)); - } - - for query_param in &endpoint.query_params { - let param_type = &query_param.param_type; - let param_type_str = quote!(#param_type).to_string(); - unique_types.insert(param_type_str, quote!(#param_type)); - } - - for version in &endpoint.versions { - if let Some(request_type) = &version.request_type { - let request_type_str = quote!(#request_type).to_string(); - unique_types.insert(request_type_str, quote!(#request_type)); - } - - let response_type = &version.response_type; - let response_type_str = quote!(#response_type).to_string(); - unique_types.insert(response_type_str, quote!(#response_type)); - - for path_param in &version.path_params { - let param_type = &path_param.param_type; - let param_type_str = quote!(#param_type).to_string(); - unique_types.insert(param_type_str, quote!(#param_type)); - } - - for query_param in &version.query_params { - let param_type = &query_param.param_type; - let param_type_str = quote!(#param_type).to_string(); - unique_types.insert(param_type_str, quote!(#param_type)); - } - } - } - - let sanitize_type_name = |type_name: &str| -> String { - if type_name == "()" { - "Unit".to_string() - } else { - type_name - .replace("::", "_") - .replace("<", "_") - .replace(">", "") - .replace(" ", "") - .replace(",", "_") - .replace("(", "_") - .replace(")", "_") - } - }; - - let schema_fns: Vec = unique_types - .iter() - .map(|(type_name, type_tokens)| { - if type_name == "()" { - quote! {} // Skip unit type, we'll handle it separately - } else { - 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! { - 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); - fix_option_types(&mut schema_value); - schema_value - } - } - } - }) - .collect(); - - let schema_insertions: Vec = unique_types - .keys() - .map(|type_name| { - if type_name == "()" { - quote! { - schemas.insert("Unit".to_string(), serde_json::json!({ - "type": "null", - "description": "Unit type (empty response)" - })); - } - } else { - 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(); - - let endpoint_infos: Vec = service_def - .endpoints - .iter() - .flat_map(|endpoint| { - let method = endpoint.method.as_str(); - let path = &endpoint.path; - let canonical_version = endpoint.version.clone(); - let canonical_version_tokens = match &canonical_version { - Some(version) => quote! { Some(#version.to_string()) }, - None => quote! { None }, - }; - let (summary, description) = match &endpoint.docs { - Some(docs) => { - let summary = &docs.summary; - let description = &docs.description; - ( - quote! { Some(#summary.to_string()) }, - quote! { Some(#description.to_string()) }, - ) - } - None => (quote! { None }, quote! { None }), - }; - let auth_required = matches!(endpoint.auth, AuthRequirement::WithPermissions(_)); - // OPTIONAL_AUTH advertises an *optional* security requirement. - let auth_optional = matches!(endpoint.auth, AuthRequirement::OptionalAuth); - let permissions = match &endpoint.auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], - AuthRequirement::WithPermissions(groups) => { - 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()) - } else { - "Unit".to_string() - }; - - let response_type = &endpoint.response_type; - let response_type_name = if quote!(#response_type).to_string() == "()" { - "Unit".to_string() - } else { - sanitize_type_name("e!(#response_type).to_string()) - }; - let path_param_infos: Vec = endpoint - .path_params - .iter() - .map(|param| { - let param_name = param.name.to_string(); - let param_type = ¶m.param_type; - let param_type_str = sanitize_type_name("e!(#param_type).to_string()); - quote! { - (#param_name.to_string(), #param_type_str.to_string()) - } - }) - .collect(); - - let query_param_infos: Vec = endpoint - .query_params - .iter() - .map(|param| { - let param_name = param.name.to_string(); - let param_type = ¶m.param_type; - let param_type_str = sanitize_type_name("e!(#param_type).to_string()); - quote! { - (#param_name.to_string(), #param_type_str.to_string()) - } - }) - .collect(); - - let mut infos = vec![quote! { - #endpoint_info_struct_name { - method: #method.to_string(), - path: #path.to_string(), - summary: #summary, - description: #description, - auth_required: #auth_required, - auth_optional: #auth_optional, - 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)>, - query_params: vec![#(#query_param_infos),*] as Vec<(String, String)>, - version: #canonical_version_tokens, - canonical_version: #canonical_version_tokens, - canonical_path: #path.to_string(), - } - }]; - - infos.extend(endpoint.versions.iter().map(|version| { - let path = &version.path; - let version_label = &version.version; - let canonical_version = canonical_version - .clone() - .unwrap_or_else(|| "current".to_string()); - let canonical_path = endpoint.path.clone(); - let request_type_name = if let Some(request_type) = &version.request_type { - sanitize_type_name("e!(#request_type).to_string()) - } else { - "Unit".to_string() - }; - let response_type = &version.response_type; - let response_type_name = if quote!(#response_type).to_string() == "()" { - "Unit".to_string() - } else { - sanitize_type_name("e!(#response_type).to_string()) - }; - let path_param_infos: Vec = version - .path_params - .iter() - .map(|param| { - let param_name = param.name.to_string(); - let param_type = ¶m.param_type; - let param_type_str = sanitize_type_name("e!(#param_type).to_string()); - quote! { - (#param_name.to_string(), #param_type_str.to_string()) - } - }) - .collect(); - let query_param_infos: Vec = version - .query_params - .iter() - .map(|param| { - let param_name = param.name.to_string(); - let param_type = ¶m.param_type; - let param_type_str = sanitize_type_name("e!(#param_type).to_string()); - quote! { - (#param_name.to_string(), #param_type_str.to_string()) - } - }) - .collect(); - let permissions = permissions.clone(); - let permission_groups_tokens = permission_groups_tokens.clone(); - let summary = summary.clone(); - let description = description.clone(); - - quote! { - #endpoint_info_struct_name { - method: #method.to_string(), - path: #path.to_string(), - summary: #summary, - description: #description, - auth_required: #auth_required, - auth_optional: #auth_optional, - 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)>, - query_params: vec![#(#query_param_infos),*] as Vec<(String, String)>, - version: Some(#version_label.to_string()), - canonical_version: Some(#canonical_version.to_string()), - canonical_path: #canonical_path.to_string(), - } - } - })); - - infos - }) - .collect(); - - quote! { - #[derive(serde::Serialize)] - struct #endpoint_info_struct_name { - method: String, - path: String, - summary: Option, - description: Option, - auth_required: bool, - auth_optional: bool, - permissions: Vec, - permission_groups: Vec>, - request_type_name: String, - response_type_name: String, - path_params: Vec<(String, String)>, // (name, type) - query_params: Vec<(String, String)>, // (name, type) - version: Option, - canonical_version: Option, - canonical_path: String, - } - - // 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) => { - if let Some(defs) = 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); - } - } - } - - 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); - } - } - } - - 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); - } - } - } - - // Remove $schema field as it's not needed in OpenAPI - obj.remove("$schema"); - - for (_, v) in obj.iter_mut() { - fix_schema_refs(v, schemas); - } - } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - fix_schema_refs(item, schemas); - } - } - _ => {} - } - } - - // Helper function to normalize nullable properties for better OpenAPI explorer compatibility. - fn normalize_nullable_properties(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(obj) => { - 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 { - 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) { - let non_null_type = type_array.iter() - .find(|t| **t != null_value) - .cloned(); - - if let Some(actual_type) = non_null_type { - prop_obj.insert("type".to_string(), actual_type); - prop_obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); - } - } - } - } - } - } - normalize_nullable_properties(prop_value); - } - } - } - - if let Some(definitions) = obj.get_mut("definitions") { - normalize_nullable_properties(definitions); - } - - 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); - } - } - _ => {} - } - } - - // Helper function to fix Option types that use anyOf with null or type arrays - fn fix_option_types(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(obj) => { - if let Some(type_val) = 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) { - let non_null_type = type_array.iter() - .find(|t| **t != null_value) - .cloned(); - - if let Some(actual_type) = non_null_type { - obj.insert("type".to_string(), actual_type); - obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); - } - } - } - } - } - - if let Some(any_of) = obj.get_mut("anyOf") { - if let serde_json::Value::Array(any_of_array) = any_of { - if any_of_array.len() == 2 { - let has_null = any_of_array.iter().any(|item| { - if let serde_json::Value::Object(item_obj) = item { - if let Some(type_val) = item_obj.get("type") { - if let serde_json::Value::String(type_str) = type_val { - return type_str == "null"; - } - } - } - false - }); - - if has_null { - let non_null_schema = any_of_array.iter().find(|item| { - if let serde_json::Value::Object(item_obj) = item { - if let Some(type_val) = item_obj.get("type") { - if let serde_json::Value::String(type_str) = type_val { - return type_str != "null"; - } - } - // If it has other properties besides type, it's not the null schema - return item_obj.len() > 1 || !item_obj.contains_key("type"); - } - true - }).cloned(); - - if let Some(schema) = non_null_schema { - obj.remove("anyOf"); - if let serde_json::Value::Object(schema_obj) = schema { - for (key, val) in schema_obj { - obj.insert(key, val); - } - } - obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); - } - } - } - } - } - - for (_, v) in obj.iter_mut() { - fix_option_types(v); - } - } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - fix_option_types(item); - } - } - _ => {} - } - } - - #(#schema_fns)* - - /// Generate OpenAPI 3.0 document for this 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),* - ]; - - let mut schemas = HashMap::new(); - - #(#schema_insertions)* - - let mut final_schemas = serde_json::Map::new(); - for (name, mut schema) in schemas { - fix_schema_refs(&mut schema, &mut final_schemas); - fix_option_types(&mut schema); - final_schemas.insert(name, schema); - } - - 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 operation_summary = endpoint - .summary - .clone() - .unwrap_or_else(|| format!("{} {}", endpoint.method, endpoint.path)); - let operation_description = endpoint - .description - .clone() - .unwrap_or_else(|| format!("Handles {} requests to {}", endpoint.method, endpoint.path)); - let mut operation = json!({ - "summary": operation_summary, - "description": operation_description, - "operationId": format!("{}_{}", method_lower, endpoint.path.replace("/", "_").replace("{", "").replace("}", "").trim_start_matches('_')), - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": format!("#/components/schemas/{}", endpoint.response_type_name) - } - } - } - }, - "400": { - "description": "Bad request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal server error" - } - } - }); - - 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) - } - })); - } - - for (param_name, param_type) in &endpoint.query_params { - let is_optional = param_type.starts_with("Option_") || param_type.starts_with("Option<") || param_type.starts_with("Option <"); - parameters.push(json!({ - "name": param_name, - "in": "query", - "required": !is_optional, - "description": format!("Query parameter of type {}", param_type), - "schema": { - "$ref": format!("#/components/schemas/{}", param_type) - } - })); - } - - if !parameters.is_empty() { - operation["parameters"] = json!(parameters); - } - - if let Some(version) = &endpoint.version { - operation["x-ras-version"] = json!(version); - } - - if let Some(canonical_version) = &endpoint.canonical_version { - operation["x-ras-canonical-version"] = json!(canonical_version); - operation["x-ras-canonical-path"] = json!(endpoint.canonical_path); - } - - if endpoint.method != "GET" && endpoint.request_type_name != "Unit" { - operation["requestBody"] = json!({ - "description": "Request body", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": format!("#/components/schemas/{}", endpoint.request_type_name) - } - } - } - }); - } - - if endpoint.auth_required { - operation["security"] = json!([{ - "bearerAuth": [] - }]); - - 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); - } - } else if endpoint.auth_optional { - // OPTIONAL_AUTH: anonymous is acceptable ({}), and a bearer is honoured. - operation["security"] = json!([{}, { "bearerAuth": [] }]); - } - - path_item[method_lower] = operation; - } - - json!({ - "openapi": "3.0.3", - "info": { - "title": format!("{} REST API", stringify!(#service_name)), - "version": "1.0.0", - "description": format!("OpenAPI 3.0 specification for the {} service", stringify!(#service_name)) - }, - "paths": paths, - "components": { - "schemas": final_schemas, - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "description": "Bearer token for authentication" - } - } - } - }) - } - - /// 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; - - 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); - - Ok(()) - } - - } -} - -fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { - match auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => 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(); - - for endpoint in &service_def.endpoints { - if let Some(request_type) = &endpoint.request_type { - unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); - } - - let response_type = &endpoint.response_type; - unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); - - for path_param in &endpoint.path_params { - let param_type = &path_param.param_type; - unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); - } - - for query_param in &endpoint.query_params { - let param_type = &query_param.param_type; - unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); - } - - for version in &endpoint.versions { - if let Some(request_type) = &version.request_type { - unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); - } - - let response_type = &version.response_type; - unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); - - for path_param in &version.path_params { - let param_type = &path_param.param_type; - unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); - } - - for query_param in &version.query_params { - let param_type = &query_param.param_type; - unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); - } - } - } - - let type_checks: Vec = unique_types - .values() - .map(|type_tokens| { - quote! { - const _: () = { - fn _assert_json_schema() {} - fn _check() { - _assert_json_schema::<#type_tokens>(); - } - }; - } - }) - .collect(); - - quote! { - #(#type_checks)* - } -} diff --git a/crates/rest/ras-rest-macro/src/openapi/mod.rs b/crates/rest/ras-rest-macro/src/openapi/mod.rs new file mode 100644 index 0000000..386dbaf --- /dev/null +++ b/crates/rest/ras-rest-macro/src/openapi/mod.rs @@ -0,0 +1,136 @@ +//! OpenAPI 3.0 document generation module +//! +//! This module provides functionality to generate OpenAPI 3.0 specification documents +//! from the rest_service macro definitions. + +use crate::{OpenApiConfig, ServiceDefinition}; +use proc_macro2::TokenStream; +use quote::quote; +mod operations; +mod schema; +pub use schema::generate_schema_impl_checks; + +/// Generates OpenAPI document creation code +pub fn generate_openapi_code( + service_def: &ServiceDefinition, + config: &OpenApiConfig, +) -> TokenStream { + let service_name = &service_def.service_name; + let openapi_fn_name = quote::format_ident!( + "generate_{}_openapi", + service_name.to_string().to_lowercase() + ); + let openapi_to_file_fn_name = quote::format_ident!( + "generate_{}_openapi_to_file", + service_name.to_string().to_lowercase() + ); + let endpoint_info_struct_name = quote::format_ident!("{}OpenApiEndpointInfo", service_name); + + 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() + } + } + }; + + let unique_types = schema::collect_types(service_def); + let (schema_fns, schema_insertions) = schema::generate_schemas(service_name, &unique_types); + let endpoint_infos = + operations::generate_endpoint_infos(service_def, &endpoint_info_struct_name); + let normalization = schema::generate_normalization(); + let paths = operations::generate_paths(); + + quote! { + #[derive(serde::Serialize)] + struct #endpoint_info_struct_name { + method: String, + path: String, + summary: Option, + description: Option, + auth_required: bool, + auth_optional: bool, + permissions: Vec, + permission_groups: Vec>, + request_type_name: String, + response_type_name: String, + path_params: Vec<(String, String)>, // (name, type) + query_params: Vec<(String, String)>, // (name, type) + version: Option, + canonical_version: Option, + canonical_path: String, + } + + #normalization + + #(#schema_fns)* + + /// Generate OpenAPI 3.0 document for this 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),* + ]; + + let mut schemas = HashMap::new(); + + #(#schema_insertions)* + + let mut final_schemas = serde_json::Map::new(); + for (name, mut schema) in schemas { + fix_schema_refs(&mut schema, &mut final_schemas); + fix_option_types(&mut schema); + final_schemas.insert(name, schema); + } + + #paths + + json!({ + "openapi": "3.0.3", + "info": { + "title": format!("{} REST API", stringify!(#service_name)), + "version": "1.0.0", + "description": format!("OpenAPI 3.0 specification for the {} service", stringify!(#service_name)) + }, + "paths": paths, + "components": { + "schemas": final_schemas, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Bearer token for authentication" + } + } + } + }) + } + + /// 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; + + 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); + + Ok(()) + } + + } +} diff --git a/crates/rest/ras-rest-macro/src/openapi/operations.rs b/crates/rest/ras-rest-macro/src/openapi/operations.rs new file mode 100644 index 0000000..ce8327b --- /dev/null +++ b/crates/rest/ras-rest-macro/src/openapi/operations.rs @@ -0,0 +1,312 @@ +use super::schema::sanitize_type_name; +use crate::{AuthRequirement, ServiceDefinition}; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; + +pub(super) fn generate_endpoint_infos( + service_def: &ServiceDefinition, + endpoint_info_struct_name: &Ident, +) -> Vec { + let endpoint_infos: Vec = service_def + .endpoints + .iter() + .flat_map(|endpoint| { + let method = endpoint.method.as_str(); + let path = &endpoint.path; + let canonical_version = endpoint.version.clone(); + let canonical_version_tokens = match &canonical_version { + Some(version) => quote! { Some(#version.to_string()) }, + None => quote! { None }, + }; + let (summary, description) = match &endpoint.docs { + Some(docs) => { + let summary = &docs.summary; + let description = &docs.description; + ( + quote! { Some(#summary.to_string()) }, + quote! { Some(#description.to_string()) }, + ) + } + None => (quote! { None }, quote! { None }), + }; + let auth_required = matches!(endpoint.auth, AuthRequirement::WithPermissions(_)); + // OPTIONAL_AUTH advertises an *optional* security requirement. + let auth_optional = matches!(endpoint.auth, AuthRequirement::OptionalAuth); + let permissions = match &endpoint.auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], + AuthRequirement::WithPermissions(groups) => { + 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()) + } else { + "Unit".to_string() + }; + + let response_type = &endpoint.response_type; + let response_type_name = if quote!(#response_type).to_string() == "()" { + "Unit".to_string() + } else { + sanitize_type_name("e!(#response_type).to_string()) + }; + let path_param_infos: Vec = endpoint + .path_params + .iter() + .map(|param| { + let param_name = param.name.to_string(); + let param_type = ¶m.param_type; + let param_type_str = sanitize_type_name("e!(#param_type).to_string()); + quote! { + (#param_name.to_string(), #param_type_str.to_string()) + } + }) + .collect(); + + let query_param_infos: Vec = endpoint + .query_params + .iter() + .map(|param| { + let param_name = param.name.to_string(); + let param_type = ¶m.param_type; + let param_type_str = sanitize_type_name("e!(#param_type).to_string()); + quote! { + (#param_name.to_string(), #param_type_str.to_string()) + } + }) + .collect(); + + let mut infos = vec![quote! { + #endpoint_info_struct_name { + method: #method.to_string(), + path: #path.to_string(), + summary: #summary, + description: #description, + auth_required: #auth_required, + auth_optional: #auth_optional, + 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)>, + query_params: vec![#(#query_param_infos),*] as Vec<(String, String)>, + version: #canonical_version_tokens, + canonical_version: #canonical_version_tokens, + canonical_path: #path.to_string(), + } + }]; + + infos.extend(endpoint.versions.iter().map(|version| { + let path = &version.path; + let version_label = &version.version; + let canonical_version = canonical_version + .clone() + .unwrap_or_else(|| "current".to_string()); + let canonical_path = endpoint.path.clone(); + let request_type_name = if let Some(request_type) = &version.request_type { + sanitize_type_name("e!(#request_type).to_string()) + } else { + "Unit".to_string() + }; + let response_type = &version.response_type; + let response_type_name = if quote!(#response_type).to_string() == "()" { + "Unit".to_string() + } else { + sanitize_type_name("e!(#response_type).to_string()) + }; + let path_param_infos: Vec = version + .path_params + .iter() + .map(|param| { + let param_name = param.name.to_string(); + let param_type = ¶m.param_type; + let param_type_str = sanitize_type_name("e!(#param_type).to_string()); + quote! { + (#param_name.to_string(), #param_type_str.to_string()) + } + }) + .collect(); + let query_param_infos: Vec = version + .query_params + .iter() + .map(|param| { + let param_name = param.name.to_string(); + let param_type = ¶m.param_type; + let param_type_str = sanitize_type_name("e!(#param_type).to_string()); + quote! { + (#param_name.to_string(), #param_type_str.to_string()) + } + }) + .collect(); + let permissions = permissions.clone(); + let permission_groups_tokens = permission_groups_tokens.clone(); + let summary = summary.clone(); + let description = description.clone(); + + quote! { + #endpoint_info_struct_name { + method: #method.to_string(), + path: #path.to_string(), + summary: #summary, + description: #description, + auth_required: #auth_required, + auth_optional: #auth_optional, + 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)>, + query_params: vec![#(#query_param_infos),*] as Vec<(String, String)>, + version: Some(#version_label.to_string()), + canonical_version: Some(#canonical_version.to_string()), + canonical_path: #canonical_path.to_string(), + } + } + })); + + infos + }) + .collect(); + + endpoint_infos +} + +pub(super) fn generate_paths() -> TokenStream { + quote! { + 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 operation_summary = endpoint + .summary + .clone() + .unwrap_or_else(|| format!("{} {}", endpoint.method, endpoint.path)); + let operation_description = endpoint + .description + .clone() + .unwrap_or_else(|| format!("Handles {} requests to {}", endpoint.method, endpoint.path)); + let mut operation = json!({ + "summary": operation_summary, + "description": operation_description, + "operationId": format!("{}_{}", method_lower, endpoint.path.replace("/", "_").replace("{", "").replace("}", "").trim_start_matches('_')), + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": format!("#/components/schemas/{}", endpoint.response_type_name) + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + } + }); + + 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) + } + })); + } + + for (param_name, param_type) in &endpoint.query_params { + let is_optional = param_type.starts_with("Option_") || param_type.starts_with("Option<") || param_type.starts_with("Option <"); + parameters.push(json!({ + "name": param_name, + "in": "query", + "required": !is_optional, + "description": format!("Query parameter of type {}", param_type), + "schema": { + "$ref": format!("#/components/schemas/{}", param_type) + } + })); + } + + if !parameters.is_empty() { + operation["parameters"] = json!(parameters); + } + + if let Some(version) = &endpoint.version { + operation["x-ras-version"] = json!(version); + } + + if let Some(canonical_version) = &endpoint.canonical_version { + operation["x-ras-canonical-version"] = json!(canonical_version); + operation["x-ras-canonical-path"] = json!(endpoint.canonical_path); + } + + if endpoint.method != "GET" && endpoint.request_type_name != "Unit" { + operation["requestBody"] = json!({ + "description": "Request body", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": format!("#/components/schemas/{}", endpoint.request_type_name) + } + } + } + }); + } + + if endpoint.auth_required { + operation["security"] = json!([{ + "bearerAuth": [] + }]); + + 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); + } + } else if endpoint.auth_optional { + // OPTIONAL_AUTH: anonymous is acceptable ({}), and a bearer is honoured. + operation["security"] = json!([{}, { "bearerAuth": [] }]); + } + + path_item[method_lower] = operation; + } + + } +} + +fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { + match auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => 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),*] } +} diff --git a/crates/rest/ras-rest-macro/src/openapi/schema.rs b/crates/rest/ras-rest-macro/src/openapi/schema.rs new file mode 100644 index 0000000..a2cc991 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/openapi/schema.rs @@ -0,0 +1,376 @@ +use crate::ServiceDefinition; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; +use std::collections::HashMap; + +pub(super) fn collect_types(service_def: &ServiceDefinition) -> HashMap { + let mut unique_types = std::collections::HashMap::new(); + for endpoint in &service_def.endpoints { + if let Some(request_type) = &endpoint.request_type { + let request_type_str = quote!(#request_type).to_string(); + unique_types.insert(request_type_str, quote!(#request_type)); + } + + let response_type = &endpoint.response_type; + let response_type_str = quote!(#response_type).to_string(); + unique_types.insert(response_type_str, quote!(#response_type)); + + for path_param in &endpoint.path_params { + let param_type = &path_param.param_type; + let param_type_str = quote!(#param_type).to_string(); + unique_types.insert(param_type_str, quote!(#param_type)); + } + + for query_param in &endpoint.query_params { + let param_type = &query_param.param_type; + let param_type_str = quote!(#param_type).to_string(); + unique_types.insert(param_type_str, quote!(#param_type)); + } + + for version in &endpoint.versions { + if let Some(request_type) = &version.request_type { + let request_type_str = quote!(#request_type).to_string(); + unique_types.insert(request_type_str, quote!(#request_type)); + } + + let response_type = &version.response_type; + let response_type_str = quote!(#response_type).to_string(); + unique_types.insert(response_type_str, quote!(#response_type)); + + for path_param in &version.path_params { + let param_type = &path_param.param_type; + let param_type_str = quote!(#param_type).to_string(); + unique_types.insert(param_type_str, quote!(#param_type)); + } + + for query_param in &version.query_params { + let param_type = &query_param.param_type; + let param_type_str = quote!(#param_type).to_string(); + unique_types.insert(param_type_str, quote!(#param_type)); + } + } + } + + unique_types +} + +pub(super) fn sanitize_type_name(type_name: &str) -> String { + if type_name == "()" { + "Unit".to_string() + } else { + type_name + .replace("::", "_") + .replace("<", "_") + .replace(">", "") + .replace(" ", "") + .replace(",", "_") + .replace("(", "_") + .replace(")", "_") + } +} + +pub(super) fn generate_schemas( + service_name: &Ident, + unique_types: &HashMap, +) -> (Vec, Vec) { + let schema_fns: Vec = unique_types + .iter() + .map(|(type_name, type_tokens)| { + if type_name == "()" { + quote! {} // Skip unit type, we'll handle it separately + } else { + 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! { + 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); + fix_option_types(&mut schema_value); + schema_value + } + } + } + }) + .collect(); + + let schema_insertions: Vec = unique_types + .keys() + .map(|type_name| { + if type_name == "()" { + quote! { + schemas.insert("Unit".to_string(), serde_json::json!({ + "type": "null", + "description": "Unit type (empty response)" + })); + } + } else { + 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(); + + (schema_fns, schema_insertions) +} + +pub(super) fn generate_normalization() -> TokenStream { + quote! { + // 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) => { + if let Some(defs) = 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); + } + } + } + + 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); + } + } + } + + 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); + } + } + } + + // Remove $schema field as it's not needed in OpenAPI + obj.remove("$schema"); + + for (_, v) in obj.iter_mut() { + fix_schema_refs(v, schemas); + } + } + serde_json::Value::Array(arr) => { + for item in arr.iter_mut() { + fix_schema_refs(item, schemas); + } + } + _ => {} + } + } + + // Helper function to normalize nullable properties for better OpenAPI explorer compatibility. + fn normalize_nullable_properties(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(obj) => { + 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 { + 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) { + let non_null_type = type_array.iter() + .find(|t| **t != null_value) + .cloned(); + + if let Some(actual_type) = non_null_type { + prop_obj.insert("type".to_string(), actual_type); + prop_obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); + } + } + } + } + } + } + normalize_nullable_properties(prop_value); + } + } + } + + if let Some(definitions) = obj.get_mut("definitions") { + normalize_nullable_properties(definitions); + } + + 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); + } + } + _ => {} + } + } + + // Helper function to fix Option types that use anyOf with null or type arrays + fn fix_option_types(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(obj) => { + if let Some(type_val) = 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) { + let non_null_type = type_array.iter() + .find(|t| **t != null_value) + .cloned(); + + if let Some(actual_type) = non_null_type { + obj.insert("type".to_string(), actual_type); + obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); + } + } + } + } + } + + if let Some(any_of) = obj.get_mut("anyOf") { + if let serde_json::Value::Array(any_of_array) = any_of { + if any_of_array.len() == 2 { + let has_null = any_of_array.iter().any(|item| { + if let serde_json::Value::Object(item_obj) = item { + if let Some(type_val) = item_obj.get("type") { + if let serde_json::Value::String(type_str) = type_val { + return type_str == "null"; + } + } + } + false + }); + + if has_null { + let non_null_schema = any_of_array.iter().find(|item| { + if let serde_json::Value::Object(item_obj) = item { + if let Some(type_val) = item_obj.get("type") { + if let serde_json::Value::String(type_str) = type_val { + return type_str != "null"; + } + } + // If it has other properties besides type, it's not the null schema + return item_obj.len() > 1 || !item_obj.contains_key("type"); + } + true + }).cloned(); + + if let Some(schema) = non_null_schema { + obj.remove("anyOf"); + if let serde_json::Value::Object(schema_obj) = schema { + for (key, val) in schema_obj { + obj.insert(key, val); + } + } + obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); + } + } + } + } + } + + for (_, v) in obj.iter_mut() { + fix_option_types(v); + } + } + serde_json::Value::Array(arr) => { + for item in arr.iter_mut() { + fix_option_types(item); + } + } + _ => {} + } + } + + } +} + +/// 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(); + + for endpoint in &service_def.endpoints { + if let Some(request_type) = &endpoint.request_type { + unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); + } + + let response_type = &endpoint.response_type; + unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); + + for path_param in &endpoint.path_params { + let param_type = &path_param.param_type; + unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); + } + + for query_param in &endpoint.query_params { + let param_type = &query_param.param_type; + unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); + } + + for version in &endpoint.versions { + if let Some(request_type) = &version.request_type { + unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); + } + + let response_type = &version.response_type; + unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); + + for path_param in &version.path_params { + let param_type = &path_param.param_type; + unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); + } + + for query_param in &version.query_params { + let param_type = &query_param.param_type; + unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); + } + } + } + + let type_checks: Vec = unique_types + .values() + .map(|type_tokens| { + quote! { + const _: () = { + fn _assert_json_schema() {} + fn _check() { + _assert_json_schema::<#type_tokens>(); + } + }; + } + }) + .collect(); + + quote! { + #(#type_checks)* + } +} diff --git a/crates/rest/ras-rest-macro/src/parser.rs b/crates/rest/ras-rest-macro/src/parser.rs new file mode 100644 index 0000000..f2676c3 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/parser.rs @@ -0,0 +1,509 @@ +//! REST service syntax and diagnostics. + +use crate::{ast::*, static_hosting}; +use quote::quote; +use syn::{Ident, LitStr, Token, Type, parse::Parse}; + +const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`"; + +fn parse_label(input: syn::parse::ParseStream) -> syn::Result { + if input.peek(LitStr) { + Ok(input.parse::()?.value()) + } else { + Ok(input.parse::()?.to_string()) + } +} + +fn parse_doc_comment_attrs( + attrs: Vec, + entry_kind: &str, +) -> syn::Result> { + let lines = attrs + .into_iter() + .map(|attr| parse_doc_comment_attr(attr, entry_kind)) + .collect::>>()?; + + Ok(DocComment::from_lines(lines)) +} + +fn parse_doc_comment_attr(attr: syn::Attribute, entry_kind: &str) -> syn::Result { + if !attr.path().is_ident("doc") { + return Err(syn::Error::new_spanned( + attr, + format!("Only doc comments (`/// ...`) are supported before {entry_kind} definitions"), + )); + } + + if let syn::Meta::NameValue(name_value) = &attr.meta + && let syn::Expr::Lit(expr_lit) = &name_value.value + && let syn::Lit::Str(doc_line) = &expr_lit.lit + { + return Ok(doc_line.value()); + } + + Err(syn::Error::new_spanned(attr, DOC_COMMENT_EXPECTED)) +} + +impl Parse for ServiceDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let content; + syn::braced!(content in input); + + let _ = content.parse::()?; // "service_name" + let _ = content.parse::()?; + let service_name = content.parse::()?; + let _ = content.parse::()?; + + let _ = content.parse::()?; // "base_path" + let _ = content.parse::()?; + let base_path_lit = content.parse::()?; + let base_path = base_path_lit.value(); + let _ = content.parse::()?; + + let mut openapi = None; + let mut static_hosting = static_hosting::StaticHostingConfig::default(); + let mut body_limit = None; + let mut feature_gated = false; + let mut require_json_content_type = true; + let mut docs_require_auth = false; + + while content.peek(Ident) { + let field_name = content.fork().parse::()?; + + if field_name == "openapi" { + let _ = content.parse::()?; // "openapi" + let _ = content.parse::()?; + + if content.peek(syn::LitBool) { + let enabled = content.parse::()?; + if enabled.value() { + openapi = Some(OpenApiConfig::Enabled); + } + } else if content.peek(syn::token::Brace) { + let openapi_content; + syn::braced!(openapi_content in content); + + let _ = openapi_content.parse::()?; // "output" + let _ = openapi_content.parse::()?; + let path = openapi_content.parse::()?; + openapi = Some(OpenApiConfig::WithPath(path.value())); + } + + let _ = content.parse::()?; + } else if field_name == "serve_docs" { + let _ = content.parse::()?; // "serve_docs" + let _ = content.parse::()?; + let enabled = content.parse::()?; + static_hosting.serve_docs = enabled.value(); + let _ = content.parse::()?; + } else if field_name == "docs_path" { + let _ = content.parse::()?; // "docs_path" + let _ = content.parse::()?; + let path = content.parse::()?; + static_hosting.docs_path = path.value(); + let _ = content.parse::()?; + } else if field_name == "ui_theme" { + let _ = content.parse::()?; // "ui_theme" + let _ = content.parse::()?; + let theme = content.parse::()?; + static_hosting.ui_theme = theme.value(); + let _ = content.parse::()?; + } else if field_name == "body_limit" { + let _ = content.parse::()?; // "body_limit" + let _ = content.parse::()?; + let limit = content.parse::()?; + body_limit = Some(limit.base10_parse::()?); + let _ = content.parse::()?; + } else if field_name == "feature_gated" { + let _ = content.parse::()?; // "feature_gated" + let _ = content.parse::()?; + let enabled = content.parse::()?; + feature_gated = enabled.value(); + let _ = content.parse::()?; + } else if field_name == "require_json_content_type" { + let _ = content.parse::()?; // "require_json_content_type" + let _ = content.parse::()?; + let enabled = content.parse::()?; + require_json_content_type = enabled.value(); + let _ = content.parse::()?; + } else if field_name == "docs_require_auth" { + let _ = content.parse::()?; // "docs_require_auth" + let _ = content.parse::()?; + let enabled = content.parse::()?; + docs_require_auth = enabled.value(); + let _ = content.parse::()?; + } else if field_name == "endpoints" { + break; // Start parsing endpoints + } else { + return Err(syn::Error::new( + field_name.span(), + format!("Unknown field: {}", field_name), + )); + } + } + + let _ = content.parse::()?; // "endpoints" + let _ = content.parse::()?; + + let endpoints_content; + syn::bracketed!(endpoints_content in content); + + let mut endpoints = Vec::new(); + while !endpoints_content.is_empty() { + let endpoint = endpoints_content.parse::()?; + endpoints.push(endpoint); + + if endpoints_content.peek(Token![,]) { + let _ = endpoints_content.parse::()?; + } + } + + Ok(ServiceDefinition { + service_name, + base_path, + openapi, + static_hosting, + body_limit, + feature_gated, + require_json_content_type, + docs_require_auth, + endpoints, + }) + } +} + +fn parse_endpoint_path( + input: syn::parse::ParseStream, +) -> syn::Result<(String, Vec, Vec)> { + let mut path_segments = Vec::new(); + let mut path_params = Vec::new(); + let mut handler_name_parts = Vec::new(); + + let first_segment = input.parse::()?; + path_segments.push(first_segment.to_string()); + handler_name_parts.push(first_segment.to_string()); + + while input.peek(Token![/]) { + let _ = input.parse::()?; + + if input.peek(syn::token::Brace) { + let param_content; + syn::braced!(param_content in input); + + let param_name = param_content.parse::()?; + let _ = param_content.parse::()?; + let param_type = param_content.parse::()?; + + path_segments.push(format!("{{{}}}", param_name)); + path_params.push(PathParam { + name: param_name.clone(), + param_type, + }); + handler_name_parts.push(format!("by_{}", param_name)); + } else { + let segment = input.parse::()?; + path_segments.push(segment.to_string()); + handler_name_parts.push(segment.to_string()); + } + } + + Ok(( + format!("/{}", path_segments.join("/")), + path_params, + handler_name_parts, + )) +} + +fn parse_query_params(input: syn::parse::ParseStream) -> syn::Result> { + let mut query_params = Vec::new(); + + if input.is_empty() { + return Ok(query_params); + } + + let param_name = input.parse::()?; + let _ = input.parse::()?; + let param_type = input.parse::()?; + query_params.push(QueryParam { + name: param_name, + param_type, + }); + + while input.peek(Token![&]) || input.peek(Token![,]) { + if input.peek(Token![&]) { + let _ = input.parse::()?; + } else { + let _ = input.parse::()?; + } + + if input.is_empty() { + break; + } + + let param_name = input.parse::()?; + let _ = input.parse::()?; + let param_type = input.parse::()?; + query_params.push(QueryParam { + name: param_name, + param_type, + }); + } + + Ok(query_params) +} + +impl Parse for EndpointDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let docs = parse_doc_comment_attrs(input.call(syn::Attribute::parse_outer)?, "endpoint")?; + + let method_ident = input.parse::()?; + let method = match method_ident.to_string().as_str() { + "GET" => HttpMethod::Get, + "POST" => HttpMethod::Post, + "PUT" => HttpMethod::Put, + "DELETE" => HttpMethod::Delete, + "PATCH" => HttpMethod::Patch, + _ => { + return Err(syn::Error::new( + method_ident.span(), + "Expected GET, POST, PUT, DELETE, or PATCH", + )); + } + }; + + let auth = if input.peek(syn::Ident) { + let auth_ident = input.parse::()?; + match auth_ident.to_string().as_str() { + "UNAUTHORIZED" => AuthRequirement::Unauthorized, + "OPTIONAL_AUTH" => AuthRequirement::OptionalAuth, + "WITH_PERMISSIONS" => { + let perms_content; + syn::parenthesized!(perms_content in input); + + let mut permission_groups = Vec::new(); + + let first_group_content; + syn::bracketed!(first_group_content in perms_content); + + let mut first_group = Vec::new(); + while !first_group_content.is_empty() { + let perm = first_group_content.parse::()?; + first_group.push(perm.value()); + + if first_group_content.peek(Token![,]) { + let _ = first_group_content.parse::()?; + } + } + permission_groups.push(first_group); + + while perms_content.peek(Token![|]) { + let _ = perms_content.parse::()?; + + let group_content; + syn::bracketed!(group_content in perms_content); + + let mut group = Vec::new(); + while !group_content.is_empty() { + let perm = group_content.parse::()?; + group.push(perm.value()); + + if group_content.peek(Token![,]) { + let _ = group_content.parse::()?; + } + } + permission_groups.push(group); + } + + if permission_groups.len() > 1 + && permission_groups.iter().any(|group| group.is_empty()) + { + return Err(syn::Error::new( + auth_ident.span(), + "an empty permission group is only valid as the entire requirement \ + (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ + empty group with non-empty groups would silently grant access to any \ + authenticated user", + )); + } + + AuthRequirement::WithPermissions(permission_groups) + } + _ => { + return Err(syn::Error::new( + auth_ident.span(), + "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", + )); + } + } + } else { + return Err(syn::Error::new( + input.span(), + "Expected authentication requirement", + )); + }; + + let (path, path_params, handler_name_parts) = parse_endpoint_path(input)?; + + let mut query_params = Vec::new(); + if input.peek(Token![?]) { + let _ = input.parse::()?; + query_params = parse_query_params(input)?; + } + + let method_str = method.as_str().to_lowercase(); + let path_str = handler_name_parts.join("_"); + let handler_name = syn::parse_str::(&format!("{}_{}", method_str, path_str))?; + + let request_type = if input.peek(syn::token::Paren) { + let request_content; + syn::parenthesized!(request_content in input); + if !request_content.is_empty() { + Some(request_content.parse::()?) + } else { + None + } + } else { + None + }; + + let _ = input.parse::]>()?; + let response_type = input.parse::()?; + + let mut version = None; + let mut versions = Vec::new(); + let mut body_limit = None; + let mut with_headers = false; + + if input.peek(syn::token::Brace) { + let content; + syn::braced!(content in input); + + while !content.is_empty() { + let field_name = content.parse::()?; + let _ = content.parse::()?; + + match field_name.to_string().as_str() { + "version" => { + version = Some(parse_label(&content)?); + } + "versions" => { + let versions_content; + syn::bracketed!(versions_content in content); + + while !versions_content.is_empty() { + versions.push(versions_content.parse::()?); + + if versions_content.peek(Token![,]) { + let _ = versions_content.parse::()?; + } + } + } + "body_limit" => { + let limit = content.parse::()?; + body_limit = Some(limit.base10_parse::()?); + } + "headers" => { + with_headers = content.parse::()?.value(); + } + _ => { + return Err(syn::Error::new( + field_name.span(), + "Expected version, versions, body_limit, or headers", + )); + } + } + + if content.peek(Token![,]) { + let _ = content.parse::()?; + } + } + } + + Ok(EndpointDefinition { + docs, + method, + auth, + path, + path_params, + query_params, + request_type, + response_type, + handler_name, + version, + versions, + body_limit, + with_headers, + }) + } +} + +impl Parse for EndpointVersionDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let version = parse_label(input)?; + + let content; + syn::braced!(content in input); + + let mut path = None; + let mut path_params = Vec::new(); + let mut query_params = Vec::new(); + let mut request_type = None; + let mut response_type = None; + let mut migration_type = None; + + while !content.is_empty() { + let field_name = content.parse::()?; + let _ = content.parse::()?; + + match field_name.to_string().as_str() { + "path" => { + let (parsed_path, parsed_path_params, _) = parse_endpoint_path(&content)?; + path = Some(parsed_path); + path_params = parsed_path_params; + } + "query" => { + let query_content; + syn::bracketed!(query_content in content); + query_params = parse_query_params(&query_content)?; + } + "body" | "request" => { + let parsed_type = content.parse::()?; + if quote!(#parsed_type).to_string() != "()" { + request_type = Some(parsed_type); + } + } + "response" => { + response_type = Some(content.parse::()?); + } + "migration" => { + migration_type = Some(content.parse::()?); + } + _ => { + return Err(syn::Error::new( + field_name.span(), + "Expected path, query, body, request, response, or migration", + )); + } + } + + if content.peek(Token![,]) { + let _ = content.parse::()?; + } + } + + Ok(Self { + version, + path: path + .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing path"))?, + path_params, + query_params, + request_type, + response_type: response_type.ok_or_else(|| { + syn::Error::new(input.span(), "Version entry is missing response") + })?, + migration_type: migration_type.ok_or_else(|| { + syn::Error::new(input.span(), "Version entry is missing migration") + })?, + }) + } +} diff --git a/crates/rest/ras-rest-macro/src/server/auth.rs b/crates/rest/ras-rest-macro/src/server/auth.rs new file mode 100644 index 0000000..a15c576 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/auth.rs @@ -0,0 +1,21 @@ +//! Permission requirements for route registration. + +use crate::ast::*; +use quote::quote; + +pub(super) fn rest_permission_groups_code(auth: &AuthRequirement) -> proc_macro2::TokenStream { + let permission_groups = match auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), + AuthRequirement::WithPermissions(groups) => groups.clone(), + }; + + if permission_groups.is_empty() { + quote! { Vec::>::new() } + } else { + let groups = permission_groups.iter().map(|group| { + let perms = group.iter(); + quote! { vec![#(#perms.to_string()),*] } + }); + quote! { vec![#(#groups),*] as Vec> } + } +} diff --git a/crates/rest/ras-rest-macro/src/server/handlers.rs b/crates/rest/ras-rest-macro/src/server/handlers.rs new file mode 100644 index 0000000..7a16f58 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/handlers.rs @@ -0,0 +1,264 @@ +//! Canonical handler invocation and response handling. + +use super::request::{effective_body_limit_tokens, generate_body_extraction}; +use crate::ast::*; +use quote::quote; +use syn::Ident; + +pub(super) fn generate_handler_body( + endpoint: &EndpointDefinition, + handler_name: &Ident, + method: &str, + path: &str, + require_json: bool, +) -> proc_macro2::TokenStream { + let body_limit_tokens = effective_body_limit_tokens(endpoint); + match &endpoint.auth { + AuthRequirement::Unauthorized => { + let mut args = Vec::new(); + + // Opt-in request headers (before path params) + if endpoint.with_headers { + args.push(quote! { headers.clone() }); + } + + if endpoint.path_params.len() == 1 { + args.push(quote! { path_params }); + } else { + for (i, _) in endpoint.path_params.iter().enumerate() { + let idx = syn::Index::from(i); + args.push(quote! { path_params.#idx }); + } + } + + for query_param in &endpoint.query_params { + let param_name = &query_param.name; + args.push(quote! { query_params.#param_name }); + } + + let json_handling = if endpoint.request_type.is_some() { + args.push(quote! { body }); + generate_body_extraction(require_json, &body_limit_tokens, method, path) + } else { + quote! {} + }; + + quote! { + #json_handling + + if let Some(tracker) = &with_usage_tracker { + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, None, #method, #path).await; + } + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#args),*).await { + Ok(rest_response) => { + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + __ras_success_response(status_code, rest_response.body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, None, duration).await; + } + + result + } + } + AuthRequirement::OptionalAuth => { + // Build argument list; the caller is passed by value as the first arg. + let mut args = vec![quote! { caller }]; + + // Opt-in request headers (after the caller, before path params) + if endpoint.with_headers { + args.push(quote! { headers.clone() }); + } + + if endpoint.path_params.len() == 1 { + args.push(quote! { path_params }); + } else { + for (i, _) in endpoint.path_params.iter().enumerate() { + let idx = syn::Index::from(i); + args.push(quote! { path_params.#idx }); + } + } + + for query_param in &endpoint.query_params { + let param_name = &query_param.name; + args.push(quote! { query_params.#param_name }); + } + + let json_handling = if endpoint.request_type.is_some() { + args.push(quote! { body }); + generate_body_extraction(require_json, &body_limit_tokens, method, path) + } else { + quote! {} + }; + + quote! { + // Best-effort authentication for an OPTIONAL_AUTH route: never + // rejected — Caller::Anonymous when no/invalid credential is + // present, Caller::Authenticated otherwise. + let caller = ras_auth_core::resolve_caller( + #method, + &headers, + &auth_transport, + auth_provider.as_deref(), + ).await; + // Snapshot the user for tracking; `caller` is moved into the handler. + let __ras_caller_user = caller.authenticated().cloned(); + + #json_handling + + if let Some(tracker) = &with_usage_tracker { + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, __ras_caller_user.as_ref(), #method, #path).await; + } + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#args),*).await { + Ok(rest_response) => { + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + __ras_success_response(status_code, rest_response.body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, __ras_caller_user.as_ref(), duration).await; + } + + result + } + } + AuthRequirement::WithPermissions(_) => { + let mut args = vec![quote! { &user }]; + + // Opt-in request headers (after the user, before path params) + if endpoint.with_headers { + args.push(quote! { headers.clone() }); + } + + if endpoint.path_params.len() == 1 { + args.push(quote! { path_params }); + } else { + for (i, _) in endpoint.path_params.iter().enumerate() { + let idx = syn::Index::from(i); + args.push(quote! { path_params.#idx }); + } + } + + for query_param in &endpoint.query_params { + let param_name = &query_param.name; + args.push(quote! { query_params.#param_name }); + } + + let json_handling = if endpoint.request_type.is_some() { + args.push(quote! { body }); + generate_body_extraction(require_json, &body_limit_tokens, method, path) + } else { + quote! {} + }; + + quote! { + // Authenticate and authorize: credential → CSRF → authenticate + // → OR-of-AND permission groups (shared ras-auth-core pipeline) + let user = match ras_auth_core::authorize_request( + #method, + &headers, + &auth_transport, + auth_provider.as_deref(), + &required_permission_groups, + ).await { + Ok(user) => user, + Err(error) => return __ras_authorize_error_response(error), + }; + + // Read and parse the body only after auth has succeeded + #json_handling + + if let Some(tracker) = &with_usage_tracker { + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, Some(&user), #method, #path).await; + } + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#args),*).await { + Ok(rest_response) => { + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + __ras_success_response(status_code, rest_response.body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, Some(&user), duration).await; + } + + result + } + } + } +} diff --git a/crates/rest/ras-rest-macro/src/server/mod.rs b/crates/rest/ras-rest-macro/src/server/mod.rs new file mode 100644 index 0000000..7b157a0 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/mod.rs @@ -0,0 +1,325 @@ +//! Server trait, builder, and route assembly. + +use crate::{ast::*, static_hosting}; +use quote::quote; +mod auth; +mod handlers; +mod request; +mod routes; +mod versioned; +use request::{generate_query_struct, generate_rest_request_part_structs}; +use routes::{generate_canonical_route_registration, generate_legacy_route_registration}; + +pub(crate) fn generate_server_code( + service_def: &ServiceDefinition, + schema_checks: proc_macro2::TokenStream, + openapi_code: proc_macro2::TokenStream, + static_hosting_code: proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let service_name = &service_def.service_name; + let service_trait_name = quote::format_ident!("{}Trait", service_name); + let builder_name = quote::format_ident!("{}Builder", service_name); + let base_path = &service_def.base_path; + let trait_methods = service_def.endpoints.iter().map(|endpoint| { + let handler_name = &endpoint.handler_name; + let response_type = &endpoint.response_type; + + let mut params = Vec::new(); + match &endpoint.auth { + AuthRequirement::Unauthorized => {} + AuthRequirement::OptionalAuth => { + params.push(quote! { caller: ras_auth_core::Caller }); + } + AuthRequirement::WithPermissions(_) => { + params.push(quote! { user: &ras_auth_core::AuthenticatedUser }); + } + } + + // Opt-in request headers (immediately after the caller/user) + if endpoint.with_headers { + params.push(quote! { headers: axum::http::HeaderMap }); + } + + for path_param in &endpoint.path_params { + let param_name = &path_param.name; + let param_type = &path_param.param_type; + params.push(quote! { #param_name: #param_type }); + } + + for query_param in &endpoint.query_params { + let param_name = &query_param.name; + let param_type = &query_param.param_type; + params.push(quote! { #param_name: #param_type }); + } + + if let Some(request_type) = &endpoint.request_type { + params.push(quote! { request: #request_type }); + } + + quote! { + async fn #handler_name(&self, #(#params),*) -> ras_rest_core::RestResult<#response_type>; + } + }); + + let request_part_structs = generate_rest_request_part_structs(service_def); + + let mut query_structs: Vec = Vec::new(); + let mut route_registrations: Vec = Vec::new(); + let mut route_idx = 0usize; + + for endpoint in &service_def.endpoints { + let query_struct_name = quote::format_ident!("QueryParams{}", route_idx); + query_structs.push(generate_query_struct( + &query_struct_name, + &endpoint.query_params, + )); + route_registrations.push(generate_canonical_route_registration( + endpoint, + &query_struct_name, + service_def.require_json_content_type, + )); + route_idx += 1; + + for version in &endpoint.versions { + let query_struct_name = quote::format_ident!("QueryParams{}", route_idx); + query_structs.push(generate_query_struct( + &query_struct_name, + &version.query_params, + )); + route_registrations.push(generate_legacy_route_registration( + &service_def.service_name, + endpoint, + version, + &query_struct_name, + service_def.require_json_content_type, + )); + route_idx += 1; + } + } + + let static_routes = if service_def.static_hosting.serve_docs { + static_hosting::generate_static_routes(service_def, &service_def.static_hosting) + } else { + quote! {} + }; + + let body_limit = service_def.body_limit.unwrap_or(DEFAULT_BODY_LIMIT); + + // Startup assertion: a service with any WITH_PERMISSIONS route needs an auth + // provider, otherwise every such route returns a runtime 500 (NoAuthProvider) + // on first request. Catch the misconfiguration at build() instead. + let any_route_requires_auth = service_def + .endpoints + .iter() + .any(|endpoint| matches!(endpoint.auth, AuthRequirement::WithPermissions(_))) + || (service_def.static_hosting.serve_docs && service_def.docs_require_auth); + let service_name_str = service_name.to_string(); + let provider_assertion = if any_route_requires_auth { + quote! { + if self.auth_provider.is_none() { + panic!(concat!( + "REST service `", + #service_name_str, + "` has endpoints requiring authorization (WITH_PERMISSIONS) but no ", + "auth_provider was configured; call .auth_provider(...) before build()" + )); + } + } + } else { + quote! {} + }; + + quote! { + /// Maximum accepted JSON body size in bytes + #[allow(dead_code)] + const __RAS_BODY_LIMIT: usize = #body_limit; + + /// Map a shared authorization failure to this service's JSON error shape + #[allow(dead_code)] + fn __ras_authorize_error_response(error: ras_auth_core::AuthorizeError) -> axum::response::Response { + use axum::response::IntoResponse; + let (status, message) = match &error { + ras_auth_core::AuthorizeError::MissingCredential => ( + axum::http::StatusCode::UNAUTHORIZED, + "Missing or invalid Authorization header", + ), + ras_auth_core::AuthorizeError::CsrfValidationFailed => ( + axum::http::StatusCode::FORBIDDEN, + "CSRF validation failed", + ), + ras_auth_core::AuthorizeError::AuthenticationFailed(_) => ( + axum::http::StatusCode::UNAUTHORIZED, + "Authentication failed", + ), + ras_auth_core::AuthorizeError::NoAuthProvider => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "No auth provider configured", + ), + ras_auth_core::AuthorizeError::InsufficientPermissions(_) => ( + axum::http::StatusCode::FORBIDDEN, + "Insufficient permissions", + ), + }; + // Rejections are otherwise invisible to the usage/duration trackers, + // which only run on the post-auth happy path; log them here so a + // client hammering an endpoint with a bad credential is observable. + // The `error` detail is logged server-side only; the client gets the + // generic `message`. + ras_rest_core::tracing::warn!( + status = status.as_u16(), + error = ?error, + "request rejected during authorization" + ); + (status, axum::Json(serde_json::json!({ "error": message }))).into_response() + } + + /// Build a success response, omitting the JSON body for status codes that + /// must not carry one (`204 No Content`, `205 Reset Content`, + /// `304 Not Modified`). Without this, `RestResponse::no_content()` would + /// emit a `204` with a serialized `null` body and + /// `Content-Type: application/json`, which violates RFC 9110 and is + /// rejected by some proxies and clients. + #[allow(dead_code)] + fn __ras_success_response( + status: axum::http::StatusCode, + body: T, + ) -> axum::response::Response { + use axum::response::IntoResponse; + if status == axum::http::StatusCode::NO_CONTENT + || status == axum::http::StatusCode::RESET_CONTENT + || status == axum::http::StatusCode::NOT_MODIFIED + { + status.into_response() + } else { + (status, axum::Json(body)).into_response() + } + } + + /// Generated service trait + #[async_trait::async_trait] + #[allow(private_interfaces, private_bounds)] + pub trait #service_trait_name: Send + Sync + 'static { + #(#trait_methods)* + } + + /// Generated builder for the REST service + pub struct #builder_name { + 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>>, + } + + const _: () = { + #schema_checks + }; + + #openapi_code + + #static_hosting_code + + use self::query_params::*; + + mod query_params { + #[allow(unused_imports)] + use super::*; + + #(#query_structs)* + } + + #request_part_structs + + impl #builder_name { + /// Create a new builder with the service implementation + pub fn new(service: T) -> Self { + Self { + service: std::sync::Arc::new(service), + auth_provider: None, + auth_transport: ras_auth_core::AuthTransportConfig::default(), + with_usage_tracker: None, + with_method_duration_tracker: None, + } + } + + /// Set the auth provider + pub fn auth_provider(mut self, provider: A) -> Self { + self.auth_provider = Some(std::sync::Arc::new(provider)); + self + } + + /// Enable cookie authentication alongside bearer tokens. + /// + /// Installs a default double-submit CSRF config when none is set, + /// because cookie credentials are CSRF-exploitable on unsafe methods. + /// Override with `csrf_protection`. + pub fn auth_cookie(mut self, cookie: ras_auth_core::AuthCookieConfig) -> Self { + self.auth_transport.cookie = Some(cookie); + if self.auth_transport.csrf.is_none() { + self.auth_transport.csrf = Some(ras_auth_core::CsrfConfig::default()); + } + 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 + where + F: Fn(&axum::http::HeaderMap, Option<&ras_auth_core::AuthenticatedUser>, &str, &str) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + self.with_usage_tracker = Some(std::sync::Arc::new(move |headers, user, method, path| { + Box::pin(tracker(headers, user, method, path)) + })); + self + } + + /// Set the method duration tracker - called after each request completes + /// The tracker receives the HTTP method, path, authenticated user (if any), and execution duration + pub fn with_method_duration_tracker(mut self, tracker: F) -> Self + where + F: Fn(&str, &str, Option<&ras_auth_core::AuthenticatedUser>, std::time::Duration) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + self.with_method_duration_tracker = Some(std::sync::Arc::new(move |method, path, user, duration| { + Box::pin(tracker(method, path, user, duration)) + })); + self + } + + /// Build the axum router for the REST service + pub fn build(self) -> axum::Router { + self.auth_transport + .validate() + .expect("invalid auth transport configuration"); + + #provider_assertion + + let mut router = axum::Router::new(); + + #(#route_registrations)* + + #static_routes + + if #base_path.is_empty() || #base_path == "/" { + router + } else { + axum::Router::new().nest(#base_path, router) + } + } + } + + } +} diff --git a/crates/rest/ras-rest-macro/src/server/request.rs b/crates/rest/ras-rest-macro/src/server/request.rs new file mode 100644 index 0000000..6929b81 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/request.rs @@ -0,0 +1,479 @@ +//! Request-part models and fallible extraction. + +use crate::ast::*; +use quote::quote; +use syn::{Ident, Type}; + +pub(super) 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() { + "Version".to_string() + } else if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { + format!("V{out}") + } else { + out + } +} + +pub(super) fn rest_request_part_idents( + service_name: &Ident, + handler_name: &Ident, + version: &str, +) -> (Ident, Ident, Ident) { + let service = service_name.to_string(); + let handler = pascal_ident_segment(&handler_name.to_string()); + let version = pascal_ident_segment(version); + let request_ident = quote::format_ident!("{}{}{}Request", service, handler, version); + let path_ident = quote::format_ident!("{}{}{}Path", service, handler, version); + let query_ident = quote::format_ident!("{}{}{}Query", service, handler, version); + (request_ident, path_ident, query_ident) +} + +pub(super) fn rest_body_type_tokens(request_type: Option<&Type>) -> proc_macro2::TokenStream { + match request_type { + Some(request_type) => quote! { #request_type }, + None => quote! { () }, + } +} + +pub(super) fn generate_rest_request_part_structs( + service_def: &ServiceDefinition, +) -> proc_macro2::TokenStream { + let structs = service_def.endpoints.iter().flat_map(|endpoint| { + if endpoint.versions.is_empty() { + return Vec::new(); + } + + let canonical_version = endpoint.version.as_deref().unwrap_or("current"); + let mut structs = vec![generate_rest_request_part_struct( + &service_def.service_name, + &endpoint.handler_name, + canonical_version, + &endpoint.path_params, + &endpoint.query_params, + endpoint.request_type.as_ref(), + )]; + + structs.extend(endpoint.versions.iter().map(|version| { + generate_rest_request_part_struct( + &service_def.service_name, + &endpoint.handler_name, + &version.version, + &version.path_params, + &version.query_params, + version.request_type.as_ref(), + ) + })); + + structs + }); + + quote! { + #(#structs)* + } +} + +pub(super) fn generate_rest_request_part_struct( + service_name: &Ident, + handler_name: &Ident, + version: &str, + path_params: &[PathParam], + query_params: &[QueryParam], + request_type: Option<&Type>, +) -> proc_macro2::TokenStream { + let (request_ident, path_ident, query_ident) = + rest_request_part_idents(service_name, handler_name, version); + let path_fields = path_params.iter().map(|param| { + let name = ¶m.name; + let param_type = ¶m.param_type; + quote! { pub #name: #param_type } + }); + let query_fields = query_params.iter().map(|param| { + let name = ¶m.name; + let param_type = ¶m.param_type; + quote! { pub #name: #param_type } + }); + let body_type = rest_body_type_tokens(request_type); + + quote! { + pub struct #path_ident { + #(#path_fields),* + } + + pub struct #query_ident { + #(#query_fields),* + } + + pub struct #request_ident { + pub path: #path_ident, + pub query: #query_ident, + pub body: #body_type, + } + } +} + +pub(super) fn generate_rest_parts_init( + service_name: &Ident, + handler_name: &Ident, + version: &str, + path_params: &[PathParam], + query_params: &[QueryParam], + request_type: Option<&Type>, +) -> proc_macro2::TokenStream { + let (request_ident, path_ident, query_ident) = + rest_request_part_idents(service_name, handler_name, version); + + let path_values = 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 } + } + }); + + let query_values = query_params.iter().map(|param| { + let name = ¶m.name; + quote! { #name: query_params.#name } + }); + + let body_value = if request_type.is_some() { + quote! { body } + } else { + quote! { () } + }; + + quote! { + #request_ident { + path: #path_ident { + #(#path_values),* + }, + query: #query_ident { + #(#query_values),* + }, + body: #body_value, + } + } +} + +pub(super) fn rest_canonical_args_from_parts( + endpoint: &EndpointDefinition, + parts_ident: &Ident, +) -> Vec { + let mut args = Vec::new(); + + for path_param in &endpoint.path_params { + let name = &path_param.name; + args.push(quote! { #parts_ident.path.#name }); + } + + for query_param in &endpoint.query_params { + let name = &query_param.name; + args.push(quote! { #parts_ident.query.#name }); + } + + if endpoint.request_type.is_some() { + args.push(quote! { #parts_ident.body }); + } + + args +} + +pub(super) fn generate_query_struct( + struct_name: &Ident, + query_params: &[QueryParam], +) -> proc_macro2::TokenStream { + if query_params.is_empty() { + return quote! {}; + } + + let fields = query_params.iter().map(|param| { + let name = ¶m.name; + let param_type = ¶m.param_type; + quote! { pub #name: #param_type } + }); + + quote! { + #[derive(serde::Deserialize)] + pub(super) struct #struct_name { + #(#fields),* + } + } +} + +/// Generated handler signature plus the prelude that unwraps fallible +/// extractors inside the handler body. +pub(super) struct AxumHandlerParts { + /// Closure parameter list (`headers`, path/query extractors, raw request). + pub(super) extractors: proc_macro2::TokenStream, + /// Emitted at the top of the handler body. Path and query extractors are + /// taken as `Result<_, Rejection>` so the axum default rejection body — + /// which echoes the offending value verbatim, e.g. + /// ``Invalid URL: Cannot parse `abc` to a `i32` `` — is never sent to the + /// client. The detail is logged at `warn` and a fixed message is returned. + pub(super) prelude: proc_macro2::TokenStream, +} + +pub(super) fn generate_axum_handler( + path_params: &[PathParam], + query_params: &[QueryParam], + request_type: Option<&Type>, + query_struct_name: &Ident, + method: &str, + path: &str, +) -> AxumHandlerParts { + let mut extractors = Vec::new(); + let mut prelude = Vec::new(); + + extractors.push(quote! { headers: axum::http::HeaderMap }); + + if !path_params.is_empty() { + let path_param_types = path_params.iter().map(|param| ¶m.param_type); + let path_ty = if path_params.len() == 1 { + quote! { axum::extract::Path<#(#path_param_types)*> } + } else { + quote! { axum::extract::Path<(#(#path_param_types),*)> } + }; + extractors.push(quote! { + __ras_path: Result<#path_ty, axum::extract::rejection::PathRejection> + }); + prelude.push(quote! { + let axum::extract::Path(path_params) = match __ras_path { + Ok(path) => path, + Err(__ras_rejection) => { + use axum::response::IntoResponse; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + status = __ras_rejection.status().as_u16(), + detail = %ras_rest_core::sanitize_log_detail(&__ras_rejection.body_text()), + "rejected request: invalid path parameters" + ); + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ "error": "Invalid path parameters" })) + ).into_response(); + } + }; + }); + } + + if !query_params.is_empty() { + extractors.push(quote! { + __ras_query: Result< + ::axum_extra::extract::Query, + ::axum_extra::extract::QueryRejection, + > + }); + prelude.push(quote! { + let ::axum_extra::extract::Query(query_params) = match __ras_query { + Ok(query) => query, + Err(__ras_rejection) => { + use axum::response::IntoResponse; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + status = __ras_rejection.status().as_u16(), + detail = %ras_rest_core::sanitize_log_detail(&__ras_rejection.body_text()), + "rejected request: invalid query parameters" + ); + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ "error": "Invalid query parameters" })) + ).into_response(); + } + }; + }); + } + + // Take the raw request when a body is declared. The body is read and + // deserialized inside the handler AFTER auth/CSRF/permission checks, so + // unauthenticated clients cannot make the server buffer or parse payloads. + if request_type.is_some() { + extractors.push(quote! { request: axum::extract::Request }); + } + + AxumHandlerParts { + extractors: quote! { #(#extractors),* }, + prelude: quote! { #(#prelude)* }, + } +} + +/// Generated code that reads and JSON-deserializes the request body from the +/// raw `request` extractor, bounded by `limit`. +/// +/// For authenticated endpoints this must be emitted AFTER the +/// auth/CSRF/permission block so unauthenticated clients cannot make the +/// server buffer or parse payloads. +/// +/// Behavior: +/// * When `require_json` is set, a request whose `Content-Type` is not +/// `application/json` (ignoring parameters like `; charset=utf-8`) is rejected +/// with `415 Unsupported Media Type` before the body is read. Requiring +/// `application/json` also forces a CORS preflight for cross-origin requests, +/// which no CORS layer answers by default — defense-in-depth against +/// simple-request CSRF on cookie-authenticated endpoints. +/// * A declared `Content-Length` over `limit` is rejected with `413` up front so +/// a subsequent `to_bytes` error is unambiguously a read failure (`400`), +/// rather than the two being conflated as "too large". +/// * A malformed JSON body is logged (category + line/column, never the rejected +/// value) at `warn` before returning `400`, matching the handler-error logging +/// convention. +pub(super) fn generate_body_extraction( + require_json: bool, + limit: &proc_macro2::TokenStream, + method: &str, + path: &str, +) -> proc_macro2::TokenStream { + let content_type_check = if require_json { + quote! { + { + let __ras_content_type_ok = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or("") + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false); + if !__ras_content_type_ok { + use axum::response::IntoResponse; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + "rejected request: Content-Type is not application/json" + ); + return ( + axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + axum::Json(serde_json::json!({ + "error": "Unsupported Media Type: expected application/json" + })) + ).into_response(); + } + } + } + } else { + quote! {} + }; + + quote! { + #content_type_check + + let body = { + // Reject an over-declared Content-Length up front so a 413 is + // unambiguous without reading the body. A chunked body with no + // declared length is still capped by `to_bytes`; that error is then + // classified below (over-limit -> 413, genuine read error -> 400). + if let Some(__ras_declared_len) = headers + .get(axum::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + { + if __ras_declared_len > #limit { + use axum::response::IntoResponse; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + declared_len = __ras_declared_len, + limit = #limit, + "rejected request: body exceeds limit" + ); + return ( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + axum::Json(serde_json::json!({ + "error": "Request body too large" + })) + ).into_response(); + } + } + + let body_bytes = match ::axum::body::to_bytes(request.into_body(), #limit).await { + Ok(bytes) => bytes, + Err(__ras_body_err) => { + use axum::response::IntoResponse; + // `to_bytes` fails for both an over-limit body and a genuine + // stream read error. axum wraps http_body_util's + // `LengthLimitError` (Display: "length limit exceeded") for + // the former; classify on it so a read failure is a 400 and + // only a real overflow is a 413. + let (__ras_status, __ras_client_msg) = + if __ras_body_err.to_string().contains("length limit exceeded") { + ( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "Request body too large", + ) + } else { + ( + axum::http::StatusCode::BAD_REQUEST, + "Could not read request body", + ) + }; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + status = __ras_status.as_u16(), + "rejected request: {}", + __ras_client_msg + ); + return ( + __ras_status, + axum::Json(serde_json::json!({ "error": __ras_client_msg })) + ).into_response(); + }, + }; + match serde_json::from_slice(&body_bytes) { + Ok(body) => body, + Err(__ras_json_err) => { + use axum::response::IntoResponse; + // Log the classification and location so the reason is + // recoverable server-side; never log the rejected value. + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + category = ?__ras_json_err.classify(), + line = __ras_json_err.line(), + column = __ras_json_err.column(), + "rejected request: malformed JSON body" + ); + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": "Invalid JSON" + })) + ).into_response(); + }, + } + }; + } +} + +/// The effective body-size limit expression for an endpoint: its per-endpoint +/// `body_limit` override when set, otherwise the service-level `__RAS_BODY_LIMIT`. +pub(super) fn effective_body_limit_tokens( + endpoint: &EndpointDefinition, +) -> proc_macro2::TokenStream { + match endpoint.body_limit { + Some(limit) => quote! { #limit }, + None => quote! { __RAS_BODY_LIMIT }, + } +} diff --git a/crates/rest/ras-rest-macro/src/server/routes.rs b/crates/rest/ras-rest-macro/src/server/routes.rs new file mode 100644 index 0000000..2745647 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/routes.rs @@ -0,0 +1,124 @@ +//! Canonical and versioned route registration. + +use super::auth::rest_permission_groups_code; +use super::handlers::generate_handler_body; +use super::request::{AxumHandlerParts, generate_axum_handler}; +use super::versioned::generate_legacy_handler_body; +use crate::ast::*; +use quote::quote; +use syn::Ident; + +pub(super) fn generate_canonical_route_registration( + endpoint: &EndpointDefinition, + query_struct_name: &Ident, + require_json: bool, +) -> proc_macro2::TokenStream { + let method_routing = endpoint.method.as_axum_method(); + let path = &endpoint.path; + let handler_name = &endpoint.handler_name; + let method_str = endpoint.method.as_str(); + let AxumHandlerParts { + extractors: axum_handler, + prelude: extractor_prelude, + } = generate_axum_handler( + &endpoint.path_params, + &endpoint.query_params, + endpoint.request_type.as_ref(), + query_struct_name, + method_str, + path, + ); + let handler_body = + generate_handler_body(endpoint, handler_name, method_str, path, require_json); + let permission_groups_code = rest_permission_groups_code(&endpoint.auth); + + quote! { + { + 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(); + + router = router.route(#path, #method_routing({ + 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(); + + async move { + #extractor_prelude + #handler_body + } + } + })); + } + } +} + +pub(super) fn generate_legacy_route_registration( + service_name: &Ident, + endpoint: &EndpointDefinition, + version: &EndpointVersionDefinition, + query_struct_name: &Ident, + require_json: bool, +) -> proc_macro2::TokenStream { + let method_routing = endpoint.method.as_axum_method(); + let path = &version.path; + let AxumHandlerParts { + extractors: axum_handler, + prelude: extractor_prelude, + } = generate_axum_handler( + &version.path_params, + &version.query_params, + version.request_type.as_ref(), + query_struct_name, + endpoint.method.as_str(), + path, + ); + let handler_body = generate_legacy_handler_body(service_name, endpoint, version, require_json); + let permission_groups_code = rest_permission_groups_code(&endpoint.auth); + + quote! { + { + 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(); + + router = router.route(#path, #method_routing({ + 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(); + + async move { + #extractor_prelude + #handler_body + } + } + })); + } + } +} + +impl HttpMethod { + fn as_axum_method(&self) -> proc_macro2::TokenStream { + match self { + HttpMethod::Get => quote! { axum::routing::get }, + HttpMethod::Post => quote! { axum::routing::post }, + HttpMethod::Put => quote! { axum::routing::put }, + HttpMethod::Delete => quote! { axum::routing::delete }, + HttpMethod::Patch => quote! { axum::routing::patch }, + } + } +} diff --git a/crates/rest/ras-rest-macro/src/server/versioned.rs b/crates/rest/ras-rest-macro/src/server/versioned.rs new file mode 100644 index 0000000..a5e4d07 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/versioned.rs @@ -0,0 +1,304 @@ +//! Request and response adaptation for versioned routes. + +use super::request::{ + effective_body_limit_tokens, generate_body_extraction, generate_rest_parts_init, + rest_canonical_args_from_parts, rest_request_part_idents, +}; +use crate::ast::*; +use quote::quote; +use syn::Ident; + +pub(super) fn generate_legacy_handler_body( + service_name: &Ident, + endpoint: &EndpointDefinition, + version: &EndpointVersionDefinition, + require_json: bool, +) -> proc_macro2::TokenStream { + let handler_name = &endpoint.handler_name; + let method = endpoint.method.as_str(); + let path = &version.path; + let body_limit_tokens = effective_body_limit_tokens(endpoint); + let migration_type = &version.migration_type; + let canonical_response_type = &endpoint.response_type; + let legacy_response_type = &version.response_type; + let canonical_version = endpoint.version.as_deref().unwrap_or("current"); + let (canonical_request_ident, _, _) = + rest_request_part_idents(service_name, handler_name, canonical_version); + let (legacy_request_ident, _, _) = + rest_request_part_idents(service_name, handler_name, &version.version); + let legacy_parts_init = generate_rest_parts_init( + service_name, + handler_name, + &version.version, + &version.path_params, + &version.query_params, + version.request_type.as_ref(), + ); + let canonical_parts_ident = quote::format_ident!("canonical_parts"); + let mut canonical_args = rest_canonical_args_from_parts(endpoint, &canonical_parts_ident); + + // Opt-in request headers, inserted before the auth arg is prepended below so + // the final order is [caller/user?, headers, path.., query.., body?]. + if endpoint.with_headers { + canonical_args.insert(0, quote! { headers.clone() }); + } + + let json_handling = if version.request_type.is_some() { + generate_body_extraction(require_json, &body_limit_tokens, method, path) + } else { + quote! {} + }; + + match &endpoint.auth { + AuthRequirement::Unauthorized => quote! { + #json_handling + + if let Some(tracker) = &with_usage_tracker { + 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; + let #canonical_parts_ident: #canonical_request_ident = + match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { + Ok(parts) => parts, + Err(e) => { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": e.to_string() + })) + ).into_response(); + }, + }; + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#canonical_args),*).await { + Ok(rest_response) => { + use axum::response::IntoResponse; + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + let body: #legacy_response_type = + match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { + Ok(body) => body, + Err(e) => { + ras_rest_core::tracing::error!(error = %e, "Response migration failed"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ + "error": "Internal server error" + })) + ).into_response(); + }, + }; + __ras_success_response(status_code, body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, None, duration).await; + } + + result + }, + AuthRequirement::OptionalAuth => { + canonical_args.insert(0, quote! { caller }); + + quote! { + // Best-effort authentication for an OPTIONAL_AUTH route — never + // rejected: resolves to Caller::Anonymous for a missing/invalid + // credential, Caller::Authenticated for a valid one. + let caller = ras_auth_core::resolve_caller( + #method, + &headers, + &auth_transport, + auth_provider.as_deref(), + ).await; + // Snapshot the user for tracking; `caller` is moved into the handler. + let __ras_caller_user = caller.authenticated().cloned(); + + #json_handling + + if let Some(tracker) = &with_usage_tracker { + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, __ras_caller_user.as_ref(), #method, #path).await; + } + + let legacy_parts: #legacy_request_ident = #legacy_parts_init; + let #canonical_parts_ident: #canonical_request_ident = + match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { + Ok(parts) => parts, + Err(e) => { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": e.to_string() + })) + ).into_response(); + }, + }; + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#canonical_args),*).await { + Ok(rest_response) => { + use axum::response::IntoResponse; + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + let body: #legacy_response_type = + match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { + Ok(body) => body, + Err(e) => { + ras_rest_core::tracing::error!(error = %e, "Response migration failed"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ + "error": "Internal server error" + })) + ).into_response(); + }, + }; + __ras_success_response(status_code, body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, __ras_caller_user.as_ref(), duration).await; + } + + result + } + } + AuthRequirement::WithPermissions(_) => { + canonical_args.insert(0, quote! { &user }); + + quote! { + // Authenticate and authorize: credential → CSRF → authenticate + // → OR-of-AND permission groups (shared ras-auth-core pipeline) + let user = match ras_auth_core::authorize_request( + #method, + &headers, + &auth_transport, + auth_provider.as_deref(), + &required_permission_groups, + ).await { + Ok(user) => user, + Err(error) => return __ras_authorize_error_response(error), + }; + + // Read and parse the body only after auth has succeeded + #json_handling + + if let Some(tracker) = &with_usage_tracker { + 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; + let #canonical_parts_ident: #canonical_request_ident = + match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { + Ok(parts) => parts, + Err(e) => { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": e.to_string() + })) + ).into_response(); + }, + }; + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#canonical_args),*).await { + Ok(rest_response) => { + use axum::response::IntoResponse; + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + let body: #legacy_response_type = + match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { + Ok(body) => body, + Err(e) => { + ras_rest_core::tracing::error!(error = %e, "Response migration failed"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ + "error": "Internal server error" + })) + ).into_response(); + }, + }; + __ras_success_response(status_code, body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, Some(&user), duration).await; + } + + result + } + } + } +} diff --git a/crates/rest/ras-rest-macro/src/static_hosting.rs b/crates/rest/ras-rest-macro/src/static_hosting.rs index 559a64a..5861dc6 100644 --- a/crates/rest/ras-rest-macro/src/static_hosting.rs +++ b/crates/rest/ras-rest-macro/src/static_hosting.rs @@ -34,7 +34,7 @@ pub fn generate_static_hosting_code( return quote! {}; } - const TEMPLATE_CONTENT: &str = include_str!("api_explorer_template.html"); + const TEMPLATE_CONTENT: &str = ras_api_explorer_assets::TEMPLATE; let service_name = &service_def.service_name; let base_path = service_def.base_path.trim_end_matches('/').to_string(); @@ -54,6 +54,7 @@ pub fn generate_static_hosting_code( let docs_handler_name = quote::format_ident!("{}_docs_handler", service_name.to_string().to_lowercase()); let template_lit = syn::LitStr::new(TEMPLATE_CONTENT, proc_macro2::Span::call_site()); + let config_placeholder = ras_api_explorer_assets::CONFIG_PLACEHOLDER; quote! { async fn #docs_handler_name() -> ::axum::response::Html { @@ -70,7 +71,7 @@ pub fn generate_static_hosting_code( .to_string() .replace("<", "\\u003c"); - TEMPLATE.replace("{EXPLORER_CONFIG_JSON}", &config_json) + TEMPLATE.replace(#config_placeholder, &config_json) }); ::axum::response::Html(html.clone()) diff --git a/crates/rest/ras-rest-macro/tests/e2e.rs b/crates/rest/ras-rest-macro/tests/e2e.rs index d8db7e6..fb6d0c2 100644 --- a/crates/rest/ras-rest-macro/tests/e2e.rs +++ b/crates/rest/ras-rest-macro/tests/e2e.rs @@ -375,325 +375,11 @@ fn client() -> DemoClient { .expect("failed to build DemoClient over AxumTestTransport") } -#[tokio::test] -async fn unauth_get_round_trips() { - 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 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, - RenamedItemV1 { - name: "renamed".to_string() - } - ); -} - -#[tokio::test] -async fn optional_auth_versioned_legacy_path_threads_caller() { - // v1 (legacy) path with a valid token: exercises the legacy/migration arm AND - // caller resolution. The migrated v1 response carries display_name only. - let response = server() - .post("/api/v1/items/7/touch?notify=true") - .authorization_bearer("user-token") - .json(&RenameItemV1 { - name: "hello".to_string(), - }) - .await; - response.assert_status_ok(); - let resp: RenamedItemV1 = response.json(); - // RenamedItemV1.name == migrated display_name == ":". - assert_eq!(resp.name, "user-1:hello"); -} - -#[tokio::test] -async fn optional_auth_versioned_canonical_path_is_anonymous_without_token() { - let response = server() - .post("/api/v2/items/9/touch?notify=false") - .json(&RenameItemV2 { - display_name: "world".to_string(), - notify: false, - }) - .await; - response.assert_status_ok(); - let resp: RenamedItemV2 = response.json(); - assert_eq!(resp.id, 9); - assert_eq!(resp.display_name, "anonymous:world"); -} - -#[tokio::test] -async fn canonical_rest_version_uses_v2_path_and_types() { - 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, - RenamedItemV2 { - id: 8, - display_name: "canonical".to_string(), - notified: true, - } - ); -} - -#[tokio::test] -async fn auth_get_with_path_param_succeeds_with_user_token() { - let response = server() - .get("/api/items/7") - .authorization_bearer("user-token") - .await; - response.assert_status_ok(); - let item: Item = response.json(); - - assert_eq!(item.id, 7); - assert_eq!(item.name, "item-7"); -} - -#[tokio::test] -async fn auth_get_rejected_without_token() { - let response = server().get("/api/items/1").await; - response.assert_status(StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn auth_post_rejected_with_insufficient_perms() { - let response = server() - .post("/api/items") - .authorization_bearer("user-token") - .json(&CreateItem { - name: "x".to_string(), - }) - .await; - response.assert_status(StatusCode::FORBIDDEN); -} - -#[tokio::test] -async fn auth_post_with_admin_succeeds_and_user_id_propagates() { - 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); -} - -#[tokio::test] -async fn optional_auth_without_token_sees_anonymous_caller() { - let response = server().get("/api/whoami").await; - response.assert_status_ok(); - let resp: WhoamiResponse = response.json(); - assert_eq!(resp.caller, "anonymous"); -} - -#[tokio::test] -async fn optional_auth_with_valid_token_sees_authenticated_caller() { - let response = server() - .get("/api/whoami") - .authorization_bearer("user-token") - .await; - response.assert_status_ok(); - let resp: WhoamiResponse = response.json(); - assert_eq!(resp.caller, "user-1"); -} - -#[tokio::test] -async fn optional_auth_with_invalid_token_is_lenient_and_anonymous() { - // A present-but-bad credential must NOT reject an OPTIONAL_AUTH route; it - // downgrades to anonymous. - let response = server() - .get("/api/whoami") - .authorization_bearer("not-a-real-token") - .await; - response.assert_status_ok(); - let resp: WhoamiResponse = response.json(); - assert_eq!(resp.caller, "anonymous"); -} - -#[tokio::test] -async fn optional_auth_post_threads_caller_and_body() { - // Anonymous POST with a body still reaches the handler. - let anon = server() - .post("/api/whoami/echo") - .json(&CreateItem { name: "hi".into() }) - .await; - anon.assert_status_ok(); - assert_eq!(anon.json::().caller, "anonymous:hi"); - - // Authenticated POST sees the caller and the body. - let authed = server() - .post("/api/whoami/echo") - .authorization_bearer("user-token") - .json(&CreateItem { name: "hi".into() }) - .await; - authed.assert_status_ok(); - assert_eq!(authed.json::().caller, "user-1:hi"); -} - -#[tokio::test] -async fn query_params_required_and_optional_serialize_correctly() { - // Drive the generated client over the in-process transport so the - // serde_urlencoded query path is exercised live (required + Option-skip). - let client = client(); - - let resp = client - .get_search("hi".to_string(), Some(3), true) - .await - .expect("get_search with limit failed"); - assert_eq!(resp.items.len(), 3); - assert_eq!(resp.items[0].name, "exact:hi-0"); - assert_eq!(resp.items[2].name, "exact:hi-2"); - - // `limit: None` must be skipped from the query string entirely. - let resp = client - .get_search("zz".to_string(), None, false) - .await - .expect("get_search without limit failed"); - assert_eq!(resp.items.len(), 2); - assert_eq!(resp.items[0].name, "fuzzy:zz-0"); -} - -#[tokio::test] -async fn generated_client_timeout_variant_accepts_duration() { - let client = client(); - - let resp = client - .get_search_with_timeout( - "timeout".to_string(), - Some(1), - false, - std::time::Duration::from_secs(1), - ) - .await - .expect("get_search_with_timeout failed"); - - assert_eq!(resp.items.len(), 1); - assert_eq!(resp.items[0].name, "fuzzy:timeout-0"); -} - -#[tokio::test] -async fn vec_query_params_serialize_as_repeated_keys() { - // `Vec` and `Option>` query params must serialize as repeated - // keys through the generated client. - let client = client(); - - let resp = client - .get_filter( - vec!["red".to_string(), "blue".to_string()], - Some(vec!["featured".to_string()]), - ) - .await - .expect("get_filter with tags failed"); - 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 - .get_filter(vec!["solo".to_string()], None) - .await - .expect("get_filter solo failed"); - 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() { - // Enum query values must honor `#[serde(rename)]` (asc/desc) rather than - // any Display/Debug formatting. - let client = client(); - - let resp = client - .get_sorted(SortOrder::Asc) - .await - .expect("get_sorted asc failed"); - assert_eq!(resp.items[0].name, "order:asc"); - - let resp = client - .get_sorted(SortOrder::Desc) - .await - .expect("get_sorted desc failed"); - assert_eq!(resp.items[0].name, "order:desc"); -} - -#[tokio::test] -async fn query_params_with_body_and_auth() { - // Combined: bool query param + JSON body + bearer auth, via the client. - let mut client = client(); - client.set_bearer_token(Some("admin-token")); - - let item = client - .post_items_batch( - true, - CreateItem { - name: "alpha".into(), - }, - ) - .await - .expect("post_items_batch notify=true failed"); - assert_eq!(item.name, "alpha(notified)"); - - let item = client - .post_items_batch( - false, - CreateItem { - name: "beta".into(), - }, - ) - .await - .expect("post_items_batch notify=false failed"); - assert_eq!(item.name, "beta(silent)"); -} - -#[tokio::test] -async fn query_params_with_path_param() { - // Path param substitution + Option query param + bearer auth, via client. - let mut client = client(); - client.set_bearer_token(Some("user-token")); - - let resp = client - .get_items_by_id_related(42, Some("featured".to_string())) - .await - .expect("get_items_by_id_related with tag failed"); - assert_eq!(resp.items[0].id, 42); - assert_eq!(resp.items[0].name, "related/featured"); - - let resp = client - .get_items_by_id_related(42, None) - .await - .expect("get_items_by_id_related without tag failed"); - assert_eq!(resp.items[0].name, "related/none"); -} - -#[tokio::test] -async fn handler_error_surfaces_to_client() { - let response = server() - .get("/api/items/404") - .authorization_bearer("user-token") - .await; - response.assert_status(StatusCode::NOT_FOUND); -} +#[path = "e2e/auth.rs"] +mod auth; +#[path = "e2e/client.rs"] +mod client; +#[path = "e2e/parameters.rs"] +mod parameters; +#[path = "e2e/versioning.rs"] +mod versioning; diff --git a/crates/rest/ras-rest-macro/tests/e2e/auth.rs b/crates/rest/ras-rest-macro/tests/e2e/auth.rs new file mode 100644 index 0000000..2a2de51 --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/e2e/auth.rs @@ -0,0 +1,96 @@ +use super::*; + +#[tokio::test] +async fn unauth_get_round_trips() { + 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 auth_get_rejected_without_token() { + let response = server().get("/api/items/1").await; + response.assert_status(StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn auth_post_rejected_with_insufficient_perms() { + let response = server() + .post("/api/items") + .authorization_bearer("user-token") + .json(&CreateItem { + name: "x".to_string(), + }) + .await; + response.assert_status(StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn auth_post_with_admin_succeeds_and_user_id_propagates() { + 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); +} + +#[tokio::test] +async fn optional_auth_without_token_sees_anonymous_caller() { + let response = server().get("/api/whoami").await; + response.assert_status_ok(); + let resp: WhoamiResponse = response.json(); + assert_eq!(resp.caller, "anonymous"); +} + +#[tokio::test] +async fn optional_auth_with_valid_token_sees_authenticated_caller() { + let response = server() + .get("/api/whoami") + .authorization_bearer("user-token") + .await; + response.assert_status_ok(); + let resp: WhoamiResponse = response.json(); + assert_eq!(resp.caller, "user-1"); +} + +#[tokio::test] +async fn optional_auth_with_invalid_token_is_lenient_and_anonymous() { + // A present-but-bad credential must NOT reject an OPTIONAL_AUTH route; it + // downgrades to anonymous. + let response = server() + .get("/api/whoami") + .authorization_bearer("not-a-real-token") + .await; + response.assert_status_ok(); + let resp: WhoamiResponse = response.json(); + assert_eq!(resp.caller, "anonymous"); +} + +#[tokio::test] +async fn optional_auth_post_threads_caller_and_body() { + // Anonymous POST with a body still reaches the handler. + let anon = server() + .post("/api/whoami/echo") + .json(&CreateItem { name: "hi".into() }) + .await; + anon.assert_status_ok(); + assert_eq!(anon.json::().caller, "anonymous:hi"); + + // Authenticated POST sees the caller and the body. + let authed = server() + .post("/api/whoami/echo") + .authorization_bearer("user-token") + .json(&CreateItem { name: "hi".into() }) + .await; + authed.assert_status_ok(); + assert_eq!(authed.json::().caller, "user-1:hi"); +} diff --git a/crates/rest/ras-rest-macro/tests/e2e/client.rs b/crates/rest/ras-rest-macro/tests/e2e/client.rs new file mode 100644 index 0000000..95b4ef5 --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/e2e/client.rs @@ -0,0 +1,28 @@ +use super::*; + +#[tokio::test] +async fn generated_client_timeout_variant_accepts_duration() { + let client = client(); + + let resp = client + .get_search_with_timeout( + "timeout".to_string(), + Some(1), + false, + std::time::Duration::from_secs(1), + ) + .await + .expect("get_search_with_timeout failed"); + + assert_eq!(resp.items.len(), 1); + assert_eq!(resp.items[0].name, "fuzzy:timeout-0"); +} + +#[tokio::test] +async fn handler_error_surfaces_to_client() { + 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/e2e/parameters.rs b/crates/rest/ras-rest-macro/tests/e2e/parameters.rs new file mode 100644 index 0000000..07a665c --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/e2e/parameters.rs @@ -0,0 +1,129 @@ +use super::*; + +#[tokio::test] +async fn auth_get_with_path_param_succeeds_with_user_token() { + let response = server() + .get("/api/items/7") + .authorization_bearer("user-token") + .await; + response.assert_status_ok(); + let item: Item = response.json(); + + assert_eq!(item.id, 7); + assert_eq!(item.name, "item-7"); +} + +#[tokio::test] +async fn query_params_required_and_optional_serialize_correctly() { + // Drive the generated client over the in-process transport so the + // serde_urlencoded query path is exercised live (required + Option-skip). + let client = client(); + + let resp = client + .get_search("hi".to_string(), Some(3), true) + .await + .expect("get_search with limit failed"); + assert_eq!(resp.items.len(), 3); + assert_eq!(resp.items[0].name, "exact:hi-0"); + assert_eq!(resp.items[2].name, "exact:hi-2"); + + // `limit: None` must be skipped from the query string entirely. + let resp = client + .get_search("zz".to_string(), None, false) + .await + .expect("get_search without limit failed"); + 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() { + // `Vec` and `Option>` query params must serialize as repeated + // keys through the generated client. + let client = client(); + + let resp = client + .get_filter( + vec!["red".to_string(), "blue".to_string()], + Some(vec!["featured".to_string()]), + ) + .await + .expect("get_filter with tags failed"); + 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 + .get_filter(vec!["solo".to_string()], None) + .await + .expect("get_filter solo failed"); + 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() { + // Enum query values must honor `#[serde(rename)]` (asc/desc) rather than + // any Display/Debug formatting. + let client = client(); + + let resp = client + .get_sorted(SortOrder::Asc) + .await + .expect("get_sorted asc failed"); + assert_eq!(resp.items[0].name, "order:asc"); + + let resp = client + .get_sorted(SortOrder::Desc) + .await + .expect("get_sorted desc failed"); + assert_eq!(resp.items[0].name, "order:desc"); +} + +#[tokio::test] +async fn query_params_with_body_and_auth() { + // Combined: bool query param + JSON body + bearer auth, via the client. + let mut client = client(); + client.set_bearer_token(Some("admin-token")); + + let item = client + .post_items_batch( + true, + CreateItem { + name: "alpha".into(), + }, + ) + .await + .expect("post_items_batch notify=true failed"); + assert_eq!(item.name, "alpha(notified)"); + + let item = client + .post_items_batch( + false, + CreateItem { + name: "beta".into(), + }, + ) + .await + .expect("post_items_batch notify=false failed"); + assert_eq!(item.name, "beta(silent)"); +} + +#[tokio::test] +async fn query_params_with_path_param() { + // Path param substitution + Option query param + bearer auth, via client. + let mut client = client(); + client.set_bearer_token(Some("user-token")); + + let resp = client + .get_items_by_id_related(42, Some("featured".to_string())) + .await + .expect("get_items_by_id_related with tag failed"); + assert_eq!(resp.items[0].id, 42); + assert_eq!(resp.items[0].name, "related/featured"); + + let resp = client + .get_items_by_id_related(42, None) + .await + .expect("get_items_by_id_related without tag failed"); + assert_eq!(resp.items[0].name, "related/none"); +} diff --git a/crates/rest/ras-rest-macro/tests/e2e/versioning.rs b/crates/rest/ras-rest-macro/tests/e2e/versioning.rs new file mode 100644 index 0000000..f2955a7 --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/e2e/versioning.rs @@ -0,0 +1,74 @@ +use super::*; + +#[tokio::test] +async fn legacy_rest_version_round_trips_through_canonical_handler() { + 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, + RenamedItemV1 { + name: "renamed".to_string() + } + ); +} + +#[tokio::test] +async fn optional_auth_versioned_legacy_path_threads_caller() { + // v1 (legacy) path with a valid token: exercises the legacy/migration arm AND + // caller resolution. The migrated v1 response carries display_name only. + let response = server() + .post("/api/v1/items/7/touch?notify=true") + .authorization_bearer("user-token") + .json(&RenameItemV1 { + name: "hello".to_string(), + }) + .await; + response.assert_status_ok(); + let resp: RenamedItemV1 = response.json(); + // RenamedItemV1.name == migrated display_name == ":". + assert_eq!(resp.name, "user-1:hello"); +} + +#[tokio::test] +async fn optional_auth_versioned_canonical_path_is_anonymous_without_token() { + let response = server() + .post("/api/v2/items/9/touch?notify=false") + .json(&RenameItemV2 { + display_name: "world".to_string(), + notify: false, + }) + .await; + response.assert_status_ok(); + let resp: RenamedItemV2 = response.json(); + assert_eq!(resp.id, 9); + assert_eq!(resp.display_name, "anonymous:world"); +} + +#[tokio::test] +async fn canonical_rest_version_uses_v2_path_and_types() { + 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, + RenamedItemV2 { + id: 8, + display_name: "canonical".to_string(), + notified: true, + } + ); +} diff --git a/crates/rest/ras-rest-macro/tests/http_integration.rs b/crates/rest/ras-rest-macro/tests/http_integration.rs index 80684d4..cf9f437 100644 --- a/crates/rest/ras-rest-macro/tests/http_integration.rs +++ b/crates/rest/ras-rest-macro/tests/http_integration.rs @@ -493,822 +493,6 @@ async fn make_rest_request( } } -#[tokio::test] -async fn test_docs_explorer_routes_generated() { - let server = create_rest_test_server(); - - let docs_response = server.get("/api/v1/docs").await; - assert_eq!(docs_response.status_code().as_u16(), 200); - - 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=\"bearer-token\"")); - assert!(docs.contains("id=\"saved-list\"")); - - 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(); - assert_eq!(spec["info"]["title"], "TestRestService REST API"); - assert!(spec["paths"].is_object()); -} - -#[tokio::test] -async fn test_unauthorized_endpoints() { - let server = create_rest_test_server(); - - // Test GET /api/v1/users without auth - let response = make_rest_request(&server, Method::GET, "/api/v1/users", None, None).await; - - 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(&server, Method::GET, "/api/v1/users/123/posts", None, None).await; - - 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(&server, Method::GET, "/api/v1/health", None, None).await; - - 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 server = create_rest_test_server(); - - // Test GET /api/v1/status without token - should fail - let response = make_rest_request(&server, Method::GET, "/api/v1/status", None, None).await; - - assert_eq!(response.status_code().as_u16(), 401); - - // Test GET /api/v1/status with valid token - should succeed - let response = make_rest_request( - &server, - Method::GET, - "/api/v1/status", - None, - Some("user-token"), - ) - .await; - - 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( - &server, - Method::GET, - "/api/v1/users/123/posts/456", - None, - Some("empty-perms-token"), - ) - .await; - - 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"); -} - -#[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(); - - // Test POST /api/v1/users with user token (insufficient permissions) - should fail - let response = make_rest_request( - &server, - Method::POST, - "/api/v1/users", - Some(json!({ - "name": "New User", - "email": "new@example.com", - "permissions": ["user"] - })), - Some("user-token"), - ) - .await; - - assert_eq!(response.status_code().as_u16(), 403); - - // Test POST /api/v1/users with admin token - should succeed - let response = make_rest_request( - &server, - Method::POST, - "/api/v1/users", - Some(json!({ - "name": "New User", - "email": "new@example.com", - "permissions": ["user"] - })), - Some("admin-token"), - ) - .await; - - 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( - &server, - Method::PUT, - "/api/v1/users/123", - Some(json!({ - "name": "Updated User", - "email": "updated@example.com" - })), - Some("admin-token"), - ) - .await; - - 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( - &server, - Method::DELETE, - "/api/v1/users/123", - None, - Some("admin-token"), - ) - .await; - - assert_eq!(response.status_code().as_u16(), 204); // No Content -} - -#[tokio::test] -async fn test_user_permission_endpoints() { - let server = create_rest_test_server(); - - // Test GET /api/v1/users/123 with empty permissions token - should fail - let response = make_rest_request( - &server, - Method::GET, - "/api/v1/users/123", - None, - Some("empty-perms-token"), - ) - .await; - - assert_eq!(response.status_code().as_u16(), 403); - - // Test GET /api/v1/users/123 with user token - should succeed - let response = make_rest_request( - &server, - Method::GET, - "/api/v1/users/123", - None, - Some("user-token"), - ) - .await; - - 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( - &server, - Method::GET, - "/api/v1/users/404", - None, - Some("user-token"), - ) - .await; - - 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( - &server, - Method::POST, - "/api/v1/users/123/posts", - Some(json!({ - "title": "My New Post", - "content": "This is my new post content", - "tags": ["personal", "test"] - })), - Some("user-token"), - ) - .await; - - 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); -} - -#[tokio::test] -async fn test_multiple_permissions_endpoints() { - 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( - &server, - Method::PUT, - "/api/v1/users/123/posts/456", - Some(json!({ - "title": "Updated Post", - "content": "Updated content", - "tags": ["updated"] - })), - Some("user-token"), - ) - .await; - - 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( - &server, - Method::PUT, - "/api/v1/users/123/posts/456", - Some(json!({ - "title": "Moderator Updated Post", - "content": "Moderator updated content", - "tags": ["moderated"] - })), - Some("moderator-token"), - ) - .await; - - assert_eq!(response.status_code().as_u16(), 200); - - 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( - &server, - Method::PUT, - "/api/v1/users/123/posts/456", - Some(json!({ - "title": "Unauthorized Update", - "content": "Should not work", - "tags": [] - })), - Some("empty-perms-token"), - ) - .await; - - 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( - &server, - Method::DELETE, - "/api/v1/users/123/posts/456", - None, - Some("admin-token"), - ) - .await; - - 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( - &server, - Method::DELETE, - "/api/v1/users/123/posts/456", - None, - Some("moderator-token"), - ) - .await; - - assert_eq!(response.status_code().as_u16(), 204); // No Content -} - -#[tokio::test] -async fn test_invalid_requests() { - let server = create_rest_test_server(); - - // Test non-existent endpoint - let response = make_rest_request(&server, Method::GET, "/api/v1/nonexistent", None, None).await; - - assert_eq!(response.status_code().as_u16(), 404); - - // Test invalid HTTP method - let response = make_rest_request(&server, Method::PATCH, "/api/v1/users", None, None).await; - - assert_eq!(response.status_code().as_u16(), 405); - - // Test invalid JSON body - let response = server - .post("/api/v1/users") - .authorization_bearer("admin-token") - .text("{invalid json") - .content_type("application/json") - .await; - - assert_eq!(response.status_code().as_u16(), 400); - - // Test missing required fields - let response = make_rest_request( - &server, - Method::POST, - "/api/v1/users", - Some(json!({ - "name": "Incomplete User" - // Missing email and permissions - })), - Some("admin-token"), - ) - .await; - - assert_eq!(response.status_code().as_u16(), 400); -} - -#[tokio::test] -async fn test_concurrent_rest_requests() { - let server = Arc::new(create_rest_test_server()); - - // Test multiple concurrent requests - let mut handles = vec![]; - - for _ in 0..10 { - let server = Arc::clone(&server); - let handle = tokio::spawn(async move { - make_rest_request(&server, Method::GET, "/api/v1/health", None, None).await - }); - handles.push(handle); - } - - // Wait for all requests to complete - let results = futures::future::join_all(handles).await; - - // All requests should succeed - for result in results { - 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 server = create_rest_test_server(); - - // Test single path parameter - let response = make_rest_request( - &server, - Method::GET, - "/api/v1/users/42", - None, - Some("user-token"), - ) - .await; - - 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( - &server, - Method::GET, - "/api/v1/users/123/posts/789", - None, - Some("user-token"), - ) - .await; - - 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( - &server, - Method::POST, - "/api/v1/users/999/posts", - Some(json!({ - "title": "Path Param Post", - "content": "Testing path parameters with body", - "tags": ["path", "test"] - })), - Some("user-token"), - ) - .await; - - 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"); -} - -#[tokio::test] -async fn test_openapi_generation() { - let _ = TestRestServiceBuilder::new(TestRestServiceImpl); - - let openapi_doc = generate_testrestservice_openapi(); - assert_eq!(openapi_doc["openapi"], "3.0.3"); - - let get_users = &openapi_doc["paths"]["/users"]["get"]; - assert_eq!(get_users["summary"], "List users."); - assert_eq!( - get_users["description"], - "List users.\n\nReturns all users visible to the caller." - ); - - let post_users = &openapi_doc["paths"]["/users"]["post"]; - assert_eq!(post_users["summary"], "Create a user."); - assert_eq!(post_users["description"], "Create a user."); - - let health = &openapi_doc["paths"]["/health"]["get"]; - assert_eq!(health["summary"], "GET /health"); - assert_eq!(health["description"], "Handles GET requests to /health"); -} - -#[tokio::test] -async fn test_missing_dependencies() { - // Import futures for the join_all function - use futures::future::join_all; - - // This test ensures that our future handling is working correctly - let handles: Vec> = vec![]; - let _results = join_all(handles).await; -} - -#[tokio::test] -async fn test_new_permission_logic() { - let server = create_rest_test_server(); - - // Test admin_action endpoint with new permission logic: - // WITH_PERMISSIONS(["admin", "moderator"] | ["super_user"]) - // This means user needs (admin AND moderator) OR (super_user) - - // Test with admin-token (has "admin" and "user", but NOT "moderator") - should FAIL - let response = make_rest_request( - &server, - Method::POST, - "/api/v1/admin_action", - Some(serde_json::Value::Null), // Send null for unit type - Some("admin-token"), - ) - .await; - assert_eq!( - 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( - &server, - Method::POST, - "/api/v1/admin_action", - Some(Value::Null), // Send null for unit type - Some("moderator-token"), - ) - .await; - assert_eq!( - 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( - &server, - Method::POST, - "/api/v1/admin_action", - Some(Value::Null), // Send null for unit type - Some("superuser-token"), - ) - .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 - - // The DELETE endpoint uses ["moderator"] | ["admin"] - should succeed with either - // Test with admin-token (has "admin") - should SUCCEED - let response = make_rest_request( - &server, - Method::DELETE, - "/api/v1/users/123/posts/456", - None, - Some("admin-token"), - ) - .await; - assert_eq!( - 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( - &server, - Method::DELETE, - "/api/v1/users/123/posts/456", - None, - Some("moderator-token"), - ) - .await; - assert_eq!( - response.status_code().as_u16(), - 204, // No Content - "Moderator token should succeed for delete - has moderator" - ); -} - -#[tokio::test] -async fn test_generated_rest_client() { - // Real end-to-end test: drive the generated client over the in-process - // AxumTestTransport against the live router. Covers unauthenticated GET, - // query-param serialization, bearer auth, a unit-type response, and HTTP - // error -> TransportError::Status mapping. - let server = create_rest_test_server_arc(); - let mut client = create_rest_test_client(server); - - // Bearer-token accessors still behave as before. - assert_eq!(client.bearer_token(), None); - - // 1. Unauthenticated GET returning a deserialized body. - let users = client.get_users().await.expect("get_users failed"); - assert_eq!(users.total, 2); - assert_eq!(users.users[0].name, "John Doe"); - - // 2. Query params (required + optional) over the serde_urlencoded path. - let search = client - .get_search_users("john".to_string(), Some(5), Some(10)) - .await - .expect("get_search_users failed"); - assert!(search.users[0].name.contains("john")); - assert!(search.users[0].name.contains("offset 10")); - - // Optional query params omitted when None. - let search = client - .get_search_users("jane".to_string(), None, None) - .await - .expect("get_search_users without optionals failed"); - assert!(search.users[0].name.contains("jane")); - - // 3. Bearer auth: a permissioned GET succeeds once the token is set. - client.set_bearer_token(Some("user-token")); - assert_eq!(client.bearer_token(), Some("user-token")); - let user = client - .get_users_by_id(7) - .await - .expect("get_users_by_id with user token failed"); - assert_eq!(user.id, Some(7)); - - // 4. Unit-type response (DELETE -> ()) with admin auth. - let mut admin_client = create_rest_test_client(create_rest_test_server_arc()); - admin_client.set_bearer_token(Some("admin-token")); - admin_client - .delete_users_by_id(5) - .await - .expect("delete_users_by_id with admin token failed"); - - // 5. HTTP error mapping: 404 -> TransportError::Status. - let err = client - .get_users_by_id(404) - .await - .expect_err("get_users_by_id(404) should fail"); - match err { - ras_transport_core::TransportError::Status { status, .. } => { - assert_eq!(status, ras_transport_core::http::StatusCode::NOT_FOUND); - } - other => panic!("expected TransportError::Status, got {other:?}"), - } -} - -#[tokio::test] -async fn test_query_parameters() { - let server = create_rest_test_server(); - - // Test search with required and optional query parameters - let response = make_rest_request( - &server, - Method::GET, - "/api/v1/search/users?q=john&limit=5&offset=10", - None, - None, - ) - .await; - - 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( - &server, - Method::GET, - "/api/v1/search/users?q=jane", - None, - None, - ) - .await; - - 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( - &server, - Method::GET, - "/api/v1/search/users?limit=5", - None, - None, - ) - .await; - - assert_eq!(response.status_code().as_u16(), 400); // Bad Request -} - -#[tokio::test] -async fn test_query_parameters_with_auth() { - let server = create_rest_test_server(); - - // Test search posts with optional query parameters and authentication - let response = make_rest_request( - &server, - Method::GET, - "/api/v1/search/posts?tag=test&published=true", - None, - Some("user-token"), - ) - .await; - - 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( - &server, - Method::GET, - "/api/v1/search/posts", - None, - Some("user-token"), - ) - .await; - - assert_eq!(response.status_code().as_u16(), 200); -} - -#[tokio::test] -async fn test_query_parameters_with_body() { - let server = create_rest_test_server(); - - // Test POST with query parameter and request body - let response = make_rest_request( - &server, - Method::POST, - "/api/v1/users/batch?notify=true", - Some(json!({ - "name": "New User", - "email": "new@example.com", - "permissions": ["user"] - })), - Some("admin-token"), - ) - .await; - - 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 server = create_rest_test_server(); - - // Test endpoint with query parameters - let response = make_rest_request( - &server, - Method::GET, - "/api/v1/posts/paginated?page=2&per_page=5", - None, - None, - ) - .await; - - 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( - &server, - Method::GET, - "/api/v1/posts/paginated?page=1", - None, - None, - ) - .await; - - assert_eq!(response.status_code().as_u16(), 200); - let posts_response: PostsResponse = response.json(); - assert_eq!(posts_response.posts.len(), 20); // Default per_page -} - // Minimal service exercising the body_limit option. rest_service!({ service_name: TinyBodyService, @@ -1328,113 +512,13 @@ impl TinyBodyServiceTrait for TinyBodyServiceImpl { } } -#[tokio::test] -async fn test_body_is_not_parsed_before_auth() { - let server = create_rest_test_server(); - - // Invalid JSON without credentials must be rejected by auth (401, not - // 400), proving the body is neither read nor parsed before the - // auth/CSRF/permission checks succeed. - let response = server - .post("/api/v1/users") - .text("{invalid json") - .content_type("application/json") - .await; - assert_eq!(response.status_code().as_u16(), 401); - - // Same body with an invalid token: still rejected by auth. - let response = server - .post("/api/v1/users") - .authorization_bearer("wrong-token") - .text("{invalid json") - .content_type("application/json") - .await; - assert_eq!(response.status_code().as_u16(), 401); - - // Valid credentials allow body parsing, which rejects the malformed payload. - let response = server - .post("/api/v1/users") - .authorization_bearer("admin-token") - .text("{invalid json") - .content_type("application/json") - .await; - assert_eq!(response.status_code().as_u16(), 400); -} - -#[tokio::test] -async fn test_body_limit_option_enforced() { - let app = TinyBodyServiceBuilder::new(TinyBodyServiceImpl).build(); - let server = TestServer::builder().mock_transport().build(app).unwrap(); - - let response = server.post("/tiny/echo").json(&json!({"ok": true})).await; - assert_eq!(response.status_code().as_u16(), 200); - - let response = server - .post("/tiny/echo") - .json(&json!({"data": "x".repeat(200)})) - .await; - assert_eq!(response.status_code().as_u16(), 413); -} - -/// F2: axum's default `Path` rejection echoes the offending value (e.g. -/// "Cannot parse `abc` to a `i32`"); the generated handler must return a fixed -/// JSON message instead and log the detail server-side. -#[tokio::test] -async fn f2_invalid_path_parameter_returns_generic_json_error() { - let server = create_rest_test_server(); - - // UNAUTHORIZED route with an `i32` path param. - let response = make_rest_request( - &server, - Method::GET, - "/api/v1/users/not-an-int-9f3c/posts", - None, - None, - ) - .await; - - assert_eq!(response.status_code().as_u16(), 400); - let text = response.text(); - assert!( - !text.contains("not-an-int-9f3c"), - "path value echoed to client: {text}" - ); - assert!( - !text.contains("i32"), - "type detail echoed to client: {text}" - ); - let body: Value = response.json(); - assert_eq!(body["error"], "Invalid path parameters"); -} - -/// F2: same for query-string rejections from `axum_extra::extract::Query`. -#[tokio::test] -async fn f2_invalid_query_parameter_returns_generic_json_error() { - let server = create_rest_test_server(); - - // `page: u32` — a non-numeric value is rejected. - let response = make_rest_request( - &server, - Method::GET, - "/api/v1/posts/paginated?page=zz-not-a-page", - None, - None, - ) - .await; - - assert_eq!(response.status_code().as_u16(), 400); - let text = response.text(); - assert!( - !text.contains("zz-not-a-page"), - "query value echoed to client: {text}" - ); - let body: Value = response.json(); - assert_eq!(body["error"], "Invalid query parameters"); - - // Missing required query param is also a generic 400. - let response = - make_rest_request(&server, Method::GET, "/api/v1/posts/paginated", None, None).await; - assert_eq!(response.status_code().as_u16(), 400); - let body: Value = response.json(); - assert_eq!(body["error"], "Invalid query parameters"); -} +#[path = "http_integration/auth.rs"] +mod auth; +#[path = "http_integration/client.rs"] +mod client; +#[path = "http_integration/errors.rs"] +mod errors; +#[path = "http_integration/parameters.rs"] +mod parameters; +#[path = "http_integration/specs.rs"] +mod specs; diff --git a/crates/rest/ras-rest-macro/tests/http_integration/auth.rs b/crates/rest/ras-rest-macro/tests/http_integration/auth.rs new file mode 100644 index 0000000..5b6c556 --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/http_integration/auth.rs @@ -0,0 +1,478 @@ +use super::*; + +#[tokio::test] +async fn test_unauthorized_endpoints() { + let server = create_rest_test_server(); + + // Test GET /api/v1/users without auth + let response = make_rest_request(&server, Method::GET, "/api/v1/users", None, None).await; + + 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(&server, Method::GET, "/api/v1/users/123/posts", None, None).await; + + 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(&server, Method::GET, "/api/v1/health", None, None).await; + + 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 server = create_rest_test_server(); + + // Test GET /api/v1/status without token - should fail + let response = make_rest_request(&server, Method::GET, "/api/v1/status", None, None).await; + + assert_eq!(response.status_code().as_u16(), 401); + + // Test GET /api/v1/status with valid token - should succeed + let response = make_rest_request( + &server, + Method::GET, + "/api/v1/status", + None, + Some("user-token"), + ) + .await; + + 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( + &server, + Method::GET, + "/api/v1/users/123/posts/456", + None, + Some("empty-perms-token"), + ) + .await; + + 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"); +} + +#[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(); + + // Test POST /api/v1/users with user token (insufficient permissions) - should fail + let response = make_rest_request( + &server, + Method::POST, + "/api/v1/users", + Some(json!({ + "name": "New User", + "email": "new@example.com", + "permissions": ["user"] + })), + Some("user-token"), + ) + .await; + + assert_eq!(response.status_code().as_u16(), 403); + + // Test POST /api/v1/users with admin token - should succeed + let response = make_rest_request( + &server, + Method::POST, + "/api/v1/users", + Some(json!({ + "name": "New User", + "email": "new@example.com", + "permissions": ["user"] + })), + Some("admin-token"), + ) + .await; + + 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( + &server, + Method::PUT, + "/api/v1/users/123", + Some(json!({ + "name": "Updated User", + "email": "updated@example.com" + })), + Some("admin-token"), + ) + .await; + + 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( + &server, + Method::DELETE, + "/api/v1/users/123", + None, + Some("admin-token"), + ) + .await; + + assert_eq!(response.status_code().as_u16(), 204); // No Content +} + +#[tokio::test] +async fn test_user_permission_endpoints() { + let server = create_rest_test_server(); + + // Test GET /api/v1/users/123 with empty permissions token - should fail + let response = make_rest_request( + &server, + Method::GET, + "/api/v1/users/123", + None, + Some("empty-perms-token"), + ) + .await; + + assert_eq!(response.status_code().as_u16(), 403); + + // Test GET /api/v1/users/123 with user token - should succeed + let response = make_rest_request( + &server, + Method::GET, + "/api/v1/users/123", + None, + Some("user-token"), + ) + .await; + + 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( + &server, + Method::GET, + "/api/v1/users/404", + None, + Some("user-token"), + ) + .await; + + 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( + &server, + Method::POST, + "/api/v1/users/123/posts", + Some(json!({ + "title": "My New Post", + "content": "This is my new post content", + "tags": ["personal", "test"] + })), + Some("user-token"), + ) + .await; + + 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); +} + +#[tokio::test] +async fn test_multiple_permissions_endpoints() { + 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( + &server, + Method::PUT, + "/api/v1/users/123/posts/456", + Some(json!({ + "title": "Updated Post", + "content": "Updated content", + "tags": ["updated"] + })), + Some("user-token"), + ) + .await; + + 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( + &server, + Method::PUT, + "/api/v1/users/123/posts/456", + Some(json!({ + "title": "Moderator Updated Post", + "content": "Moderator updated content", + "tags": ["moderated"] + })), + Some("moderator-token"), + ) + .await; + + assert_eq!(response.status_code().as_u16(), 200); + + 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( + &server, + Method::PUT, + "/api/v1/users/123/posts/456", + Some(json!({ + "title": "Unauthorized Update", + "content": "Should not work", + "tags": [] + })), + Some("empty-perms-token"), + ) + .await; + + 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( + &server, + Method::DELETE, + "/api/v1/users/123/posts/456", + None, + Some("admin-token"), + ) + .await; + + 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( + &server, + Method::DELETE, + "/api/v1/users/123/posts/456", + None, + Some("moderator-token"), + ) + .await; + + assert_eq!(response.status_code().as_u16(), 204); // No Content +} + +#[tokio::test] +async fn test_new_permission_logic() { + let server = create_rest_test_server(); + + // Test admin_action endpoint with new permission logic: + // WITH_PERMISSIONS(["admin", "moderator"] | ["super_user"]) + // This means user needs (admin AND moderator) OR (super_user) + + // Test with admin-token (has "admin" and "user", but NOT "moderator") - should FAIL + let response = make_rest_request( + &server, + Method::POST, + "/api/v1/admin_action", + Some(serde_json::Value::Null), // Send null for unit type + Some("admin-token"), + ) + .await; + assert_eq!( + 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( + &server, + Method::POST, + "/api/v1/admin_action", + Some(Value::Null), // Send null for unit type + Some("moderator-token"), + ) + .await; + assert_eq!( + 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( + &server, + Method::POST, + "/api/v1/admin_action", + Some(Value::Null), // Send null for unit type + Some("superuser-token"), + ) + .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 + + // The DELETE endpoint uses ["moderator"] | ["admin"] - should succeed with either + // Test with admin-token (has "admin") - should SUCCEED + let response = make_rest_request( + &server, + Method::DELETE, + "/api/v1/users/123/posts/456", + None, + Some("admin-token"), + ) + .await; + assert_eq!( + 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( + &server, + Method::DELETE, + "/api/v1/users/123/posts/456", + None, + Some("moderator-token"), + ) + .await; + assert_eq!( + response.status_code().as_u16(), + 204, // No Content + "Moderator token should succeed for delete - has moderator" + ); +} + +#[tokio::test] +async fn test_body_is_not_parsed_before_auth() { + let server = create_rest_test_server(); + + // Invalid JSON without credentials must be rejected by auth (401, not + // 400), proving the body is neither read nor parsed before the + // auth/CSRF/permission checks succeed. + let response = server + .post("/api/v1/users") + .text("{invalid json") + .content_type("application/json") + .await; + assert_eq!(response.status_code().as_u16(), 401); + + // Same body with an invalid token: still rejected by auth. + let response = server + .post("/api/v1/users") + .authorization_bearer("wrong-token") + .text("{invalid json") + .content_type("application/json") + .await; + assert_eq!(response.status_code().as_u16(), 401); + + // Valid credentials allow body parsing, which rejects the malformed payload. + let response = server + .post("/api/v1/users") + .authorization_bearer("admin-token") + .text("{invalid json") + .content_type("application/json") + .await; + assert_eq!(response.status_code().as_u16(), 400); +} diff --git a/crates/rest/ras-rest-macro/tests/http_integration/client.rs b/crates/rest/ras-rest-macro/tests/http_integration/client.rs new file mode 100644 index 0000000..c492077 --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/http_integration/client.rs @@ -0,0 +1,90 @@ +use super::*; + +#[tokio::test] +async fn test_concurrent_rest_requests() { + let server = Arc::new(create_rest_test_server()); + + // Test multiple concurrent requests + let mut handles = vec![]; + + for _ in 0..10 { + let server = Arc::clone(&server); + let handle = tokio::spawn(async move { + make_rest_request(&server, Method::GET, "/api/v1/health", None, None).await + }); + handles.push(handle); + } + + // Wait for all requests to complete + let results = futures::future::join_all(handles).await; + + // All requests should succeed + for result in results { + 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_generated_rest_client() { + // Real end-to-end test: drive the generated client over the in-process + // AxumTestTransport against the live router. Covers unauthenticated GET, + // query-param serialization, bearer auth, a unit-type response, and HTTP + // error -> TransportError::Status mapping. + let server = create_rest_test_server_arc(); + let mut client = create_rest_test_client(server); + + // Bearer-token accessors still behave as before. + assert_eq!(client.bearer_token(), None); + + // 1. Unauthenticated GET returning a deserialized body. + let users = client.get_users().await.expect("get_users failed"); + assert_eq!(users.total, 2); + assert_eq!(users.users[0].name, "John Doe"); + + // 2. Query params (required + optional) over the serde_urlencoded path. + let search = client + .get_search_users("john".to_string(), Some(5), Some(10)) + .await + .expect("get_search_users failed"); + assert!(search.users[0].name.contains("john")); + assert!(search.users[0].name.contains("offset 10")); + + // Optional query params omitted when None. + let search = client + .get_search_users("jane".to_string(), None, None) + .await + .expect("get_search_users without optionals failed"); + assert!(search.users[0].name.contains("jane")); + + // 3. Bearer auth: a permissioned GET succeeds once the token is set. + client.set_bearer_token(Some("user-token")); + assert_eq!(client.bearer_token(), Some("user-token")); + let user = client + .get_users_by_id(7) + .await + .expect("get_users_by_id with user token failed"); + assert_eq!(user.id, Some(7)); + + // 4. Unit-type response (DELETE -> ()) with admin auth. + let mut admin_client = create_rest_test_client(create_rest_test_server_arc()); + admin_client.set_bearer_token(Some("admin-token")); + admin_client + .delete_users_by_id(5) + .await + .expect("delete_users_by_id with admin token failed"); + + // 5. HTTP error mapping: 404 -> TransportError::Status. + let err = client + .get_users_by_id(404) + .await + .expect_err("get_users_by_id(404) should fail"); + match err { + ras_transport_core::TransportError::Status { status, .. } => { + assert_eq!(status, ras_transport_core::http::StatusCode::NOT_FOUND); + } + other => panic!("expected TransportError::Status, got {other:?}"), + } +} diff --git a/crates/rest/ras-rest-macro/tests/http_integration/errors.rs b/crates/rest/ras-rest-macro/tests/http_integration/errors.rs new file mode 100644 index 0000000..62db8bb --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/http_integration/errors.rs @@ -0,0 +1,9 @@ +#[tokio::test] +async fn test_missing_dependencies() { + // Import futures for the join_all function + use futures::future::join_all; + + // This test ensures that our future handling is working correctly + let handles: Vec> = vec![]; + let _results = join_all(handles).await; +} diff --git a/crates/rest/ras-rest-macro/tests/http_integration/parameters.rs b/crates/rest/ras-rest-macro/tests/http_integration/parameters.rs new file mode 100644 index 0000000..e347cf7 --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/http_integration/parameters.rs @@ -0,0 +1,307 @@ +use super::*; + +#[tokio::test] +async fn test_invalid_requests() { + let server = create_rest_test_server(); + + // Test non-existent endpoint + let response = make_rest_request(&server, Method::GET, "/api/v1/nonexistent", None, None).await; + + assert_eq!(response.status_code().as_u16(), 404); + + // Test invalid HTTP method + let response = make_rest_request(&server, Method::PATCH, "/api/v1/users", None, None).await; + + assert_eq!(response.status_code().as_u16(), 405); + + // Test invalid JSON body + let response = server + .post("/api/v1/users") + .authorization_bearer("admin-token") + .text("{invalid json") + .content_type("application/json") + .await; + + assert_eq!(response.status_code().as_u16(), 400); + + // Test missing required fields + let response = make_rest_request( + &server, + Method::POST, + "/api/v1/users", + Some(json!({ + "name": "Incomplete User" + // Missing email and permissions + })), + Some("admin-token"), + ) + .await; + + assert_eq!(response.status_code().as_u16(), 400); +} + +#[tokio::test] +async fn test_path_parameters() { + let server = create_rest_test_server(); + + // Test single path parameter + let response = make_rest_request( + &server, + Method::GET, + "/api/v1/users/42", + None, + Some("user-token"), + ) + .await; + + 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( + &server, + Method::GET, + "/api/v1/users/123/posts/789", + None, + Some("user-token"), + ) + .await; + + 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( + &server, + Method::POST, + "/api/v1/users/999/posts", + Some(json!({ + "title": "Path Param Post", + "content": "Testing path parameters with body", + "tags": ["path", "test"] + })), + Some("user-token"), + ) + .await; + + 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"); +} + +#[tokio::test] +async fn test_query_parameters() { + let server = create_rest_test_server(); + + // Test search with required and optional query parameters + let response = make_rest_request( + &server, + Method::GET, + "/api/v1/search/users?q=john&limit=5&offset=10", + None, + None, + ) + .await; + + 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( + &server, + Method::GET, + "/api/v1/search/users?q=jane", + None, + None, + ) + .await; + + 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( + &server, + Method::GET, + "/api/v1/search/users?limit=5", + None, + None, + ) + .await; + + assert_eq!(response.status_code().as_u16(), 400); // Bad Request +} + +#[tokio::test] +async fn test_query_parameters_with_auth() { + let server = create_rest_test_server(); + + // Test search posts with optional query parameters and authentication + let response = make_rest_request( + &server, + Method::GET, + "/api/v1/search/posts?tag=test&published=true", + None, + Some("user-token"), + ) + .await; + + 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( + &server, + Method::GET, + "/api/v1/search/posts", + None, + Some("user-token"), + ) + .await; + + assert_eq!(response.status_code().as_u16(), 200); +} + +#[tokio::test] +async fn test_query_parameters_with_body() { + let server = create_rest_test_server(); + + // Test POST with query parameter and request body + let response = make_rest_request( + &server, + Method::POST, + "/api/v1/users/batch?notify=true", + Some(json!({ + "name": "New User", + "email": "new@example.com", + "permissions": ["user"] + })), + Some("admin-token"), + ) + .await; + + 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 server = create_rest_test_server(); + + // Test endpoint with query parameters + let response = make_rest_request( + &server, + Method::GET, + "/api/v1/posts/paginated?page=2&per_page=5", + None, + None, + ) + .await; + + 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( + &server, + Method::GET, + "/api/v1/posts/paginated?page=1", + None, + None, + ) + .await; + + assert_eq!(response.status_code().as_u16(), 200); + let posts_response: PostsResponse = response.json(); + assert_eq!(posts_response.posts.len(), 20); // Default per_page +} + +#[tokio::test] +async fn test_body_limit_option_enforced() { + let app = TinyBodyServiceBuilder::new(TinyBodyServiceImpl).build(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); + + let response = server.post("/tiny/echo").json(&json!({"ok": true})).await; + assert_eq!(response.status_code().as_u16(), 200); + + let response = server + .post("/tiny/echo") + .json(&json!({"data": "x".repeat(200)})) + .await; + assert_eq!(response.status_code().as_u16(), 413); +} + +/// F2: axum's default `Path` rejection echoes the offending value (e.g. +/// "Cannot parse `abc` to a `i32`"); the generated handler must return a fixed +/// JSON message instead and log the detail server-side. +#[tokio::test] +async fn f2_invalid_path_parameter_returns_generic_json_error() { + let server = create_rest_test_server(); + + // UNAUTHORIZED route with an `i32` path param. + let response = make_rest_request( + &server, + Method::GET, + "/api/v1/users/not-an-int-9f3c/posts", + None, + None, + ) + .await; + + assert_eq!(response.status_code().as_u16(), 400); + let text = response.text(); + assert!( + !text.contains("not-an-int-9f3c"), + "path value echoed to client: {text}" + ); + assert!( + !text.contains("i32"), + "type detail echoed to client: {text}" + ); + let body: Value = response.json(); + assert_eq!(body["error"], "Invalid path parameters"); +} + +/// F2: same for query-string rejections from `axum_extra::extract::Query`. +#[tokio::test] +async fn f2_invalid_query_parameter_returns_generic_json_error() { + let server = create_rest_test_server(); + + // `page: u32` — a non-numeric value is rejected. + let response = make_rest_request( + &server, + Method::GET, + "/api/v1/posts/paginated?page=zz-not-a-page", + None, + None, + ) + .await; + + assert_eq!(response.status_code().as_u16(), 400); + let text = response.text(); + assert!( + !text.contains("zz-not-a-page"), + "query value echoed to client: {text}" + ); + let body: Value = response.json(); + assert_eq!(body["error"], "Invalid query parameters"); + + // Missing required query param is also a generic 400. + let response = + make_rest_request(&server, Method::GET, "/api/v1/posts/paginated", None, None).await; + assert_eq!(response.status_code().as_u16(), 400); + let body: Value = response.json(); + assert_eq!(body["error"], "Invalid query parameters"); +} diff --git a/crates/rest/ras-rest-macro/tests/http_integration/specs.rs b/crates/rest/ras-rest-macro/tests/http_integration/specs.rs new file mode 100644 index 0000000..42f8569 --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/http_integration/specs.rs @@ -0,0 +1,46 @@ +use super::*; + +#[tokio::test] +async fn test_docs_explorer_routes_generated() { + let server = create_rest_test_server(); + + let docs_response = server.get("/api/v1/docs").await; + assert_eq!(docs_response.status_code().as_u16(), 200); + + 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=\"bearer-token\"")); + assert!(docs.contains("id=\"saved-list\"")); + + 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(); + assert_eq!(spec["info"]["title"], "TestRestService REST API"); + assert!(spec["paths"].is_object()); +} + +#[tokio::test] +async fn test_openapi_generation() { + let _ = TestRestServiceBuilder::new(TestRestServiceImpl); + + let openapi_doc = generate_testrestservice_openapi(); + assert_eq!(openapi_doc["openapi"], "3.0.3"); + + let get_users = &openapi_doc["paths"]["/users"]["get"]; + assert_eq!(get_users["summary"], "List users."); + assert_eq!( + get_users["description"], + "List users.\n\nReturns all users visible to the caller." + ); + + let post_users = &openapi_doc["paths"]["/users"]["post"]; + assert_eq!(post_users["summary"], "Create a user."); + assert_eq!(post_users["description"], "Create a user."); + + let health = &openapi_doc["paths"]["/health"]["get"]; + assert_eq!(health["summary"], "GET /health"); + assert_eq!(health["description"], "Handles GET requests to /health"); +} diff --git a/crates/rest/ras-rest-macro/tests/xm_feedback_hardening_test.rs b/crates/rest/ras-rest-macro/tests/http_service_contracts.rs similarity index 100% rename from crates/rest/ras-rest-macro/tests/xm_feedback_hardening_test.rs rename to crates/rest/ras-rest-macro/tests/http_service_contracts.rs 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 0b5ec1c..71c2a5a 100644 --- a/crates/rest/ras-rest-macro/tests/xss_protection_test.rs +++ b/crates/rest/ras-rest-macro/tests/xss_protection_test.rs @@ -19,7 +19,7 @@ fn test_xss_protection_in_generated_html() { #[test] fn test_generated_docs_do_not_store_bearer_token_in_local_storage() { - let template = include_str!("../src/api_explorer_template.html"); + let template = ras_api_explorer_assets::TEMPLATE; assert!(!template.contains("localStorage.getItem('bearer-token')")); assert!(!template.contains("localStorage.setItem('bearer-token'")); assert!(!template.contains("localStorage.removeItem('bearer-token'")); diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client.rs deleted file mode 100644 index c46d72d..0000000 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client.rs +++ /dev/null @@ -1,1436 +0,0 @@ -//! Main client implementation for bidirectional JSON-RPC communication - -use crate::{ - ClientState, ConnectionEvent, ConnectionEventHandler, NotificationHandler, PendingRequest, - RpcRequestHandler, Subscription, WebSocketTransport, - config::{AuthConfig, ClientConfig, ReconnectConfig}, - error::{ClientError, ClientResult}, -}; -use dashmap::DashMap; -use ras_jsonrpc_bidirectional_types::{BidirectionalMessage, ConnectionId}; -use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse}; -use serde_json::Value; -use std::{ - collections::HashMap, - future::Future, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, - time::{Duration, Instant}, -}; -use tokio::sync::{RwLock, mpsc, oneshot}; -use tracing::{debug, error, info, warn}; - -#[cfg(not(target_arch = "wasm32"))] -use crate::native::NativeWebSocketTransport; - -#[cfg(target_arch = "wasm32")] -use crate::wasm::WasmWebSocketTransport; - -/// Bidirectional JSON-RPC WebSocket client -pub struct Client { - config: ClientConfig, - transport: Arc>>, - state: Arc>, - connection_id: Arc>>, - pending_requests: Arc>, - subscriptions: Arc>, - notification_handlers: Arc>, - rpc_request_handlers: Arc>, - connection_event_handlers: Arc>, - request_id_counter: Arc, - shutdown_tx: Arc>>>, - message_tx: Arc>>>, - /// Signaled when the server's ConnectionEstablished message arrives - connected_notify: 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>>, - connected_notify: &'a tokio::sync::Notify, -} - -impl Client { - /// Create a new client with the given configuration - pub async fn new(config: ClientConfig) -> ClientResult { - config.validate().map_err(ClientError::configuration)?; - - #[cfg(not(target_arch = "wasm32"))] - let transport: Box = - Box::new(NativeWebSocketTransport::new(config.clone())); - - #[cfg(target_arch = "wasm32")] - let transport: Box = - Box::new(WasmWebSocketTransport::new(config.clone())); - - Ok(Self { - config, - transport: Arc::new(RwLock::new(transport)), - state: Arc::new(RwLock::new(ClientState::Disconnected)), - connection_id: Arc::new(RwLock::new(None)), - pending_requests: Arc::new(DashMap::new()), - subscriptions: Arc::new(DashMap::new()), - notification_handlers: Arc::new(DashMap::new()), - rpc_request_handlers: Arc::new(DashMap::new()), - connection_event_handlers: Arc::new(DashMap::new()), - request_id_counter: Arc::new(AtomicU64::new(1)), - shutdown_tx: Arc::new(RwLock::new(None)), - message_tx: Arc::new(RwLock::new(None)), - connected_notify: Arc::new(tokio::sync::Notify::new()), - }) - } - - /// Connect to the WebSocket server - pub async fn connect(&self) -> ClientResult<()> { - let mut state = self.state.write().await; - if *state != ClientState::Disconnected { - return Err(ClientError::AlreadyConnected); - } - *state = ClientState::Connecting; - drop(state); - - let mut transport = self.transport.write().await; - transport - .connect() - .await - .map_err(|e| ClientError::connection(format!("Failed to connect: {}", e)))?; - drop(transport); - - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - let (message_tx, message_rx) = mpsc::channel(self.config.message_buffer_size); - - *self.shutdown_tx.write().await = Some(shutdown_tx); - *self.message_tx.write().await = Some(message_tx); - - self.start_message_handler(message_rx, shutdown_rx).await?; - - // Wait for the server's ConnectionEstablished message before - // reporting the client as connected. The notify is signaled by the - // message handler; bound the wait so a silent server cannot hang us. - let handshake = async { - loop { - if self.connection_id.read().await.is_some() { - break; - } - self.connected_notify.notified().await; - } - }; - if tokio::time::timeout(self.config.connection_timeout, handshake) - .await - .is_err() - { - // Tear down the half-open connection - let _ = self.disconnect().await; - return Err(ClientError::timeout( - self.config.connection_timeout.as_secs(), - )); - } - - *self.state.write().await = ClientState::Connected; - - // Start heartbeat once connected (its loop exits when state leaves - // Connected, so starting earlier would race it to an immediate stop) - if let Some(interval) = self.config.heartbeat_interval { - self.start_heartbeat(interval).await; - } - - info!("Client connected to {}", self.config.url); - - Ok(()) - } - - /// Disconnect from the WebSocket server - pub async fn disconnect(&self) -> ClientResult<()> { - let mut state = self.state.write().await; - if *state == ClientState::Disconnected { - return Ok(()); - } - *state = ClientState::Disconnected; - drop(state); - - if let Some(shutdown_tx) = self.shutdown_tx.write().await.take() { - let _ = shutdown_tx.send(()); - } - - let mut transport = self.transport.write().await; - transport - .disconnect() - .await - .map_err(|e| ClientError::connection(format!("Failed to disconnect: {}", e)))?; - - *self.connection_id.write().await = None; - *self.message_tx.write().await = None; - - let pending_ids: Vec = self - .pending_requests - .iter() - .map(|entry| entry.key().clone()) - .collect(); - for id in pending_ids { - if let Some((_, pending)) = self.pending_requests.remove(&id) { - let _ = pending.sender.send(JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error( - "Client disconnected".to_string(), - ), - Some(pending.id), - )); - } - } - self.pending_requests.clear(); - - self.emit_connection_event(ConnectionEvent::Disconnected { reason: None }) - .await; - info!("Client disconnected"); - - Ok(()) - } - - /// Make a JSON-RPC call and wait for the response - pub async fn call(&self, method: &str, params: Option) -> ClientResult { - let state = self.state.read().await; - if *state != ClientState::Connected { - return Err(ClientError::NotConnected); - } - drop(state); - - let request_id = Value::Number(serde_json::Number::from( - self.request_id_counter.fetch_add(1, Ordering::SeqCst), - )); - - let request = JsonRpcRequest::new(method.to_string(), params, Some(request_id.clone())); - - let (response_tx, response_rx) = oneshot::channel(); - let pending = PendingRequest { - id: request_id.clone(), - sender: response_tx, - created_at: Instant::now(), - }; - - if self.pending_requests.len() >= self.config.max_pending_requests { - return Err(ClientError::internal("Too many pending requests")); - } - - self.pending_requests.insert(request_id.clone(), pending); - - // Send the request; on failure, drop our pending entry so the map - // cannot fill up with waiters that will never be answered. - let message = BidirectionalMessage::Request(request); - if let Err(e) = self.send_message(message).await { - self.pending_requests.remove(&request_id); - return Err(e); - } - - // Wait for response with timeout; every failure path removes our - // entry for the same reason (the success path is removed by the - // message handler when the response arrives). - match tokio::time::timeout(self.config.request_timeout, response_rx).await { - Ok(Ok(response)) => Ok(response), - Ok(Err(_)) => { - self.pending_requests.remove(&request_id); - Err(ClientError::internal("Response channel closed")) - } - Err(_) => { - self.pending_requests.remove(&request_id); - Err(ClientError::timeout(self.config.request_timeout.as_secs())) - } - } - } - - /// Send a notification (fire-and-forget) - pub async fn notify(&self, method: &str, params: Option) -> ClientResult<()> { - let state = self.state.read().await; - if *state != ClientState::Connected { - return Err(ClientError::NotConnected); - } - drop(state); - - let request = JsonRpcRequest::new(method.to_string(), params, None); - let message = BidirectionalMessage::Request(request); - self.send_message(message).await - } - - /// Subscribe to a topic for receiving notifications - pub async fn subscribe(&self, topic: &str, handler: NotificationHandler) -> ClientResult<()> { - let state = self.state.read().await; - if *state != ClientState::Connected { - return Err(ClientError::NotConnected); - } - drop(state); - - let subscription = Subscription { - topic: topic.to_string(), - handler: handler.clone(), - created_at: Instant::now(), - }; - - self.subscriptions.insert(topic.to_string(), subscription); - - let message = BidirectionalMessage::Subscribe { - topics: vec![topic.to_string()], - }; - self.send_message(message).await?; - - debug!("Subscribed to topic: {}", topic); - Ok(()) - } - - /// Unsubscribe from a topic - pub async fn unsubscribe(&self, topic: &str) -> ClientResult<()> { - let state = self.state.read().await; - if *state != ClientState::Connected { - return Err(ClientError::NotConnected); - } - drop(state); - - self.subscriptions.remove(topic); - - let message = BidirectionalMessage::Unsubscribe { - topics: vec![topic.to_string()], - }; - self.send_message(message).await?; - - debug!("Unsubscribed from topic: {}", topic); - Ok(()) - } - - /// Register a handler for specific notification methods - pub fn on_notification(&self, method: &str, handler: NotificationHandler) { - self.notification_handlers - .insert(method.to_string(), handler); - debug!("Registered notification handler for method: {}", method); - } - - /// Register a handler for connection events - pub fn on_connection_event(&self, name: &str, handler: ConnectionEventHandler) { - self.connection_event_handlers - .insert(name.to_string(), handler); - debug!("Registered connection event handler: {}", name); - } - - /// Register a handler for RPC requests from the server - pub fn on_rpc_request(&self, method: &str, handler: RpcRequestHandler) { - self.rpc_request_handlers - .insert(method.to_string(), handler); - debug!("Registered RPC request handler for method: {}", method); - } - - /// Get the current connection state - pub async fn state(&self) -> ClientState { - *self.state.read().await - } - - /// Get the current connection ID (if connected) - pub async fn connection_id(&self) -> Option { - *self.connection_id.read().await - } - - /// Check if the client is currently connected - pub async fn is_connected(&self) -> bool { - *self.state.read().await == ClientState::Connected - } - - /// Get client configuration - pub fn config(&self) -> &ClientConfig { - &self.config - } - - /// Get the number of pending requests - pub fn pending_requests_count(&self) -> usize { - self.pending_requests.len() - } - - /// Get the list of active subscriptions - pub fn active_subscriptions(&self) -> Vec { - self.subscriptions - .iter() - .map(|entry| entry.key().clone()) - .collect() - } - - async fn send_message(&self, message: BidirectionalMessage) -> ClientResult<()> { - if let Some(tx) = self.message_tx.read().await.as_ref() { - tx.send(message) - .await - .map_err(|_| ClientError::send_failed("Message channel closed"))?; - } else { - return Err(ClientError::NotConnected); - } - Ok(()) - } - - async fn start_message_handler( - &self, - mut message_rx: mpsc::Receiver, - mut shutdown_rx: oneshot::Receiver<()>, - ) -> ClientResult<()> { - let transport = Arc::clone(&self.transport); - let pending_requests = Arc::clone(&self.pending_requests); - let subscriptions = Arc::clone(&self.subscriptions); - let notification_handlers = Arc::clone(&self.notification_handlers); - let rpc_request_handlers = Arc::clone(&self.rpc_request_handlers); - let connection_event_handlers = Arc::clone(&self.connection_event_handlers); - let connection_id = Arc::clone(&self.connection_id); - let state = Arc::clone(&self.state); - let message_tx_clone = Arc::clone(&self.message_tx); - let connected_notify = Arc::clone(&self.connected_notify); - - spawn_background(async move { - let mut receive_interval = tokio::time::interval(Duration::from_millis(10)); - - loop { - tokio::select! { - _ = &mut shutdown_rx => { - debug!("Message handler received shutdown signal"); - break; - } - - message = message_rx.recv() => { - if let Some(message) = message { - let mut transport = transport.write().await; - if let Err(e) = transport.send(&message).await { - error!("Failed to send message: {}", e); - } - } else { - debug!("Message channel closed"); - break; - } - } - - _ = receive_interval.tick() => { - let transport_clone = Arc::clone(&transport); - 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, - connected_notify: &connected_notify, - }; - Self::handle_incoming_message( - message, - context, - ).await; - } - Ok(None) => { - } - Err(e) => { - error!("Failed to receive message: {}", e); - *state.write().await = ClientState::Failed; - break; - } - } - } - } - } - }); - - Ok(()) - } - - async fn handle_incoming_message( - message: BidirectionalMessage, - context: IncomingMessageContext<'_>, - ) { - match message { - BidirectionalMessage::Response(response) => { - if let Some(id) = &response.id { - if let Some((_, pending)) = context.pending_requests.remove(id) { - let _ = pending.sender.send(response); - } else { - warn!("Received response for unknown request ID: {:?}", id); - } - } - } - BidirectionalMessage::ServerNotification(notification) => { - if let Some(handler) = context.notification_handlers.get(¬ification.method) { - handler(¬ification.method, ¬ification.params); - } - } - BidirectionalMessage::Broadcast(broadcast) => { - if let Some(subscription) = context.subscriptions.get(&broadcast.topic) { - (subscription.value().handler)(&broadcast.method, &broadcast.params); - } - } - BidirectionalMessage::ConnectionEstablished { - connection_id: conn_id, - } => { - *context.connection_id.write().await = Some(conn_id); - // Wake a connect() call waiting on the handshake. notify_one - // stores a permit, so this works even if connect() has not - // started waiting yet. - context.connected_notify.notify_one(); - Self::emit_connection_event_static( - ConnectionEvent::Connected { - connection_id: conn_id, - }, - context.connection_event_handlers, - ) - .await; - } - BidirectionalMessage::ConnectionClosed { reason, .. } => { - *context.connection_id.write().await = None; - Self::emit_connection_event_static( - ConnectionEvent::Disconnected { reason }, - context.connection_event_handlers, - ) - .await; - } - BidirectionalMessage::Request(request) => { - if let Some(_id) = &request.id { - if let Some(handler) = context.rpc_request_handlers.get(&request.method) { - debug!("Handling RPC request: {}", request.method); - let response = handler(request).await; - - let response_message = BidirectionalMessage::Response(response); - 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); - let error_response = JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::new( - -32601, - "Method not found".to_string(), - None, - ), - request.id.clone(), - ); - let response_message = BidirectionalMessage::Response(error_response); - 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 { - debug!( - "Received RPC request without ID (notification): {}", - request.method - ); - } - } - BidirectionalMessage::Pong => { - debug!("Received pong"); - } - _ => { - debug!("Received unhandled message: {:?}", message); - } - } - } - - async fn emit_connection_event(&self, event: ConnectionEvent) { - Self::emit_connection_event_static(event, &self.connection_event_handlers).await; - } - - async fn emit_connection_event_static( - event: ConnectionEvent, - handlers: &DashMap, - ) { - for handler in handlers.iter() { - handler.value()(event.clone()); - } - } - - async fn start_heartbeat(&self, interval: Duration) { - let message_tx = Arc::clone(&self.message_tx); - let state = Arc::clone(&self.state); - - spawn_background(async move { - let mut heartbeat_interval = tokio::time::interval(interval); - heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - heartbeat_interval.tick().await; - - let current_state = *state.read().await; - if current_state != ClientState::Connected { - break; - } - - let tx_guard = message_tx.read().await; - if let Some(tx) = tx_guard.as_ref() { - if tx.send(BidirectionalMessage::Ping).await.is_err() { - break; - } - } else { - break; - } - } - }); - } - - /// Clean up expired pending requests - pub async fn cleanup_expired_requests(&self) { - let timeout = self.config.request_timeout; - let now = Instant::now(); - - let expired_ids: Vec = self - .pending_requests - .iter() - .filter_map(|entry| { - if now.duration_since(entry.created_at) > timeout { - Some(entry.id.clone()) - } else { - None - } - }) - .collect(); - - for id in expired_ids { - if let Some((_, pending)) = self.pending_requests.remove(&id) { - let _ = pending.sender.send(JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error("Request timeout".to_string()), - Some(pending.id), - )); - } - } - } -} - -#[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 - url: String, - - /// JWT token for authentication - jwt_token: Option, - - /// Custom headers - custom_headers: HashMap, - - /// Request timeout - request_timeout: Duration, - - /// Reconnection configuration - reconnect_config: Option, - - /// Heartbeat interval - heartbeat_interval: Option, - - /// Connection timeout - connection_timeout: Duration, - - /// Auto-connect after building - auto_connect: bool, -} - -impl ClientBuilder { - /// Create a new client builder with the given URL - pub fn new>(url: S) -> Self { - Self { - url: url.into(), - jwt_token: None, - custom_headers: HashMap::new(), - request_timeout: Duration::from_secs(30), - reconnect_config: None, - heartbeat_interval: Some(Duration::from_secs(30)), - connection_timeout: Duration::from_secs(10), - auto_connect: false, - } - } - - /// Set JWT token for authentication - pub fn with_jwt_token(mut self, token: String) -> Self { - self.jwt_token = Some(token); - self - } - - /// No-op kept for source compatibility. - /// - /// Tokens are always sent out-of-URL: in the `Authorization` header on - /// native targets and as the `token.` subprotocol in browsers. The - /// query-string transport was removed because URLs leak into logs and the - /// bundled server never accepted it. - #[deprecated(note = "tokens are never sent in the URL; this flag has no effect")] - pub fn with_jwt_in_header(self, _in_header: bool) -> Self { - self - } - - /// Add a custom header - pub fn with_header, V: Into>(mut self, key: K, value: V) -> Self { - self.custom_headers.insert(key.into(), value.into()); - self - } - - /// Set request timeout - pub fn with_request_timeout(mut self, timeout: Duration) -> Self { - self.request_timeout = timeout; - self - } - - /// Set reconnection configuration - pub fn with_reconnect_config(mut self, config: ReconnectConfig) -> Self { - self.reconnect_config = Some(config); - self - } - - /// Set heartbeat interval - pub fn with_heartbeat_interval(mut self, interval: Option) -> Self { - self.heartbeat_interval = interval; - self - } - - /// Set connection timeout - pub fn with_connection_timeout(mut self, timeout: Duration) -> Self { - self.connection_timeout = timeout; - self - } - - /// Enable auto-connect after building - pub fn with_auto_connect(mut self, auto_connect: bool) -> Self { - self.auto_connect = auto_connect; - self - } - - /// Build the client - pub async fn build(self) -> ClientResult { - let auth = match self.jwt_token { - Some(token) => AuthConfig::JwtHeader { token }, - None => AuthConfig::None, - }; - - let config = ClientConfig { - url: self.url, - auth, - reconnect: self.reconnect_config.unwrap_or_default(), - request_timeout: self.request_timeout, - heartbeat_interval: self.heartbeat_interval, - max_pending_requests: 1000, - custom_headers: self.custom_headers, - connection_timeout: self.connection_timeout, - message_buffer_size: 1024, - auto_subscribe_events: true, - }; - - let client = Client::new(config).await?; - - if self.auto_connect { - client.connect().await?; - } - - Ok(client) - } -} - -#[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>>, - connected_notify: tokio::sync::Notify, - } - - 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), - connected_notify: tokio::sync::Notify::new(), - } - } - - 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, - connected_notify: &self.connected_notify, - } - } - } - - #[tokio::test] - async fn test_client_builder() { - let client = ClientBuilder::new("ws://localhost:8080") - .with_jwt_token("test_token".to_string()) - .with_request_timeout(Duration::from_secs(60)) - .build() - .await - .expect("Failed to build client"); - - assert_eq!(client.config().url, "ws://localhost:8080"); - assert_eq!(client.config().request_timeout, Duration::from_secs(60)); - assert!(matches!(client.config().auth, AuthConfig::JwtHeader { .. })); - } - - #[tokio::test] - async fn test_client_state() { - let client = ClientBuilder::new("ws://localhost:8080") - .build() - .await - .expect("Failed to build client"); - - assert_eq!(client.state().await, ClientState::Disconnected); - assert!(!client.is_connected().await); - assert!(client.connection_id().await.is_none()); - } - - #[tokio::test] - async fn builder_jwt_in_query_params_and_full_setters() { - // Builder options must survive construction without a connection. - let custom = ReconnectConfig::default(); - let client = ClientBuilder::new("ws://localhost:8080") - .with_jwt_token("tok".into()) - .with_header("X-Custom", "v") - .with_request_timeout(Duration::from_secs(11)) - .with_reconnect_config(custom) - .with_heartbeat_interval(None) - .with_connection_timeout(Duration::from_secs(7)) - .with_auto_connect(false) - .build() - .await - .expect("build"); - - assert!(matches!(client.config().auth, AuthConfig::JwtHeader { .. })); - assert_eq!(client.config().request_timeout, Duration::from_secs(11)); - assert_eq!(client.config().connection_timeout, Duration::from_secs(7)); - assert!(client.config().heartbeat_interval.is_none()); - assert_eq!( - client.config().custom_headers.get("X-Custom"), - Some(&"v".to_string()) - ); - assert!(client.active_subscriptions().is_empty()); - assert_eq!(client.pending_requests_count(), 0); - } - - #[tokio::test] - async fn builder_without_token_yields_no_auth() { - let client = ClientBuilder::new("ws://localhost:8080") - .build() - .await - .expect("build"); - assert!(matches!(client.config().auth, AuthConfig::None)); - } - - #[tokio::test] - async fn call_notify_subscribe_unsubscribe_require_connected_state() { - let client = ClientBuilder::new("ws://localhost:8080") - .build() - .await - .expect("build"); - - // call → NotConnected - let err = client.call("m", None).await.unwrap_err(); - assert!(matches!(err, ClientError::NotConnected)); - - // notify → NotConnected - let err = client.notify("m", None).await.unwrap_err(); - assert!(matches!(err, ClientError::NotConnected)); - - // subscribe → NotConnected - let handler: NotificationHandler = std::sync::Arc::new(|_method: &str, _params: &Value| {}); - let err = client.subscribe("t", handler.clone()).await.unwrap_err(); - assert!(matches!(err, ClientError::NotConnected)); - - // unsubscribe → NotConnected - let err = client.unsubscribe("t").await.unwrap_err(); - assert!(matches!(err, ClientError::NotConnected)); - } - - #[tokio::test] - async fn handler_registration_does_not_require_connected_state() { - let client = ClientBuilder::new("ws://localhost:8080") - .build() - .await - .expect("build"); - - let n: NotificationHandler = std::sync::Arc::new(|_, _| {}); - let e: ConnectionEventHandler = std::sync::Arc::new(|_event| {}); - client.on_notification("evt", n); - client.on_connection_event("named", e); - - // cleanup_expired_requests is callable even with nothing pending. - client.cleanup_expired_requests().await; - - // 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()); - } - - #[tokio::test] - async fn connection_established_wakes_handshake_waiter() { - let harness = std::sync::Arc::new(IncomingHarness::new()); - - // Mirrors the wait loop in connect(): park on the notify until the - // connection id is set. Without notify_one in the message handler - // this would hang and the timeout below would fail the test. - let waiter = { - let harness = std::sync::Arc::clone(&harness); - tokio::spawn(async move { - loop { - if harness.connection_id.read().await.is_some() { - break; - } - harness.connected_notify.notified().await; - } - }) - }; - - tokio::task::yield_now().await; - - Client::handle_incoming_message( - BidirectionalMessage::ConnectionEstablished { - connection_id: ConnectionId::new(), - }, - harness.context(), - ) - .await; - - tokio::time::timeout(Duration::from_secs(5), waiter) - .await - .expect("handshake waiter woke up") - .expect("waiter task completed"); - } - - #[tokio::test(start_paused = true)] - async fn call_timeout_removes_pending_entry_and_allows_retry() { - let client = ClientBuilder::new("ws://localhost:8080") - .with_request_timeout(Duration::from_millis(20)) - .build() - .await - .expect("build"); - *client.state.write().await = ClientState::Connected; - - let (tx, mut rx) = mpsc::channel(8); - *client.message_tx.write().await = Some(tx); - - let err = client.call("svc.slow", None).await.unwrap_err(); - assert!(matches!(err, ClientError::Timeout { .. })); - assert!( - client.pending_requests.is_empty(), - "timed-out call must remove its pending entry" - ); - let _ = rx.recv().await; - - // The map must not fill up with dead waiters: a retry times out - // again rather than failing with "Too many pending requests". - let err = client.call("svc.slow", None).await.unwrap_err(); - assert!(matches!(err, ClientError::Timeout { .. })); - assert!(client.pending_requests.is_empty()); - } - - #[tokio::test] - async fn call_send_failure_removes_pending_entry() { - let client = ClientBuilder::new("ws://localhost:8080") - .build() - .await - .expect("build"); - *client.state.write().await = ClientState::Connected; - - // Install a sender whose receiver is already gone so send fails. - let (tx, rx) = mpsc::channel(1); - drop(rx); - *client.message_tx.write().await = Some(tx); - - let err = client.call("svc.echo", None).await.unwrap_err(); - assert!(!matches!(err, ClientError::Timeout { .. })); - assert!( - client.pending_requests.is_empty(), - "failed send must remove its pending entry" - ); - } -} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/builder.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/builder.rs new file mode 100644 index 0000000..435aaa1 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/builder.rs @@ -0,0 +1,131 @@ +use super::Client; +use crate::{ + config::{AuthConfig, ClientConfig, ReconnectConfig}, + error::ClientResult, +}; +use std::{collections::HashMap, time::Duration}; + +/// Builder for creating a client with configuration +pub struct ClientBuilder { + /// WebSocket URL to connect to + url: String, + + /// JWT token for authentication + jwt_token: Option, + + /// Custom headers + custom_headers: HashMap, + + /// Request timeout + request_timeout: Duration, + + /// Reconnection configuration + reconnect_config: Option, + + /// Heartbeat interval + heartbeat_interval: Option, + + /// Connection timeout + connection_timeout: Duration, + + /// Auto-connect after building + auto_connect: bool, +} + +impl ClientBuilder { + /// Create a new client builder with the given URL + pub fn new>(url: S) -> Self { + Self { + url: url.into(), + jwt_token: None, + custom_headers: HashMap::new(), + request_timeout: Duration::from_secs(30), + reconnect_config: None, + heartbeat_interval: Some(Duration::from_secs(30)), + connection_timeout: Duration::from_secs(10), + auto_connect: false, + } + } + + /// Set JWT token for authentication + pub fn with_jwt_token(mut self, token: String) -> Self { + self.jwt_token = Some(token); + self + } + + /// No-op kept for source compatibility. + /// + /// Tokens are always sent out-of-URL: in the `Authorization` header on + /// native targets and as the `token.` subprotocol in browsers. The + /// query-string transport was removed because URLs leak into logs and the + /// bundled server never accepted it. + #[deprecated(note = "tokens are never sent in the URL; this flag has no effect")] + pub fn with_jwt_in_header(self, _in_header: bool) -> Self { + self + } + + /// Add a custom header + pub fn with_header, V: Into>(mut self, key: K, value: V) -> Self { + self.custom_headers.insert(key.into(), value.into()); + self + } + + /// Set request timeout + pub fn with_request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + /// Set reconnection configuration + pub fn with_reconnect_config(mut self, config: ReconnectConfig) -> Self { + self.reconnect_config = Some(config); + self + } + + /// Set heartbeat interval + pub fn with_heartbeat_interval(mut self, interval: Option) -> Self { + self.heartbeat_interval = interval; + self + } + + /// Set connection timeout + pub fn with_connection_timeout(mut self, timeout: Duration) -> Self { + self.connection_timeout = timeout; + self + } + + /// Enable auto-connect after building + pub fn with_auto_connect(mut self, auto_connect: bool) -> Self { + self.auto_connect = auto_connect; + self + } + + /// Build the client + pub async fn build(self) -> ClientResult { + let auth = match self.jwt_token { + Some(token) => AuthConfig::JwtHeader { token }, + None => AuthConfig::None, + }; + + let config = ClientConfig { + url: self.url, + auth, + reconnect: self.reconnect_config.unwrap_or_default(), + request_timeout: self.request_timeout, + heartbeat_interval: self.heartbeat_interval, + max_pending_requests: 1000, + custom_headers: self.custom_headers, + connection_timeout: self.connection_timeout, + message_buffer_size: 1024, + auto_subscribe_events: true, + }; + + let client = Client::new(config).await?; + + if self.auto_connect { + client.connect().await?; + } + + Ok(client) + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/driver.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/driver.rs new file mode 100644 index 0000000..aa431a8 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/driver.rs @@ -0,0 +1,251 @@ +use super::Client; +use crate::{ + ClientState, ConnectionEvent, ConnectionEventHandler, NotificationHandler, PendingRequest, + RpcRequestHandler, Subscription, error::ClientResult, +}; +use dashmap::DashMap; +use ras_jsonrpc_bidirectional_types::{BidirectionalMessage, ConnectionId}; +use ras_jsonrpc_types::JsonRpcResponse; +use serde_json::Value; +use std::{future::Future, sync::Arc, time::Duration}; +use tokio::sync::{RwLock, mpsc, oneshot}; +use tracing::{debug, error, warn}; + +pub(super) struct IncomingMessageContext<'a> { + pub(super) pending_requests: &'a DashMap, + pub(super) subscriptions: &'a DashMap, + pub(super) notification_handlers: &'a DashMap, + pub(super) rpc_request_handlers: &'a DashMap, + pub(super) connection_event_handlers: &'a DashMap, + pub(super) connection_id: &'a RwLock>, + pub(super) message_tx: &'a RwLock>>, + pub(super) connected_notify: &'a tokio::sync::Notify, +} + +impl Client { + pub(super) async fn start_message_handler( + &self, + mut message_rx: mpsc::Receiver, + mut shutdown_rx: oneshot::Receiver<()>, + ) -> ClientResult<()> { + let transport = Arc::clone(&self.transport); + let pending_requests = Arc::clone(&self.pending_requests); + let subscriptions = Arc::clone(&self.subscriptions); + let notification_handlers = Arc::clone(&self.notification_handlers); + let rpc_request_handlers = Arc::clone(&self.rpc_request_handlers); + let connection_event_handlers = Arc::clone(&self.connection_event_handlers); + let connection_id = Arc::clone(&self.connection_id); + let state = Arc::clone(&self.state); + let message_tx_clone = Arc::clone(&self.message_tx); + let connected_notify = Arc::clone(&self.connected_notify); + + spawn_background(async move { + let mut receive_interval = tokio::time::interval(Duration::from_millis(10)); + + loop { + tokio::select! { + _ = &mut shutdown_rx => { + debug!("Message handler received shutdown signal"); + break; + } + + message = message_rx.recv() => { + if let Some(message) = message { + let mut transport = transport.write().await; + if let Err(e) = transport.send(&message).await { + error!("Failed to send message: {}", e); + } + } else { + debug!("Message channel closed"); + break; + } + } + + _ = receive_interval.tick() => { + let transport_clone = Arc::clone(&transport); + 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, + connected_notify: &connected_notify, + }; + Self::handle_incoming_message( + message, + context, + ).await; + } + Ok(None) => { + } + Err(e) => { + error!("Failed to receive message: {}", e); + *state.write().await = ClientState::Failed; + break; + } + } + } + } + } + }); + + Ok(()) + } + + pub(super) async fn handle_incoming_message( + message: BidirectionalMessage, + context: IncomingMessageContext<'_>, + ) { + match message { + BidirectionalMessage::Response(response) => { + if let Some(id) = &response.id { + if let Some((_, pending)) = context.pending_requests.remove(id) { + let _ = pending.sender.send(response); + } else { + warn!("Received response for unknown request ID: {:?}", id); + } + } + } + BidirectionalMessage::ServerNotification(notification) => { + if let Some(handler) = context.notification_handlers.get(¬ification.method) { + handler(¬ification.method, ¬ification.params); + } + } + BidirectionalMessage::Broadcast(broadcast) => { + if let Some(subscription) = context.subscriptions.get(&broadcast.topic) { + (subscription.value().handler)(&broadcast.method, &broadcast.params); + } + } + BidirectionalMessage::ConnectionEstablished { + connection_id: conn_id, + } => { + *context.connection_id.write().await = Some(conn_id); + // Wake a connect() call waiting on the handshake. notify_one + // stores a permit, so this works even if connect() has not + // started waiting yet. + context.connected_notify.notify_one(); + Self::emit_connection_event_static( + ConnectionEvent::Connected { + connection_id: conn_id, + }, + context.connection_event_handlers, + ) + .await; + } + BidirectionalMessage::ConnectionClosed { reason, .. } => { + *context.connection_id.write().await = None; + Self::emit_connection_event_static( + ConnectionEvent::Disconnected { reason }, + context.connection_event_handlers, + ) + .await; + } + BidirectionalMessage::Request(request) => { + if let Some(_id) = &request.id { + if let Some(handler) = context.rpc_request_handlers.get(&request.method) { + debug!("Handling RPC request: {}", request.method); + let response = handler(request).await; + + let response_message = BidirectionalMessage::Response(response); + 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); + let error_response = JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::new( + -32601, + "Method not found".to_string(), + None, + ), + request.id.clone(), + ); + let response_message = BidirectionalMessage::Response(error_response); + 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 { + debug!( + "Received RPC request without ID (notification): {}", + request.method + ); + } + } + BidirectionalMessage::Pong => { + debug!("Received pong"); + } + _ => { + debug!("Received unhandled message: {:?}", message); + } + } + } + + pub(super) async fn emit_connection_event(&self, event: ConnectionEvent) { + Self::emit_connection_event_static(event, &self.connection_event_handlers).await; + } + + pub(super) async fn emit_connection_event_static( + event: ConnectionEvent, + handlers: &DashMap, + ) { + for handler in handlers.iter() { + handler.value()(event.clone()); + } + } + + pub(super) async fn start_heartbeat(&self, interval: Duration) { + let message_tx = Arc::clone(&self.message_tx); + let state = Arc::clone(&self.state); + + spawn_background(async move { + let mut heartbeat_interval = tokio::time::interval(interval); + heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + heartbeat_interval.tick().await; + + let current_state = *state.read().await; + if current_state != ClientState::Connected { + break; + } + + let tx_guard = message_tx.read().await; + if let Some(tx) = tx_guard.as_ref() { + if tx.send(BidirectionalMessage::Ping).await.is_err() { + break; + } + } else { + break; + } + } + }); + } +} + +#[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); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/mod.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/mod.rs new file mode 100644 index 0000000..c219760 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/mod.rs @@ -0,0 +1,388 @@ +//! Main client implementation for bidirectional JSON-RPC communication + +use crate::{ + ClientState, ConnectionEvent, ConnectionEventHandler, NotificationHandler, PendingRequest, + RpcRequestHandler, Subscription, WebSocketTransport, + config::ClientConfig, + error::{ClientError, ClientResult}, +}; +use dashmap::DashMap; +use ras_jsonrpc_bidirectional_types::{BidirectionalMessage, ConnectionId}; +use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse}; +use serde_json::Value; +use std::{ + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Instant, +}; +use tokio::sync::{RwLock, mpsc, oneshot}; +use tracing::{debug, info}; + +#[cfg(not(target_arch = "wasm32"))] +use crate::native::NativeWebSocketTransport; + +#[cfg(target_arch = "wasm32")] +use crate::wasm::WasmWebSocketTransport; + +mod builder; +mod driver; +pub use builder::ClientBuilder; + +/// Bidirectional JSON-RPC WebSocket client +pub struct Client { + config: ClientConfig, + transport: Arc>>, + state: Arc>, + connection_id: Arc>>, + pending_requests: Arc>, + subscriptions: Arc>, + notification_handlers: Arc>, + rpc_request_handlers: Arc>, + connection_event_handlers: Arc>, + request_id_counter: Arc, + shutdown_tx: Arc>>>, + message_tx: Arc>>>, + /// Signaled when the server's ConnectionEstablished message arrives + connected_notify: Arc, +} + +impl Client { + /// Create a new client with the given configuration + pub async fn new(config: ClientConfig) -> ClientResult { + config.validate().map_err(ClientError::configuration)?; + + #[cfg(not(target_arch = "wasm32"))] + let transport: Box = + Box::new(NativeWebSocketTransport::new(config.clone())); + + #[cfg(target_arch = "wasm32")] + let transport: Box = + Box::new(WasmWebSocketTransport::new(config.clone())); + + Ok(Self { + config, + transport: Arc::new(RwLock::new(transport)), + state: Arc::new(RwLock::new(ClientState::Disconnected)), + connection_id: Arc::new(RwLock::new(None)), + pending_requests: Arc::new(DashMap::new()), + subscriptions: Arc::new(DashMap::new()), + notification_handlers: Arc::new(DashMap::new()), + rpc_request_handlers: Arc::new(DashMap::new()), + connection_event_handlers: Arc::new(DashMap::new()), + request_id_counter: Arc::new(AtomicU64::new(1)), + shutdown_tx: Arc::new(RwLock::new(None)), + message_tx: Arc::new(RwLock::new(None)), + connected_notify: Arc::new(tokio::sync::Notify::new()), + }) + } + + /// Connect to the WebSocket server + pub async fn connect(&self) -> ClientResult<()> { + let mut state = self.state.write().await; + if *state != ClientState::Disconnected { + return Err(ClientError::AlreadyConnected); + } + *state = ClientState::Connecting; + drop(state); + + let mut transport = self.transport.write().await; + transport + .connect() + .await + .map_err(|e| ClientError::connection(format!("Failed to connect: {}", e)))?; + drop(transport); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let (message_tx, message_rx) = mpsc::channel(self.config.message_buffer_size); + + *self.shutdown_tx.write().await = Some(shutdown_tx); + *self.message_tx.write().await = Some(message_tx); + + self.start_message_handler(message_rx, shutdown_rx).await?; + + // Wait for the server's ConnectionEstablished message before + // reporting the client as connected. The notify is signaled by the + // message handler; bound the wait so a silent server cannot hang us. + let handshake = async { + loop { + if self.connection_id.read().await.is_some() { + break; + } + self.connected_notify.notified().await; + } + }; + if tokio::time::timeout(self.config.connection_timeout, handshake) + .await + .is_err() + { + // Tear down the half-open connection + let _ = self.disconnect().await; + return Err(ClientError::timeout( + self.config.connection_timeout.as_secs(), + )); + } + + *self.state.write().await = ClientState::Connected; + + // Start heartbeat once connected (its loop exits when state leaves + // Connected, so starting earlier would race it to an immediate stop) + if let Some(interval) = self.config.heartbeat_interval { + self.start_heartbeat(interval).await; + } + + info!("Client connected to {}", self.config.url); + + Ok(()) + } + + /// Disconnect from the WebSocket server + pub async fn disconnect(&self) -> ClientResult<()> { + let mut state = self.state.write().await; + if *state == ClientState::Disconnected { + return Ok(()); + } + *state = ClientState::Disconnected; + drop(state); + + if let Some(shutdown_tx) = self.shutdown_tx.write().await.take() { + let _ = shutdown_tx.send(()); + } + + let mut transport = self.transport.write().await; + transport + .disconnect() + .await + .map_err(|e| ClientError::connection(format!("Failed to disconnect: {}", e)))?; + + *self.connection_id.write().await = None; + *self.message_tx.write().await = None; + + let pending_ids: Vec = self + .pending_requests + .iter() + .map(|entry| entry.key().clone()) + .collect(); + for id in pending_ids { + if let Some((_, pending)) = self.pending_requests.remove(&id) { + let _ = pending.sender.send(JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error( + "Client disconnected".to_string(), + ), + Some(pending.id), + )); + } + } + self.pending_requests.clear(); + + self.emit_connection_event(ConnectionEvent::Disconnected { reason: None }) + .await; + info!("Client disconnected"); + + Ok(()) + } + + /// Make a JSON-RPC call and wait for the response + pub async fn call(&self, method: &str, params: Option) -> ClientResult { + let state = self.state.read().await; + if *state != ClientState::Connected { + return Err(ClientError::NotConnected); + } + drop(state); + + let request_id = Value::Number(serde_json::Number::from( + self.request_id_counter.fetch_add(1, Ordering::SeqCst), + )); + + let request = JsonRpcRequest::new(method.to_string(), params, Some(request_id.clone())); + + let (response_tx, response_rx) = oneshot::channel(); + let pending = PendingRequest { + id: request_id.clone(), + sender: response_tx, + created_at: Instant::now(), + }; + + if self.pending_requests.len() >= self.config.max_pending_requests { + return Err(ClientError::internal("Too many pending requests")); + } + + self.pending_requests.insert(request_id.clone(), pending); + + // Send the request; on failure, drop our pending entry so the map + // cannot fill up with waiters that will never be answered. + let message = BidirectionalMessage::Request(request); + if let Err(e) = self.send_message(message).await { + self.pending_requests.remove(&request_id); + return Err(e); + } + + // Wait for response with timeout; every failure path removes our + // entry for the same reason (the success path is removed by the + // message handler when the response arrives). + match tokio::time::timeout(self.config.request_timeout, response_rx).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(_)) => { + self.pending_requests.remove(&request_id); + Err(ClientError::internal("Response channel closed")) + } + Err(_) => { + self.pending_requests.remove(&request_id); + Err(ClientError::timeout(self.config.request_timeout.as_secs())) + } + } + } + + /// Send a notification (fire-and-forget) + pub async fn notify(&self, method: &str, params: Option) -> ClientResult<()> { + let state = self.state.read().await; + if *state != ClientState::Connected { + return Err(ClientError::NotConnected); + } + drop(state); + + let request = JsonRpcRequest::new(method.to_string(), params, None); + let message = BidirectionalMessage::Request(request); + self.send_message(message).await + } + + /// Subscribe to a topic for receiving notifications + pub async fn subscribe(&self, topic: &str, handler: NotificationHandler) -> ClientResult<()> { + let state = self.state.read().await; + if *state != ClientState::Connected { + return Err(ClientError::NotConnected); + } + drop(state); + + let subscription = Subscription { + topic: topic.to_string(), + handler: handler.clone(), + created_at: Instant::now(), + }; + + self.subscriptions.insert(topic.to_string(), subscription); + + let message = BidirectionalMessage::Subscribe { + topics: vec![topic.to_string()], + }; + self.send_message(message).await?; + + debug!("Subscribed to topic: {}", topic); + Ok(()) + } + + /// Unsubscribe from a topic + pub async fn unsubscribe(&self, topic: &str) -> ClientResult<()> { + let state = self.state.read().await; + if *state != ClientState::Connected { + return Err(ClientError::NotConnected); + } + drop(state); + + self.subscriptions.remove(topic); + + let message = BidirectionalMessage::Unsubscribe { + topics: vec![topic.to_string()], + }; + self.send_message(message).await?; + + debug!("Unsubscribed from topic: {}", topic); + Ok(()) + } + + /// Register a handler for specific notification methods + pub fn on_notification(&self, method: &str, handler: NotificationHandler) { + self.notification_handlers + .insert(method.to_string(), handler); + debug!("Registered notification handler for method: {}", method); + } + + /// Register a handler for connection events + pub fn on_connection_event(&self, name: &str, handler: ConnectionEventHandler) { + self.connection_event_handlers + .insert(name.to_string(), handler); + debug!("Registered connection event handler: {}", name); + } + + /// Register a handler for RPC requests from the server + pub fn on_rpc_request(&self, method: &str, handler: RpcRequestHandler) { + self.rpc_request_handlers + .insert(method.to_string(), handler); + debug!("Registered RPC request handler for method: {}", method); + } + + /// Get the current connection state + pub async fn state(&self) -> ClientState { + *self.state.read().await + } + + /// Get the current connection ID (if connected) + pub async fn connection_id(&self) -> Option { + *self.connection_id.read().await + } + + /// Check if the client is currently connected + pub async fn is_connected(&self) -> bool { + *self.state.read().await == ClientState::Connected + } + + /// Get client configuration + pub fn config(&self) -> &ClientConfig { + &self.config + } + + /// Get the number of pending requests + pub fn pending_requests_count(&self) -> usize { + self.pending_requests.len() + } + + /// Get the list of active subscriptions + pub fn active_subscriptions(&self) -> Vec { + self.subscriptions + .iter() + .map(|entry| entry.key().clone()) + .collect() + } + + async fn send_message(&self, message: BidirectionalMessage) -> ClientResult<()> { + if let Some(tx) = self.message_tx.read().await.as_ref() { + tx.send(message) + .await + .map_err(|_| ClientError::send_failed("Message channel closed"))?; + } else { + return Err(ClientError::NotConnected); + } + Ok(()) + } + + /// Clean up expired pending requests + pub async fn cleanup_expired_requests(&self) { + let timeout = self.config.request_timeout; + let now = Instant::now(); + + let expired_ids: Vec = self + .pending_requests + .iter() + .filter_map(|entry| { + if now.duration_since(entry.created_at) > timeout { + Some(entry.id.clone()) + } else { + None + } + }) + .collect(); + + for id in expired_ids { + if let Some((_, pending)) = self.pending_requests.remove(&id) { + let _ = pending.sender.send(JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error("Request timeout".to_string()), + Some(pending.id), + )); + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/builder.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/builder.rs new file mode 100644 index 0000000..2475a96 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/builder.rs @@ -0,0 +1,52 @@ +use super::*; + +#[tokio::test] +async fn test_client_builder() { + let client = ClientBuilder::new("ws://localhost:8080") + .with_jwt_token("test_token".to_string()) + .with_request_timeout(Duration::from_secs(60)) + .build() + .await + .expect("Failed to build client"); + + assert_eq!(client.config().url, "ws://localhost:8080"); + assert_eq!(client.config().request_timeout, Duration::from_secs(60)); + assert!(matches!(client.config().auth, AuthConfig::JwtHeader { .. })); +} + +#[tokio::test] +async fn builder_jwt_in_query_params_and_full_setters() { + // Builder options must survive construction without a connection. + let custom = ReconnectConfig::default(); + let client = ClientBuilder::new("ws://localhost:8080") + .with_jwt_token("tok".into()) + .with_header("X-Custom", "v") + .with_request_timeout(Duration::from_secs(11)) + .with_reconnect_config(custom) + .with_heartbeat_interval(None) + .with_connection_timeout(Duration::from_secs(7)) + .with_auto_connect(false) + .build() + .await + .expect("build"); + + assert!(matches!(client.config().auth, AuthConfig::JwtHeader { .. })); + assert_eq!(client.config().request_timeout, Duration::from_secs(11)); + assert_eq!(client.config().connection_timeout, Duration::from_secs(7)); + assert!(client.config().heartbeat_interval.is_none()); + assert_eq!( + client.config().custom_headers.get("X-Custom"), + Some(&"v".to_string()) + ); + assert!(client.active_subscriptions().is_empty()); + assert_eq!(client.pending_requests_count(), 0); +} + +#[tokio::test] +async fn builder_without_token_yields_no_auth() { + let client = ClientBuilder::new("ws://localhost:8080") + .build() + .await + .expect("build"); + assert!(matches!(client.config().auth, AuthConfig::None)); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/dispatch.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/dispatch.rs new file mode 100644 index 0000000..6817f21 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/dispatch.rs @@ -0,0 +1,236 @@ +use super::*; + +#[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/client/tests/lifecycle.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/lifecycle.rs new file mode 100644 index 0000000..16fcaa7 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/lifecycle.rs @@ -0,0 +1,97 @@ +use super::*; + +#[tokio::test] +async fn test_client_state() { + let client = ClientBuilder::new("ws://localhost:8080") + .build() + .await + .expect("Failed to build client"); + + assert_eq!(client.state().await, ClientState::Disconnected); + assert!(!client.is_connected().await); + assert!(client.connection_id().await.is_none()); +} + +#[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 connection_established_wakes_handshake_waiter() { + let harness = std::sync::Arc::new(IncomingHarness::new()); + + // Mirrors the wait loop in connect(): park on the notify until the + // connection id is set. Without notify_one in the message handler + // this would hang and the timeout below would fail the test. + let waiter = { + let harness = std::sync::Arc::clone(&harness); + tokio::spawn(async move { + loop { + if harness.connection_id.read().await.is_some() { + break; + } + harness.connected_notify.notified().await; + } + }) + }; + + tokio::task::yield_now().await; + + Client::handle_incoming_message( + BidirectionalMessage::ConnectionEstablished { + connection_id: ConnectionId::new(), + }, + harness.context(), + ) + .await; + + tokio::time::timeout(Duration::from_secs(5), waiter) + .await + .expect("handshake waiter woke up") + .expect("waiter task completed"); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/mod.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/mod.rs new file mode 100644 index 0000000..acdcd16 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/mod.rs @@ -0,0 +1,50 @@ +use super::driver::IncomingMessageContext; +use super::*; +use crate::config::{AuthConfig, ReconnectConfig}; +use std::sync::Mutex; +use std::time::Duration; + +struct IncomingHarness { + pending_requests: DashMap, + subscriptions: DashMap, + notification_handlers: DashMap, + rpc_request_handlers: DashMap, + connection_event_handlers: DashMap, + connection_id: RwLock>, + message_tx: RwLock>>, + connected_notify: tokio::sync::Notify, +} + +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), + connected_notify: tokio::sync::Notify::new(), + } + } + + 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, + connected_notify: &self.connected_notify, + } + } +} + +mod builder; +mod dispatch; +mod lifecycle; +mod requests; +mod subscriptions; diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/requests.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/requests.rs new file mode 100644 index 0000000..c88e428 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/requests.rs @@ -0,0 +1,195 @@ +use super::*; + +#[tokio::test] +async fn call_notify_subscribe_unsubscribe_require_connected_state() { + let client = ClientBuilder::new("ws://localhost:8080") + .build() + .await + .expect("build"); + + // call → NotConnected + let err = client.call("m", None).await.unwrap_err(); + assert!(matches!(err, ClientError::NotConnected)); + + // notify → NotConnected + let err = client.notify("m", None).await.unwrap_err(); + assert!(matches!(err, ClientError::NotConnected)); + + // subscribe → NotConnected + let handler: NotificationHandler = std::sync::Arc::new(|_method: &str, _params: &Value| {}); + let err = client.subscribe("t", handler.clone()).await.unwrap_err(); + assert!(matches!(err, ClientError::NotConnected)); + + // unsubscribe → NotConnected + let err = client.unsubscribe("t").await.unwrap_err(); + assert!(matches!(err, ClientError::NotConnected)); +} + +#[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(start_paused = true)] +async fn call_timeout_removes_pending_entry_and_allows_retry() { + let client = ClientBuilder::new("ws://localhost:8080") + .with_request_timeout(Duration::from_millis(20)) + .build() + .await + .expect("build"); + *client.state.write().await = ClientState::Connected; + + let (tx, mut rx) = mpsc::channel(8); + *client.message_tx.write().await = Some(tx); + + let err = client.call("svc.slow", None).await.unwrap_err(); + assert!(matches!(err, ClientError::Timeout { .. })); + assert!( + client.pending_requests.is_empty(), + "timed-out call must remove its pending entry" + ); + let _ = rx.recv().await; + + // The map must not fill up with dead waiters: a retry times out + // again rather than failing with "Too many pending requests". + let err = client.call("svc.slow", None).await.unwrap_err(); + assert!(matches!(err, ClientError::Timeout { .. })); + assert!(client.pending_requests.is_empty()); +} + +#[tokio::test] +async fn call_send_failure_removes_pending_entry() { + let client = ClientBuilder::new("ws://localhost:8080") + .build() + .await + .expect("build"); + *client.state.write().await = ClientState::Connected; + + // Install a sender whose receiver is already gone so send fails. + let (tx, rx) = mpsc::channel(1); + drop(rx); + *client.message_tx.write().await = Some(tx); + + let err = client.call("svc.echo", None).await.unwrap_err(); + assert!(!matches!(err, ClientError::Timeout { .. })); + assert!( + client.pending_requests.is_empty(), + "failed send must remove its pending entry" + ); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/subscriptions.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/subscriptions.rs new file mode 100644 index 0000000..77d62f0 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client/tests/subscriptions.rs @@ -0,0 +1,67 @@ +use super::*; + +#[tokio::test] +async fn handler_registration_does_not_require_connected_state() { + let client = ClientBuilder::new("ws://localhost:8080") + .build() + .await + .expect("build"); + + let n: NotificationHandler = std::sync::Arc::new(|_, _| {}); + let e: ConnectionEventHandler = std::sync::Arc::new(|_event| {}); + client.on_notification("evt", n); + client.on_connection_event("named", e); + + // cleanup_expired_requests is callable even with nothing pending. + client.cleanup_expired_requests().await; + + // 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()); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/connection.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/connection.rs index f04ef88..012fa0e 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/connection.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/connection.rs @@ -1,9 +1,9 @@ //! Connection context and management -use crate::handler::{SubscriptionAccounting, SubscriptionLimits}; +pub use crate::subscriptions::SubscriptionPolicy; use ras_auth_core::AuthenticatedUser; use ras_jsonrpc_bidirectional_types::{ - BidirectionalError, BidirectionalMessage, ConnectionId, ConnectionInfo, ConnectionManager, + BidirectionalError, BidirectionalMessage, ConnectionId, ConnectionInfo, }; use std::sync::Arc; use tokio::sync::{RwLock, mpsc}; @@ -77,28 +77,6 @@ impl ChannelMessageSender { } } -/// Everything a subscription mutation has to be checked against and mirrored -/// into. Owned by the service and shared by all of its connections. -#[derive(Clone, Default)] -pub struct SubscriptionPolicy { - /// Caps applied to every subscribe - pub limits: SubscriptionLimits, - /// Service-wide counter behind the global cap - pub accounting: Arc, - /// Manager whose topic index mirrors accepted subscriptions - pub manager: Option>, -} - -impl std::fmt::Debug for SubscriptionPolicy { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SubscriptionPolicy") - .field("limits", &self.limits) - .field("held", &self.accounting.total()) - .field("manager", &self.manager.is_some()) - .finish() - } -} - /// Context information for an active WebSocket connection. /// /// Subscription state can only change through [`subscribe`](Self::subscribe) diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs deleted file mode 100644 index e266140..0000000 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs +++ /dev/null @@ -1,2060 +0,0 @@ -//! Message handlers for WebSocket communication - -use crate::{ConnectionContext, ServerError, ServerResult, connection::OutboundMessage}; -use async_trait::async_trait; -use axum::extract::ws::{CloseFrame, Message, WebSocket}; -use futures::stream::StreamExt; -use ras_auth_core::AuthProvider; -use ras_jsonrpc_bidirectional_types::{BidirectionalMessage, ConnectionManager}; -use ras_jsonrpc_types::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, error_codes}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; - -/// Trait for handling JSON-RPC requests within a WebSocket context -#[async_trait] -pub trait MessageHandler: Send + Sync + 'static { - /// Handle an incoming JSON-RPC request - /// - /// # Arguments - /// * `request` - The JSON-RPC request to handle - /// * `context` - The connection context containing auth info and metadata - /// - /// # Returns - /// * `Ok(Some(response))` - Response to send back to client - /// * `Ok(None)` - No response needed (for notifications) - /// * `Err(error)` - Error occurred during handling - async fn handle_request( - &self, - request: JsonRpcRequest, - context: Arc, - ) -> ServerResult>; - - /// Decide whether this connection may subscribe to `topic`. - /// - /// Default-deny: services that broadcast over topics must override this - /// (or `handle_subscribe`) to allow the topics a connection is entitled - /// to. Errors propagate to the handler loop and close the connection. - async fn authorize_subscribe( - &self, - _topic: &str, - _context: &Arc, - ) -> ServerResult { - Ok(false) - } - - /// Handle subscription requests - async fn handle_subscribe( - &self, - topics: Vec, - context: Arc, - ) -> ServerResult<()> { - // Default implementation subscribes the connection to each topic the - // service authorizes via `authorize_subscribe`; denied topics are - // skipped without closing the connection. - for topic in topics { - if self.authorize_subscribe(&topic, &context).await? { - if let Err(e) = context.subscribe(topic.clone()).await { - warn!( - "Refused subscription to topic '{}' for connection {}: {}", - topic, context.id, e - ); - } - } else { - warn!( - "Denied subscription to topic '{}' for connection {}", - topic, context.id - ); - } - } - Ok(()) - } - - /// Handle unsubscription requests - async fn handle_unsubscribe( - &self, - topics: Vec, - context: Arc, - ) -> ServerResult<()> { - for topic in topics { - context.unsubscribe(&topic).await; - } - Ok(()) - } - - /// Handle connection established event - async fn on_connect(&self, context: Arc) -> ServerResult<()> { - info!("Connection established: {}", context.id); - Ok(()) - } - - /// Handle connection closed event - async fn on_disconnect( - &self, - context: Arc, - reason: Option, - ) -> ServerResult<()> { - info!("Connection closed: {} (reason: {:?})", context.id, reason); - Ok(()) - } - - /// Handle ping message - async fn on_ping(&self, _context: Arc) -> ServerResult<()> { - debug!("Received ping"); - Ok(()) - } - - /// Handle pong message - async fn on_pong(&self, _context: Arc) -> ServerResult<()> { - 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())) - }) - } -} - -/// Default interval between credential re-validations on long-lived connections. -pub const DEFAULT_AUTH_REVALIDATION_INTERVAL: Duration = Duration::from_secs(30); - -/// Periodic credential re-validation for a long-lived connection. -/// -/// The token is captured before the WebSocket upgrade and re-run through the -/// auth provider on every `interval` tick. Failure closes the connection; -/// success refreshes the cached user (so permission changes propagate). This -/// bounds the lifetime of revoked/expired credentials on an open socket to at -/// most one interval. -pub struct AuthRevalidation { - /// Provider used to re-run authentication - pub auth_provider: Arc, - /// Token captured at upgrade time - pub token: String, - /// How often to re-validate - pub interval: Duration, - /// What to do when re-validation succeeds but the permission set changed - pub on_permission_change: PermissionChangePolicy, -} - -/// Policy applied when a live connection's permissions change on -/// re-validation (W1). -/// -/// In both modes every held subscription is re-run through -/// [`MessageHandler::authorize_subscribe`] against the refreshed user and -/// topics that are no longer authorized are dropped, so a downgraded -/// connection stops receiving topic broadcasts within one interval. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PermissionChangePolicy { - /// Keep the socket open and silently drop subscriptions that are no - /// longer authorized. - #[default] - DropSubscriptions, - /// Close the socket so the client must reconnect and re-authenticate. - Close, -} - -/// Limits on client-initiated subscriptions (W3). -/// -/// Enforced by the handler loop before [`MessageHandler::handle_subscribe`] -/// runs, so services never see an over-limit request. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SubscriptionLimits { - /// Maximum topics in one `Subscribe`/`Unsubscribe` message - pub max_topics_per_message: usize, - /// Maximum concurrent subscriptions held by one connection - pub max_topics_per_connection: usize, - /// Maximum topic name length in bytes - pub max_topic_length: usize, - /// Maximum (connection, topic) pairs across the whole manager. `0` - /// disables the cap. Enforced only when the manager reports its count - /// (`ConnectionManager::total_subscription_count`); the default manager - /// does. - pub max_total_subscriptions: usize, -} - -impl Default for SubscriptionLimits { - fn default() -> Self { - Self { - max_topics_per_message: 64, - max_topics_per_connection: 256, - max_topic_length: 256, - max_total_subscriptions: 100_000, - } - } -} - -/// Service-wide count of held subscriptions, shared by every connection of a -/// service so the global cap is enforced by the server itself, independently -/// of which `ConnectionManager` or `MessageHandler` is plugged in. -#[derive(Debug, Default)] -pub struct SubscriptionAccounting { - total: std::sync::atomic::AtomicUsize, -} - -impl SubscriptionAccounting { - /// Current number of (connection, topic) pairs held across the service. - pub fn total(&self) -> usize { - self.total.load(std::sync::atomic::Ordering::Acquire) - } - - /// Atomically reserve one slot; `false` when `max` (non-zero) is reached. - pub(crate) fn reserve(&self, max: usize) -> bool { - use std::sync::atomic::Ordering; - let previous = self.total.fetch_add(1, Ordering::AcqRel); - if max > 0 && previous >= max { - self.total.fetch_sub(1, Ordering::AcqRel); - return false; - } - true - } - - pub(crate) fn release(&self, count: usize) { - self.total - .fetch_sub(count, std::sync::atomic::Ordering::AcqRel); - } -} - -/// Server-side keepalive for a connection (W4). -/// -/// The server sends a WebSocket ping every `ping_interval`; browsers and -/// tungstenite answer pings automatically, and any inbound frame (including -/// the pong) resets the idle clock. A connection that stays silent for -/// `idle_timeout` is closed, so half-open sockets are reclaimed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct KeepaliveConfig { - /// Interval between server-initiated pings (`None` disables pings) - pub ping_interval: Option, - /// Close the socket after this long without any inbound frame - /// (`None` disables the idle timeout) - pub idle_timeout: Option, -} - -impl Default for KeepaliveConfig { - fn default() -> Self { - Self { - ping_interval: Some(Duration::from_secs(30)), - idle_timeout: Some(Duration::from_secs(90)), - } - } -} - -/// WebSocket connection handler that manages the message flow -pub struct WebSocketHandler { - /// The message handler for processing requests - handler: Arc, - /// Connection context - context: Arc, - /// Channel for receiving messages to send to client - message_rx: mpsc::Receiver, - max_message_size: usize, - /// Optional periodic credential re-validation - auth_revalidation: Option, - /// Connection manager kept in step with the cached user on re-validation - /// (None when running without a manager, e.g. unit tests). Subscription - /// mirroring goes through the context's `SubscriptionPolicy`. - connection_manager: Option>, - keepalive: KeepaliveConfig, -} - -impl WebSocketHandler { - /// Create a new WebSocket handler - pub fn new( - handler: Arc, - context: Arc, - message_rx: mpsc::Receiver, - max_message_size: usize, - ) -> Self { - Self { - handler, - context, - message_rx, - max_message_size, - auth_revalidation: None, - connection_manager: None, - keepalive: KeepaliveConfig::default(), - } - } - - /// Enable periodic credential re-validation for this connection. - pub fn with_auth_revalidation(mut self, revalidation: AuthRevalidation) -> Self { - self.auth_revalidation = Some(revalidation); - self - } - - /// Keep the manager's cached user in step on re-validation. - pub fn with_connection_manager(mut self, manager: Arc) -> Self { - self.connection_manager = Some(manager); - self - } - - /// Override the default keepalive settings. - pub fn with_keepalive(mut self, keepalive: KeepaliveConfig) -> Self { - self.keepalive = keepalive; - self - } - - /// Re-run authentication and re-authorize every held subscription. - /// - /// Returns `Ok(true)` to keep the connection, `Ok(false)` to close it. - async fn revalidate_credentials(&mut self) -> ServerResult { - let revalidation = self - .auth_revalidation - .as_ref() - .expect("revalidation timer implies config"); - let user = match revalidation - .auth_provider - .authenticate(revalidation.token.clone()) - .await - { - Ok(user) => user, - Err(e) => { - warn!( - "Closing connection {}: credential re-validation failed: {}", - self.context.id, e - ); - return Ok(false); - } - }; - - let previous = self.context.get_user().await; - let permissions_changed = previous - .as_ref() - .map(|prev| prev.permissions != user.permissions || prev.user_id != user.user_id) - .unwrap_or(true); - - // Refresh cached identity/permissions in both stores - self.context.set_user(user.clone()).await; - if let Some(manager) = &self.connection_manager { - let _ = manager.set_connection_user(self.context.id, user).await; - } - - if permissions_changed && revalidation.on_permission_change == PermissionChangePolicy::Close - { - warn!( - "Closing connection {}: permissions changed on re-validation", - self.context.id - ); - return Ok(false); - } - - // Re-authorize held subscriptions against the refreshed user (W1) - for topic in self.context.get_subscriptions().await { - if !self - .handler - .authorize_subscribe(&topic, &self.context) - .await? - { - warn!( - "Dropping subscription to '{}' on connection {}: no longer authorized", - topic, self.context.id - ); - self.context.unsubscribe(&topic).await; - } - } - Ok(true) - } - - /// Fast-path validation of a subscribe/unsubscribe request so the client - /// gets an error response. The authoritative checks live in - /// `ConnectionContext::subscribe`. - fn check_subscription_limits(&self, topics: &[String]) -> Result<(), &'static str> { - let limits = &self.context.subscription_policy().limits; - if topics.len() > limits.max_topics_per_message { - return Err("too many topics in one message"); - } - if topics - .iter() - .any(|topic| topic.len() > limits.max_topic_length || topic.is_empty()) - { - return Err("topic name length out of range"); - } - Ok(()) - } - - /// Run the WebSocket handler loop - 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 - ); - - if let Err(e) = self.handler.on_connect(self.context.clone()).await { - error!("Error in on_connect handler: {}", e); - } - - let established_msg = BidirectionalMessage::ConnectionEstablished { - connection_id: self.context.id, - }; - if let Err(e) = socket - .send(WebSocketIoMessage::Text(serde_json::to_string( - &established_msg, - )?)) - .await - { - error!("Failed to send connection established message: {}", e); - } - - let mut revalidation_timer = self.auth_revalidation.as_ref().map(|revalidation| { - // tokio panics on a zero period; a zero interval is a config - // error, not a request to hammer the provider. Fall back to the - // default rather than disabling re-validation. - let interval = if revalidation.interval.is_zero() { - warn!( - "auth re-validation interval is zero; using default {:?}", - DEFAULT_AUTH_REVALIDATION_INTERVAL - ); - DEFAULT_AUTH_REVALIDATION_INTERVAL - } else { - revalidation.interval - }; - let mut timer = - tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); - timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - timer - }); - - let mut ping_timer = self - .keepalive - .ping_interval - .filter(|interval| { - if interval.is_zero() { - warn!("keepalive ping interval is zero; pings disabled"); - } - !interval.is_zero() - }) - .map(|interval| { - let mut timer = - tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); - timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - timer - }); - let idle_timeout = self.keepalive.idle_timeout.filter(|timeout| { - if timeout.is_zero() { - warn!("keepalive idle timeout is zero; idle timeout disabled"); - } - !timeout.is_zero() - }); - let idle_deadline = tokio::time::sleep(idle_timeout.unwrap_or(Duration::from_secs(0))); - tokio::pin!(idle_deadline); - - loop { - tokio::select! { - // Re-validate credentials so revoked/expired tokens are - // bounded to at most one interval on a long-lived connection - _ = async { revalidation_timer.as_mut().expect("guarded by is_some").tick().await }, - if revalidation_timer.is_some() => - { - match self.revalidate_credentials().await { - Ok(true) => {} - Ok(false) => { - let _ = socket - .send(WebSocketIoMessage::Close(Some( - "credentials no longer valid".to_string(), - ))) - .await; - break; - } - Err(e) => { - error!("Error re-authorizing subscriptions: {}", e); - break; - } - } - } - - // Server-initiated keepalive ping - _ = async { ping_timer.as_mut().expect("guarded by is_some").tick().await }, - if ping_timer.is_some() => - { - if let Err(e) = socket.send(WebSocketIoMessage::Ping(Vec::new())).await { - error!("Error sending keepalive ping: {}", e); - break; - } - } - - // Idle timeout: no inbound frame for the configured period - _ = &mut idle_deadline, if idle_timeout.is_some() => { - warn!( - "Closing connection {}: idle for {:?}", - self.context.id, - idle_timeout.expect("guarded") - ); - let _ = socket - .send(WebSocketIoMessage::Close(Some("idle timeout".to_string()))) - .await; - break; - } - - msg = socket.recv() => { - if let Some(timeout) = idle_timeout { - idle_deadline - .as_mut() - .reset(tokio::time::Instant::now() + timeout); - } - match msg { - Some(Ok(msg)) => { - if let Err(e) = self.handle_websocket_message(msg, socket).await { - error!("Error handling WebSocket message: {}", e); - break; - } - } - Some(Err(e)) => { - error!("WebSocket error: {}", e); - break; - } - None => { - debug!("WebSocket connection closed by client"); - break; - } - } - } - - msg = self.message_rx.recv() => { - match msg { - Some(OutboundMessage { message, topic }) => { - // Egress gate: a message routed on a topic while - // the subscription was still in the manager index - // is dropped here if the connection no longer - // holds it, closing the window between - // re-authorization and index removal. - if let Some(topic) = topic - && !self.context.is_subscribed_to(&topic).await - { - debug!( - "Dropping message on '{}' for connection {}: not subscribed", - topic, self.context.id - ); - continue; - } - if let Err(e) = self.send_message(socket, message).await { - error!("Error sending message: {}", e); - break; - } - } - None => { - debug!("Message channel closed"); - break; - } - } - } - } - } - - // Return this connection's subscription slots to the service pool - self.context.release_all_subscriptions().await; - - if let Err(e) = self.handler.on_disconnect(self.context.clone(), None).await { - error!("Error in on_disconnect handler: {}", e); - } - - let closed_msg = BidirectionalMessage::ConnectionClosed { - connection_id: self.context.id, - reason: None, - }; - let _ = socket - .send(WebSocketIoMessage::Text(serde_json::to_string( - &closed_msg, - )?)) - .await; - - info!( - "WebSocket handler finished for connection: {}", - self.context.id - ); - Ok(()) - } - - /// Handle incoming WebSocket messages - async fn handle_websocket_message( - &mut self, - msg: WebSocketIoMessage, - socket: &mut S, - ) -> ServerResult<()> { - match msg { - WebSocketIoMessage::Text(text) => { - if text.len() > self.max_message_size { - warn!("Received oversized text message: {} bytes", text.len()); - return Err(ServerError::InvalidRequest( - "Message exceeds maximum size".to_string(), - )); - } - debug!("Received text message ({} bytes)", text.len()); - self.handle_text_message(text, socket).await - } - WebSocketIoMessage::Binary(data) => { - if data.len() > self.max_message_size { - warn!("Received oversized binary message: {} bytes", data.len()); - return Err(ServerError::InvalidRequest( - "Message exceeds maximum size".to_string(), - )); - } - debug!("Received binary message ({} bytes)", data.len()); - match String::from_utf8(data) { - Ok(text) => self.handle_text_message(text, socket).await, - Err(_) => { - warn!("Received non-UTF-8 binary message, ignoring"); - Ok(()) - } - } - } - WebSocketIoMessage::Ping(data) => { - debug!("Received ping"); - socket.send(WebSocketIoMessage::Pong(data)).await?; - self.handler.on_ping(self.context.clone()).await - } - WebSocketIoMessage::Pong(_) => { - debug!("Received pong"); - self.handler.on_pong(self.context.clone()).await - } - WebSocketIoMessage::Close(reason) => { - debug!("Received close frame: {:?}", reason); - self.handler - .on_disconnect(self.context.clone(), reason.clone()) - .await?; - Err(ServerError::WebSocketError("Connection closed".to_string())) - } - } - } - - /// Handle text messages (JSON-RPC or bidirectional messages) - async fn handle_text_message( - &mut self, - text: String, - socket: &mut S, - ) -> ServerResult<()> { - if let Ok(msg) = serde_json::from_str::(&text) { - return self.handle_bidirectional_message(msg, socket).await; - } - - if let Ok(request) = serde_json::from_str::(&text) { - return self.handle_jsonrpc_request(request, socket).await; - } - - // Neither shape parsed. Per JSON-RPC 2.0, answer with a Parse Error - // (-32700, id null) and keep the connection open; only transport - // failures terminate the handler loop. - warn!( - "Could not parse message as JSON-RPC or bidirectional message on connection {}", - self.context.id - ); - let response = JsonRpcResponse::error(JsonRpcError::parse_error(), None); - self.send_message(socket, BidirectionalMessage::Response(response)) - .await - } - - /// Handle bidirectional messages - async fn handle_bidirectional_message( - &mut self, - msg: BidirectionalMessage, - _socket: &mut S, - ) -> ServerResult<()> { - match msg { - BidirectionalMessage::Request(request) => { - self.handle_jsonrpc_request(request, _socket).await - } - BidirectionalMessage::Subscribe { topics } => { - let before = self.context.get_subscriptions().await; - let new = topics.iter().filter(|t| !before.contains(t)).count(); - let policy = self.context.subscription_policy(); - let mut limit_error = self.check_subscription_limits(&topics).err().or_else(|| { - (before.len() + new > policy.limits.max_topics_per_connection) - .then_some("subscription limit for this connection reached") - }); - if limit_error.is_none() - && policy.limits.max_total_subscriptions > 0 - && policy.accounting.total() + new > policy.limits.max_total_subscriptions - { - limit_error = Some("global subscription limit reached"); - } - if let Some(reason) = limit_error { - warn!( - "Rejected subscribe on connection {}: {}", - self.context.id, reason - ); - let response = JsonRpcResponse::error( - JsonRpcError::invalid_params(reason.to_string()), - None, - ); - return self - .send_message(_socket, BidirectionalMessage::Response(response)) - .await; - } - self.handler - .handle_subscribe(topics, self.context.clone()) - .await - } - BidirectionalMessage::Unsubscribe { topics } => { - if let Err(reason) = self.check_subscription_limits(&topics) { - warn!( - "Rejected unsubscribe on connection {}: {}", - self.context.id, reason - ); - return Ok(()); - } - self.handler - .handle_unsubscribe(topics, self.context.clone()) - .await - } - BidirectionalMessage::Ping => self.handler.on_ping(self.context.clone()).await, - BidirectionalMessage::Pong => self.handler.on_pong(self.context.clone()).await, - // Other message types are typically server-to-client - _ => { - warn!("Received unexpected bidirectional message type from client"); - Ok(()) - } - } - } - - /// Handle JSON-RPC requests - async fn handle_jsonrpc_request( - &mut self, - request: JsonRpcRequest, - socket: &mut S, - ) -> ServerResult<()> { - debug!("Handling JSON-RPC request: {}", request.method); - let request_id = request.id.clone(); - - match self - .handler - .handle_request(request, self.context.clone()) - .await - { - Ok(Some(response)) => { - let response_msg = BidirectionalMessage::Response(response); - self.send_message(socket, response_msg).await - } - Ok(None) => { - // No response needed (notification) - Ok(()) - } - Err(e) => { - error!("Error handling request: {}", 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( - &self, - socket: &mut S, - msg: BidirectionalMessage, - ) -> ServerResult<()> { - let json = serde_json::to_string(&msg)?; - 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, - }; - - // Send only a generic per-class message; the full error was already logged - // server-side by the caller. Never interpolate handler/AuthError Display. - JsonRpcError::new(code, error.client_message().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; - - #[test] - fn jsonrpc_error_from_server_error_sends_generic_message_not_handler_detail() { - // Handler error carrying a secret -> client sees only a generic message, - // stable code preserved, no data field. - let err = ServerError::Internal("database password is hunter2".into()); - let jsonrpc = jsonrpc_error_from_server_error(&err); - assert_eq!(jsonrpc.code, error_codes::INTERNAL_ERROR); - assert_eq!(jsonrpc.message, "Internal error"); - assert!(!jsonrpc.message.contains("hunter2")); - assert!(jsonrpc.data.is_none()); - - // AuthError detail must not reach the client either. - let auth = ServerError::AuthenticationFailed(ras_auth_core::AuthError::Internal( - "dsn=postgres://user:pw@host/db".into(), - )); - let jsonrpc = jsonrpc_error_from_server_error(&auth); - assert_eq!(jsonrpc.code, error_codes::AUTHENTICATION_REQUIRED); - assert_eq!(jsonrpc.message, "Authentication failed"); - assert!(!jsonrpc.message.contains("dsn")); - - // Stable codes for the invalid-request / method-not-found classes. - assert_eq!( - jsonrpc_error_from_server_error(&ServerError::InvalidRequest( - "Invalid params: x".into() - )) - .code, - error_codes::INVALID_REQUEST - ); - assert_eq!( - jsonrpc_error_from_server_error(&ServerError::HandlerNotFound("m".into())).code, - error_codes::METHOD_NOT_FOUND - ); - } - - /// A minimal MessageHandler that only implements the required method — - /// every other method falls through to the default impl, which is what - /// these tests are verifying. - struct PassThrough; - - #[async_trait] - impl MessageHandler for PassThrough { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - } - - 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); - let sender = ChannelMessageSender::new(id, tx); - Arc::new(ConnectionContext::new(id, sender)) - } - - fn ctx_with(policy: crate::connection::SubscriptionPolicy) -> Arc { - let id = ConnectionId::new(); - let (tx, _rx) = mpsc::channel(4); - let sender = ChannelMessageSender::new(id, tx); - Arc::new(ConnectionContext::new(id, sender).with_subscription_policy(policy)) - } - - fn limits_policy(limits: SubscriptionLimits) -> crate::connection::SubscriptionPolicy { - crate::connection::SubscriptionPolicy { - limits, - ..Default::default() - } - } - - #[tokio::test] - async fn default_handle_subscribe_denies_all_topics() { - let h = PassThrough; - let c = ctx(); - h.handle_subscribe(vec!["a".into(), "b".into()], c.clone()) - .await - .unwrap(); - assert!(!c.is_subscribed_to("a").await); - assert!(!c.is_subscribed_to("b").await); - } - - #[tokio::test] - async fn default_authorize_subscribe_denies() { - let h = PassThrough; - let c = ctx(); - assert!(!h.authorize_subscribe("any-topic", &c).await.unwrap()); - } - - struct AllowListHandler; - - #[async_trait] - impl MessageHandler for AllowListHandler { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - - async fn authorize_subscribe( - &self, - topic: &str, - _context: &Arc, - ) -> ServerResult { - Ok(topic == "room:allowed") - } - } - - #[tokio::test] - async fn handle_subscribe_only_subscribes_authorized_topics() { - let h = AllowListHandler; - let c = ctx(); - h.handle_subscribe(vec!["room:allowed".into(), "room:denied".into()], c.clone()) - .await - .unwrap(); - assert!(c.is_subscribed_to("room:allowed").await); - assert!(!c.is_subscribed_to("room:denied").await); - } - - #[tokio::test] - async fn default_handle_unsubscribe_removes_from_context() { - let h = PassThrough; - let c = ctx(); - c.subscribe("a".into()).await.unwrap(); - c.subscribe("b".into()).await.unwrap(); - h.handle_unsubscribe(vec!["a".into()], c.clone()) - .await - .unwrap(); - assert!(!c.is_subscribed_to("a").await); - assert!(c.is_subscribed_to("b").await); - } - - #[tokio::test] - async fn default_lifecycle_methods_succeed() { - let h = PassThrough; - let c = ctx(); - h.on_connect(c.clone()).await.unwrap(); - h.on_ping(c.clone()).await.unwrap(); - h.on_pong(c.clone()).await.unwrap(); - h.on_disconnect(c.clone(), Some("bye".into())) - .await - .unwrap(); - // 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); - // Message is the generic per-class string; the handler's detail - // ("bad request") stays server-side. - assert_eq!(error.message, "Invalid 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(OutboundMessage::from(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_answers_malformed_text_with_parse_error_and_continues() { - let request = JsonRpcRequest::new( - "echo".into(), - Some(serde_json::json!({})), - Some(serde_json::json!(9)), - ); - let mut socket = InMemorySocket::closing([ - WebSocketIoMessage::Text("not json-rpc".to_string()), - WebSocketIoMessage::Text( - serde_json::to_string(&BidirectionalMessage::Request(request)).unwrap(), - ), - ]); - 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 { .. } - )); - - // The garbage frame is answered with -32700 (id null)... - let parse_error = match &messages[1] { - BidirectionalMessage::Response(response) => response, - other => panic!("expected parse error response, got {other:?}"), - }; - assert_eq!(parse_error.id, None); - let error = parse_error.error.as_ref().expect("parse error"); - assert_eq!(error.code, ras_jsonrpc_types::error_codes::PARSE_ERROR); - - // ...and the connection keeps serving subsequent requests. - let response = match &messages[2] { - BidirectionalMessage::Response(response) => response, - other => panic!("expected response, got {other:?}"), - }; - assert_eq!(response.id, Some(serde_json::json!(9))); - - assert!(matches!( - messages[3], - 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 auth_user(id: &str) -> ras_auth_core::AuthenticatedUser { - ras_auth_core::AuthenticatedUser { - user_id: id.to_string(), - permissions: std::collections::HashSet::new(), - metadata: None, - } - } - - /// Auth provider that replays a fixed sequence of results, then fails. - struct SequenceAuthProvider( - Mutex>>, - ); - - impl SequenceAuthProvider { - fn new( - results: impl IntoIterator< - Item = Result, - >, - ) -> Self { - Self(Mutex::new(results.into_iter().collect())) - } - } - - impl AuthProvider for SequenceAuthProvider { - fn authenticate(&self, _token: String) -> ras_auth_core::AuthFuture<'_> { - let result = self - .0 - .lock() - .expect("results lock") - .pop_front() - .unwrap_or(Err(ras_auth_core::AuthError::InvalidToken)); - Box::pin(async move { result }) - } - } - - #[tokio::test(start_paused = true)] - async fn revalidation_failure_closes_connection() { - let context = ctx(); - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - - WebSocketHandler::new(Arc::new(PassThrough), context, rx, 1024) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(SequenceAuthProvider::new([])), - token: "revoked-token".into(), - interval: Duration::from_secs(30), - on_permission_change: PermissionChangePolicy::default(), - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(socket.outgoing.iter().any(|message| matches!( - message, - WebSocketIoMessage::Close(Some(reason)) if reason == "credentials no longer valid" - ))); - } - - #[tokio::test(start_paused = true)] - async fn revalidation_success_refreshes_cached_user() { - let context = ctx(); - context.set_user(auth_user("stale")).await; - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - - WebSocketHandler::new(Arc::new(PassThrough), context.clone(), rx, 1024) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user("fresh"))])), - token: "valid-token".into(), - interval: Duration::from_secs(30), - on_permission_change: PermissionChangePolicy::default(), - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - // First tick refreshed the cached user; the second (sequence - // exhausted) failed and closed the connection. - assert_eq!(context.get_user().await.expect("user").user_id, "fresh"); - assert!( - socket - .outgoing - .iter() - .any(|message| matches!(message, WebSocketIoMessage::Close(_))) - ); - } - - fn auth_user_with(id: &str, perms: &[&str]) -> ras_auth_core::AuthenticatedUser { - let mut user = auth_user(id); - user.permissions = perms.iter().map(|p| p.to_string()).collect(); - user - } - - /// Authorizes any topic for connections holding `room:read`. - struct PermissionGated; - - #[async_trait] - impl MessageHandler for PermissionGated { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - - async fn authorize_subscribe( - &self, - _topic: &str, - context: &Arc, - ) -> ServerResult { - Ok(context.has_permission("room:read").await) - } - } - - fn subscribe_msg(topics: Vec) -> WebSocketIoMessage { - WebSocketIoMessage::Text( - serde_json::to_string(&BidirectionalMessage::Subscribe { topics }).unwrap(), - ) - } - - async fn manager_with(context: &ConnectionContext) -> Arc { - let manager: Arc = Arc::new(crate::DefaultConnectionManager::new()); - manager - .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( - context.id, - )) - .await - .unwrap(); - manager - } - - #[tokio::test(start_paused = true)] - async fn w1_revalidation_drops_subscriptions_no_longer_authorized() { - let context = ctx(); - context.set_user(auth_user_with("u", &["room:read"])).await; - let manager = manager_with(&context).await; - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - socket - .incoming - .push_back(subscribe_msg(vec!["room:1".into()])); - - // Tick 1 returns the same user with the permission revoked; tick 2 fails. - let provider = SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))]); - WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) - .with_connection_manager(manager.clone()) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(provider), - token: "t".into(), - interval: Duration::from_secs(30), - on_permission_change: PermissionChangePolicy::DropSubscriptions, - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(!context.is_subscribed_to("room:1").await); - assert!( - manager - .get_subscriptions(context.id) - .await - .unwrap() - .is_empty() - ); - assert!( - manager - .get_subscribed_connections("room:1") - .await - .unwrap() - .is_empty() - ); - } - - #[tokio::test] - async fn w1_subscribe_mirrors_into_manager_index() { - let manager: Arc = Arc::new(crate::DefaultConnectionManager::new()); - let context = ctx_with(crate::connection::SubscriptionPolicy { - manager: Some(manager.clone()), - ..Default::default() - }); - manager - .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( - context.id, - )) - .await - .unwrap(); - - context.subscribe("room:1".into()).await.unwrap(); - assert!(context.is_subscribed_to("room:1").await); - assert_eq!( - manager.get_subscriptions(context.id).await.unwrap(), - vec!["room:1".to_string()] - ); - - assert!(context.unsubscribe("room:1").await); - assert!( - manager - .get_subscriptions(context.id) - .await - .unwrap() - .is_empty() - ); - assert_eq!(context.subscription_policy().accounting.total(), 0); - } - - #[tokio::test(start_paused = true)] - async fn w1_close_policy_closes_socket_when_permissions_change() { - let context = ctx(); - context.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - - WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))])), - token: "t".into(), - interval: Duration::from_secs(30), - on_permission_change: PermissionChangePolicy::Close, - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - // Closed on the first tick (permission change), not the second (failure). - assert_eq!( - socket - .outgoing - .iter() - .filter(|m| matches!(m, WebSocketIoMessage::Close(_))) - .count(), - 1 - ); - assert!(context.get_user().await.unwrap().permissions.is_empty()); - } - - #[tokio::test] - async fn w3_subscribe_over_per_message_limit_is_rejected() { - let context = ctx_with(limits_policy(SubscriptionLimits { - max_topics_per_message: 2, - ..SubscriptionLimits::default() - })); - context.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx, rx) = mpsc::channel(4); - let topics: Vec = (0..3).map(|i| format!("room:{i}")).collect(); - let mut socket = InMemorySocket::closing([subscribe_msg(topics)]); - - WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(context.get_subscriptions().await.is_empty()); - assert!(bidirectional_outgoing(&socket).iter().any(|m| matches!( - m, - BidirectionalMessage::Response(r) if r.error.is_some() - ))); - } - - #[tokio::test] - async fn w3_subscribe_over_per_connection_limit_is_rejected() { - let context = ctx_with(limits_policy(SubscriptionLimits { - max_topics_per_connection: 1, - ..SubscriptionLimits::default() - })); - context.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::closing([ - subscribe_msg(vec!["room:1".into()]), - subscribe_msg(vec!["room:2".into()]), - ]); - - WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - // First subscribe accepted silently; second answered with an error. - let errors = bidirectional_outgoing(&socket) - .iter() - .filter(|m| matches!(m, BidirectionalMessage::Response(r) if r.error.is_some())) - .count(); - assert_eq!(errors, 1); - // Teardown released the held slot. - assert_eq!(context.subscription_policy().accounting.total(), 0); - assert!(context.get_subscriptions().await.is_empty()); - - // Direct path: the context itself refuses the second topic. - let direct = ctx_with(limits_policy(SubscriptionLimits { - max_topics_per_connection: 1, - ..SubscriptionLimits::default() - })); - direct.subscribe("room:1".into()).await.unwrap(); - assert!(matches!( - direct.subscribe("room:2".into()).await, - Err(ras_jsonrpc_bidirectional_types::BidirectionalError::SubscriptionLimitReached(_)) - )); - } - - #[tokio::test] - async fn w3_overlong_topic_is_rejected() { - let context = ctx(); - context.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::closing([subscribe_msg(vec!["x".repeat(300)])]); - - WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(context.get_subscriptions().await.is_empty()); - } - - #[tokio::test(start_paused = true)] - async fn w4_idle_connection_is_pinged_then_closed() { - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - - WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) - .with_keepalive(KeepaliveConfig { - ping_interval: Some(Duration::from_secs(5)), - idle_timeout: Some(Duration::from_secs(12)), - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - let pings = socket - .outgoing - .iter() - .filter(|m| matches!(m, WebSocketIoMessage::Ping(_))) - .count(); - assert_eq!(pings, 2, "pings at 5s and 10s before the 12s idle close"); - assert!(socket.outgoing.iter().any(|m| matches!( - m, - WebSocketIoMessage::Close(Some(reason)) if reason == "idle timeout" - ))); - } - - /// Like `PermissionGated`, but when it denies a topic during - /// re-authorization it first pushes a broadcast for that topic into the - /// connection's queue, simulating a `broadcast_to_topic` that snapshotted - /// the manager index in the window before the subscription was removed. - struct RacingAuthorizer; - - #[async_trait] - impl MessageHandler for RacingAuthorizer { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - - async fn authorize_subscribe( - &self, - topic: &str, - context: &Arc, - ) -> ServerResult { - if context.has_permission("room:read").await { - return Ok(true); - } - let stale = BidirectionalMessage::Broadcast( - ras_jsonrpc_bidirectional_types::BroadcastMessage { - topic: topic.to_string(), - method: "secret".into(), - params: serde_json::json!({}), - metadata: None, - }, - ); - context.sender.send_on_topic(topic, stale).await.unwrap(); - Ok(false) - } - } - - #[tokio::test(start_paused = true)] - async fn w1_broadcast_queued_during_revocation_window_is_not_delivered() { - let context = ctx(); - context.set_user(auth_user_with("u", &["room:read"])).await; - let manager = manager_with(&context).await; - let (tx, rx) = mpsc::channel(4); - // The handler's context must share this channel so the authorizer - // can enqueue through `context.sender`. - let context = Arc::new(ConnectionContext::new( - context.id, - ChannelMessageSender::new(context.id, tx), - )); - context.set_user(auth_user_with("u", &["room:read"])).await; - let mut socket = InMemorySocket::pending(); - socket - .incoming - .push_back(subscribe_msg(vec!["room:1".into()])); - - WebSocketHandler::new(Arc::new(RacingAuthorizer), context.clone(), rx, 1024) - .with_connection_manager(manager) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))])), - token: "t".into(), - interval: Duration::from_secs(30), - on_permission_change: PermissionChangePolicy::DropSubscriptions, - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(!context.is_subscribed_to("room:1").await); - let leaked = bidirectional_outgoing(&socket) - .iter() - .any(|m| matches!(m, BidirectionalMessage::Broadcast(b) if b.method == "secret")); - assert!( - !leaked, - "broadcast queued during the revocation window must be dropped" - ); - } - - #[tokio::test] - async fn w1_egress_gate_only_filters_topic_routed_messages() { - let context = ctx(); - let (tx, rx) = mpsc::channel(4); - let ping = BidirectionalMessage::Ping; - tx.send(OutboundMessage::from(ping.clone())).await.unwrap(); - tx.send(OutboundMessage { - message: ping, - topic: Some("room:never".into()), - }) - .await - .unwrap(); - drop(tx); - - let mut socket = InMemorySocket::pending(); - WebSocketHandler::new(Arc::new(PassThrough), context, rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - let pings = bidirectional_outgoing(&socket) - .iter() - .filter(|m| matches!(m, BidirectionalMessage::Ping)) - .count(); - assert_eq!( - pings, 1, - "untagged delivered, topic-tagged unsubscribed dropped" - ); - } - - #[tokio::test] - async fn w3_global_subscription_cap_is_enforced_across_connections() { - // Service-level accounting shared by both contexts. The first - // connection stays open (pending socket) so its slots remain held - // while the second connection tries to subscribe. - let limits = SubscriptionLimits { - max_total_subscriptions: 2, - ..SubscriptionLimits::default() - }; - let accounting = Arc::new(SubscriptionAccounting::default()); - let policy = crate::connection::SubscriptionPolicy { - limits, - accounting: accounting.clone(), - manager: None, - }; - - let first = ctx_with(policy.clone()); - first.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx1, rx1) = mpsc::channel(4); - let mut socket1 = InMemorySocket::pending(); - socket1 - .incoming - .push_back(subscribe_msg(vec!["a".into(), "b".into()])); - let first_run = { - let first = first.clone(); - tokio::spawn(async move { - WebSocketHandler::new(Arc::new(PermissionGated), first, rx1, 1024) - .with_keepalive(KeepaliveConfig { - ping_interval: None, - idle_timeout: None, - }) - .run_with_io(&mut socket1) - .await - }) - }; - tokio::time::timeout(Duration::from_secs(10), async { - while accounting.total() != 2 { - tokio::task::yield_now().await; - } - }) - .await - .expect("first connection should reserve its 2 slots"); - assert_eq!(first.get_subscriptions().await.len(), 2); - - let second = ctx_with(policy); - second.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx2, rx2) = mpsc::channel(4); - let mut socket2 = InMemorySocket::closing([subscribe_msg(vec!["c".into()])]); - WebSocketHandler::new(Arc::new(PermissionGated), second.clone(), rx2, 1024) - .run_with_io(&mut socket2) - .await - .unwrap(); - - assert!(second.get_subscriptions().await.is_empty()); - assert_eq!(accounting.total(), 2, "second connection reserved nothing"); - first_run.abort(); - } - - #[tokio::test(start_paused = true)] - async fn w4_zero_durations_do_not_panic() { - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - - // Zero ping and idle: both disabled; zero revalidation: default used. - // The sequence provider fails on its first tick, which closes the loop. - WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) - .with_keepalive(KeepaliveConfig { - ping_interval: Some(Duration::ZERO), - idle_timeout: Some(Duration::ZERO), - }) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(SequenceAuthProvider::new([])), - token: "t".into(), - interval: Duration::ZERO, - on_permission_change: PermissionChangePolicy::default(), - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!( - !socket - .outgoing - .iter() - .any(|m| matches!(m, WebSocketIoMessage::Ping(_))) - ); - assert!(socket.outgoing.iter().any(|m| matches!( - m, - WebSocketIoMessage::Close(Some(reason)) if reason == "credentials no longer valid" - ))); - } - - static GREEDY_ACCEPTED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - - /// A custom handler that subscribes to far more topics than any limit - /// allows, from `on_connect` as well as `handle_subscribe`, straight on - /// the context. - struct GreedyHandler; - - impl GreedyHandler { - async fn grab(context: &ConnectionContext, prefix: &str) { - for i in 0..10 { - if context.subscribe(format!("{prefix}:{i}")).await.is_ok() { - GREEDY_ACCEPTED.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - } - } - } - } - - #[async_trait] - impl MessageHandler for GreedyHandler { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - - async fn on_connect(&self, context: Arc) -> ServerResult<()> { - Self::grab(&context, "connect").await; - Ok(()) - } - - async fn handle_subscribe( - &self, - _topics: Vec, - context: Arc, - ) -> ServerResult<()> { - Self::grab(&context, "greedy").await; - Ok(()) - } - } - - #[tokio::test] - async fn w3_custom_handler_cannot_exceed_limits_from_any_callback() { - let limits = SubscriptionLimits { - max_topics_per_connection: 3, - ..SubscriptionLimits::default() - }; - let manager: Arc = Arc::new( - crate::DefaultConnectionManager::with_subscription_limits(limits), - ); - let accounting = Arc::new(SubscriptionAccounting::default()); - let context = ctx_with(crate::connection::SubscriptionPolicy { - limits, - accounting: accounting.clone(), - manager: Some(manager.clone()), - }); - manager - .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( - context.id, - )) - .await - .unwrap(); - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::closing([subscribe_msg(vec!["x".into()])]); - - WebSocketHandler::new(Arc::new(GreedyHandler), context.clone(), rx, 1024) - .with_connection_manager(manager.clone()) - .run_with_io(&mut socket) - .await - .unwrap(); - - // Greedy on_connect (10), handle_request-free, then greedy - // handle_subscribe (10 more): the context admitted three in total, - // the manager saw exactly those, and disconnect released exactly - // those, so the counter is back to zero, not underflowed. - assert_eq!( - context.get_subscriptions().await.len(), - 0, - "released on disconnect" - ); - assert_eq!( - manager.get_subscriptions(context.id).await.unwrap().len(), - 0 - ); - assert_eq!(accounting.total(), 0, "no underflow"); - assert_eq!(manager.total_subscription_count().await.unwrap(), 0); - assert_eq!( - GREEDY_ACCEPTED.load(std::sync::atomic::Ordering::SeqCst), - 3, - "exactly the cap accepted across on_connect and handle_subscribe" - ); - } - - #[tokio::test] - async fn handler_without_revalidation_does_not_authenticate() { - // No auth provider involved at all: the loop must terminate on - // socket close without ticking a revalidation timer. - let mut socket = InMemorySocket::closing([]); - 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); - } - - 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/handler/config.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/config.rs new file mode 100644 index 0000000..2ab82e8 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/config.rs @@ -0,0 +1,67 @@ +//! Revalidation and keepalive policy for a connection. + +use ras_auth_core::AuthProvider; +use std::sync::Arc; +use std::time::Duration; + +/// Default interval between credential re-validations on long-lived connections. +pub const DEFAULT_AUTH_REVALIDATION_INTERVAL: Duration = Duration::from_secs(30); + +/// Periodic credential re-validation for a long-lived connection. +/// +/// The token is captured before the WebSocket upgrade and re-run through the +/// auth provider on every `interval` tick. Failure closes the connection; +/// success refreshes the cached user (so permission changes propagate). This +/// bounds the lifetime of revoked/expired credentials on an open socket to at +/// most one interval. +pub struct AuthRevalidation { + /// Provider used to re-run authentication + pub auth_provider: Arc, + /// Token captured at upgrade time + pub token: String, + /// How often to re-validate + pub interval: Duration, + /// What to do when re-validation succeeds but the permission set changed + pub on_permission_change: PermissionChangePolicy, +} + +/// Policy applied when a live connection's permissions change on +/// re-validation (W1). +/// +/// In both modes every held subscription is re-run through +/// [`MessageHandler::authorize_subscribe`](super::MessageHandler::authorize_subscribe) against the refreshed user and +/// topics that are no longer authorized are dropped, so a downgraded +/// connection stops receiving topic broadcasts within one interval. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PermissionChangePolicy { + /// Keep the socket open and silently drop subscriptions that are no + /// longer authorized. + #[default] + DropSubscriptions, + /// Close the socket so the client must reconnect and re-authenticate. + Close, +} + +/// Server-side keepalive for a connection (W4). +/// +/// The server sends a WebSocket ping every `ping_interval`; browsers and +/// tungstenite answer pings automatically, and any inbound frame (including +/// the pong) resets the idle clock. A connection that stays silent for +/// `idle_timeout` is closed, so half-open sockets are reclaimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeepaliveConfig { + /// Interval between server-initiated pings (`None` disables pings) + pub ping_interval: Option, + /// Close the socket after this long without any inbound frame + /// (`None` disables the idle timeout) + pub idle_timeout: Option, +} + +impl Default for KeepaliveConfig { + fn default() -> Self { + Self { + ping_interval: Some(Duration::from_secs(30)), + idle_timeout: Some(Duration::from_secs(90)), + } + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/contract.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/contract.rs new file mode 100644 index 0000000..14d0f1c --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/contract.rs @@ -0,0 +1,107 @@ +//! Service callbacks and subscription authorization contract. + +use crate::{ConnectionContext, ServerResult}; +use async_trait::async_trait; +use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse}; +use std::sync::Arc; +use tracing::{debug, info, warn}; + +/// Trait for handling JSON-RPC requests within a WebSocket context +#[async_trait] +pub trait MessageHandler: Send + Sync + 'static { + /// Handle an incoming JSON-RPC request + /// + /// # Arguments + /// * `request` - The JSON-RPC request to handle + /// * `context` - The connection context containing auth info and metadata + /// + /// # Returns + /// * `Ok(Some(response))` - Response to send back to client + /// * `Ok(None)` - No response needed (for notifications) + /// * `Err(error)` - Error occurred during handling + async fn handle_request( + &self, + request: JsonRpcRequest, + context: Arc, + ) -> ServerResult>; + + /// Decide whether this connection may subscribe to `topic`. + /// + /// Default-deny: services that broadcast over topics must override this + /// (or `handle_subscribe`) to allow the topics a connection is entitled + /// to. Errors propagate to the handler loop and close the connection. + async fn authorize_subscribe( + &self, + _topic: &str, + _context: &Arc, + ) -> ServerResult { + Ok(false) + } + + /// Handle subscription requests + async fn handle_subscribe( + &self, + topics: Vec, + context: Arc, + ) -> ServerResult<()> { + // Default implementation subscribes the connection to each topic the + // service authorizes via `authorize_subscribe`; denied topics are + // skipped without closing the connection. + for topic in topics { + if self.authorize_subscribe(&topic, &context).await? { + if let Err(e) = context.subscribe(topic.clone()).await { + warn!( + "Refused subscription to topic '{}' for connection {}: {}", + topic, context.id, e + ); + } + } else { + warn!( + "Denied subscription to topic '{}' for connection {}", + topic, context.id + ); + } + } + Ok(()) + } + + /// Handle unsubscription requests + async fn handle_unsubscribe( + &self, + topics: Vec, + context: Arc, + ) -> ServerResult<()> { + for topic in topics { + context.unsubscribe(&topic).await; + } + Ok(()) + } + + /// Handle connection established event + async fn on_connect(&self, context: Arc) -> ServerResult<()> { + info!("Connection established: {}", context.id); + Ok(()) + } + + /// Handle connection closed event + async fn on_disconnect( + &self, + context: Arc, + reason: Option, + ) -> ServerResult<()> { + info!("Connection closed: {} (reason: {:?})", context.id, reason); + Ok(()) + } + + /// Handle ping message + async fn on_ping(&self, _context: Arc) -> ServerResult<()> { + debug!("Received ping"); + Ok(()) + } + + /// Handle pong message + async fn on_pong(&self, _context: Arc) -> ServerResult<()> { + debug!("Received pong"); + Ok(()) + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/io.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/io.rs new file mode 100644 index 0000000..86cb6ee --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/io.rs @@ -0,0 +1,74 @@ +//! Socket IO boundary and Axum adapter. + +use crate::{ServerError, ServerResult}; +use async_trait::async_trait; +use axum::extract::ws::{CloseFrame, Message, WebSocket}; +use futures::stream::StreamExt; + +/// 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())) + }) + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs new file mode 100644 index 0000000..b644697 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs @@ -0,0 +1,562 @@ +//! Message handlers for WebSocket communication + +use crate::{ConnectionContext, ServerError, ServerResult, connection::OutboundMessage}; +use axum::extract::ws::WebSocket; +use ras_jsonrpc_bidirectional_types::{BidirectionalMessage, ConnectionManager}; +use ras_jsonrpc_types::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, error_codes}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +mod config; +mod contract; +mod io; +pub use crate::subscriptions::{SubscriptionAccounting, SubscriptionLimits}; +pub use config::{ + AuthRevalidation, DEFAULT_AUTH_REVALIDATION_INTERVAL, KeepaliveConfig, PermissionChangePolicy, +}; +pub use contract::MessageHandler; +pub(crate) use io::AxumWebSocketIo; +pub use io::{WebSocketIo, WebSocketIoMessage}; + +/// WebSocket connection handler that manages the message flow +pub struct WebSocketHandler { + /// The message handler for processing requests + handler: Arc, + /// Connection context + context: Arc, + /// Channel for receiving messages to send to client + message_rx: mpsc::Receiver, + max_message_size: usize, + /// Optional periodic credential re-validation + auth_revalidation: Option, + /// Connection manager kept in step with the cached user on re-validation + /// (None when running without a manager, e.g. unit tests). Subscription + /// mirroring goes through the context's `SubscriptionPolicy`. + connection_manager: Option>, + keepalive: KeepaliveConfig, +} + +impl WebSocketHandler { + /// Create a new WebSocket handler + pub fn new( + handler: Arc, + context: Arc, + message_rx: mpsc::Receiver, + max_message_size: usize, + ) -> Self { + Self { + handler, + context, + message_rx, + max_message_size, + auth_revalidation: None, + connection_manager: None, + keepalive: KeepaliveConfig::default(), + } + } + + /// Enable periodic credential re-validation for this connection. + pub fn with_auth_revalidation(mut self, revalidation: AuthRevalidation) -> Self { + self.auth_revalidation = Some(revalidation); + self + } + + /// Keep the manager's cached user in step on re-validation. + pub fn with_connection_manager(mut self, manager: Arc) -> Self { + self.connection_manager = Some(manager); + self + } + + /// Override the default keepalive settings. + pub fn with_keepalive(mut self, keepalive: KeepaliveConfig) -> Self { + self.keepalive = keepalive; + self + } + + /// Re-run authentication and re-authorize every held subscription. + /// + /// Returns `Ok(true)` to keep the connection, `Ok(false)` to close it. + async fn revalidate_credentials(&mut self) -> ServerResult { + let revalidation = self + .auth_revalidation + .as_ref() + .expect("revalidation timer implies config"); + let user = match revalidation + .auth_provider + .authenticate(revalidation.token.clone()) + .await + { + Ok(user) => user, + Err(e) => { + warn!( + "Closing connection {}: credential re-validation failed: {}", + self.context.id, e + ); + return Ok(false); + } + }; + + let previous = self.context.get_user().await; + let permissions_changed = previous + .as_ref() + .map(|prev| prev.permissions != user.permissions || prev.user_id != user.user_id) + .unwrap_or(true); + + // Refresh cached identity/permissions in both stores + self.context.set_user(user.clone()).await; + if let Some(manager) = &self.connection_manager { + let _ = manager.set_connection_user(self.context.id, user).await; + } + + if permissions_changed && revalidation.on_permission_change == PermissionChangePolicy::Close + { + warn!( + "Closing connection {}: permissions changed on re-validation", + self.context.id + ); + return Ok(false); + } + + // Re-authorize held subscriptions against the refreshed user (W1) + for topic in self.context.get_subscriptions().await { + if !self + .handler + .authorize_subscribe(&topic, &self.context) + .await? + { + warn!( + "Dropping subscription to '{}' on connection {}: no longer authorized", + topic, self.context.id + ); + self.context.unsubscribe(&topic).await; + } + } + Ok(true) + } + + /// Fast-path validation of a subscribe/unsubscribe request so the client + /// gets an error response. The authoritative checks live in + /// `ConnectionContext::subscribe`. + fn check_subscription_limits(&self, topics: &[String]) -> Result<(), &'static str> { + let limits = &self.context.subscription_policy().limits; + if topics.len() > limits.max_topics_per_message { + return Err("too many topics in one message"); + } + if topics + .iter() + .any(|topic| topic.len() > limits.max_topic_length || topic.is_empty()) + { + return Err("topic name length out of range"); + } + Ok(()) + } + + /// Run the WebSocket handler loop + 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 + ); + + if let Err(e) = self.handler.on_connect(self.context.clone()).await { + error!("Error in on_connect handler: {}", e); + } + + let established_msg = BidirectionalMessage::ConnectionEstablished { + connection_id: self.context.id, + }; + if let Err(e) = socket + .send(WebSocketIoMessage::Text(serde_json::to_string( + &established_msg, + )?)) + .await + { + error!("Failed to send connection established message: {}", e); + } + + let mut revalidation_timer = self.auth_revalidation.as_ref().map(|revalidation| { + // tokio panics on a zero period; a zero interval is a config + // error, not a request to hammer the provider. Fall back to the + // default rather than disabling re-validation. + let interval = if revalidation.interval.is_zero() { + warn!( + "auth re-validation interval is zero; using default {:?}", + DEFAULT_AUTH_REVALIDATION_INTERVAL + ); + DEFAULT_AUTH_REVALIDATION_INTERVAL + } else { + revalidation.interval + }; + let mut timer = + tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + timer + }); + + let mut ping_timer = self + .keepalive + .ping_interval + .filter(|interval| { + if interval.is_zero() { + warn!("keepalive ping interval is zero; pings disabled"); + } + !interval.is_zero() + }) + .map(|interval| { + let mut timer = + tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + timer + }); + let idle_timeout = self.keepalive.idle_timeout.filter(|timeout| { + if timeout.is_zero() { + warn!("keepalive idle timeout is zero; idle timeout disabled"); + } + !timeout.is_zero() + }); + let idle_deadline = tokio::time::sleep(idle_timeout.unwrap_or(Duration::from_secs(0))); + tokio::pin!(idle_deadline); + + loop { + tokio::select! { + // Re-validate credentials so revoked/expired tokens are + // bounded to at most one interval on a long-lived connection + _ = async { revalidation_timer.as_mut().expect("guarded by is_some").tick().await }, + if revalidation_timer.is_some() => + { + match self.revalidate_credentials().await { + Ok(true) => {} + Ok(false) => { + let _ = socket + .send(WebSocketIoMessage::Close(Some( + "credentials no longer valid".to_string(), + ))) + .await; + break; + } + Err(e) => { + error!("Error re-authorizing subscriptions: {}", e); + break; + } + } + } + + // Server-initiated keepalive ping + _ = async { ping_timer.as_mut().expect("guarded by is_some").tick().await }, + if ping_timer.is_some() => + { + if let Err(e) = socket.send(WebSocketIoMessage::Ping(Vec::new())).await { + error!("Error sending keepalive ping: {}", e); + break; + } + } + + // Idle timeout: no inbound frame for the configured period + _ = &mut idle_deadline, if idle_timeout.is_some() => { + warn!( + "Closing connection {}: idle for {:?}", + self.context.id, + idle_timeout.expect("guarded") + ); + let _ = socket + .send(WebSocketIoMessage::Close(Some("idle timeout".to_string()))) + .await; + break; + } + + msg = socket.recv() => { + if let Some(timeout) = idle_timeout { + idle_deadline + .as_mut() + .reset(tokio::time::Instant::now() + timeout); + } + match msg { + Some(Ok(msg)) => { + if let Err(e) = self.handle_websocket_message(msg, socket).await { + error!("Error handling WebSocket message: {}", e); + break; + } + } + Some(Err(e)) => { + error!("WebSocket error: {}", e); + break; + } + None => { + debug!("WebSocket connection closed by client"); + break; + } + } + } + + msg = self.message_rx.recv() => { + match msg { + Some(OutboundMessage { message, topic }) => { + // Egress gate: a message routed on a topic while + // the subscription was still in the manager index + // is dropped here if the connection no longer + // holds it, closing the window between + // re-authorization and index removal. + if let Some(topic) = topic + && !self.context.is_subscribed_to(&topic).await + { + debug!( + "Dropping message on '{}' for connection {}: not subscribed", + topic, self.context.id + ); + continue; + } + if let Err(e) = self.send_message(socket, message).await { + error!("Error sending message: {}", e); + break; + } + } + None => { + debug!("Message channel closed"); + break; + } + } + } + } + } + + // Return this connection's subscription slots to the service pool + self.context.release_all_subscriptions().await; + + if let Err(e) = self.handler.on_disconnect(self.context.clone(), None).await { + error!("Error in on_disconnect handler: {}", e); + } + + let closed_msg = BidirectionalMessage::ConnectionClosed { + connection_id: self.context.id, + reason: None, + }; + let _ = socket + .send(WebSocketIoMessage::Text(serde_json::to_string( + &closed_msg, + )?)) + .await; + + info!( + "WebSocket handler finished for connection: {}", + self.context.id + ); + Ok(()) + } + + /// Handle incoming WebSocket messages + async fn handle_websocket_message( + &mut self, + msg: WebSocketIoMessage, + socket: &mut S, + ) -> ServerResult<()> { + match msg { + WebSocketIoMessage::Text(text) => { + if text.len() > self.max_message_size { + warn!("Received oversized text message: {} bytes", text.len()); + return Err(ServerError::InvalidRequest( + "Message exceeds maximum size".to_string(), + )); + } + debug!("Received text message ({} bytes)", text.len()); + self.handle_text_message(text, socket).await + } + WebSocketIoMessage::Binary(data) => { + if data.len() > self.max_message_size { + warn!("Received oversized binary message: {} bytes", data.len()); + return Err(ServerError::InvalidRequest( + "Message exceeds maximum size".to_string(), + )); + } + debug!("Received binary message ({} bytes)", data.len()); + match String::from_utf8(data) { + Ok(text) => self.handle_text_message(text, socket).await, + Err(_) => { + warn!("Received non-UTF-8 binary message, ignoring"); + Ok(()) + } + } + } + WebSocketIoMessage::Ping(data) => { + debug!("Received ping"); + socket.send(WebSocketIoMessage::Pong(data)).await?; + self.handler.on_ping(self.context.clone()).await + } + WebSocketIoMessage::Pong(_) => { + debug!("Received pong"); + self.handler.on_pong(self.context.clone()).await + } + WebSocketIoMessage::Close(reason) => { + debug!("Received close frame: {:?}", reason); + self.handler + .on_disconnect(self.context.clone(), reason.clone()) + .await?; + Err(ServerError::WebSocketError("Connection closed".to_string())) + } + } + } + + /// Handle text messages (JSON-RPC or bidirectional messages) + async fn handle_text_message( + &mut self, + text: String, + socket: &mut S, + ) -> ServerResult<()> { + if let Ok(msg) = serde_json::from_str::(&text) { + return self.handle_bidirectional_message(msg, socket).await; + } + + if let Ok(request) = serde_json::from_str::(&text) { + return self.handle_jsonrpc_request(request, socket).await; + } + + // Neither shape parsed. Per JSON-RPC 2.0, answer with a Parse Error + // (-32700, id null) and keep the connection open; only transport + // failures terminate the handler loop. + warn!( + "Could not parse message as JSON-RPC or bidirectional message on connection {}", + self.context.id + ); + let response = JsonRpcResponse::error(JsonRpcError::parse_error(), None); + self.send_message(socket, BidirectionalMessage::Response(response)) + .await + } + + /// Handle bidirectional messages + async fn handle_bidirectional_message( + &mut self, + msg: BidirectionalMessage, + _socket: &mut S, + ) -> ServerResult<()> { + match msg { + BidirectionalMessage::Request(request) => { + self.handle_jsonrpc_request(request, _socket).await + } + BidirectionalMessage::Subscribe { topics } => { + let before = self.context.get_subscriptions().await; + let new = topics.iter().filter(|t| !before.contains(t)).count(); + let policy = self.context.subscription_policy(); + let mut limit_error = self.check_subscription_limits(&topics).err().or_else(|| { + (before.len() + new > policy.limits.max_topics_per_connection) + .then_some("subscription limit for this connection reached") + }); + if limit_error.is_none() + && policy.limits.max_total_subscriptions > 0 + && policy.accounting.total() + new > policy.limits.max_total_subscriptions + { + limit_error = Some("global subscription limit reached"); + } + if let Some(reason) = limit_error { + warn!( + "Rejected subscribe on connection {}: {}", + self.context.id, reason + ); + let response = JsonRpcResponse::error( + JsonRpcError::invalid_params(reason.to_string()), + None, + ); + return self + .send_message(_socket, BidirectionalMessage::Response(response)) + .await; + } + self.handler + .handle_subscribe(topics, self.context.clone()) + .await + } + BidirectionalMessage::Unsubscribe { topics } => { + if let Err(reason) = self.check_subscription_limits(&topics) { + warn!( + "Rejected unsubscribe on connection {}: {}", + self.context.id, reason + ); + return Ok(()); + } + self.handler + .handle_unsubscribe(topics, self.context.clone()) + .await + } + BidirectionalMessage::Ping => self.handler.on_ping(self.context.clone()).await, + BidirectionalMessage::Pong => self.handler.on_pong(self.context.clone()).await, + // Other message types are typically server-to-client + _ => { + warn!("Received unexpected bidirectional message type from client"); + Ok(()) + } + } + } + + /// Handle JSON-RPC requests + async fn handle_jsonrpc_request( + &mut self, + request: JsonRpcRequest, + socket: &mut S, + ) -> ServerResult<()> { + debug!("Handling JSON-RPC request: {}", request.method); + let request_id = request.id.clone(); + + match self + .handler + .handle_request(request, self.context.clone()) + .await + { + Ok(Some(response)) => { + let response_msg = BidirectionalMessage::Response(response); + self.send_message(socket, response_msg).await + } + Ok(None) => { + // No response needed (notification) + Ok(()) + } + Err(e) => { + error!("Error handling request: {}", 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( + &self, + socket: &mut S, + msg: BidirectionalMessage, + ) -> ServerResult<()> { + let json = serde_json::to_string(&msg)?; + 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, + }; + + // Send only a generic per-class message; the full error was already logged + // server-side by the caller. Never interpolate handler/AuthError Display. + JsonRpcError::new(code, error.client_message().to_string(), None) +} + +#[cfg(test)] +mod tests; diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/keepalive.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/keepalive.rs new file mode 100644 index 0000000..3542dfe --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/keepalive.rs @@ -0,0 +1,61 @@ +use super::*; + +#[tokio::test(start_paused = true)] +async fn w4_idle_connection_is_pinged_then_closed() { + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) + .with_keepalive(KeepaliveConfig { + ping_interval: Some(Duration::from_secs(5)), + idle_timeout: Some(Duration::from_secs(12)), + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + let pings = socket + .outgoing + .iter() + .filter(|m| matches!(m, WebSocketIoMessage::Ping(_))) + .count(); + assert_eq!(pings, 2, "pings at 5s and 10s before the 12s idle close"); + assert!(socket.outgoing.iter().any(|m| matches!( + m, + WebSocketIoMessage::Close(Some(reason)) if reason == "idle timeout" + ))); +} + +#[tokio::test(start_paused = true)] +async fn w4_zero_durations_do_not_panic() { + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + + // Zero ping and idle: both disabled; zero revalidation: default used. + // The sequence provider fails on its first tick, which closes the loop. + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) + .with_keepalive(KeepaliveConfig { + ping_interval: Some(Duration::ZERO), + idle_timeout: Some(Duration::ZERO), + }) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(SequenceAuthProvider::new([])), + token: "t".into(), + interval: Duration::ZERO, + on_permission_change: PermissionChangePolicy::default(), + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!( + !socket + .outgoing + .iter() + .any(|m| matches!(m, WebSocketIoMessage::Ping(_))) + ); + assert!(socket.outgoing.iter().any(|m| matches!( + m, + WebSocketIoMessage::Close(Some(reason)) if reason == "credentials no longer valid" + ))); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/lifecycle.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/lifecycle.rs new file mode 100644 index 0000000..14a2676 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/lifecycle.rs @@ -0,0 +1,140 @@ +use super::*; + +#[tokio::test] +async fn default_lifecycle_methods_succeed() { + let h = PassThrough; + let c = ctx(); + h.on_connect(c.clone()).await.unwrap(); + h.on_ping(c.clone()).await.unwrap(); + h.on_pong(c.clone()).await.unwrap(); + h.on_disconnect(c.clone(), Some("bye".into())) + .await + .unwrap(); + // 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_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(OutboundMessage::from(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_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())) + ); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs new file mode 100644 index 0000000..773e3ae --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs @@ -0,0 +1,367 @@ +use super::*; +use crate::connection::ChannelMessageSender; +use async_trait::async_trait; +use ras_auth_core::AuthProvider; +use ras_jsonrpc_bidirectional_types::ConnectionId; +use ras_jsonrpc_types::JsonRpcResponse; +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 +/// these tests are verifying. +struct PassThrough; + +#[async_trait] +impl MessageHandler for PassThrough { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } +} + +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); + let sender = ChannelMessageSender::new(id, tx); + Arc::new(ConnectionContext::new(id, sender)) +} + +fn ctx_with(policy: crate::connection::SubscriptionPolicy) -> Arc { + let id = ConnectionId::new(); + let (tx, _rx) = mpsc::channel(4); + let sender = ChannelMessageSender::new(id, tx); + Arc::new(ConnectionContext::new(id, sender).with_subscription_policy(policy)) +} + +fn limits_policy(limits: SubscriptionLimits) -> crate::connection::SubscriptionPolicy { + crate::connection::SubscriptionPolicy { + limits, + ..Default::default() + } +} + +struct AllowListHandler; + +#[async_trait] +impl MessageHandler for AllowListHandler { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } + + async fn authorize_subscribe( + &self, + topic: &str, + _context: &Arc, + ) -> ServerResult { + Ok(topic == "room:allowed") + } +} + +fn auth_user(id: &str) -> ras_auth_core::AuthenticatedUser { + ras_auth_core::AuthenticatedUser { + user_id: id.to_string(), + permissions: std::collections::HashSet::new(), + metadata: None, + } +} + +/// Auth provider that replays a fixed sequence of results, then fails. +struct SequenceAuthProvider( + Mutex>>, +); + +impl SequenceAuthProvider { + fn new( + results: impl IntoIterator< + Item = Result, + >, + ) -> Self { + Self(Mutex::new(results.into_iter().collect())) + } +} + +impl AuthProvider for SequenceAuthProvider { + fn authenticate(&self, _token: String) -> ras_auth_core::AuthFuture<'_> { + let result = self + .0 + .lock() + .expect("results lock") + .pop_front() + .unwrap_or(Err(ras_auth_core::AuthError::InvalidToken)); + Box::pin(async move { result }) + } +} + +fn auth_user_with(id: &str, perms: &[&str]) -> ras_auth_core::AuthenticatedUser { + let mut user = auth_user(id); + user.permissions = perms.iter().map(|p| p.to_string()).collect(); + user +} + +/// Authorizes any topic for connections holding `room:read`. +struct PermissionGated; + +#[async_trait] +impl MessageHandler for PermissionGated { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } + + async fn authorize_subscribe( + &self, + _topic: &str, + context: &Arc, + ) -> ServerResult { + Ok(context.has_permission("room:read").await) + } +} + +fn subscribe_msg(topics: Vec) -> WebSocketIoMessage { + WebSocketIoMessage::Text( + serde_json::to_string(&BidirectionalMessage::Subscribe { topics }).unwrap(), + ) +} + +async fn manager_with(context: &ConnectionContext) -> Arc { + let manager: Arc = Arc::new(crate::DefaultConnectionManager::new()); + manager + .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( + context.id, + )) + .await + .unwrap(); + manager +} + +/// Like `PermissionGated`, but when it denies a topic during +/// re-authorization it first pushes a broadcast for that topic into the +/// connection's queue, simulating a `broadcast_to_topic` that snapshotted +/// the manager index in the window before the subscription was removed. +struct RacingAuthorizer; + +#[async_trait] +impl MessageHandler for RacingAuthorizer { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } + + async fn authorize_subscribe( + &self, + topic: &str, + context: &Arc, + ) -> ServerResult { + if context.has_permission("room:read").await { + return Ok(true); + } + let stale = + BidirectionalMessage::Broadcast(ras_jsonrpc_bidirectional_types::BroadcastMessage { + topic: topic.to_string(), + method: "secret".into(), + params: serde_json::json!({}), + metadata: None, + }); + context.sender.send_on_topic(topic, stale).await.unwrap(); + Ok(false) + } +} + +static GREEDY_ACCEPTED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// A custom handler that subscribes to far more topics than any limit +/// allows, from `on_connect` as well as `handle_subscribe`, straight on +/// the context. +struct GreedyHandler; + +impl GreedyHandler { + async fn grab(context: &ConnectionContext, prefix: &str) { + for i in 0..10 { + if context.subscribe(format!("{prefix}:{i}")).await.is_ok() { + GREEDY_ACCEPTED.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + } +} + +#[async_trait] +impl MessageHandler for GreedyHandler { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } + + async fn on_connect(&self, context: Arc) -> ServerResult<()> { + Self::grab(&context, "connect").await; + Ok(()) + } + + async fn handle_subscribe( + &self, + _topics: Vec, + context: Arc, + ) -> ServerResult<()> { + Self::grab(&context, "greedy").await; + Ok(()) + } +} + +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() +} +mod keepalive; +mod lifecycle; +mod protocol; +mod revalidation; +mod subscriptions; diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/protocol.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/protocol.rs new file mode 100644 index 0000000..3400a2e --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/protocol.rs @@ -0,0 +1,182 @@ +use super::*; + +#[test] +fn jsonrpc_error_from_server_error_sends_generic_message_not_handler_detail() { + // Handler error carrying a secret -> client sees only a generic message, + // stable code preserved, no data field. + let err = ServerError::Internal("database password is hunter2".into()); + let jsonrpc = jsonrpc_error_from_server_error(&err); + assert_eq!(jsonrpc.code, error_codes::INTERNAL_ERROR); + assert_eq!(jsonrpc.message, "Internal error"); + assert!(!jsonrpc.message.contains("hunter2")); + assert!(jsonrpc.data.is_none()); + + // AuthError detail must not reach the client either. + let auth = ServerError::AuthenticationFailed(ras_auth_core::AuthError::Internal( + "dsn=postgres://user:pw@host/db".into(), + )); + let jsonrpc = jsonrpc_error_from_server_error(&auth); + assert_eq!(jsonrpc.code, error_codes::AUTHENTICATION_REQUIRED); + assert_eq!(jsonrpc.message, "Authentication failed"); + assert!(!jsonrpc.message.contains("dsn")); + + // Stable codes for the invalid-request / method-not-found classes. + assert_eq!( + jsonrpc_error_from_server_error(&ServerError::InvalidRequest("Invalid params: x".into())) + .code, + error_codes::INVALID_REQUEST + ); + assert_eq!( + jsonrpc_error_from_server_error(&ServerError::HandlerNotFound("m".into())).code, + error_codes::METHOD_NOT_FOUND + ); +} + +#[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); + // Message is the generic per-class string; the handler's detail + // ("bad request") stays server-side. + assert_eq!(error.message, "Invalid 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_answers_malformed_text_with_parse_error_and_continues() { + let request = JsonRpcRequest::new( + "echo".into(), + Some(serde_json::json!({})), + Some(serde_json::json!(9)), + ); + let mut socket = InMemorySocket::closing([ + WebSocketIoMessage::Text("not json-rpc".to_string()), + WebSocketIoMessage::Text( + serde_json::to_string(&BidirectionalMessage::Request(request)).unwrap(), + ), + ]); + 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 { .. } + )); + + // The garbage frame is answered with -32700 (id null)... + let parse_error = match &messages[1] { + BidirectionalMessage::Response(response) => response, + other => panic!("expected parse error response, got {other:?}"), + }; + assert_eq!(parse_error.id, None); + let error = parse_error.error.as_ref().expect("parse error"); + assert_eq!(error.code, ras_jsonrpc_types::error_codes::PARSE_ERROR); + + // ...and the connection keeps serving subsequent requests. + let response = match &messages[2] { + BidirectionalMessage::Response(response) => response, + other => panic!("expected response, got {other:?}"), + }; + assert_eq!(response.id, Some(serde_json::json!(9))); + + assert!(matches!( + messages[3], + 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 { .. } + )); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/revalidation.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/revalidation.rs new file mode 100644 index 0000000..b1ec68f --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/revalidation.rs @@ -0,0 +1,243 @@ +use super::*; + +#[tokio::test(start_paused = true)] +async fn revalidation_failure_closes_connection() { + let context = ctx(); + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + + WebSocketHandler::new(Arc::new(PassThrough), context, rx, 1024) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(SequenceAuthProvider::new([])), + token: "revoked-token".into(), + interval: Duration::from_secs(30), + on_permission_change: PermissionChangePolicy::default(), + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(socket.outgoing.iter().any(|message| matches!( + message, + WebSocketIoMessage::Close(Some(reason)) if reason == "credentials no longer valid" + ))); +} + +#[tokio::test(start_paused = true)] +async fn revalidation_success_refreshes_cached_user() { + let context = ctx(); + context.set_user(auth_user("stale")).await; + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + + WebSocketHandler::new(Arc::new(PassThrough), context.clone(), rx, 1024) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user("fresh"))])), + token: "valid-token".into(), + interval: Duration::from_secs(30), + on_permission_change: PermissionChangePolicy::default(), + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + // First tick refreshed the cached user; the second (sequence + // exhausted) failed and closed the connection. + assert_eq!(context.get_user().await.expect("user").user_id, "fresh"); + assert!( + socket + .outgoing + .iter() + .any(|message| matches!(message, WebSocketIoMessage::Close(_))) + ); +} + +#[tokio::test(start_paused = true)] +async fn w1_revalidation_drops_subscriptions_no_longer_authorized() { + let context = ctx(); + context.set_user(auth_user_with("u", &["room:read"])).await; + let manager = manager_with(&context).await; + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + socket + .incoming + .push_back(subscribe_msg(vec!["room:1".into()])); + + // Tick 1 returns the same user with the permission revoked; tick 2 fails. + let provider = SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))]); + WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) + .with_connection_manager(manager.clone()) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(provider), + token: "t".into(), + interval: Duration::from_secs(30), + on_permission_change: PermissionChangePolicy::DropSubscriptions, + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(!context.is_subscribed_to("room:1").await); + assert!( + manager + .get_subscriptions(context.id) + .await + .unwrap() + .is_empty() + ); + assert!( + manager + .get_subscribed_connections("room:1") + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn w1_subscribe_mirrors_into_manager_index() { + let manager: Arc = Arc::new(crate::DefaultConnectionManager::new()); + let context = ctx_with(crate::connection::SubscriptionPolicy { + manager: Some(manager.clone()), + ..Default::default() + }); + manager + .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( + context.id, + )) + .await + .unwrap(); + + context.subscribe("room:1".into()).await.unwrap(); + assert!(context.is_subscribed_to("room:1").await); + assert_eq!( + manager.get_subscriptions(context.id).await.unwrap(), + vec!["room:1".to_string()] + ); + + assert!(context.unsubscribe("room:1").await); + assert!( + manager + .get_subscriptions(context.id) + .await + .unwrap() + .is_empty() + ); + assert_eq!(context.subscription_policy().accounting.total(), 0); +} + +#[tokio::test(start_paused = true)] +async fn w1_close_policy_closes_socket_when_permissions_change() { + let context = ctx(); + context.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + + WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))])), + token: "t".into(), + interval: Duration::from_secs(30), + on_permission_change: PermissionChangePolicy::Close, + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + // Closed on the first tick (permission change), not the second (failure). + assert_eq!( + socket + .outgoing + .iter() + .filter(|m| matches!(m, WebSocketIoMessage::Close(_))) + .count(), + 1 + ); + assert!(context.get_user().await.unwrap().permissions.is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn w1_broadcast_queued_during_revocation_window_is_not_delivered() { + let context = ctx(); + context.set_user(auth_user_with("u", &["room:read"])).await; + let manager = manager_with(&context).await; + let (tx, rx) = mpsc::channel(4); + // The handler's context must share this channel so the authorizer + // can enqueue through `context.sender`. + let context = Arc::new(ConnectionContext::new( + context.id, + ChannelMessageSender::new(context.id, tx), + )); + context.set_user(auth_user_with("u", &["room:read"])).await; + let mut socket = InMemorySocket::pending(); + socket + .incoming + .push_back(subscribe_msg(vec!["room:1".into()])); + + WebSocketHandler::new(Arc::new(RacingAuthorizer), context.clone(), rx, 1024) + .with_connection_manager(manager) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))])), + token: "t".into(), + interval: Duration::from_secs(30), + on_permission_change: PermissionChangePolicy::DropSubscriptions, + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(!context.is_subscribed_to("room:1").await); + let leaked = bidirectional_outgoing(&socket) + .iter() + .any(|m| matches!(m, BidirectionalMessage::Broadcast(b) if b.method == "secret")); + assert!( + !leaked, + "broadcast queued during the revocation window must be dropped" + ); +} + +#[tokio::test] +async fn w1_egress_gate_only_filters_topic_routed_messages() { + let context = ctx(); + let (tx, rx) = mpsc::channel(4); + let ping = BidirectionalMessage::Ping; + tx.send(OutboundMessage::from(ping.clone())).await.unwrap(); + tx.send(OutboundMessage { + message: ping, + topic: Some("room:never".into()), + }) + .await + .unwrap(); + drop(tx); + + let mut socket = InMemorySocket::pending(); + WebSocketHandler::new(Arc::new(PassThrough), context, rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let pings = bidirectional_outgoing(&socket) + .iter() + .filter(|m| matches!(m, BidirectionalMessage::Ping)) + .count(); + assert_eq!( + pings, 1, + "untagged delivered, topic-tagged unsubscribed dropped" + ); +} + +#[tokio::test] +async fn handler_without_revalidation_does_not_authenticate() { + // No auth provider involved at all: the loop must terminate on + // socket close without ticking a revalidation timer. + let mut socket = InMemorySocket::closing([]); + 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); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/subscriptions.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/subscriptions.rs new file mode 100644 index 0000000..8408304 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/subscriptions.rs @@ -0,0 +1,231 @@ +use super::*; + +#[tokio::test] +async fn default_handle_subscribe_denies_all_topics() { + let h = PassThrough; + let c = ctx(); + h.handle_subscribe(vec!["a".into(), "b".into()], c.clone()) + .await + .unwrap(); + assert!(!c.is_subscribed_to("a").await); + assert!(!c.is_subscribed_to("b").await); +} + +#[tokio::test] +async fn default_authorize_subscribe_denies() { + let h = PassThrough; + let c = ctx(); + assert!(!h.authorize_subscribe("any-topic", &c).await.unwrap()); +} + +#[tokio::test] +async fn handle_subscribe_only_subscribes_authorized_topics() { + let h = AllowListHandler; + let c = ctx(); + h.handle_subscribe(vec!["room:allowed".into(), "room:denied".into()], c.clone()) + .await + .unwrap(); + assert!(c.is_subscribed_to("room:allowed").await); + assert!(!c.is_subscribed_to("room:denied").await); +} + +#[tokio::test] +async fn default_handle_unsubscribe_removes_from_context() { + let h = PassThrough; + let c = ctx(); + c.subscribe("a".into()).await.unwrap(); + c.subscribe("b".into()).await.unwrap(); + h.handle_unsubscribe(vec!["a".into()], c.clone()) + .await + .unwrap(); + assert!(!c.is_subscribed_to("a").await); + assert!(c.is_subscribed_to("b").await); +} + +#[tokio::test] +async fn w3_subscribe_over_per_message_limit_is_rejected() { + let context = ctx_with(limits_policy(SubscriptionLimits { + max_topics_per_message: 2, + ..SubscriptionLimits::default() + })); + context.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx, rx) = mpsc::channel(4); + let topics: Vec = (0..3).map(|i| format!("room:{i}")).collect(); + let mut socket = InMemorySocket::closing([subscribe_msg(topics)]); + + WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(context.get_subscriptions().await.is_empty()); + assert!(bidirectional_outgoing(&socket).iter().any(|m| matches!( + m, + BidirectionalMessage::Response(r) if r.error.is_some() + ))); +} + +#[tokio::test] +async fn w3_subscribe_over_per_connection_limit_is_rejected() { + let context = ctx_with(limits_policy(SubscriptionLimits { + max_topics_per_connection: 1, + ..SubscriptionLimits::default() + })); + context.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::closing([ + subscribe_msg(vec!["room:1".into()]), + subscribe_msg(vec!["room:2".into()]), + ]); + + WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + // First subscribe accepted silently; second answered with an error. + let errors = bidirectional_outgoing(&socket) + .iter() + .filter(|m| matches!(m, BidirectionalMessage::Response(r) if r.error.is_some())) + .count(); + assert_eq!(errors, 1); + // Teardown released the held slot. + assert_eq!(context.subscription_policy().accounting.total(), 0); + assert!(context.get_subscriptions().await.is_empty()); + + // Direct path: the context itself refuses the second topic. + let direct = ctx_with(limits_policy(SubscriptionLimits { + max_topics_per_connection: 1, + ..SubscriptionLimits::default() + })); + direct.subscribe("room:1".into()).await.unwrap(); + assert!(matches!( + direct.subscribe("room:2".into()).await, + Err(ras_jsonrpc_bidirectional_types::BidirectionalError::SubscriptionLimitReached(_)) + )); +} + +#[tokio::test] +async fn w3_overlong_topic_is_rejected() { + let context = ctx(); + context.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::closing([subscribe_msg(vec!["x".repeat(300)])]); + + WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(context.get_subscriptions().await.is_empty()); +} + +#[tokio::test] +async fn w3_global_subscription_cap_is_enforced_across_connections() { + // Service-level accounting shared by both contexts. The first + // connection stays open (pending socket) so its slots remain held + // while the second connection tries to subscribe. + let limits = SubscriptionLimits { + max_total_subscriptions: 2, + ..SubscriptionLimits::default() + }; + let accounting = Arc::new(SubscriptionAccounting::default()); + let policy = crate::connection::SubscriptionPolicy { + limits, + accounting: accounting.clone(), + manager: None, + }; + + let first = ctx_with(policy.clone()); + first.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx1, rx1) = mpsc::channel(4); + let mut socket1 = InMemorySocket::pending(); + socket1 + .incoming + .push_back(subscribe_msg(vec!["a".into(), "b".into()])); + let first_run = { + let first = first.clone(); + tokio::spawn(async move { + WebSocketHandler::new(Arc::new(PermissionGated), first, rx1, 1024) + .with_keepalive(KeepaliveConfig { + ping_interval: None, + idle_timeout: None, + }) + .run_with_io(&mut socket1) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(10), async { + while accounting.total() != 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("first connection should reserve its 2 slots"); + assert_eq!(first.get_subscriptions().await.len(), 2); + + let second = ctx_with(policy); + second.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx2, rx2) = mpsc::channel(4); + let mut socket2 = InMemorySocket::closing([subscribe_msg(vec!["c".into()])]); + WebSocketHandler::new(Arc::new(PermissionGated), second.clone(), rx2, 1024) + .run_with_io(&mut socket2) + .await + .unwrap(); + + assert!(second.get_subscriptions().await.is_empty()); + assert_eq!(accounting.total(), 2, "second connection reserved nothing"); + first_run.abort(); +} + +#[tokio::test] +async fn w3_custom_handler_cannot_exceed_limits_from_any_callback() { + let limits = SubscriptionLimits { + max_topics_per_connection: 3, + ..SubscriptionLimits::default() + }; + let manager: Arc = Arc::new( + crate::DefaultConnectionManager::with_subscription_limits(limits), + ); + let accounting = Arc::new(SubscriptionAccounting::default()); + let context = ctx_with(crate::connection::SubscriptionPolicy { + limits, + accounting: accounting.clone(), + manager: Some(manager.clone()), + }); + manager + .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( + context.id, + )) + .await + .unwrap(); + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::closing([subscribe_msg(vec!["x".into()])]); + + WebSocketHandler::new(Arc::new(GreedyHandler), context.clone(), rx, 1024) + .with_connection_manager(manager.clone()) + .run_with_io(&mut socket) + .await + .unwrap(); + + // Greedy on_connect (10), handle_request-free, then greedy + // handle_subscribe (10 more): the context admitted three in total, + // the manager saw exactly those, and disconnect released exactly + // those, so the counter is back to zero, not underflowed. + assert_eq!( + context.get_subscriptions().await.len(), + 0, + "released on disconnect" + ); + assert_eq!( + manager.get_subscriptions(context.id).await.unwrap().len(), + 0 + ); + assert_eq!(accounting.total(), 0, "no underflow"); + assert_eq!(manager.total_subscription_count().await.unwrap(), 0); + assert_eq!( + GREEDY_ACCEPTED.load(std::sync::atomic::Ordering::SeqCst), + 3, + "exactly the cap accepted across on_connect and handle_subscribe" + ); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/lib.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/lib.rs index f8aa494..262084e 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/lib.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/lib.rs @@ -9,6 +9,7 @@ pub mod handler; pub mod manager; pub mod router; pub mod service; +mod subscriptions; pub mod upgrade; pub use connection::{ diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/subscriptions.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/subscriptions.rs new file mode 100644 index 0000000..123905f --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/subscriptions.rs @@ -0,0 +1,87 @@ +//! Subscription limits, service-wide accounting, and policy configuration. + +use ras_jsonrpc_bidirectional_types::ConnectionManager; +use std::sync::Arc; + +/// Limits on client-initiated subscriptions (W3). +/// +/// Enforced by the handler loop before [`MessageHandler::handle_subscribe`](crate::handler::MessageHandler::handle_subscribe) +/// runs, so services never see an over-limit request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SubscriptionLimits { + /// Maximum topics in one `Subscribe`/`Unsubscribe` message + pub max_topics_per_message: usize, + /// Maximum concurrent subscriptions held by one connection + pub max_topics_per_connection: usize, + /// Maximum topic name length in bytes + pub max_topic_length: usize, + /// Maximum (connection, topic) pairs across the whole manager. `0` + /// disables the cap. Enforced only when the manager reports its count + /// (`ConnectionManager::total_subscription_count`); the default manager + /// does. + pub max_total_subscriptions: usize, +} + +impl Default for SubscriptionLimits { + fn default() -> Self { + Self { + max_topics_per_message: 64, + max_topics_per_connection: 256, + max_topic_length: 256, + max_total_subscriptions: 100_000, + } + } +} + +/// Service-wide count of held subscriptions, shared by every connection of a +/// service so the global cap is enforced by the server itself, independently +/// of which `ConnectionManager` or `MessageHandler` is plugged in. +#[derive(Debug, Default)] +pub struct SubscriptionAccounting { + total: std::sync::atomic::AtomicUsize, +} + +impl SubscriptionAccounting { + /// Current number of (connection, topic) pairs held across the service. + pub fn total(&self) -> usize { + self.total.load(std::sync::atomic::Ordering::Acquire) + } + + /// Atomically reserve one slot; `false` when `max` (non-zero) is reached. + pub(crate) fn reserve(&self, max: usize) -> bool { + use std::sync::atomic::Ordering; + let previous = self.total.fetch_add(1, Ordering::AcqRel); + if max > 0 && previous >= max { + self.total.fetch_sub(1, Ordering::AcqRel); + return false; + } + true + } + + pub(crate) fn release(&self, count: usize) { + self.total + .fetch_sub(count, std::sync::atomic::Ordering::AcqRel); + } +} + +/// Everything a subscription mutation has to be checked against and mirrored +/// into. Owned by the service and shared by all of its connections. +#[derive(Clone, Default)] +pub struct SubscriptionPolicy { + /// Caps applied to every subscribe + pub limits: SubscriptionLimits, + /// Service-wide counter behind the global cap + pub accounting: Arc, + /// Manager whose topic index mirrors accepted subscriptions + pub manager: Option>, +} + +impl std::fmt::Debug for SubscriptionPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubscriptionPolicy") + .field("limits", &self.limits) + .field("held", &self.accounting.total()) + .field("manager", &self.manager.is_some()) + .finish() + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/connection.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/connection.rs new file mode 100644 index 0000000..960f7cf --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/connection.rs @@ -0,0 +1,103 @@ +use ras_auth_core::AuthenticatedUser; +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, fmt, sync::Arc}; +use uuid::Uuid; + +/// Unique identifier for a WebSocket connection +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ConnectionId(Uuid); + +impl ConnectionId { + /// Create a new random connection ID + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + /// Create a connection ID from a UUID + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + /// Get the inner UUID + pub fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl Default for ConnectionId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for ConnectionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Information about a connected client +#[derive(Debug, Clone)] +pub struct ConnectionInfo { + /// Unique connection identifier + pub id: ConnectionId, + /// Authenticated user information (if authenticated) + pub user: Option>, + /// Topics this connection is subscribed to + pub subscriptions: HashSet, + /// Connection metadata (e.g., user agent, IP address) + pub metadata: serde_json::Value, + /// When the connection was established + pub connected_at: chrono::DateTime, +} + +impl ConnectionInfo { + /// Create a new connection info + pub fn new(id: ConnectionId) -> Self { + Self { + id, + user: None, + subscriptions: HashSet::new(), + metadata: serde_json::Value::Object(serde_json::Map::new()), + connected_at: chrono::Utc::now(), + } + } + + /// Check if the connection is authenticated + pub fn is_authenticated(&self) -> bool { + self.user.is_some() + } + + /// Check if the connection has a specific permission + pub fn has_permission(&self, permission: &str) -> bool { + self.user + .as_ref() + .map(|u| u.permissions.contains(permission)) + .unwrap_or(false) + } + + /// Check if the connection is subscribed to a topic + pub fn is_subscribed_to(&self, topic: &str) -> bool { + self.subscriptions.contains(topic) + } + + /// Add a subscription + pub fn subscribe(&mut self, topic: String) { + self.subscriptions.insert(topic); + } + + /// Remove a subscription + pub fn unsubscribe(&mut self, topic: &str) -> bool { + self.subscriptions.remove(topic) + } + + /// Set authenticated user + pub fn set_user(&mut self, user: AuthenticatedUser) { + self.user = Some(Arc::new(user)); + } + + /// Clear authenticated user + pub fn clear_user(&mut self) { + self.user = None; + } +} 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 5478ebe..c1d6e61 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/lib.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/lib.rs @@ -4,14 +4,6 @@ //! JSON-RPC communication, including connection management, message routing, //! and subscription handling. -use ras_auth_core::AuthenticatedUser; -use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse}; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::fmt; -use std::sync::Arc; -use uuid::Uuid; - pub mod error; pub mod manager; pub mod sender; @@ -22,225 +14,13 @@ pub use manager::ConnectionManager; 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)] -pub struct ConnectionId(Uuid); - -impl ConnectionId { - /// Create a new random connection ID - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - - /// Create a connection ID from a UUID - pub fn from_uuid(uuid: Uuid) -> Self { - Self(uuid) - } - - /// Get the inner UUID - pub fn as_uuid(&self) -> &Uuid { - &self.0 - } -} - -impl Default for ConnectionId { - fn default() -> Self { - Self::new() - } -} - -impl fmt::Display for ConnectionId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -/// Messages that can be sent bidirectionally between client and server -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum BidirectionalMessage { - /// JSON-RPC request from either client or server - Request(JsonRpcRequest), - /// JSON-RPC response from either client or server - Response(JsonRpcResponse), - /// Server-initiated notification - ServerNotification(ServerNotification), - /// Broadcast message from server to multiple clients - Broadcast(BroadcastMessage), - /// Subscription management - Subscribe { - topics: Vec, - }, - Unsubscribe { - topics: Vec, - }, - /// Connection lifecycle - ConnectionEstablished { - connection_id: ConnectionId, - }, - ConnectionClosed { - connection_id: ConnectionId, - reason: Option, - }, - /// Heartbeat/keepalive - Ping, - Pong, -} - -/// Server-initiated messages (not including broadcasts) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerMessage { - /// The connection to send to - pub connection_id: ConnectionId, - /// The message to send - pub message: BidirectionalMessage, -} - -/// Server-initiated notification to specific client(s) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerNotification { - /// Notification method name - pub method: String, - /// Notification parameters - pub params: serde_json::Value, - /// Optional metadata - pub metadata: Option, -} - -/// Broadcast message from server to multiple clients -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BroadcastMessage { - /// Topic/channel for the broadcast - pub topic: String, - /// Broadcast method name - pub method: String, - /// Broadcast parameters - pub params: serde_json::Value, - /// Optional metadata - pub metadata: Option, -} - -/// Information about a connected client -#[derive(Debug, Clone)] -pub struct ConnectionInfo { - /// Unique connection identifier - pub id: ConnectionId, - /// Authenticated user information (if authenticated) - pub user: Option>, - /// Topics this connection is subscribed to - pub subscriptions: HashSet, - /// Connection metadata (e.g., user agent, IP address) - pub metadata: serde_json::Value, - /// When the connection was established - pub connected_at: chrono::DateTime, -} - -impl ConnectionInfo { - /// Create a new connection info - pub fn new(id: ConnectionId) -> Self { - Self { - id, - user: None, - subscriptions: HashSet::new(), - metadata: serde_json::Value::Object(serde_json::Map::new()), - connected_at: chrono::Utc::now(), - } - } - - /// Check if the connection is authenticated - pub fn is_authenticated(&self) -> bool { - self.user.is_some() - } - - /// Check if the connection has a specific permission - pub fn has_permission(&self, permission: &str) -> bool { - self.user - .as_ref() - .map(|u| u.permissions.contains(permission)) - .unwrap_or(false) - } - - /// Check if the connection is subscribed to a topic - pub fn is_subscribed_to(&self, topic: &str) -> bool { - self.subscriptions.contains(topic) - } - - /// Add a subscription - pub fn subscribe(&mut self, topic: String) { - self.subscriptions.insert(topic); - } - - /// Remove a subscription - pub fn unsubscribe(&mut self, topic: &str) -> bool { - self.subscriptions.remove(topic) - } - - /// Set authenticated user - pub fn set_user(&mut self, user: AuthenticatedUser) { - self.user = Some(Arc::new(user)); - } - - /// Clear authenticated user - pub fn clear_user(&mut self) { - self.user = None; - } -} +mod connection; +mod wire; +pub use connection::{ConnectionId, ConnectionInfo}; +pub use wire::{BidirectionalMessage, BroadcastMessage, ServerMessage, ServerNotification}; /// Result type for bidirectional operations pub type Result = std::result::Result; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_connection_id() { - let id1 = ConnectionId::new(); - let id2 = ConnectionId::new(); - assert_ne!(id1, id2); - - let uuid = Uuid::new_v4(); - let id3 = ConnectionId::from_uuid(uuid); - assert_eq!(id3.as_uuid(), &uuid); - } - - #[test] - fn test_connection_info() { - let mut info = ConnectionInfo::new(ConnectionId::new()); - assert!(!info.is_authenticated()); - assert!(!info.has_permission("admin")); - - // Test subscriptions - info.subscribe("topic1".to_string()); - info.subscribe("topic2".to_string()); - assert!(info.is_subscribed_to("topic1")); - assert!(info.is_subscribed_to("topic2")); - assert!(!info.is_subscribed_to("topic3")); - - assert!(info.unsubscribe("topic1")); - assert!(!info.is_subscribed_to("topic1")); - assert!(!info.unsubscribe("topic1")); // Already unsubscribed - } - - #[test] - fn test_message_serialization() { - let msg = BidirectionalMessage::Ping; - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains("\"type\":\"ping\"")); - - let notification = ServerNotification { - method: "test.notify".to_string(), - params: serde_json::json!({"data": "test"}), - metadata: None, - }; - let msg = BidirectionalMessage::ServerNotification(notification); - let json = serde_json::to_string(&msg).unwrap(); - let deserialized: BidirectionalMessage = serde_json::from_str(&json).unwrap(); - - if let BidirectionalMessage::ServerNotification(notif) = deserialized { - assert_eq!(notif.method, "test.notify"); - } else { - panic!("Expected ServerNotification"); - } - } -} +mod tests; diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender.rs deleted file mode 100644 index 39287c8..0000000 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender.rs +++ /dev/null @@ -1,380 +0,0 @@ -//! Message sender trait for bidirectional JSON-RPC - -#[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 -#[async_trait] -pub trait MessageSender: Send + Sync { - /// Send a message to a WebSocket connection - async fn send_message(&self, message: BidirectionalMessage) -> Result<()>; - - /// Close the connection - async fn close(&self) -> Result<()>; - - /// Check if the connection is still open - async fn is_connected(&self) -> bool; - - /// Get the connection ID - fn connection_id(&self) -> ConnectionId; -} - -/// A message sender implementation using tokio-tungstenite -#[cfg(not(target_arch = "wasm32"))] -pub struct WebSocketMessageSender -where - S: SinkExt + Send + Unpin, -{ - connection_id: ConnectionId, - sink: Arc>, - is_closed: Arc>, -} - -#[cfg(not(target_arch = "wasm32"))] -impl WebSocketMessageSender -where - S: SinkExt + Send + Unpin, - S::Error: std::error::Error + Send + Sync + 'static, -{ - /// Create a new WebSocket message sender - pub fn new(connection_id: ConnectionId, sink: S) -> Self { - Self { - connection_id, - sink: Arc::new(Mutex::new(sink)), - is_closed: Arc::new(Mutex::new(false)), - } - } -} - -#[cfg(not(target_arch = "wasm32"))] -#[async_trait] -impl MessageSender for WebSocketMessageSender -where - S: SinkExt + Send + Unpin, - S::Error: std::error::Error + Send + Sync + 'static, -{ - async fn send_message(&self, message: BidirectionalMessage) -> Result<()> { - if self.is_connected().await { - let json = serde_json::to_string(&message)?; - let ws_message = WsMessage::Text(json.into()); - - let mut sink = self.sink.lock().await; - sink.send(ws_message) - .await - .map_err(|e| BidirectionalError::SendError(e.to_string()))?; - - Ok(()) - } else { - Err(BidirectionalError::ConnectionClosed) - } - } - - async fn close(&self) -> Result<()> { - let mut is_closed = self.is_closed.lock().await; - if !*is_closed { - *is_closed = true; - - let mut sink = self.sink.lock().await; - sink.send(WsMessage::Close(None)) - .await - .map_err(|e| BidirectionalError::SendError(e.to_string()))?; - } - Ok(()) - } - - async fn is_connected(&self) -> bool { - !*self.is_closed.lock().await - } - - fn connection_id(&self) -> ConnectionId { - self.connection_id - } -} - -/// Extension trait for message senders with convenience methods -#[async_trait] -pub trait MessageSenderExt: MessageSender { - /// Send a JSON-RPC request - async fn send_request(&self, request: ras_jsonrpc_types::JsonRpcRequest) -> Result<()> { - self.send_message(BidirectionalMessage::Request(request)) - .await - } - - /// Send a JSON-RPC response - async fn send_response(&self, response: ras_jsonrpc_types::JsonRpcResponse) -> Result<()> { - self.send_message(BidirectionalMessage::Response(response)) - .await - } - - /// Send a server notification - async fn send_notification(&self, method: &str, params: serde_json::Value) -> Result<()> { - let notification = crate::ServerNotification { - method: method.to_string(), - params, - metadata: None, - }; - self.send_message(BidirectionalMessage::ServerNotification(notification)) - .await - } - - /// Send a ping message - async fn send_ping(&self) -> Result<()> { - self.send_message(BidirectionalMessage::Ping).await - } - - /// Send a pong message - async fn send_pong(&self) -> Result<()> { - self.send_message(BidirectionalMessage::Pong).await - } - - /// Send a subscription confirmation - async fn send_subscription_update(&self, topics: Vec, subscribed: bool) -> Result<()> { - let message = if subscribed { - BidirectionalMessage::Subscribe { topics } - } else { - BidirectionalMessage::Unsubscribe { topics } - }; - self.send_message(message).await - } -} - -// Blanket implementation for all MessageSender types -impl MessageSenderExt for T {} - -/// A no-operation message sender that does nothing -pub struct NoOpMessageSender { - connection_id: ConnectionId, -} - -impl NoOpMessageSender { - /// Create a new no-op message sender - pub fn new() -> Self { - Self { - connection_id: ConnectionId::new(), - } - } - - /// Create a new no-op message sender with a specific connection ID - pub fn with_connection_id(connection_id: ConnectionId) -> Self { - Self { connection_id } - } -} - -impl Default for NoOpMessageSender { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl MessageSender for NoOpMessageSender { - async fn send_message(&self, _message: BidirectionalMessage) -> Result<()> { - // No-op senders acknowledge messages without producing side effects. - Ok(()) - } - - async fn close(&self) -> Result<()> { - // Closing a no-op sender has no external state to update. - Ok(()) - } - - async fn is_connected(&self) -> bool { - // Always report as connected for testing purposes - true - } - - fn connection_id(&self) -> ConnectionId { - self.connection_id - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - use tokio::sync::Mutex; - - #[tokio::test] - async fn test_message_sender_ext() { - // Create a mock sender - struct MockSender { - connection_id: ConnectionId, - sent_messages: Arc>>, - } - - #[async_trait] - impl MessageSender for MockSender { - async fn send_message(&self, message: BidirectionalMessage) -> Result<()> { - self.sent_messages.lock().await.push(message); - Ok(()) - } - - async fn close(&self) -> Result<()> { - Ok(()) - } - - async fn is_connected(&self) -> bool { - true - } - - fn connection_id(&self) -> ConnectionId { - self.connection_id - } - } - - let sender = MockSender { - connection_id: ConnectionId::new(), - sent_messages: Arc::new(Mutex::new(Vec::new())), - }; - - // Test convenience methods - sender.send_ping().await.unwrap(); - sender.send_pong().await.unwrap(); - sender - .send_notification("test.method", serde_json::json!({"key": "value"})) - .await - .unwrap(); - - let messages = sender.sent_messages.lock().await; - assert_eq!(messages.len(), 3); - - // Check message types - assert!(matches!(messages[0], BidirectionalMessage::Ping)); - assert!(matches!(messages[1], BidirectionalMessage::Pong)); - assert!(matches!( - &messages[2], - BidirectionalMessage::ServerNotification(n) if n.method == "test.method" - )); - } - - #[tokio::test] - async fn message_sender_ext_request_response_subscription() { - struct Recorder { - id: ConnectionId, - sent: Arc>>, - } - #[async_trait] - impl MessageSender for Recorder { - async fn send_message(&self, message: BidirectionalMessage) -> Result<()> { - self.sent.lock().await.push(message); - Ok(()) - } - async fn close(&self) -> Result<()> { - Ok(()) - } - async fn is_connected(&self) -> bool { - true - } - fn connection_id(&self) -> ConnectionId { - self.id - } - } - let r = Recorder { - id: ConnectionId::new(), - sent: Arc::new(Mutex::new(Vec::new())), - }; - - r.send_request(ras_jsonrpc_types::JsonRpcRequest { - jsonrpc: "2.0".into(), - method: "m".into(), - params: None, - id: Some(serde_json::json!(1)), - }) - .await - .unwrap(); - r.send_response(ras_jsonrpc_types::JsonRpcResponse::success( - serde_json::json!("ok"), - Some(serde_json::json!(1)), - )) - .await - .unwrap(); - r.send_subscription_update(vec!["t1".into()], true) - .await - .unwrap(); - r.send_subscription_update(vec!["t1".into()], false) - .await - .unwrap(); - - let s = r.sent.lock().await; - assert!(matches!(s[0], BidirectionalMessage::Request(_))); - assert!(matches!(s[1], BidirectionalMessage::Response(_))); - assert!(matches!(s[2], BidirectionalMessage::Subscribe { .. })); - assert!(matches!(s[3], BidirectionalMessage::Unsubscribe { .. })); - } - - #[tokio::test] - async fn noop_message_sender_round_trip() { - let id = ConnectionId::new(); - let sender = NoOpMessageSender::with_connection_id(id); - assert_eq!(sender.connection_id(), id); - assert!(sender.is_connected().await); - sender - .send_message(BidirectionalMessage::Ping) - .await - .unwrap(); - sender.close().await.unwrap(); - - // Default constructor + Default impl. - let s2 = NoOpMessageSender::new(); - let s3 = NoOpMessageSender::default(); - 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; - use futures::stream::StreamExt; - - // mpsc::channel's Sender impls Sink, satisfying the SinkExt bound - // on `WebSocketMessageSender::new`. - let (tx, mut rx) = mpsc::channel::(8); - let id = ConnectionId::new(); - let sender = WebSocketMessageSender::new(id, tx); - - assert_eq!(sender.connection_id(), id); - assert!(sender.is_connected().await); - - sender - .send_message(BidirectionalMessage::Ping) - .await - .unwrap(); - // close once → emits a Close frame and flips is_closed. - sender.close().await.unwrap(); - assert!(!sender.is_connected().await); - // close again is idempotent (no panic, no extra send). - sender.close().await.unwrap(); - - // Sending after close yields ConnectionClosed. - let err = sender - .send_message(BidirectionalMessage::Pong) - .await - .unwrap_err(); - assert!(matches!(err, BidirectionalError::ConnectionClosed)); - - // Drain what we actually pushed: a Text(Ping) and a Close. - let mut received: Vec = Vec::new(); - while let Some(m) = rx.next().await { - received.push(m); - if received.len() == 2 { - break; - } - } - assert_eq!(received.len(), 2); - match &received[0] { - WsMessage::Text(t) => assert!(t.contains("ping")), - other => panic!("expected Text(ping), got {other:?}"), - } - assert!(matches!(received[1], WsMessage::Close(_))); - } -} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/mod.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/mod.rs new file mode 100644 index 0000000..f31d7cd --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/mod.rs @@ -0,0 +1,78 @@ +//! Message sender contracts for bidirectional JSON-RPC. +use crate::{BidirectionalMessage, ConnectionId, Result}; +use async_trait::async_trait; +mod noop; +pub use noop::NoOpMessageSender; +#[cfg(not(target_arch = "wasm32"))] +mod websocket; +#[cfg(not(target_arch = "wasm32"))] +pub use websocket::WebSocketMessageSender; + +/// Trait for sending messages over WebSocket connections +#[async_trait] +pub trait MessageSender: Send + Sync { + /// Send a message to a WebSocket connection + async fn send_message(&self, message: BidirectionalMessage) -> Result<()>; + + /// Close the connection + async fn close(&self) -> Result<()>; + + /// Check if the connection is still open + async fn is_connected(&self) -> bool; + + /// Get the connection ID + fn connection_id(&self) -> ConnectionId; +} + +/// Extension trait for message senders with convenience methods +#[async_trait] +pub trait MessageSenderExt: MessageSender { + /// Send a JSON-RPC request + async fn send_request(&self, request: ras_jsonrpc_types::JsonRpcRequest) -> Result<()> { + self.send_message(BidirectionalMessage::Request(request)) + .await + } + + /// Send a JSON-RPC response + async fn send_response(&self, response: ras_jsonrpc_types::JsonRpcResponse) -> Result<()> { + self.send_message(BidirectionalMessage::Response(response)) + .await + } + + /// Send a server notification + async fn send_notification(&self, method: &str, params: serde_json::Value) -> Result<()> { + let notification = crate::ServerNotification { + method: method.to_string(), + params, + metadata: None, + }; + self.send_message(BidirectionalMessage::ServerNotification(notification)) + .await + } + + /// Send a ping message + async fn send_ping(&self) -> Result<()> { + self.send_message(BidirectionalMessage::Ping).await + } + + /// Send a pong message + async fn send_pong(&self) -> Result<()> { + self.send_message(BidirectionalMessage::Pong).await + } + + /// Send a subscription confirmation + async fn send_subscription_update(&self, topics: Vec, subscribed: bool) -> Result<()> { + let message = if subscribed { + BidirectionalMessage::Subscribe { topics } + } else { + BidirectionalMessage::Unsubscribe { topics } + }; + self.send_message(message).await + } +} + +// Blanket implementation for all MessageSender types +impl MessageSenderExt for T {} + +#[cfg(test)] +mod tests; diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/noop.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/noop.rs new file mode 100644 index 0000000..3e20b55 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/noop.rs @@ -0,0 +1,50 @@ +use super::MessageSender; +use crate::{BidirectionalMessage, ConnectionId, Result}; +use async_trait::async_trait; + +/// A no-operation message sender that does nothing +pub struct NoOpMessageSender { + connection_id: ConnectionId, +} + +impl NoOpMessageSender { + /// Create a new no-op message sender + pub fn new() -> Self { + Self { + connection_id: ConnectionId::new(), + } + } + + /// Create a new no-op message sender with a specific connection ID + pub fn with_connection_id(connection_id: ConnectionId) -> Self { + Self { connection_id } + } +} + +impl Default for NoOpMessageSender { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl MessageSender for NoOpMessageSender { + async fn send_message(&self, _message: BidirectionalMessage) -> Result<()> { + // No-op senders acknowledge messages without producing side effects. + Ok(()) + } + + async fn close(&self) -> Result<()> { + // Closing a no-op sender has no external state to update. + Ok(()) + } + + async fn is_connected(&self) -> bool { + // Always report as connected for testing purposes + true + } + + fn connection_id(&self) -> ConnectionId { + self.connection_id + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/tests.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/tests.rs new file mode 100644 index 0000000..4b9fe17 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/tests.rs @@ -0,0 +1,181 @@ +use super::*; +#[cfg(not(target_arch = "wasm32"))] +use crate::BidirectionalError; +use std::sync::Arc; +use tokio::sync::Mutex; +#[cfg(not(target_arch = "wasm32"))] +use tokio_tungstenite::tungstenite::Message as WsMessage; + +#[tokio::test] +async fn test_message_sender_ext() { + // Create a mock sender + struct MockSender { + connection_id: ConnectionId, + sent_messages: Arc>>, + } + + #[async_trait] + impl MessageSender for MockSender { + async fn send_message(&self, message: BidirectionalMessage) -> Result<()> { + self.sent_messages.lock().await.push(message); + Ok(()) + } + + async fn close(&self) -> Result<()> { + Ok(()) + } + + async fn is_connected(&self) -> bool { + true + } + + fn connection_id(&self) -> ConnectionId { + self.connection_id + } + } + + let sender = MockSender { + connection_id: ConnectionId::new(), + sent_messages: Arc::new(Mutex::new(Vec::new())), + }; + + // Test convenience methods + sender.send_ping().await.unwrap(); + sender.send_pong().await.unwrap(); + sender + .send_notification("test.method", serde_json::json!({"key": "value"})) + .await + .unwrap(); + + let messages = sender.sent_messages.lock().await; + assert_eq!(messages.len(), 3); + + // Check message types + assert!(matches!(messages[0], BidirectionalMessage::Ping)); + assert!(matches!(messages[1], BidirectionalMessage::Pong)); + assert!(matches!( + &messages[2], + BidirectionalMessage::ServerNotification(n) if n.method == "test.method" + )); +} + +#[tokio::test] +async fn message_sender_ext_request_response_subscription() { + struct Recorder { + id: ConnectionId, + sent: Arc>>, + } + #[async_trait] + impl MessageSender for Recorder { + async fn send_message(&self, message: BidirectionalMessage) -> Result<()> { + self.sent.lock().await.push(message); + Ok(()) + } + async fn close(&self) -> Result<()> { + Ok(()) + } + async fn is_connected(&self) -> bool { + true + } + fn connection_id(&self) -> ConnectionId { + self.id + } + } + let r = Recorder { + id: ConnectionId::new(), + sent: Arc::new(Mutex::new(Vec::new())), + }; + + r.send_request(ras_jsonrpc_types::JsonRpcRequest { + jsonrpc: "2.0".into(), + method: "m".into(), + params: None, + id: Some(serde_json::json!(1)), + }) + .await + .unwrap(); + r.send_response(ras_jsonrpc_types::JsonRpcResponse::success( + serde_json::json!("ok"), + Some(serde_json::json!(1)), + )) + .await + .unwrap(); + r.send_subscription_update(vec!["t1".into()], true) + .await + .unwrap(); + r.send_subscription_update(vec!["t1".into()], false) + .await + .unwrap(); + + let s = r.sent.lock().await; + assert!(matches!(s[0], BidirectionalMessage::Request(_))); + assert!(matches!(s[1], BidirectionalMessage::Response(_))); + assert!(matches!(s[2], BidirectionalMessage::Subscribe { .. })); + assert!(matches!(s[3], BidirectionalMessage::Unsubscribe { .. })); +} + +#[tokio::test] +async fn noop_message_sender_round_trip() { + let id = ConnectionId::new(); + let sender = NoOpMessageSender::with_connection_id(id); + assert_eq!(sender.connection_id(), id); + assert!(sender.is_connected().await); + sender + .send_message(BidirectionalMessage::Ping) + .await + .unwrap(); + sender.close().await.unwrap(); + + // Default constructor + Default impl. + let s2 = NoOpMessageSender::new(); + let s3 = NoOpMessageSender::default(); + 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; + use futures::stream::StreamExt; + + // mpsc::channel's Sender impls Sink, satisfying the SinkExt bound + // on `WebSocketMessageSender::new`. + let (tx, mut rx) = mpsc::channel::(8); + let id = ConnectionId::new(); + let sender = WebSocketMessageSender::new(id, tx); + + assert_eq!(sender.connection_id(), id); + assert!(sender.is_connected().await); + + sender + .send_message(BidirectionalMessage::Ping) + .await + .unwrap(); + // close once → emits a Close frame and flips is_closed. + sender.close().await.unwrap(); + assert!(!sender.is_connected().await); + // close again is idempotent (no panic, no extra send). + sender.close().await.unwrap(); + + // Sending after close yields ConnectionClosed. + let err = sender + .send_message(BidirectionalMessage::Pong) + .await + .unwrap_err(); + assert!(matches!(err, BidirectionalError::ConnectionClosed)); + + // Drain what we actually pushed: a Text(Ping) and a Close. + let mut received: Vec = Vec::new(); + while let Some(m) = rx.next().await { + received.push(m); + if received.len() == 2 { + break; + } + } + assert_eq!(received.len(), 2); + match &received[0] { + WsMessage::Text(t) => assert!(t.contains("ping")), + other => panic!("expected Text(ping), got {other:?}"), + } + assert!(matches!(received[1], WsMessage::Close(_))); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/websocket.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/websocket.rs new file mode 100644 index 0000000..f47b443 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/sender/websocket.rs @@ -0,0 +1,79 @@ +use super::MessageSender; +use crate::{BidirectionalError, BidirectionalMessage, ConnectionId, Result}; +use async_trait::async_trait; +use futures::sink::SinkExt; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +/// A message sender implementation using tokio-tungstenite +#[cfg(not(target_arch = "wasm32"))] +pub struct WebSocketMessageSender +where + S: SinkExt + Send + Unpin, +{ + connection_id: ConnectionId, + sink: Arc>, + is_closed: Arc>, +} + +#[cfg(not(target_arch = "wasm32"))] +impl WebSocketMessageSender +where + S: SinkExt + Send + Unpin, + S::Error: std::error::Error + Send + Sync + 'static, +{ + /// Create a new WebSocket message sender + pub fn new(connection_id: ConnectionId, sink: S) -> Self { + Self { + connection_id, + sink: Arc::new(Mutex::new(sink)), + is_closed: Arc::new(Mutex::new(false)), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[async_trait] +impl MessageSender for WebSocketMessageSender +where + S: SinkExt + Send + Unpin, + S::Error: std::error::Error + Send + Sync + 'static, +{ + async fn send_message(&self, message: BidirectionalMessage) -> Result<()> { + if self.is_connected().await { + let json = serde_json::to_string(&message)?; + let ws_message = WsMessage::Text(json.into()); + + let mut sink = self.sink.lock().await; + sink.send(ws_message) + .await + .map_err(|e| BidirectionalError::SendError(e.to_string()))?; + + Ok(()) + } else { + Err(BidirectionalError::ConnectionClosed) + } + } + + async fn close(&self) -> Result<()> { + let mut is_closed = self.is_closed.lock().await; + if !*is_closed { + *is_closed = true; + + let mut sink = self.sink.lock().await; + sink.send(WsMessage::Close(None)) + .await + .map_err(|e| BidirectionalError::SendError(e.to_string()))?; + } + Ok(()) + } + + async fn is_connected(&self) -> bool { + !*self.is_closed.lock().await + } + + fn connection_id(&self) -> ConnectionId { + self.connection_id + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/tests.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/tests.rs new file mode 100644 index 0000000..6b3dae4 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/tests.rs @@ -0,0 +1,53 @@ +use super::*; +use uuid::Uuid; + +#[test] +fn test_connection_id() { + let id1 = ConnectionId::new(); + let id2 = ConnectionId::new(); + assert_ne!(id1, id2); + + let uuid = Uuid::new_v4(); + let id3 = ConnectionId::from_uuid(uuid); + assert_eq!(id3.as_uuid(), &uuid); +} + +#[test] +fn test_connection_info() { + let mut info = ConnectionInfo::new(ConnectionId::new()); + assert!(!info.is_authenticated()); + assert!(!info.has_permission("admin")); + + // Test subscriptions + info.subscribe("topic1".to_string()); + info.subscribe("topic2".to_string()); + assert!(info.is_subscribed_to("topic1")); + assert!(info.is_subscribed_to("topic2")); + assert!(!info.is_subscribed_to("topic3")); + + assert!(info.unsubscribe("topic1")); + assert!(!info.is_subscribed_to("topic1")); + assert!(!info.unsubscribe("topic1")); // Already unsubscribed +} + +#[test] +fn test_message_serialization() { + let msg = BidirectionalMessage::Ping; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"ping\"")); + + let notification = ServerNotification { + method: "test.notify".to_string(), + params: serde_json::json!({"data": "test"}), + metadata: None, + }; + let msg = BidirectionalMessage::ServerNotification(notification); + let json = serde_json::to_string(&msg).unwrap(); + let deserialized: BidirectionalMessage = serde_json::from_str(&json).unwrap(); + + if let BidirectionalMessage::ServerNotification(notif) = deserialized { + assert_eq!(notif.method, "test.notify"); + } else { + panic!("Expected ServerNotification"); + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/wire.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/wire.rs new file mode 100644 index 0000000..fc7d0fb --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/src/wire.rs @@ -0,0 +1,68 @@ +use crate::ConnectionId; +use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse}; +use serde::{Deserialize, Serialize}; + +/// Messages that can be sent bidirectionally between client and server +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum BidirectionalMessage { + /// JSON-RPC request from either client or server + Request(JsonRpcRequest), + /// JSON-RPC response from either client or server + Response(JsonRpcResponse), + /// Server-initiated notification + ServerNotification(ServerNotification), + /// Broadcast message from server to multiple clients + Broadcast(BroadcastMessage), + /// Subscription management + Subscribe { + topics: Vec, + }, + Unsubscribe { + topics: Vec, + }, + /// Connection lifecycle + ConnectionEstablished { + connection_id: ConnectionId, + }, + ConnectionClosed { + connection_id: ConnectionId, + reason: Option, + }, + /// Heartbeat/keepalive + Ping, + Pong, +} + +/// Server-initiated messages (not including broadcasts) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerMessage { + /// The connection to send to + pub connection_id: ConnectionId, + /// The message to send + pub message: BidirectionalMessage, +} + +/// Server-initiated notification to specific client(s) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerNotification { + /// Notification method name + pub method: String, + /// Notification parameters + pub params: serde_json::Value, + /// Optional metadata + pub metadata: Option, +} + +/// Broadcast message from server to multiple clients +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BroadcastMessage { + /// Topic/channel for the broadcast + pub topic: String, + /// Broadcast method name + pub method: String, + /// Broadcast parameters + pub params: serde_json::Value, + /// Optional metadata + pub metadata: Option, +} diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index b903829..c29ac87 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -20,6 +20,7 @@ reqwest = ["client", "ras-transport-core/reqwest"] permissions = [] [dependencies] +ras-api-explorer-assets = { path = "../../specs/ras-api-explorer-assets", version = "0.1.0" } syn = { workspace = true } quote = { workspace = true } proc-macro2 = { workspace = true } diff --git a/crates/rpc/ras-jsonrpc-macro/src/ast.rs b/crates/rpc/ras-jsonrpc-macro/src/ast.rs new file mode 100644 index 0000000..53e21da --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/ast.rs @@ -0,0 +1,89 @@ +//! Parsed JSON-RPC service contract shared by parser and emitters. + +use syn::{Ident, Type}; + +#[derive(Debug)] +pub(crate) struct ServiceDefinition { + pub(crate) service_name: Ident, + pub(crate) openrpc: Option, + pub(crate) explorer: Option, + pub(crate) feature_gated: bool, + /// Require an `application/json` request `Content-Type`. Defaults to `true`. + /// Set `require_json_content_type: false` to accept any content type. + pub(crate) require_json_content_type: bool, + /// Maximum request body size in bytes. Defaults to 2 MiB (axum's default). + pub(crate) body_limit: Option, + /// Gate the explorer page and `openrpc.json` behind authentication (any + /// authenticated user). Defaults to `false` — the explorer is public when + /// enabled, matching conventional API-explorer behavior. + pub(crate) docs_require_auth: bool, + pub(crate) methods: Vec, +} + +/// Default maximum JSON body size in bytes (matches axum's default). +pub(crate) const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; + +#[derive(Debug)] +pub(crate) enum OpenRpcConfig { + Enabled, + WithPath(String), +} + +#[derive(Debug)] +pub(crate) enum ExplorerConfig { + Enabled, + WithPath(String), +} + +#[derive(Debug)] +pub(crate) struct MethodDefinition { + pub(crate) docs: Option, + pub(crate) auth: AuthRequirement, + pub(crate) name: Ident, + pub(crate) request_type: Type, + pub(crate) response_type: Type, + pub(crate) version: Option, + pub(crate) wire_name: Option, + pub(crate) versions: Vec, +} + +#[derive(Debug)] +pub(crate) struct MethodVersionDefinition { + pub(crate) version: String, + pub(crate) wire_name: String, + pub(crate) request_type: Type, + pub(crate) response_type: Type, + pub(crate) migration_type: Type, +} + +#[derive(Debug)] +pub(crate) struct DocComment { + pub(crate) summary: String, + pub(crate) description: String, +} + +impl DocComment { + pub(crate) fn from_lines(lines: Vec) -> Option { + let lines: Vec = lines + .into_iter() + .map(|line| line.trim().to_string()) + .collect(); + let start = lines.iter().position(|line| !line.is_empty())?; + let end = lines.iter().rposition(|line| !line.is_empty())?; + let lines = &lines[start..=end]; + + Some(Self { + summary: lines[0].clone(), + description: lines.join("\n"), + }) + } +} + +#[derive(Debug)] +pub(crate) enum AuthRequirement { + Unauthorized, + /// Public method that opportunistically identifies its caller. Never rejected + /// for auth reasons; the handler receives a `ras_jsonrpc_core::Caller`. + OptionalAuth, + WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/expand.rs b/crates/rpc/ras-jsonrpc-macro/src/expand.rs new file mode 100644 index 0000000..98d286d --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/expand.rs @@ -0,0 +1,112 @@ +//! Assemble server, client, and specification expansions. + +use crate::{ast::*, openrpc, permissions, server::generate_server_code, static_hosting}; +use quote::{format_ident, quote}; + +pub(crate) 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); + + 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), + ) + } else { + (quote! {}, quote! {}) + }; + + let server_impl = generate_server_code(&service_def); + + 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(), + }; + + // The explorer and RPC endpoint share the service root. + static_hosting::generate_static_hosting_code( + &explorer_config, + &service_def.service_name, + "", + ) + } else { + quote! {} + }; + + // With `feature_gated: true` the generated code is wrapped in + // `#[cfg(feature = ...)]` attributes resolved against the CONSUMER + // crate's features, immune to workspace feature unification of the + // macro crate's own features (which `cfg!` evaluates). + let feature_gated = service_def.feature_gated; + let cfg_server = if feature_gated { + quote! { #[cfg(feature = "server")] } + } else { + quote! {} + }; + let cfg_client = if feature_gated { + quote! { #[cfg(feature = "client")] } + } else { + quote! {} + }; + + let server_code = if feature_gated || cfg!(feature = "server") { + quote! { + #cfg_server + mod #server_mod { + use super::*; + + #server_impl + #explorer_code + } + + #cfg_server + pub use #server_mod::*; + } + } else { + quote! {} + }; + + let client_impl = crate::client::generate_client_code(&service_def); + let permissions_code = if cfg!(feature = "permissions") { + permissions::generate_permissions_code(&service_def) + } else { + quote! {} + }; + + let client_code = if feature_gated || cfg!(feature = "client") { + quote! { + #cfg_client + mod #client_mod { + use super::*; + + #client_impl + } + + #cfg_client + pub use #client_mod::*; + } + } else { + quote! {} + }; + + let output = quote! { + #permissions_code + #openrpc_code + #schema_checks + #server_code + #client_code + }; + + Ok(output) +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index fbcc4d6..c62760f 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -1,6 +1,11 @@ +use ast::*; use proc_macro::TokenStream; -use quote::{format_ident, quote}; -use syn::{Ident, LitStr, Token, Type, parse::Parse, parse_macro_input}; +use syn::parse_macro_input; + +mod ast; +mod expand; +mod parser; +mod server; mod client; mod openrpc; @@ -60,1227 +65,8 @@ mod static_hosting; pub fn jsonrpc_service(input: TokenStream) -> TokenStream { let service_definition = parse_macro_input!(input as ServiceDefinition); - match generate_service_code(service_definition) { + match expand::generate_service_code(service_definition) { Ok(tokens) => tokens.into(), Err(err) => err.to_compile_error().into(), } } - -#[derive(Debug)] -struct ServiceDefinition { - service_name: Ident, - openrpc: Option, - explorer: Option, - feature_gated: bool, - /// Require an `application/json` request `Content-Type`. Defaults to `true`. - /// Set `require_json_content_type: false` to accept any content type. - require_json_content_type: bool, - /// Maximum request body size in bytes. Defaults to 2 MiB (axum's default). - body_limit: Option, - /// Gate the explorer page and `openrpc.json` behind authentication (any - /// authenticated user). Defaults to `false` — the explorer is public when - /// enabled, matching conventional API-explorer behavior. - docs_require_auth: bool, - methods: Vec, -} - -/// Default maximum JSON body size in bytes (matches axum's default). -const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; - -#[derive(Debug)] -enum OpenRpcConfig { - Enabled, - WithPath(String), -} - -#[derive(Debug)] -enum ExplorerConfig { - Enabled, - WithPath(String), -} - -#[derive(Debug)] -struct MethodDefinition { - docs: Option, - auth: AuthRequirement, - name: Ident, - request_type: Type, - response_type: Type, - version: Option, - wire_name: Option, - versions: Vec, -} - -#[derive(Debug)] -struct MethodVersionDefinition { - version: String, - wire_name: String, - request_type: Type, - response_type: Type, - migration_type: Type, -} - -#[derive(Debug)] -struct DocComment { - summary: String, - description: String, -} - -impl DocComment { - fn from_lines(lines: Vec) -> Option { - let lines: Vec = lines - .into_iter() - .map(|line| line.trim().to_string()) - .collect(); - let start = lines.iter().position(|line| !line.is_empty())?; - let end = lines.iter().rposition(|line| !line.is_empty())?; - let lines = &lines[start..=end]; - - Some(Self { - summary: lines[0].clone(), - description: lines.join("\n"), - }) - } -} - -#[derive(Debug)] -enum AuthRequirement { - Unauthorized, - /// Public method that opportunistically identifies its caller. Never rejected - /// for auth reasons; the handler receives a `ras_jsonrpc_core::Caller`. - OptionalAuth, - WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups -} - -const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`"; - -fn parse_label(input: syn::parse::ParseStream) -> syn::Result { - if input.peek(LitStr) { - Ok(input.parse::()?.value()) - } else { - Ok(input.parse::()?.to_string()) - } -} - -fn parse_doc_comment_attrs( - attrs: Vec, - entry_kind: &str, -) -> syn::Result> { - let lines = attrs - .into_iter() - .map(|attr| parse_doc_comment_attr(attr, entry_kind)) - .collect::>>()?; - - Ok(DocComment::from_lines(lines)) -} - -fn parse_doc_comment_attr(attr: syn::Attribute, entry_kind: &str) -> syn::Result { - if !attr.path().is_ident("doc") { - return Err(syn::Error::new_spanned( - attr, - format!("Only doc comments (`/// ...`) are supported before {entry_kind} definitions"), - )); - } - - if let syn::Meta::NameValue(name_value) = &attr.meta - && let syn::Expr::Lit(expr_lit) = &name_value.value - && let syn::Lit::Str(doc_line) = &expr_lit.lit - { - return Ok(doc_line.value()); - } - - Err(syn::Error::new_spanned(attr, DOC_COMMENT_EXPECTED)) -} - -impl Parse for ServiceDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let content; - syn::braced!(content in input); - - let _ = content.parse::()?; // "service_name" - let _ = content.parse::()?; - let service_name = content.parse::()?; - let _ = content.parse::()?; - - let mut openrpc = None; - let mut explorer = None; - let mut feature_gated = false; - let mut require_json_content_type = true; - let mut body_limit = None; - let mut docs_require_auth = false; - - while content.peek(Ident) { - let field_name = content.fork().parse::()?; - if field_name == "methods" { - break; - } - - let _ = content.parse::()?; // field name - let _ = content.parse::()?; - - if field_name == "openrpc" { - if content.peek(syn::LitBool) { - let enabled = content.parse::()?; - if enabled.value() { - openrpc = Some(OpenRpcConfig::Enabled); - } - } else if content.peek(syn::token::Brace) { - let openrpc_content; - syn::braced!(openrpc_content in content); - - let _ = openrpc_content.parse::()?; // "output" - let _ = openrpc_content.parse::()?; - let path = openrpc_content.parse::()?; - openrpc = Some(OpenRpcConfig::WithPath(path.value())); - } - } else if field_name == "explorer" { - if content.peek(syn::LitBool) { - let enabled = content.parse::()?; - if enabled.value() { - explorer = Some(ExplorerConfig::Enabled); - } - } else if content.peek(syn::token::Brace) { - let explorer_content; - syn::braced!(explorer_content in content); - - let _ = explorer_content.parse::()?; // "path" - let _ = explorer_content.parse::()?; - let path = explorer_content.parse::()?; - explorer = Some(ExplorerConfig::WithPath(path.value())); - } - } else if field_name == "feature_gated" { - let enabled = content.parse::()?; - feature_gated = enabled.value(); - } else if field_name == "require_json_content_type" { - let enabled = content.parse::()?; - require_json_content_type = enabled.value(); - } else if field_name == "body_limit" { - let limit = content.parse::()?; - body_limit = Some(limit.base10_parse::()?); - } else if field_name == "docs_require_auth" { - let enabled = content.parse::()?; - docs_require_auth = enabled.value(); - } else { - return Err(syn::Error::new( - field_name.span(), - format!("Unknown field: {field_name}"), - )); - } - - let _ = content.parse::()?; - } - - let _ = content.parse::()?; // "methods" - let _ = content.parse::()?; - - let methods_content; - syn::bracketed!(methods_content in content); - - let mut methods = Vec::new(); - while !methods_content.is_empty() { - let method = methods_content.parse::()?; - methods.push(method); - - if methods_content.peek(Token![,]) { - let _ = methods_content.parse::()?; - } - } - - Ok(ServiceDefinition { - service_name, - openrpc, - explorer, - feature_gated, - require_json_content_type, - body_limit, - docs_require_auth, - methods, - }) - } -} - -impl Parse for MethodDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let docs = parse_doc_comment_attrs(input.call(syn::Attribute::parse_outer)?, "method")?; - - let auth = if input.peek(syn::Ident) { - let auth_ident = input.parse::()?; - match auth_ident.to_string().as_str() { - "UNAUTHORIZED" => AuthRequirement::Unauthorized, - "OPTIONAL_AUTH" => AuthRequirement::OptionalAuth, - "WITH_PERMISSIONS" => { - let perms_content; - syn::parenthesized!(perms_content in input); - - let mut permission_groups = Vec::new(); - - let first_group_content; - syn::bracketed!(first_group_content in perms_content); - - let mut first_group = Vec::new(); - while !first_group_content.is_empty() { - let perm = first_group_content.parse::()?; - first_group.push(perm.value()); - - if first_group_content.peek(Token![,]) { - let _ = first_group_content.parse::()?; - } - } - permission_groups.push(first_group); - - while perms_content.peek(Token![|]) { - let _ = perms_content.parse::()?; - - let group_content; - syn::bracketed!(group_content in perms_content); - - let mut group = Vec::new(); - while !group_content.is_empty() { - let perm = group_content.parse::()?; - group.push(perm.value()); - - if group_content.peek(Token![,]) { - let _ = group_content.parse::()?; - } - } - permission_groups.push(group); - } - - if permission_groups.len() > 1 - && permission_groups.iter().any(|group| group.is_empty()) - { - return Err(syn::Error::new( - auth_ident.span(), - "an empty permission group is only valid as the entire requirement \ - (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ - empty group with non-empty groups would silently grant access to any \ - authenticated user", - )); - } - - AuthRequirement::WithPermissions(permission_groups) - } - _ => { - return Err(syn::Error::new( - auth_ident.span(), - "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", - )); - } - } - } else { - return Err(syn::Error::new( - input.span(), - "Expected authentication requirement", - )); - }; - - 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::()?; - - let mut version = None; - let mut wire_name = None; - let mut versions = Vec::new(); - - if input.peek(syn::token::Brace) { - let content; - syn::braced!(content in input); - - while !content.is_empty() { - let field_name = content.parse::()?; - let _ = content.parse::()?; - - match field_name.to_string().as_str() { - "version" => { - version = Some(parse_label(&content)?); - } - "wire" => { - wire_name = Some(content.parse::()?.value()); - } - "versions" => { - let versions_content; - syn::bracketed!(versions_content in content); - - while !versions_content.is_empty() { - versions.push(versions_content.parse::()?); - - if versions_content.peek(Token![,]) { - let _ = versions_content.parse::()?; - } - } - } - _ => { - return Err(syn::Error::new( - field_name.span(), - "Expected version, wire, or versions", - )); - } - } - - if content.peek(Token![,]) { - let _ = content.parse::()?; - } - } - } - - Ok(MethodDefinition { - docs, - auth, - name, - request_type, - response_type, - version, - wire_name, - versions, - }) - } -} - -impl Parse for MethodVersionDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let version = parse_label(input)?; - - let content; - syn::braced!(content in input); - - let mut wire_name = None; - let mut request_type = None; - let mut response_type = None; - let mut migration_type = None; - - while !content.is_empty() { - let field_name = content.parse::()?; - let _ = content.parse::()?; - - match field_name.to_string().as_str() { - "wire" => { - wire_name = Some(content.parse::()?.value()); - } - "request" => { - request_type = Some(content.parse::()?); - } - "response" => { - response_type = Some(content.parse::()?); - } - "migration" => { - migration_type = Some(content.parse::()?); - } - _ => { - return Err(syn::Error::new( - field_name.span(), - "Expected wire, request, response, or migration", - )); - } - } - - if content.peek(Token![,]) { - let _ = content.parse::()?; - } - } - - Ok(Self { - version, - wire_name: wire_name - .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing wire"))?, - request_type: request_type - .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing request"))?, - response_type: response_type.ok_or_else(|| { - syn::Error::new(input.span(), "Version entry is missing response") - })?, - migration_type: migration_type.ok_or_else(|| { - syn::Error::new(input.span(), "Version entry is missing migration") - })?, - }) - } -} - -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); - - 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), - ) - } else { - (quote! {}, quote! {}) - }; - - let server_impl = generate_server_code(&service_def); - - 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(), - }; - - // The explorer and RPC endpoint share the service root. - static_hosting::generate_static_hosting_code( - &explorer_config, - &service_def.service_name, - "", - ) - } else { - quote! {} - }; - - // With `feature_gated: true` the generated code is wrapped in - // `#[cfg(feature = ...)]` attributes resolved against the CONSUMER - // crate's features, immune to workspace feature unification of the - // macro crate's own features (which `cfg!` evaluates). - let feature_gated = service_def.feature_gated; - let cfg_server = if feature_gated { - quote! { #[cfg(feature = "server")] } - } else { - quote! {} - }; - let cfg_client = if feature_gated { - quote! { #[cfg(feature = "client")] } - } else { - quote! {} - }; - - let server_code = if feature_gated || cfg!(feature = "server") { - quote! { - #cfg_server - mod #server_mod { - use super::*; - - #server_impl - #explorer_code - } - - #cfg_server - pub use #server_mod::*; - } - } else { - quote! {} - }; - - let client_impl = crate::client::generate_client_code(&service_def); - let permissions_code = if cfg!(feature = "permissions") { - permissions::generate_permissions_code(&service_def) - } else { - quote! {} - }; - - let client_code = if feature_gated || cfg!(feature = "client") { - quote! { - #cfg_client - mod #client_mod { - use super::*; - - #client_impl - } - - #cfg_client - pub use #client_mod::*; - } - } else { - quote! {} - }; - - let output = quote! { - #permissions_code - #openrpc_code - #schema_checks - #server_code - #client_code - }; - - Ok(output) -} - -fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenStream { - let service_name = &service_def.service_name; - let service_name_str = service_name.to_string(); - let service_trait_name = quote::format_ident!("{}Trait", service_name); - let builder_name = quote::format_ident!("{}Builder", service_name); - - let explorer_enabled = service_def.explorer.is_some() && service_def.openrpc.is_some(); - - // Content-Type gate: reject a non-`application/json` body with 415 before - // parsing. Requiring `application/json` forces a CORS preflight for - // cross-origin requests, closing the simple-request CSRF shape. - let content_type_gate = if service_def.require_json_content_type { - quote! { - { - let __ras_content_type_ok = headers - .get(axum::http::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| { - value - .split(';') - .next() - .unwrap_or("") - .trim() - .eq_ignore_ascii_case("application/json") - }) - .unwrap_or(false); - if !__ras_content_type_ok { - ras_jsonrpc_core::tracing::warn!( - "rejected JSON-RPC request: Content-Type is not application/json" - ); - return ( - axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, - [("Content-Type", "application/json")], - serde_json::to_string(&ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::invalid_request(), - None, - )) - .unwrap_or_else(|_| "{}".to_string()), - ); - } - } - } - } else { - quote! {} - }; - - // Body-size cap: apply as a DefaultBodyLimit layer so an over-limit body - // is rejected by the extractor before the handler runs. - let body_limit_value = service_def.body_limit.unwrap_or(DEFAULT_BODY_LIMIT); - - // Startup assertion: a service with any WITH_PERMISSIONS method (or a - // gated explorer) needs an auth provider, else every such call silently fails - // authentication at runtime. Fail the build instead. - let any_route_requires_auth = service_def - .methods - .iter() - .any(|method| matches!(method.auth, AuthRequirement::WithPermissions(_))) - || (explorer_enabled && service_def.docs_require_auth); - let provider_check = if any_route_requires_auth { - quote! { - if self.auth_provider.is_none() { - return Err(concat!( - "JSON-RPC service `", - #service_name_str, - "` has methods requiring authorization (WITH_PERMISSIONS) but no ", - "auth_provider was configured; call .auth_provider(...) before build()" - ) - .to_string()); - } - } - } else { - quote! {} - }; - - // Apply the explorer's auth policy where the service auth configuration - // is available. - let explorer_route_integration = if explorer_enabled { - let service_name_lower = service_name_str.to_lowercase(); - let explorer_routes_fn_str = [&service_name_lower, "_explorer_routes"].concat(); - let explorer_routes_fn = syn::Ident::new(&explorer_routes_fn_str, service_name.span()); - if service_def.docs_require_auth { - quote! { - { - let __ras_docs_service = service.clone(); - // `route_layer` (not `layer`) so the gate runs only for the - // explorer/openrpc routes that actually match — an unrelated - // path 404s without the middleware, and the RPC endpoint - // (a separate route) is never gated. - let __ras_explorer = #explorer_routes_fn(&base_url).route_layer( - axum::middleware::from_fn(move |__ras_req: axum::extract::Request, __ras_next: axum::middleware::Next| { - let __ras_docs_service = __ras_docs_service.clone(); - async move { - use axum::response::IntoResponse; - let __ras_headers = __ras_req.headers().clone(); - let __ras_empty: Vec> = Vec::new(); - match ras_jsonrpc_core::authorize_request( - "GET", - &__ras_headers, - &__ras_docs_service.auth_transport, - __ras_docs_service.auth_provider.as_deref(), - &__ras_empty, - ).await { - Ok(_) => __ras_next.run(__ras_req).await, - Err(__ras_err) => { - let __ras_status = match __ras_err { - ras_jsonrpc_core::AuthorizeError::CsrfValidationFailed - | ras_jsonrpc_core::AuthorizeError::InsufficientPermissions(_) => - axum::http::StatusCode::FORBIDDEN, - ras_jsonrpc_core::AuthorizeError::NoAuthProvider => - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - _ => axum::http::StatusCode::UNAUTHORIZED, - }; - ras_jsonrpc_core::tracing::warn!(status = __ras_status.as_u16(), "explorer request rejected"); - ( - __ras_status, - [("Content-Type", "application/json")], - serde_json::json!({ "error": "Authentication required" }).to_string(), - ).into_response() - } - } - } - }) - ); - router = router.merge(__ras_explorer); - } - } - } else { - quote! { router = router.merge(#explorer_routes_fn(&base_url)); } - } - } else { - quote! {} - }; - - let trait_methods = service_def.methods.iter().map(|method| { - let method_name = &method.name; - let request_type = &method.request_type; - let response_type = &method.response_type; - - match &method.auth { - AuthRequirement::Unauthorized => { - quote! { - fn #method_name(&self, request: #request_type) -> impl std::future::Future>> + Send; - } - } - AuthRequirement::OptionalAuth => { - quote! { - fn #method_name(&self, caller: ras_jsonrpc_core::Caller, request: #request_type) -> impl std::future::Future>> + Send; - } - } - AuthRequirement::WithPermissions(_) => { - quote! { - fn #method_name(&self, user: &ras_jsonrpc_core::AuthenticatedUser, request: #request_type) -> impl std::future::Future>> + Send; - } - } - } - }); - - // Wire names of OPTIONAL_AUTH methods (canonical + legacy versions). The - // request-level auth step rejects bad credentials globally; for these methods - // we instead downgrade to anonymous so the route stays lenient/public. - let optional_auth_wire_names: Vec = service_def - .methods - .iter() - .filter(|method| matches!(method.auth, AuthRequirement::OptionalAuth)) - .flat_map(|method| { - std::iter::once(jsonrpc_method_wire_name(method)).chain( - method - .versions - .iter() - .map(|version| version.wire_name.clone()), - ) - }) - .collect(); - let optional_method_check = if optional_auth_wire_names.is_empty() { - quote! { false } - } else { - quote! { matches!(request.method.as_str(), #(#optional_auth_wire_names)|*) } - }; - - let method_dispatch = service_def - .methods - .iter() - .flat_map(generate_jsonrpc_method_dispatches); - - quote! { - /// Generated service trait - #[allow(private_interfaces, private_bounds)] - pub trait #service_trait_name: Send + Sync + 'static { - #(#trait_methods)* - } - - /// Generated builder for the JSON-RPC service - pub struct #builder_name { - 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>>, - } - - impl #builder_name { - /// Create a new builder with the service implementation. - /// - /// The JSON-RPC route defaults to `/rpc`; use `base_url` to override it. - pub fn new(service: T) -> Self { - Self { - 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, - } - } - - /// Override the JSON-RPC route path. - pub fn base_url(mut self, base_url: impl Into) -> Self { - self.base_url = base_url.into(); - self - } - - /// Set the auth provider - pub fn auth_provider(mut self, provider: A) -> Self { - self.auth_provider = Some(Box::new(provider)); - self - } - - /// Enable cookie authentication alongside bearer tokens. - /// - /// Installs a default double-submit CSRF config when none is set, - /// because cookie credentials are CSRF-exploitable on unsafe methods. - /// Override with `csrf_protection`. - pub fn auth_cookie(mut self, cookie: ras_jsonrpc_core::AuthCookieConfig) -> Self { - self.auth_transport.cookie = Some(cookie); - if self.auth_transport.csrf.is_none() { - self.auth_transport.csrf = Some(ras_jsonrpc_core::CsrfConfig::default()); - } - 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 - where - F: Fn(&axum::http::HeaderMap, Option<&ras_jsonrpc_core::AuthenticatedUser>, &ras_jsonrpc_types::JsonRpcRequest) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.usage_tracker = Some(Box::new(move |headers, user, request| { - Box::pin(tracker(headers, user, request)) - })); - self - } - - /// Set the method duration tracker function - /// This function will be called after each method completes with the method name, authenticated user (if any), and the duration - pub fn with_method_duration_tracker(mut self, tracker: F) -> Self - where - F: Fn(&str, Option<&ras_jsonrpc_core::AuthenticatedUser>, std::time::Duration) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.method_duration_tracker = Some(Box::new(move |method, user, duration| { - Box::pin(tracker(method, user, duration)) - })); - self - } - - /// Build the axum router for the JSON-RPC service - pub fn build(self) -> Result { - self.auth_transport - .validate() - .map_err(|err| err.to_string())?; - - #provider_check - - let base_url = self.base_url.clone(); - let service = std::sync::Arc::new(self); - - let rpc_handler = axum::routing::post({ - // Clone into the handler so the outer `service` survives for - // the (optional) docs auth gate below. - let service = service.clone(); - move |headers: axum::http::HeaderMap, body: String| { - let service = service.clone(); - async move { - // Reject a non-`application/json` body with 415 before parsing. - #content_type_gate - - let response = service.handle_request(headers, body).await; - - // Determine HTTP status code based on JSON-RPC error code - // Map authentication/authorization errors to appropriate HTTP status codes - // while maintaining JSON-RPC protocol compatibility - let status_code = if let Some(ref error) = response.error { - match error.code { - 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 { - axum::http::StatusCode::OK - }; - - // Rejections otherwise bypass the usage/duration trackers - // (which run mid-dispatch); log auth/CSRF/permission - // rejections here so a bad-credential caller is observable. - if status_code != axum::http::StatusCode::OK { - if let Some(ref error) = response.error { - ras_jsonrpc_core::tracing::warn!( - status = status_code.as_u16(), - code = error.code, - "JSON-RPC request rejected" - ); - } - } - - ( - status_code, - [("Content-Type", "application/json")], - serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string()) - ) - } - } - }); - - let mut router = axum::Router::new(); - - router = router.route(&base_url, rpc_handler); - - // Bound the request body size for the JSON-RPC endpoint. - router = router.layer(axum::extract::DefaultBodyLimit::max(#body_limit_value)); - - #explorer_route_integration - - Ok(router) - } - - async fn handle_request(&self, headers: axum::http::HeaderMap, body: String) -> ras_jsonrpc_types::JsonRpcResponse { - let request: ras_jsonrpc_types::JsonRpcRequest = match serde_json::from_str(&body) { - Ok(req) => req, - Err(__ras_json_err) => { - // Log the classification and location so the reason is - // recoverable server-side; never log the rejected value. - ras_jsonrpc_core::tracing::warn!( - category = ?__ras_json_err.classify(), - line = __ras_json_err.line(), - column = __ras_json_err.column(), - "rejected JSON-RPC request: malformed JSON body" - ); - return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::parse_error(), None); - } - }; - - let request_id = request.id.clone(); - - if request.jsonrpc != "2.0" { - return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::invalid_request(), request_id); - } - - // Resolve the credential to Ok(Some/None) or Err(error response), then - // apply a single downgrade decision: OPTIONAL_AUTH methods are public, - // so any credential failure (failed CSRF, invalid/expired token) - // downgrades to anonymous rather than rejecting the whole request. - let __ras_method_is_optional = #optional_method_check; - - let auth_outcome: Result< - Option, - ras_jsonrpc_types::JsonRpcResponse, - > = if let Some(auth_provider) = &self.auth_provider { - match ras_jsonrpc_core::extract_auth_credential(&headers, &self.auth_transport) { - Ok(credential) => { - if ras_jsonrpc_core::validate_csrf_for_credential("POST", &headers, &credential, &self.auth_transport).is_err() { - Err(ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::csrf_validation_failed(), - request_id.clone(), - )) - } else { - match auth_provider.authenticate(credential.token().to_string()).await { - Ok(user) => Ok(Some(user)), - Err(ras_jsonrpc_core::AuthError::TokenExpired) => { - Err(ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::token_expired(), - request_id.clone(), - )) - } - Err(_) => Err(ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::authentication_required(), - request_id.clone(), - )), - } - } - } - Err(ras_jsonrpc_core::AuthTransportError::MissingCredentials) => Ok(None), - Err(_) => Err(ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::authentication_required(), - request_id.clone(), - )), - } - } else { - Ok(None) - }; - - let authenticated_user = match auth_outcome { - Ok(user) => user, - Err(error_response) => { - if __ras_method_is_optional { - None - } else { - return error_response; - } - } - }; - - if let Some(tracker) = &self.usage_tracker { - let user_ref = authenticated_user.as_ref(); - let tracker_headers = - ras_jsonrpc_core::redact_sensitive_headers_for_auth_transport(&headers, &self.auth_transport); - tracker(&tracker_headers, user_ref, &request).await; - } - - match request.method.as_str() { - #(#method_dispatch)* - _ => ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::method_not_found(&request.method), - request_id - ) - } - } - } - } -} - -fn jsonrpc_method_wire_name(method: &MethodDefinition) -> String { - method - .wire_name - .clone() - .unwrap_or_else(|| method.name.to_string()) -} - -fn jsonrpc_permission_groups_code(auth: &AuthRequirement) -> proc_macro2::TokenStream { - let permission_groups = match auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), - AuthRequirement::WithPermissions(groups) => groups.clone(), - }; - - if permission_groups.is_empty() { - quote! { Vec::>::new() } - } else { - let groups = permission_groups.iter().map(|group| { - let perms = group.iter(); - quote! { vec![#(#perms.to_string()),*] } - }); - quote! { vec![#(#groups),*] as Vec> } - } -} - -fn jsonrpc_auth_check_code( - auth: &AuthRequirement, -) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) { - match auth { - AuthRequirement::Unauthorized => (quote! {}, quote! { None }), - AuthRequirement::OptionalAuth => ( - quote! { - // OPTIONAL_AUTH: surface the (optional) caller. The request-level - // auth step already resolved `authenticated_user` best-effort; a - // present-but-bad credential was downgraded to None for this method. - // Cloned because `authenticated_user` is still needed for tracking below. - let caller = ras_jsonrpc_core::Caller::from(authenticated_user.clone()); - }, - quote! { authenticated_user.as_ref() }, - ), - AuthRequirement::WithPermissions(_) => { - let permission_groups_code = jsonrpc_permission_groups_code(auth); - ( - quote! { - let user = match &authenticated_user { - Some(u) => u, - None => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::authentication_required(), - request.id.clone() - ), - }; - - // OR-of-AND permission check (shared ras-auth-core implementation) - let required_permission_groups: Vec> = #permission_groups_code; - let provider = self.auth_provider.as_ref().expect("auth provider required for WITH_PERMISSIONS methods"); - if let Err(error) = ras_jsonrpc_core::check_permission_groups(provider.as_ref(), user, &required_permission_groups) { - // Only `required` is surfaced to the client; the caller's - // full grant set (`has`) stays server-side. - let required = match error { - ras_jsonrpc_core::AuthError::InsufficientPermissions { required, .. } => required, - _ => Vec::new(), - }; - return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::insufficient_permissions(required), - request.id.clone() - ); - } - }, - quote! { Some(user) }, - ) - } - } -} - -fn jsonrpc_parse_params_code( - params_ident: &Ident, - request_type: &Type, -) -> proc_macro2::TokenStream { - quote! { - let #params_ident: #request_type = match request.params { - Some(params) => match serde_json::from_value(params) { - Ok(p) => p, - Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), - request.id.clone() - ), - }, - None => match serde_json::from_value(serde_json::Value::Null) { - Ok(p) => p, - Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), - request.id.clone() - ), - } - }; - } -} - -fn generate_jsonrpc_method_dispatches(method: &MethodDefinition) -> Vec { - let mut dispatches = vec![generate_jsonrpc_canonical_dispatch(method)]; - dispatches.extend( - method - .versions - .iter() - .map(|version| generate_jsonrpc_legacy_dispatch(method, version)), - ); - dispatches -} - -fn generate_jsonrpc_canonical_dispatch(method: &MethodDefinition) -> proc_macro2::TokenStream { - let method_name = &method.name; - let method_wire = jsonrpc_method_wire_name(method); - let request_type = &method.request_type; - let params_ident = quote::format_ident!("params"); - let parse_params = jsonrpc_parse_params_code(¶ms_ident, request_type); - let (auth_check, tracker_user) = jsonrpc_auth_check_code(&method.auth); - - let handler_call = match &method.auth { - AuthRequirement::Unauthorized => quote! { self.service.#method_name(#params_ident).await }, - AuthRequirement::OptionalAuth => { - quote! { self.service.#method_name(caller, #params_ident).await } - } - AuthRequirement::WithPermissions(_) => { - quote! { self.service.#method_name(user, #params_ident).await } - } - }; - - quote! { - #method_wire => { - #auth_check - #parse_params - - let start_time = std::time::Instant::now(); - let handler_result = #handler_call; - let duration = start_time.elapsed(); - - if let Some(duration_tracker) = &self.method_duration_tracker { - duration_tracker(#method_wire, #tracker_user, duration).await; - } - - match handler_result { - Ok(result) => { - match serde_json::to_value(result) { - Ok(result_value) => ras_jsonrpc_types::JsonRpcResponse::success(result_value, request.id.clone()), - Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), - request.id.clone() - ), - } - } - Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), - request.id.clone() - ), - } - } - } -} - -fn generate_jsonrpc_legacy_dispatch( - method: &MethodDefinition, - version: &MethodVersionDefinition, -) -> proc_macro2::TokenStream { - let method_name = &method.name; - let method_wire = &version.wire_name; - let canonical_request_type = &method.request_type; - let canonical_response_type = &method.response_type; - let legacy_request_type = &version.request_type; - let legacy_response_type = &version.response_type; - let migration_type = &version.migration_type; - let legacy_params_ident = quote::format_ident!("legacy_params"); - let params_ident = quote::format_ident!("params"); - let parse_params = jsonrpc_parse_params_code(&legacy_params_ident, legacy_request_type); - let (auth_check, tracker_user) = jsonrpc_auth_check_code(&method.auth); - - let handler_call = match &method.auth { - AuthRequirement::Unauthorized => quote! { self.service.#method_name(#params_ident).await }, - AuthRequirement::OptionalAuth => { - quote! { self.service.#method_name(caller, #params_ident).await } - } - AuthRequirement::WithPermissions(_) => { - quote! { self.service.#method_name(user, #params_ident).await } - } - }; - - quote! { - #method_wire => { - #auth_check - #parse_params - - let #params_ident: #canonical_request_type = - match <#migration_type as ras_jsonrpc_core::VersionMigration<#legacy_request_type, #canonical_request_type>>::migrate(#legacy_params_ident) { - Ok(params) => params, - Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), - request.id.clone() - ), - }; - - let start_time = std::time::Instant::now(); - let handler_result = #handler_call; - let duration = start_time.elapsed(); - - if let Some(duration_tracker) = &self.method_duration_tracker { - duration_tracker(#method_wire, #tracker_user, duration).await; - } - - match handler_result { - Ok(result) => { - let result: #legacy_response_type = - match <#migration_type as ras_jsonrpc_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(result) { - Ok(result) => result, - Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), - request.id.clone() - ), - }; - - match serde_json::to_value(result) { - Ok(result_value) => ras_jsonrpc_types::JsonRpcResponse::success(result_value, request.id.clone()), - Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), - request.id.clone() - ), - } - } - Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), - request.id.clone() - ), - } - } - } -} diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs deleted file mode 100644 index b83bbaa..0000000 --- a/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs +++ /dev/null @@ -1,611 +0,0 @@ -//! OpenRPC document generation module -//! -//! This module provides functionality to generate OpenRPC specification documents -//! from the jsonrpc_service macro definitions. - -use crate::{AuthRequirement, OpenRpcConfig, ServiceDefinition}; -use proc_macro2::TokenStream; -use quote::quote; -use std::collections::HashMap; - -/// Generates OpenRPC document creation code -pub fn generate_openrpc_code( - service_def: &ServiceDefinition, - config: &OpenRpcConfig, -) -> TokenStream { - let service_name = &service_def.service_name; - let openrpc_fn_name = quote::format_ident!( - "generate_{}_openrpc", - service_name.to_string().to_lowercase() - ); - let openrpc_to_file_fn_name = quote::format_ident!( - "generate_{}_openrpc_to_file", - service_name.to_string().to_lowercase() - ); - let method_info_struct_name = quote::format_ident!("{}OpenRpcMethodInfo", service_name); - - let output_path_code = match config { - OpenRpcConfig::Enabled => { - let service_name_lower = service_name.to_string().to_lowercase(); - quote! { - format!("target/openrpc/{}.json", #service_name_lower) - } - } - OpenRpcConfig::WithPath(path) => { - quote! { - #path.to_string() - } - } - }; - - let flatten_fn_name = quote::format_ident!( - "_flatten_schema_defs_{}", - service_name.to_string().to_lowercase() - ); - let update_refs_fn_name = quote::format_ident!( - "_update_refs_recursive_{}", - service_name.to_string().to_lowercase() - ); - let generate_example_fn_name = quote::format_ident!( - "_generate_example_from_schema_{}", - service_name.to_string().to_lowercase() - ); - - let mut unique_types = std::collections::HashMap::new(); - for method in &service_def.methods { - let request_type = &method.request_type; - let response_type = &method.response_type; - - let request_type_str = quote!(#request_type).to_string(); - let response_type_str = quote!(#response_type).to_string(); - - unique_types.insert(request_type_str, quote!(#request_type)); - unique_types.insert(response_type_str, quote!(#response_type)); - - for version in &method.versions { - let request_type = &version.request_type; - let response_type = &version.response_type; - let request_type_str = quote!(#request_type).to_string(); - let response_type_str = quote!(#response_type).to_string(); - - unique_types.insert(request_type_str, quote!(#request_type)); - unique_types.insert(response_type_str, quote!(#response_type)); - } - } - - let schema_fns: Vec = unique_types - .iter() - .map(|(type_name, type_tokens)| { - if type_name == "()" { - quote! {} // Skip unit type, we'll handle it separately - } else { - let fn_name = quote::format_ident!( - "_generate_schema_for_{}_{}", - service_name.to_string().to_lowercase(), - type_name - .replace("::", "_") - .replace("<", "_") - .replace(">", "_") - .replace(" ", "_") - ); - quote! { - fn #fn_name() -> (serde_json::Value, std::collections::HashMap) { - let schema = schemars::schema_for!(#type_tokens); - let schema_value = serde_json::to_value(&schema).unwrap_or_else(|_| { - serde_json::json!({ - "type": "object", - "description": format!("Schema for {}", #type_name) - }) - }); - - let mut extracted_defs = std::collections::HashMap::new(); - let flattened_schema = #flatten_fn_name(schema_value, &mut extracted_defs); - (flattened_schema, extracted_defs) - } - } - } - }) - .collect(); - - let schema_insertions: Vec = unique_types - .keys() - .map(|type_name| { - if type_name == "()" { - quote! { - schemas.insert("()".to_string(), serde_json::json!({ - "type": "null", - "description": "Unit type" - })); - } - } else { - let fn_name = quote::format_ident!( - "_generate_schema_for_{}_{}", - service_name.to_string().to_lowercase(), - type_name - .replace("::", "_") - .replace("<", "_") - .replace(">", "_") - .replace(" ", "_") - ); - quote! { - let (schema, defs) = #fn_name(); - let sanitized_name = #type_name.to_string().replace(" ", ""); - schemas.insert(sanitized_name, schema); - for (def_name, def_schema) in defs { - let sanitized_def_name = def_name.replace(" ", ""); - schemas.insert(sanitized_def_name, def_schema); - } - } - } - }) - .collect(); - - let method_infos: Vec = service_def - .methods - .iter() - .flat_map(|method| { - let canonical_method_name = method - .wire_name - .clone() - .unwrap_or_else(|| method.name.to_string()); - let canonical_version = method.version.clone(); - let canonical_version_tokens = match &canonical_version { - Some(version) => quote! { Some(#version.to_string()) }, - None => quote! { None }, - }; - let auth_required = matches!(method.auth, AuthRequirement::WithPermissions(_)); - let auth_optional = matches!(method.auth, AuthRequirement::OptionalAuth); - let permissions = match &method.auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], - AuthRequirement::WithPermissions(groups) => { - groups.iter().flatten().cloned().collect() - } - }; - let permission_groups = permission_groups_for_spec(&method.auth); - let permission_groups_tokens = permission_groups_tokens(&permission_groups); - - let request_type = &method.request_type; - let response_type = &method.response_type; - let (summary, description) = match &method.docs { - Some(docs) => { - let summary = &docs.summary; - let description = &docs.description; - ( - quote! { Some(#summary.to_string()) }, - quote! { Some(#description.to_string()) }, - ) - } - None => (quote! { None }, quote! { None }), - }; - - let mut infos = vec![quote! { - #method_info_struct_name { - name: #canonical_method_name.to_string(), - summary: #summary, - description: #description, - auth_required: #auth_required, - auth_optional: #auth_optional, - permissions: vec![#(#permissions.to_string()),*], - permission_groups: #permission_groups_tokens, - request_type_name: stringify!(#request_type).to_string(), - response_type_name: stringify!(#response_type).to_string(), - version: #canonical_version_tokens, - canonical_version: #canonical_version_tokens, - canonical_method: #canonical_method_name.to_string(), - } - }]; - - infos.extend(method.versions.iter().map(|version| { - let method_name = &version.wire_name; - let version_label = &version.version; - let request_type = &version.request_type; - let response_type = &version.response_type; - let canonical_version = canonical_version - .clone() - .unwrap_or_else(|| "current".to_string()); - let canonical_method_name = canonical_method_name.clone(); - let permissions = permissions.clone(); - let permission_groups_tokens = permission_groups_tokens.clone(); - let summary = summary.clone(); - let description = description.clone(); - - quote! { - #method_info_struct_name { - name: #method_name.to_string(), - summary: #summary, - description: #description, - auth_required: #auth_required, - auth_optional: #auth_optional, - permissions: vec![#(#permissions.to_string()),*], - permission_groups: #permission_groups_tokens, - request_type_name: stringify!(#request_type).to_string(), - response_type_name: stringify!(#response_type).to_string(), - version: Some(#version_label.to_string()), - canonical_version: Some(#canonical_version.to_string()), - canonical_method: #canonical_method_name.to_string(), - } - } - })); - - infos - }) - .collect(); - - quote! { - #[derive(serde::Serialize)] - struct #method_info_struct_name { - name: String, - summary: Option, - description: Option, - auth_required: bool, - auth_optional: bool, - permissions: Vec, - permission_groups: Vec>, - request_type_name: String, - response_type_name: String, - version: Option, - canonical_version: Option, - canonical_method: String, - } - - /// Helper function to extract examples from a JSON schema - fn #flatten_fn_name( - mut schema: serde_json::Value, - extracted_defs: &mut std::collections::HashMap - ) -> serde_json::Value { - if let Some(obj) = schema.as_object_mut() { - if let Some(defs) = obj.remove("$defs") { - if let Some(defs_obj) = defs.as_object() { - for (def_name, def_schema) in defs_obj { - let flattened_def = #flatten_fn_name(def_schema.clone(), extracted_defs); - extracted_defs.insert(def_name.clone(), flattened_def); - } - } - } - - #update_refs_fn_name(&mut schema); - } - - schema - } - - /// Recursively update all $ref paths from #/$defs/ to #/components/schemas/ - fn #update_refs_fn_name(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(obj) => { - for (key, val) in obj.iter_mut() { - if key == "$ref" { - if let Some(ref_str) = val.as_str() { - if ref_str.starts_with("#/$defs/") { - *val = serde_json::Value::String( - ref_str.replace("#/$defs/", "#/components/schemas/") - ); - } - } - } else { - #update_refs_fn_name(val); - } - } - } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - #update_refs_fn_name(item); - } - } - _ => {} - } - } - - /// Generate example value from schema - fn #generate_example_fn_name(schema: &serde_json::Value, schemas: &std::collections::HashMap) -> serde_json::Value { - if let Some(examples) = schema.get("examples") { - if let Some(arr) = examples.as_array() { - if let Some(first) = arr.first() { - return first.clone(); - } - } - } - - if let Some(example) = schema.get("example") { - return example.clone(); - } - - if let Some(ref_str) = schema.get("$ref").and_then(|v| v.as_str()) { - if let Some(ref_name) = ref_str.strip_prefix("#/components/schemas/") { - if let Some(ref_schema) = schemas.get(ref_name) { - return #generate_example_fn_name(ref_schema, schemas); - } - } - } - - // Handle oneOf/anyOf - pick the first variant - if let Some(one_of) = schema.get("oneOf").and_then(|v| v.as_array()) { - if let Some(first_variant) = one_of.first() { - return #generate_example_fn_name(first_variant, schemas); - } - } - if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) { - if let Some(first_variant) = any_of.first() { - return #generate_example_fn_name(first_variant, schemas); - } - } - - match schema.get("type").and_then(|v| v.as_str()) { - Some("string") => serde_json::json!("example_string"), - Some("number") | Some("integer") => serde_json::json!(42), - Some("boolean") => serde_json::json!(true), - Some("array") => { - if let Some(items) = schema.get("items") { - serde_json::json!([#generate_example_fn_name(items, schemas)]) - } else { - serde_json::json!(["example_item"]) - } - } - Some("object") => { - let mut obj = serde_json::Map::new(); - if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { - for (key, prop_schema) in props { - obj.insert(key.clone(), #generate_example_fn_name(prop_schema, schemas)); - } - serde_json::json!(obj) - } else { - serde_json::json!({"example_key": "example_value"}) - } - } - Some("null") => serde_json::json!(null), - _ => serde_json::json!({"example": "value"}) - } - } - - #(#schema_fns)* - - /// Generate OpenRPC document for this service - pub fn #openrpc_fn_name() -> serde_json::Value { - use serde_json::json; - use schemars::{schema_for, JsonSchema}; - use std::collections::HashMap; - - let methods = vec![ - #(#method_infos),* - ]; - - let mut schemas = HashMap::new(); - - #(#schema_insertions)* - - let openrpc_methods: Vec = methods.iter().map(|method| { - let mut params = vec![]; - - if method.request_type_name != "()" { - let sanitized_request_type = method.request_type_name.replace(" ", ""); - let example = if let Some(schema) = schemas.get(&sanitized_request_type) { - #generate_example_fn_name(schema, &schemas) - } else { - json!({"example": "value"}) - }; - - params.push(json!({ - "name": "params", - "summary": format!("Request parameters of type {}", method.request_type_name), - "required": true, - "schema": { - "$ref": format!("#/components/schemas/{}", sanitized_request_type) - } - })); - } - - let mut extensions: std::collections::HashMap = std::collections::HashMap::new(); - - if method.auth_required { - extensions.insert("x-authentication".to_string(), json!({ - "required": true, - "type": "bearer" - })); - - 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)); - } - } else if method.auth_optional { - // OPTIONAL_AUTH: authentication is honoured but not required. - extensions.insert("x-authentication".to_string(), json!({ - "required": false, - "type": "bearer" - })); - } - - if let Some(version) = &method.version { - extensions.insert("x-ras-version".to_string(), json!(version)); - } - - if let Some(canonical_version) = &method.canonical_version { - extensions.insert("x-ras-canonical-version".to_string(), json!(canonical_version)); - extensions.insert("x-ras-canonical-method".to_string(), json!(method.canonical_method)); - } - - let mut examples = vec![]; - if method.request_type_name != "()" { - let sanitized_request_type = method.request_type_name.replace(" ", ""); - let sanitized_response_type = method.response_type_name.replace(" ", ""); - - let request_example = if let Some(schema) = schemas.get(&sanitized_request_type) { - #generate_example_fn_name(schema, &schemas) - } else { - json!({"example": "value"}) - }; - - let response_example = if method.response_type_name != "()" { - if let Some(schema) = schemas.get(&sanitized_response_type) { - #generate_example_fn_name(schema, &schemas) - } else { - json!({"example": "response"}) - } - } else { - json!(null) - }; - - examples.push(json!({ - "name": format!("{}_example", method.name), - "description": format!("Example call to {}", method.name), - "params": [{"name": "params", "value": request_example}], - "result": {"name": "result", "value": response_example} - })); - } - - let sanitized_response_type = method.response_type_name.replace(" ", ""); - let method_summary = method - .summary - .clone() - .unwrap_or_else(|| format!("Calls the {} method", method.name)); - - let mut method_obj = json!({ - "name": method.name, - "summary": method_summary, - "params": params, - "result": { - "name": "result", - "description": format!("Response of type {}", method.response_type_name), - "schema": { - "$ref": format!("#/components/schemas/{}", sanitized_response_type) - } - } - }); - - // Note: Examples are intentionally omitted as they're optional in OpenRPC - // and can cause validation issues with some validators - - if let Some(obj) = method_obj.as_object_mut() { - if let Some(description) = &method.description { - obj.insert("description".to_string(), json!(description)); - } - - for (key, value) in extensions { - obj.insert(key, value); - } - } - - method_obj - }).collect(); - - json!({ - "openrpc": "1.3.2", - "info": { - "title": format!("{} JSON-RPC API", stringify!(#service_name)), - "version": "1.0.0", - "description": format!("OpenRPC specification for the {} service", stringify!(#service_name)) - }, - "methods": openrpc_methods, - "components": { - "schemas": schemas, - "errors": { - "ParseError": { - "code": -32700, - "message": "Parse error" - }, - "InvalidRequest": { - "code": -32600, - "message": "Invalid Request" - }, - "MethodNotFound": { - "code": -32601, - "message": "Method not found" - }, - "InvalidParams": { - "code": -32602, - "message": "Invalid params" - }, - "InternalError": { - "code": -32603, - "message": "Internal error" - }, - "AuthenticationRequired": { - "code": -32001, - "message": "Authentication required" - }, - "InsufficientPermissions": { - "code": -32002, - "message": "Insufficient permissions" - }, - "TokenExpired": { - "code": -32003, - "message": "Token expired" - } - } - } - }) - } - - /// Write OpenRPC document to the target directory - pub fn #openrpc_to_file_fn_name() -> std::io::Result<()> { - let doc = #openrpc_fn_name(); - let output_path = #output_path_code; - - 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 OpenRPC document at: {}", output_path); - - Ok(()) - } - } -} - -fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { - match auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => 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(); - - for method in &service_def.methods { - let request_type = &method.request_type; - let response_type = &method.response_type; - - unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); - unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); - - for version in &method.versions { - let request_type = &version.request_type; - let response_type = &version.response_type; - - unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); - unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); - } - } - - let type_checks: Vec = unique_types - .values() - .map(|type_tokens| { - quote! { - const _: () = { - fn _assert_json_schema() {} - fn _check() { - _assert_json_schema::<#type_tokens>(); - } - }; - } - }) - .collect(); - - quote! { - #(#type_checks)* - } -} diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc/examples.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc/examples.rs new file mode 100644 index 0000000..8b35f66 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc/examples.rs @@ -0,0 +1,68 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::quote; + +pub(super) fn generate_examples(generate_example_fn_name: &Ident) -> TokenStream { + quote! { + /// Generate example value from schema + fn #generate_example_fn_name(schema: &serde_json::Value, schemas: &std::collections::HashMap) -> serde_json::Value { + if let Some(examples) = schema.get("examples") { + if let Some(arr) = examples.as_array() { + if let Some(first) = arr.first() { + return first.clone(); + } + } + } + + if let Some(example) = schema.get("example") { + return example.clone(); + } + + if let Some(ref_str) = schema.get("$ref").and_then(|v| v.as_str()) { + if let Some(ref_name) = ref_str.strip_prefix("#/components/schemas/") { + if let Some(ref_schema) = schemas.get(ref_name) { + return #generate_example_fn_name(ref_schema, schemas); + } + } + } + + // Handle oneOf/anyOf - pick the first variant + if let Some(one_of) = schema.get("oneOf").and_then(|v| v.as_array()) { + if let Some(first_variant) = one_of.first() { + return #generate_example_fn_name(first_variant, schemas); + } + } + if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) { + if let Some(first_variant) = any_of.first() { + return #generate_example_fn_name(first_variant, schemas); + } + } + + match schema.get("type").and_then(|v| v.as_str()) { + Some("string") => serde_json::json!("example_string"), + Some("number") | Some("integer") => serde_json::json!(42), + Some("boolean") => serde_json::json!(true), + Some("array") => { + if let Some(items) = schema.get("items") { + serde_json::json!([#generate_example_fn_name(items, schemas)]) + } else { + serde_json::json!(["example_item"]) + } + } + Some("object") => { + let mut obj = serde_json::Map::new(); + if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { + for (key, prop_schema) in props { + obj.insert(key.clone(), #generate_example_fn_name(prop_schema, schemas)); + } + serde_json::json!(obj) + } else { + serde_json::json!({"example_key": "example_value"}) + } + } + Some("null") => serde_json::json!(null), + _ => serde_json::json!({"example": "value"}) + } + } + + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc/methods.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc/methods.rs new file mode 100644 index 0000000..d5c3462 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc/methods.rs @@ -0,0 +1,237 @@ +use crate::{AuthRequirement, ServiceDefinition}; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; + +pub(super) fn generate_method_infos( + service_def: &ServiceDefinition, + method_info_struct_name: &Ident, +) -> Vec { + let method_infos: Vec = service_def + .methods + .iter() + .flat_map(|method| { + let canonical_method_name = method + .wire_name + .clone() + .unwrap_or_else(|| method.name.to_string()); + let canonical_version = method.version.clone(); + let canonical_version_tokens = match &canonical_version { + Some(version) => quote! { Some(#version.to_string()) }, + None => quote! { None }, + }; + let auth_required = matches!(method.auth, AuthRequirement::WithPermissions(_)); + let auth_optional = matches!(method.auth, AuthRequirement::OptionalAuth); + let permissions = match &method.auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], + AuthRequirement::WithPermissions(groups) => { + groups.iter().flatten().cloned().collect() + } + }; + let permission_groups = permission_groups_for_spec(&method.auth); + let permission_groups_tokens = permission_groups_tokens(&permission_groups); + + let request_type = &method.request_type; + let response_type = &method.response_type; + let (summary, description) = match &method.docs { + Some(docs) => { + let summary = &docs.summary; + let description = &docs.description; + ( + quote! { Some(#summary.to_string()) }, + quote! { Some(#description.to_string()) }, + ) + } + None => (quote! { None }, quote! { None }), + }; + + let mut infos = vec![quote! { + #method_info_struct_name { + name: #canonical_method_name.to_string(), + summary: #summary, + description: #description, + auth_required: #auth_required, + auth_optional: #auth_optional, + permissions: vec![#(#permissions.to_string()),*], + permission_groups: #permission_groups_tokens, + request_type_name: stringify!(#request_type).to_string(), + response_type_name: stringify!(#response_type).to_string(), + version: #canonical_version_tokens, + canonical_version: #canonical_version_tokens, + canonical_method: #canonical_method_name.to_string(), + } + }]; + + infos.extend(method.versions.iter().map(|version| { + let method_name = &version.wire_name; + let version_label = &version.version; + let request_type = &version.request_type; + let response_type = &version.response_type; + let canonical_version = canonical_version + .clone() + .unwrap_or_else(|| "current".to_string()); + let canonical_method_name = canonical_method_name.clone(); + let permissions = permissions.clone(); + let permission_groups_tokens = permission_groups_tokens.clone(); + let summary = summary.clone(); + let description = description.clone(); + + quote! { + #method_info_struct_name { + name: #method_name.to_string(), + summary: #summary, + description: #description, + auth_required: #auth_required, + auth_optional: #auth_optional, + permissions: vec![#(#permissions.to_string()),*], + permission_groups: #permission_groups_tokens, + request_type_name: stringify!(#request_type).to_string(), + response_type_name: stringify!(#response_type).to_string(), + version: Some(#version_label.to_string()), + canonical_version: Some(#canonical_version.to_string()), + canonical_method: #canonical_method_name.to_string(), + } + } + })); + + infos + }) + .collect(); + + method_infos +} + +pub(super) fn generate_methods(generate_example_fn_name: &Ident) -> TokenStream { + quote! { + let openrpc_methods: Vec = methods.iter().map(|method| { + let mut params = vec![]; + + if method.request_type_name != "()" { + let sanitized_request_type = method.request_type_name.replace(" ", ""); + let example = if let Some(schema) = schemas.get(&sanitized_request_type) { + #generate_example_fn_name(schema, &schemas) + } else { + json!({"example": "value"}) + }; + + params.push(json!({ + "name": "params", + "summary": format!("Request parameters of type {}", method.request_type_name), + "required": true, + "schema": { + "$ref": format!("#/components/schemas/{}", sanitized_request_type) + } + })); + } + + let mut extensions: std::collections::HashMap = std::collections::HashMap::new(); + + if method.auth_required { + extensions.insert("x-authentication".to_string(), json!({ + "required": true, + "type": "bearer" + })); + + 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)); + } + } else if method.auth_optional { + // OPTIONAL_AUTH: authentication is honoured but not required. + extensions.insert("x-authentication".to_string(), json!({ + "required": false, + "type": "bearer" + })); + } + + if let Some(version) = &method.version { + extensions.insert("x-ras-version".to_string(), json!(version)); + } + + if let Some(canonical_version) = &method.canonical_version { + extensions.insert("x-ras-canonical-version".to_string(), json!(canonical_version)); + extensions.insert("x-ras-canonical-method".to_string(), json!(method.canonical_method)); + } + + let mut examples = vec![]; + if method.request_type_name != "()" { + let sanitized_request_type = method.request_type_name.replace(" ", ""); + let sanitized_response_type = method.response_type_name.replace(" ", ""); + + let request_example = if let Some(schema) = schemas.get(&sanitized_request_type) { + #generate_example_fn_name(schema, &schemas) + } else { + json!({"example": "value"}) + }; + + let response_example = if method.response_type_name != "()" { + if let Some(schema) = schemas.get(&sanitized_response_type) { + #generate_example_fn_name(schema, &schemas) + } else { + json!({"example": "response"}) + } + } else { + json!(null) + }; + + examples.push(json!({ + "name": format!("{}_example", method.name), + "description": format!("Example call to {}", method.name), + "params": [{"name": "params", "value": request_example}], + "result": {"name": "result", "value": response_example} + })); + } + + let sanitized_response_type = method.response_type_name.replace(" ", ""); + let method_summary = method + .summary + .clone() + .unwrap_or_else(|| format!("Calls the {} method", method.name)); + + let mut method_obj = json!({ + "name": method.name, + "summary": method_summary, + "params": params, + "result": { + "name": "result", + "description": format!("Response of type {}", method.response_type_name), + "schema": { + "$ref": format!("#/components/schemas/{}", sanitized_response_type) + } + } + }); + + // Note: Examples are intentionally omitted as they're optional in OpenRPC + // and can cause validation issues with some validators + + if let Some(obj) = method_obj.as_object_mut() { + if let Some(description) = &method.description { + obj.insert("description".to_string(), json!(description)); + } + + for (key, value) in extensions { + obj.insert(key, value); + } + } + + method_obj + }).collect(); + + } +} + +fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { + match auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => 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),*] } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc/mod.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc/mod.rs new file mode 100644 index 0000000..faeab0b --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc/mod.rs @@ -0,0 +1,168 @@ +//! OpenRPC document generation module +//! +//! This module provides functionality to generate OpenRPC specification documents +//! from the jsonrpc_service macro definitions. + +use crate::{OpenRpcConfig, ServiceDefinition}; +use proc_macro2::TokenStream; +use quote::quote; +mod examples; +mod methods; +mod schema; +pub use schema::generate_schema_impl_checks; + +/// Generates OpenRPC document creation code +pub fn generate_openrpc_code( + service_def: &ServiceDefinition, + config: &OpenRpcConfig, +) -> TokenStream { + let service_name = &service_def.service_name; + let openrpc_fn_name = quote::format_ident!( + "generate_{}_openrpc", + service_name.to_string().to_lowercase() + ); + let openrpc_to_file_fn_name = quote::format_ident!( + "generate_{}_openrpc_to_file", + service_name.to_string().to_lowercase() + ); + let method_info_struct_name = quote::format_ident!("{}OpenRpcMethodInfo", service_name); + + let output_path_code = match config { + OpenRpcConfig::Enabled => { + let service_name_lower = service_name.to_string().to_lowercase(); + quote! { + format!("target/openrpc/{}.json", #service_name_lower) + } + } + OpenRpcConfig::WithPath(path) => { + quote! { + #path.to_string() + } + } + }; + + let flatten_fn_name = quote::format_ident!( + "_flatten_schema_defs_{}", + service_name.to_string().to_lowercase() + ); + let update_refs_fn_name = quote::format_ident!( + "_update_refs_recursive_{}", + service_name.to_string().to_lowercase() + ); + let generate_example_fn_name = quote::format_ident!( + "_generate_example_from_schema_{}", + service_name.to_string().to_lowercase() + ); + + let unique_types = schema::collect_types(service_def); + let (schema_fns, schema_insertions) = + schema::generate_schemas(service_name, &flatten_fn_name, &unique_types); + let method_infos = methods::generate_method_infos(service_def, &method_info_struct_name); + let normalization = schema::generate_normalization(&flatten_fn_name, &update_refs_fn_name); + let examples = examples::generate_examples(&generate_example_fn_name); + let methods = methods::generate_methods(&generate_example_fn_name); + + quote! { + #[derive(serde::Serialize)] + struct #method_info_struct_name { + name: String, + summary: Option, + description: Option, + auth_required: bool, + auth_optional: bool, + permissions: Vec, + permission_groups: Vec>, + request_type_name: String, + response_type_name: String, + version: Option, + canonical_version: Option, + canonical_method: String, + } + + #normalization + #examples + + #(#schema_fns)* + + /// Generate OpenRPC document for this service + pub fn #openrpc_fn_name() -> serde_json::Value { + use serde_json::json; + use schemars::{schema_for, JsonSchema}; + use std::collections::HashMap; + + let methods = vec![ + #(#method_infos),* + ]; + + let mut schemas = HashMap::new(); + + #(#schema_insertions)* + + #methods + + json!({ + "openrpc": "1.3.2", + "info": { + "title": format!("{} JSON-RPC API", stringify!(#service_name)), + "version": "1.0.0", + "description": format!("OpenRPC specification for the {} service", stringify!(#service_name)) + }, + "methods": openrpc_methods, + "components": { + "schemas": schemas, + "errors": { + "ParseError": { + "code": -32700, + "message": "Parse error" + }, + "InvalidRequest": { + "code": -32600, + "message": "Invalid Request" + }, + "MethodNotFound": { + "code": -32601, + "message": "Method not found" + }, + "InvalidParams": { + "code": -32602, + "message": "Invalid params" + }, + "InternalError": { + "code": -32603, + "message": "Internal error" + }, + "AuthenticationRequired": { + "code": -32001, + "message": "Authentication required" + }, + "InsufficientPermissions": { + "code": -32002, + "message": "Insufficient permissions" + }, + "TokenExpired": { + "code": -32003, + "message": "Token expired" + } + } + } + }) + } + + /// Write OpenRPC document to the target directory + pub fn #openrpc_to_file_fn_name() -> std::io::Result<()> { + let doc = #openrpc_fn_name(); + let output_path = #output_path_code; + + 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 OpenRPC document at: {}", output_path); + + Ok(()) + } + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc/schema.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc/schema.rs new file mode 100644 index 0000000..3735b65 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc/schema.rs @@ -0,0 +1,200 @@ +use crate::ServiceDefinition; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; +use std::collections::HashMap; + +pub(super) fn collect_types(service_def: &ServiceDefinition) -> HashMap { + let mut unique_types = std::collections::HashMap::new(); + for method in &service_def.methods { + let request_type = &method.request_type; + let response_type = &method.response_type; + + let request_type_str = quote!(#request_type).to_string(); + let response_type_str = quote!(#response_type).to_string(); + + unique_types.insert(request_type_str, quote!(#request_type)); + unique_types.insert(response_type_str, quote!(#response_type)); + + for version in &method.versions { + let request_type = &version.request_type; + let response_type = &version.response_type; + let request_type_str = quote!(#request_type).to_string(); + let response_type_str = quote!(#response_type).to_string(); + + unique_types.insert(request_type_str, quote!(#request_type)); + unique_types.insert(response_type_str, quote!(#response_type)); + } + } + + unique_types +} + +pub(super) fn generate_schemas( + service_name: &Ident, + flatten_fn_name: &Ident, + unique_types: &HashMap, +) -> (Vec, Vec) { + let schema_fns: Vec = unique_types + .iter() + .map(|(type_name, type_tokens)| { + if type_name == "()" { + quote! {} // Skip unit type, we'll handle it separately + } else { + let fn_name = quote::format_ident!( + "_generate_schema_for_{}_{}", + service_name.to_string().to_lowercase(), + type_name + .replace("::", "_") + .replace("<", "_") + .replace(">", "_") + .replace(" ", "_") + ); + quote! { + fn #fn_name() -> (serde_json::Value, std::collections::HashMap) { + let schema = schemars::schema_for!(#type_tokens); + let schema_value = serde_json::to_value(&schema).unwrap_or_else(|_| { + serde_json::json!({ + "type": "object", + "description": format!("Schema for {}", #type_name) + }) + }); + + let mut extracted_defs = std::collections::HashMap::new(); + let flattened_schema = #flatten_fn_name(schema_value, &mut extracted_defs); + (flattened_schema, extracted_defs) + } + } + } + }) + .collect(); + + let schema_insertions: Vec = unique_types + .keys() + .map(|type_name| { + if type_name == "()" { + quote! { + schemas.insert("()".to_string(), serde_json::json!({ + "type": "null", + "description": "Unit type" + })); + } + } else { + let fn_name = quote::format_ident!( + "_generate_schema_for_{}_{}", + service_name.to_string().to_lowercase(), + type_name + .replace("::", "_") + .replace("<", "_") + .replace(">", "_") + .replace(" ", "_") + ); + quote! { + let (schema, defs) = #fn_name(); + let sanitized_name = #type_name.to_string().replace(" ", ""); + schemas.insert(sanitized_name, schema); + for (def_name, def_schema) in defs { + let sanitized_def_name = def_name.replace(" ", ""); + schemas.insert(sanitized_def_name, def_schema); + } + } + } + }) + .collect(); + + (schema_fns, schema_insertions) +} + +pub(super) fn generate_normalization( + flatten_fn_name: &Ident, + update_refs_fn_name: &Ident, +) -> TokenStream { + quote! { + /// Helper function to extract examples from a JSON schema + fn #flatten_fn_name( + mut schema: serde_json::Value, + extracted_defs: &mut std::collections::HashMap + ) -> serde_json::Value { + if let Some(obj) = schema.as_object_mut() { + if let Some(defs) = obj.remove("$defs") { + if let Some(defs_obj) = defs.as_object() { + for (def_name, def_schema) in defs_obj { + let flattened_def = #flatten_fn_name(def_schema.clone(), extracted_defs); + extracted_defs.insert(def_name.clone(), flattened_def); + } + } + } + + #update_refs_fn_name(&mut schema); + } + + schema + } + + /// Recursively update all $ref paths from #/$defs/ to #/components/schemas/ + fn #update_refs_fn_name(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(obj) => { + for (key, val) in obj.iter_mut() { + if key == "$ref" { + if let Some(ref_str) = val.as_str() { + if ref_str.starts_with("#/$defs/") { + *val = serde_json::Value::String( + ref_str.replace("#/$defs/", "#/components/schemas/") + ); + } + } + } else { + #update_refs_fn_name(val); + } + } + } + serde_json::Value::Array(arr) => { + for item in arr.iter_mut() { + #update_refs_fn_name(item); + } + } + _ => {} + } + } + + } +} + +/// 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(); + + for method in &service_def.methods { + let request_type = &method.request_type; + let response_type = &method.response_type; + + unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); + unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); + + for version in &method.versions { + let request_type = &version.request_type; + let response_type = &version.response_type; + + unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); + unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); + } + } + + let type_checks: Vec = unique_types + .values() + .map(|type_tokens| { + quote! { + const _: () = { + fn _assert_json_schema() {} + fn _check() { + _assert_json_schema::<#type_tokens>(); + } + }; + } + }) + .collect(); + + quote! { + #(#type_checks)* + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/parser.rs b/crates/rpc/ras-jsonrpc-macro/src/parser.rs new file mode 100644 index 0000000..e485fdd --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/parser.rs @@ -0,0 +1,351 @@ +//! JSON-RPC service syntax and diagnostics. + +use crate::ast::*; +use syn::{Ident, LitStr, Token, Type, parse::Parse}; + +const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`"; + +fn parse_label(input: syn::parse::ParseStream) -> syn::Result { + if input.peek(LitStr) { + Ok(input.parse::()?.value()) + } else { + Ok(input.parse::()?.to_string()) + } +} + +fn parse_doc_comment_attrs( + attrs: Vec, + entry_kind: &str, +) -> syn::Result> { + let lines = attrs + .into_iter() + .map(|attr| parse_doc_comment_attr(attr, entry_kind)) + .collect::>>()?; + + Ok(DocComment::from_lines(lines)) +} + +fn parse_doc_comment_attr(attr: syn::Attribute, entry_kind: &str) -> syn::Result { + if !attr.path().is_ident("doc") { + return Err(syn::Error::new_spanned( + attr, + format!("Only doc comments (`/// ...`) are supported before {entry_kind} definitions"), + )); + } + + if let syn::Meta::NameValue(name_value) = &attr.meta + && let syn::Expr::Lit(expr_lit) = &name_value.value + && let syn::Lit::Str(doc_line) = &expr_lit.lit + { + return Ok(doc_line.value()); + } + + Err(syn::Error::new_spanned(attr, DOC_COMMENT_EXPECTED)) +} + +impl Parse for ServiceDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let content; + syn::braced!(content in input); + + let _ = content.parse::()?; // "service_name" + let _ = content.parse::()?; + let service_name = content.parse::()?; + let _ = content.parse::()?; + + let mut openrpc = None; + let mut explorer = None; + let mut feature_gated = false; + let mut require_json_content_type = true; + let mut body_limit = None; + let mut docs_require_auth = false; + + while content.peek(Ident) { + let field_name = content.fork().parse::()?; + if field_name == "methods" { + break; + } + + let _ = content.parse::()?; // field name + let _ = content.parse::()?; + + if field_name == "openrpc" { + if content.peek(syn::LitBool) { + let enabled = content.parse::()?; + if enabled.value() { + openrpc = Some(OpenRpcConfig::Enabled); + } + } else if content.peek(syn::token::Brace) { + let openrpc_content; + syn::braced!(openrpc_content in content); + + let _ = openrpc_content.parse::()?; // "output" + let _ = openrpc_content.parse::()?; + let path = openrpc_content.parse::()?; + openrpc = Some(OpenRpcConfig::WithPath(path.value())); + } + } else if field_name == "explorer" { + if content.peek(syn::LitBool) { + let enabled = content.parse::()?; + if enabled.value() { + explorer = Some(ExplorerConfig::Enabled); + } + } else if content.peek(syn::token::Brace) { + let explorer_content; + syn::braced!(explorer_content in content); + + let _ = explorer_content.parse::()?; // "path" + let _ = explorer_content.parse::()?; + let path = explorer_content.parse::()?; + explorer = Some(ExplorerConfig::WithPath(path.value())); + } + } else if field_name == "feature_gated" { + let enabled = content.parse::()?; + feature_gated = enabled.value(); + } else if field_name == "require_json_content_type" { + let enabled = content.parse::()?; + require_json_content_type = enabled.value(); + } else if field_name == "body_limit" { + let limit = content.parse::()?; + body_limit = Some(limit.base10_parse::()?); + } else if field_name == "docs_require_auth" { + let enabled = content.parse::()?; + docs_require_auth = enabled.value(); + } else { + return Err(syn::Error::new( + field_name.span(), + format!("Unknown field: {field_name}"), + )); + } + + let _ = content.parse::()?; + } + + let _ = content.parse::()?; // "methods" + let _ = content.parse::()?; + + let methods_content; + syn::bracketed!(methods_content in content); + + let mut methods = Vec::new(); + while !methods_content.is_empty() { + let method = methods_content.parse::()?; + methods.push(method); + + if methods_content.peek(Token![,]) { + let _ = methods_content.parse::()?; + } + } + + Ok(ServiceDefinition { + service_name, + openrpc, + explorer, + feature_gated, + require_json_content_type, + body_limit, + docs_require_auth, + methods, + }) + } +} + +impl Parse for MethodDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let docs = parse_doc_comment_attrs(input.call(syn::Attribute::parse_outer)?, "method")?; + + let auth = if input.peek(syn::Ident) { + let auth_ident = input.parse::()?; + match auth_ident.to_string().as_str() { + "UNAUTHORIZED" => AuthRequirement::Unauthorized, + "OPTIONAL_AUTH" => AuthRequirement::OptionalAuth, + "WITH_PERMISSIONS" => { + let perms_content; + syn::parenthesized!(perms_content in input); + + let mut permission_groups = Vec::new(); + + let first_group_content; + syn::bracketed!(first_group_content in perms_content); + + let mut first_group = Vec::new(); + while !first_group_content.is_empty() { + let perm = first_group_content.parse::()?; + first_group.push(perm.value()); + + if first_group_content.peek(Token![,]) { + let _ = first_group_content.parse::()?; + } + } + permission_groups.push(first_group); + + while perms_content.peek(Token![|]) { + let _ = perms_content.parse::()?; + + let group_content; + syn::bracketed!(group_content in perms_content); + + let mut group = Vec::new(); + while !group_content.is_empty() { + let perm = group_content.parse::()?; + group.push(perm.value()); + + if group_content.peek(Token![,]) { + let _ = group_content.parse::()?; + } + } + permission_groups.push(group); + } + + if permission_groups.len() > 1 + && permission_groups.iter().any(|group| group.is_empty()) + { + return Err(syn::Error::new( + auth_ident.span(), + "an empty permission group is only valid as the entire requirement \ + (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ + empty group with non-empty groups would silently grant access to any \ + authenticated user", + )); + } + + AuthRequirement::WithPermissions(permission_groups) + } + _ => { + return Err(syn::Error::new( + auth_ident.span(), + "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", + )); + } + } + } else { + return Err(syn::Error::new( + input.span(), + "Expected authentication requirement", + )); + }; + + 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::()?; + + let mut version = None; + let mut wire_name = None; + let mut versions = Vec::new(); + + if input.peek(syn::token::Brace) { + let content; + syn::braced!(content in input); + + while !content.is_empty() { + let field_name = content.parse::()?; + let _ = content.parse::()?; + + match field_name.to_string().as_str() { + "version" => { + version = Some(parse_label(&content)?); + } + "wire" => { + wire_name = Some(content.parse::()?.value()); + } + "versions" => { + let versions_content; + syn::bracketed!(versions_content in content); + + while !versions_content.is_empty() { + versions.push(versions_content.parse::()?); + + if versions_content.peek(Token![,]) { + let _ = versions_content.parse::()?; + } + } + } + _ => { + return Err(syn::Error::new( + field_name.span(), + "Expected version, wire, or versions", + )); + } + } + + if content.peek(Token![,]) { + let _ = content.parse::()?; + } + } + } + + Ok(MethodDefinition { + docs, + auth, + name, + request_type, + response_type, + version, + wire_name, + versions, + }) + } +} + +impl Parse for MethodVersionDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let version = parse_label(input)?; + + let content; + syn::braced!(content in input); + + let mut wire_name = None; + let mut request_type = None; + let mut response_type = None; + let mut migration_type = None; + + while !content.is_empty() { + let field_name = content.parse::()?; + let _ = content.parse::()?; + + match field_name.to_string().as_str() { + "wire" => { + wire_name = Some(content.parse::()?.value()); + } + "request" => { + request_type = Some(content.parse::()?); + } + "response" => { + response_type = Some(content.parse::()?); + } + "migration" => { + migration_type = Some(content.parse::()?); + } + _ => { + return Err(syn::Error::new( + field_name.span(), + "Expected wire, request, response, or migration", + )); + } + } + + if content.peek(Token![,]) { + let _ = content.parse::()?; + } + } + + Ok(Self { + version, + wire_name: wire_name + .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing wire"))?, + request_type: request_type + .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing request"))?, + response_type: response_type.ok_or_else(|| { + syn::Error::new(input.span(), "Version entry is missing response") + })?, + migration_type: migration_type.ok_or_else(|| { + syn::Error::new(input.span(), "Version entry is missing migration") + })?, + }) + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/server/dispatch.rs b/crates/rpc/ras-jsonrpc-macro/src/server/dispatch.rs new file mode 100644 index 0000000..2c2ddc8 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/server/dispatch.rs @@ -0,0 +1,241 @@ +//! Method authorization, parameter decoding, invocation, and version adaptation. + +use crate::ast::*; +use quote::quote; +use syn::{Ident, Type}; + +pub(super) fn jsonrpc_method_wire_name(method: &MethodDefinition) -> String { + method + .wire_name + .clone() + .unwrap_or_else(|| method.name.to_string()) +} + +fn jsonrpc_permission_groups_code(auth: &AuthRequirement) -> proc_macro2::TokenStream { + let permission_groups = match auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), + AuthRequirement::WithPermissions(groups) => groups.clone(), + }; + + if permission_groups.is_empty() { + quote! { Vec::>::new() } + } else { + let groups = permission_groups.iter().map(|group| { + let perms = group.iter(); + quote! { vec![#(#perms.to_string()),*] } + }); + quote! { vec![#(#groups),*] as Vec> } + } +} + +fn jsonrpc_auth_check_code( + auth: &AuthRequirement, +) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) { + match auth { + AuthRequirement::Unauthorized => (quote! {}, quote! { None }), + AuthRequirement::OptionalAuth => ( + quote! { + // OPTIONAL_AUTH: surface the (optional) caller. The request-level + // auth step already resolved `authenticated_user` best-effort; a + // present-but-bad credential was downgraded to None for this method. + // Cloned because `authenticated_user` is still needed for tracking below. + let caller = ras_jsonrpc_core::Caller::from(authenticated_user.clone()); + }, + quote! { authenticated_user.as_ref() }, + ), + AuthRequirement::WithPermissions(_) => { + let permission_groups_code = jsonrpc_permission_groups_code(auth); + ( + quote! { + let user = match &authenticated_user { + Some(u) => u, + None => return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::authentication_required(), + request.id.clone() + ), + }; + + // OR-of-AND permission check (shared ras-auth-core implementation) + let required_permission_groups: Vec> = #permission_groups_code; + let provider = self.auth_provider.as_ref().expect("auth provider required for WITH_PERMISSIONS methods"); + if let Err(error) = ras_jsonrpc_core::check_permission_groups(provider.as_ref(), user, &required_permission_groups) { + // Only `required` is surfaced to the client; the caller's + // full grant set (`has`) stays server-side. + let required = match error { + ras_jsonrpc_core::AuthError::InsufficientPermissions { required, .. } => required, + _ => Vec::new(), + }; + return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::insufficient_permissions(required), + request.id.clone() + ); + } + }, + quote! { Some(user) }, + ) + } + } +} + +fn jsonrpc_parse_params_code( + params_ident: &Ident, + request_type: &Type, +) -> proc_macro2::TokenStream { + quote! { + let #params_ident: #request_type = match request.params { + Some(params) => match serde_json::from_value(params) { + Ok(p) => p, + Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), + request.id.clone() + ), + }, + None => match serde_json::from_value(serde_json::Value::Null) { + Ok(p) => p, + Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), + request.id.clone() + ), + } + }; + } +} + +pub(super) fn generate_jsonrpc_method_dispatches( + method: &MethodDefinition, +) -> Vec { + let mut dispatches = vec![generate_jsonrpc_canonical_dispatch(method)]; + dispatches.extend( + method + .versions + .iter() + .map(|version| generate_jsonrpc_legacy_dispatch(method, version)), + ); + dispatches +} + +fn generate_jsonrpc_canonical_dispatch(method: &MethodDefinition) -> proc_macro2::TokenStream { + let method_name = &method.name; + let method_wire = jsonrpc_method_wire_name(method); + let request_type = &method.request_type; + let params_ident = quote::format_ident!("params"); + let parse_params = jsonrpc_parse_params_code(¶ms_ident, request_type); + let (auth_check, tracker_user) = jsonrpc_auth_check_code(&method.auth); + + let handler_call = match &method.auth { + AuthRequirement::Unauthorized => quote! { self.service.#method_name(#params_ident).await }, + AuthRequirement::OptionalAuth => { + quote! { self.service.#method_name(caller, #params_ident).await } + } + AuthRequirement::WithPermissions(_) => { + quote! { self.service.#method_name(user, #params_ident).await } + } + }; + + quote! { + #method_wire => { + #auth_check + #parse_params + + let start_time = std::time::Instant::now(); + let handler_result = #handler_call; + let duration = start_time.elapsed(); + + if let Some(duration_tracker) = &self.method_duration_tracker { + duration_tracker(#method_wire, #tracker_user, duration).await; + } + + match handler_result { + Ok(result) => { + match serde_json::to_value(result) { + Ok(result_value) => ras_jsonrpc_types::JsonRpcResponse::success(result_value, request.id.clone()), + Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), + request.id.clone() + ), + } + } + Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), + request.id.clone() + ), + } + } + } +} + +fn generate_jsonrpc_legacy_dispatch( + method: &MethodDefinition, + version: &MethodVersionDefinition, +) -> proc_macro2::TokenStream { + let method_name = &method.name; + let method_wire = &version.wire_name; + let canonical_request_type = &method.request_type; + let canonical_response_type = &method.response_type; + let legacy_request_type = &version.request_type; + let legacy_response_type = &version.response_type; + let migration_type = &version.migration_type; + let legacy_params_ident = quote::format_ident!("legacy_params"); + let params_ident = quote::format_ident!("params"); + let parse_params = jsonrpc_parse_params_code(&legacy_params_ident, legacy_request_type); + let (auth_check, tracker_user) = jsonrpc_auth_check_code(&method.auth); + + let handler_call = match &method.auth { + AuthRequirement::Unauthorized => quote! { self.service.#method_name(#params_ident).await }, + AuthRequirement::OptionalAuth => { + quote! { self.service.#method_name(caller, #params_ident).await } + } + AuthRequirement::WithPermissions(_) => { + quote! { self.service.#method_name(user, #params_ident).await } + } + }; + + quote! { + #method_wire => { + #auth_check + #parse_params + + let #params_ident: #canonical_request_type = + match <#migration_type as ras_jsonrpc_core::VersionMigration<#legacy_request_type, #canonical_request_type>>::migrate(#legacy_params_ident) { + Ok(params) => params, + Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), + request.id.clone() + ), + }; + + let start_time = std::time::Instant::now(); + let handler_result = #handler_call; + let duration = start_time.elapsed(); + + if let Some(duration_tracker) = &self.method_duration_tracker { + duration_tracker(#method_wire, #tracker_user, duration).await; + } + + match handler_result { + Ok(result) => { + let result: #legacy_response_type = + match <#migration_type as ras_jsonrpc_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(result) { + Ok(result) => result, + Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), + request.id.clone() + ), + }; + + match serde_json::to_value(result) { + Ok(result_value) => ras_jsonrpc_types::JsonRpcResponse::success(result_value, request.id.clone()), + Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), + request.id.clone() + ), + } + } + Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), + request.id.clone() + ), + } + } + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/server/http.rs b/crates/rpc/ras-jsonrpc-macro/src/server/http.rs new file mode 100644 index 0000000..ed2d080 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/server/http.rs @@ -0,0 +1,330 @@ +//! HTTP routing, envelope handling, and request-level authentication. + +use super::dispatch::{generate_jsonrpc_method_dispatches, jsonrpc_method_wire_name}; +use crate::ast::*; +use quote::quote; + +pub(super) fn generate_http_methods(service_def: &ServiceDefinition) -> proc_macro2::TokenStream { + let service_name = &service_def.service_name; + let service_name_str = service_name.to_string(); + + let explorer_enabled = service_def.explorer.is_some() && service_def.openrpc.is_some(); + + // Content-Type gate: reject a non-`application/json` body with 415 before + // parsing. Requiring `application/json` forces a CORS preflight for + // cross-origin requests, closing the simple-request CSRF shape. + let content_type_gate = if service_def.require_json_content_type { + quote! { + { + let __ras_content_type_ok = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or("") + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false); + if !__ras_content_type_ok { + ras_jsonrpc_core::tracing::warn!( + "rejected JSON-RPC request: Content-Type is not application/json" + ); + return ( + axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + [("Content-Type", "application/json")], + serde_json::to_string(&ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::invalid_request(), + None, + )) + .unwrap_or_else(|_| "{}".to_string()), + ); + } + } + } + } else { + quote! {} + }; + + // Body-size cap: apply as a DefaultBodyLimit layer so an over-limit body + // is rejected by the extractor before the handler runs. + let body_limit_value = service_def.body_limit.unwrap_or(DEFAULT_BODY_LIMIT); + + // Startup assertion: a service with any WITH_PERMISSIONS method (or a + // gated explorer) needs an auth provider, else every such call silently fails + // authentication at runtime. Fail the build instead. + let any_route_requires_auth = service_def + .methods + .iter() + .any(|method| matches!(method.auth, AuthRequirement::WithPermissions(_))) + || (explorer_enabled && service_def.docs_require_auth); + let provider_check = if any_route_requires_auth { + quote! { + if self.auth_provider.is_none() { + return Err(concat!( + "JSON-RPC service `", + #service_name_str, + "` has methods requiring authorization (WITH_PERMISSIONS) but no ", + "auth_provider was configured; call .auth_provider(...) before build()" + ) + .to_string()); + } + } + } else { + quote! {} + }; + + // Apply the explorer's auth policy where the service auth configuration + // is available. + let explorer_route_integration = if explorer_enabled { + let service_name_lower = service_name_str.to_lowercase(); + let explorer_routes_fn_str = [&service_name_lower, "_explorer_routes"].concat(); + let explorer_routes_fn = syn::Ident::new(&explorer_routes_fn_str, service_name.span()); + if service_def.docs_require_auth { + quote! { + { + let __ras_docs_service = service.clone(); + // `route_layer` (not `layer`) so the gate runs only for the + // explorer/openrpc routes that actually match — an unrelated + // path 404s without the middleware, and the RPC endpoint + // (a separate route) is never gated. + let __ras_explorer = #explorer_routes_fn(&base_url).route_layer( + axum::middleware::from_fn(move |__ras_req: axum::extract::Request, __ras_next: axum::middleware::Next| { + let __ras_docs_service = __ras_docs_service.clone(); + async move { + use axum::response::IntoResponse; + let __ras_headers = __ras_req.headers().clone(); + let __ras_empty: Vec> = Vec::new(); + match ras_jsonrpc_core::authorize_request( + "GET", + &__ras_headers, + &__ras_docs_service.auth_transport, + __ras_docs_service.auth_provider.as_deref(), + &__ras_empty, + ).await { + Ok(_) => __ras_next.run(__ras_req).await, + Err(__ras_err) => { + let __ras_status = match __ras_err { + ras_jsonrpc_core::AuthorizeError::CsrfValidationFailed + | ras_jsonrpc_core::AuthorizeError::InsufficientPermissions(_) => + axum::http::StatusCode::FORBIDDEN, + ras_jsonrpc_core::AuthorizeError::NoAuthProvider => + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + _ => axum::http::StatusCode::UNAUTHORIZED, + }; + ras_jsonrpc_core::tracing::warn!(status = __ras_status.as_u16(), "explorer request rejected"); + ( + __ras_status, + [("Content-Type", "application/json")], + serde_json::json!({ "error": "Authentication required" }).to_string(), + ).into_response() + } + } + } + }) + ); + router = router.merge(__ras_explorer); + } + } + } else { + quote! { router = router.merge(#explorer_routes_fn(&base_url)); } + } + } else { + quote! {} + }; + + // Wire names of OPTIONAL_AUTH methods (canonical + legacy versions). The + // request-level auth step rejects bad credentials globally; for these methods + // we instead downgrade to anonymous so the route stays lenient/public. + let optional_auth_wire_names: Vec = service_def + .methods + .iter() + .filter(|method| matches!(method.auth, AuthRequirement::OptionalAuth)) + .flat_map(|method| { + std::iter::once(jsonrpc_method_wire_name(method)).chain( + method + .versions + .iter() + .map(|version| version.wire_name.clone()), + ) + }) + .collect(); + let optional_method_check = if optional_auth_wire_names.is_empty() { + quote! { false } + } else { + quote! { matches!(request.method.as_str(), #(#optional_auth_wire_names)|*) } + }; + + let method_dispatch = service_def + .methods + .iter() + .flat_map(generate_jsonrpc_method_dispatches); + + quote! { + /// Build the axum router for the JSON-RPC service + pub fn build(self) -> Result { + self.auth_transport + .validate() + .map_err(|err| err.to_string())?; + + #provider_check + + let base_url = self.base_url.clone(); + let service = std::sync::Arc::new(self); + + let rpc_handler = axum::routing::post({ + // Clone into the handler so the outer `service` survives for + // the (optional) docs auth gate below. + let service = service.clone(); + move |headers: axum::http::HeaderMap, body: String| { + let service = service.clone(); + async move { + // Reject a non-`application/json` body with 415 before parsing. + #content_type_gate + + let response = service.handle_request(headers, body).await; + + // Determine HTTP status code based on JSON-RPC error code + // Map authentication/authorization errors to appropriate HTTP status codes + // while maintaining JSON-RPC protocol compatibility + let status_code = if let Some(ref error) = response.error { + match error.code { + 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 { + axum::http::StatusCode::OK + }; + + // Rejections otherwise bypass the usage/duration trackers + // (which run mid-dispatch); log auth/CSRF/permission + // rejections here so a bad-credential caller is observable. + if status_code != axum::http::StatusCode::OK { + if let Some(ref error) = response.error { + ras_jsonrpc_core::tracing::warn!( + status = status_code.as_u16(), + code = error.code, + "JSON-RPC request rejected" + ); + } + } + + ( + status_code, + [("Content-Type", "application/json")], + serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string()) + ) + } + } + }); + + let mut router = axum::Router::new(); + + router = router.route(&base_url, rpc_handler); + + // Bound the request body size for the JSON-RPC endpoint. + router = router.layer(axum::extract::DefaultBodyLimit::max(#body_limit_value)); + + #explorer_route_integration + + Ok(router) + } + + async fn handle_request(&self, headers: axum::http::HeaderMap, body: String) -> ras_jsonrpc_types::JsonRpcResponse { + let request: ras_jsonrpc_types::JsonRpcRequest = match serde_json::from_str(&body) { + Ok(req) => req, + Err(__ras_json_err) => { + // Log the classification and location so the reason is + // recoverable server-side; never log the rejected value. + ras_jsonrpc_core::tracing::warn!( + category = ?__ras_json_err.classify(), + line = __ras_json_err.line(), + column = __ras_json_err.column(), + "rejected JSON-RPC request: malformed JSON body" + ); + return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::parse_error(), None); + } + }; + + let request_id = request.id.clone(); + + if request.jsonrpc != "2.0" { + return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::invalid_request(), request_id); + } + + // Resolve the credential to Ok(Some/None) or Err(error response), then + // apply a single downgrade decision: OPTIONAL_AUTH methods are public, + // so any credential failure (failed CSRF, invalid/expired token) + // downgrades to anonymous rather than rejecting the whole request. + let __ras_method_is_optional = #optional_method_check; + + let auth_outcome: Result< + Option, + ras_jsonrpc_types::JsonRpcResponse, + > = if let Some(auth_provider) = &self.auth_provider { + match ras_jsonrpc_core::extract_auth_credential(&headers, &self.auth_transport) { + Ok(credential) => { + if ras_jsonrpc_core::validate_csrf_for_credential("POST", &headers, &credential, &self.auth_transport).is_err() { + Err(ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::csrf_validation_failed(), + request_id.clone(), + )) + } else { + match auth_provider.authenticate(credential.token().to_string()).await { + Ok(user) => Ok(Some(user)), + Err(ras_jsonrpc_core::AuthError::TokenExpired) => { + Err(ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::token_expired(), + request_id.clone(), + )) + } + Err(_) => Err(ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::authentication_required(), + request_id.clone(), + )), + } + } + } + Err(ras_jsonrpc_core::AuthTransportError::MissingCredentials) => Ok(None), + Err(_) => Err(ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::authentication_required(), + request_id.clone(), + )), + } + } else { + Ok(None) + }; + + let authenticated_user = match auth_outcome { + Ok(user) => user, + Err(error_response) => { + if __ras_method_is_optional { + None + } else { + return error_response; + } + } + }; + + if let Some(tracker) = &self.usage_tracker { + let user_ref = authenticated_user.as_ref(); + let tracker_headers = + ras_jsonrpc_core::redact_sensitive_headers_for_auth_transport(&headers, &self.auth_transport); + tracker(&tracker_headers, user_ref, &request).await; + } + + match request.method.as_str() { + #(#method_dispatch)* + _ => ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::method_not_found(&request.method), + request_id + ) + } + } + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/server/mod.rs b/crates/rpc/ras-jsonrpc-macro/src/server/mod.rs new file mode 100644 index 0000000..1283f7a --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/server/mod.rs @@ -0,0 +1,137 @@ +//! JSON-RPC service trait and builder. + +use crate::ast::*; +use quote::quote; +mod dispatch; +mod http; + +pub(crate) fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenStream { + let service_name = &service_def.service_name; + let service_trait_name = quote::format_ident!("{}Trait", service_name); + let builder_name = quote::format_ident!("{}Builder", service_name); + + let trait_methods = service_def.methods.iter().map(|method| { + let method_name = &method.name; + let request_type = &method.request_type; + let response_type = &method.response_type; + + match &method.auth { + AuthRequirement::Unauthorized => { + quote! { + fn #method_name(&self, request: #request_type) -> impl std::future::Future>> + Send; + } + } + AuthRequirement::OptionalAuth => { + quote! { + fn #method_name(&self, caller: ras_jsonrpc_core::Caller, request: #request_type) -> impl std::future::Future>> + Send; + } + } + AuthRequirement::WithPermissions(_) => { + quote! { + fn #method_name(&self, user: &ras_jsonrpc_core::AuthenticatedUser, request: #request_type) -> impl std::future::Future>> + Send; + } + } + } + }); + + let http_methods = http::generate_http_methods(service_def); + + quote! { + /// Generated service trait + #[allow(private_interfaces, private_bounds)] + pub trait #service_trait_name: Send + Sync + 'static { + #(#trait_methods)* + } + + /// Generated builder for the JSON-RPC service + pub struct #builder_name { + 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>>, + } + + impl #builder_name { + /// Create a new builder with the service implementation. + /// + /// The JSON-RPC route defaults to `/rpc`; use `base_url` to override it. + pub fn new(service: T) -> Self { + Self { + 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, + } + } + + /// Override the JSON-RPC route path. + pub fn base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + /// Set the auth provider + pub fn auth_provider(mut self, provider: A) -> Self { + self.auth_provider = Some(Box::new(provider)); + self + } + + /// Enable cookie authentication alongside bearer tokens. + /// + /// Installs a default double-submit CSRF config when none is set, + /// because cookie credentials are CSRF-exploitable on unsafe methods. + /// Override with `csrf_protection`. + pub fn auth_cookie(mut self, cookie: ras_jsonrpc_core::AuthCookieConfig) -> Self { + self.auth_transport.cookie = Some(cookie); + if self.auth_transport.csrf.is_none() { + self.auth_transport.csrf = Some(ras_jsonrpc_core::CsrfConfig::default()); + } + 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 + where + F: Fn(&axum::http::HeaderMap, Option<&ras_jsonrpc_core::AuthenticatedUser>, &ras_jsonrpc_types::JsonRpcRequest) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + self.usage_tracker = Some(Box::new(move |headers, user, request| { + Box::pin(tracker(headers, user, request)) + })); + self + } + + /// Set the method duration tracker function + /// This function will be called after each method completes with the method name, authenticated user (if any), and the duration + pub fn with_method_duration_tracker(mut self, tracker: F) -> Self + where + F: Fn(&str, Option<&ras_jsonrpc_core::AuthenticatedUser>, std::time::Duration) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + self.method_duration_tracker = Some(Box::new(move |method, user, duration| { + Box::pin(tracker(method, user, duration)) + })); + self + } + + #http_methods + } + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs index 2503d1e..a8359f1 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs @@ -29,8 +29,7 @@ pub fn generate_static_hosting_code( return TokenStream::new(); } - const TEMPLATE_CONTENT: &str = - include_str!("../../../rest/ras-rest-macro/src/api_explorer_template.html"); + const TEMPLATE_CONTENT: &str = ras_api_explorer_assets::TEMPLATE; let explorer_path_suffix = normalize_explorer_path(&config.explorer_path); let service_name_str = service_name.to_string(); @@ -42,6 +41,7 @@ pub fn generate_static_hosting_code( // Embed the template as a string literal let template_lit = syn::LitStr::new(TEMPLATE_CONTENT, proc_macro2::Span::call_site()); + let config_placeholder = ras_api_explorer_assets::CONFIG_PLACEHOLDER; quote! { /// Routes for the JSON-RPC explorer @@ -62,7 +62,7 @@ pub fn generate_static_hosting_code( .to_string() .replace("<", "\\u003c"); - ::std::sync::Arc::new(TEMPLATE.replace("{EXPLORER_CONFIG_JSON}", &config_json)) + ::std::sync::Arc::new(TEMPLATE.replace(#config_placeholder, &config_json)) }; let serve_explorer = { diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs index e9a4308..179d4d8 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs @@ -172,13 +172,6 @@ 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; -} - /// Build the generated `DemoClient` wired to drive requests through the /// in-process `AxumTestTransport`, exercising the full envelope-build + /// transport-execute + error-extraction path of the migrated client. @@ -193,97 +186,6 @@ fn demo_client() -> DemoClient { .expect("build DemoClient over AxumTestTransport") } -#[cfg(feature = "client")] -#[tokio::test] -async fn generated_client_round_trips_over_axum_transport() { - let client = demo_client(); - - let resp = client - .ping(EchoRequest { - msg: "hello-from-client".to_string(), - }) - .await - .expect("ping over transport should succeed"); - - assert_eq!(resp.msg, "hello-from-client"); - assert_eq!(resp.user_id, None); -} - -#[cfg(feature = "client")] -#[tokio::test] -async fn generated_client_timeout_variant_accepts_duration() { - let client = demo_client(); - - let resp = client - .ping_with_timeout( - EchoRequest { - msg: "timeout-client".to_string(), - }, - std::time::Duration::from_secs(1), - ) - .await - .expect("ping_with_timeout over transport should succeed"); - - assert_eq!(resp.msg, "timeout-client"); - assert_eq!(resp.user_id, None); -} - -#[cfg(feature = "client")] -#[tokio::test] -async fn generated_client_sends_bearer_and_succeeds_with_permission() { - let mut client = demo_client(); - client.set_bearer_token(Some("user-token")); - - let resp = client - .add(AddRequest { a: 7, b: 35 }) - .await - .expect("authenticated add should succeed"); - - assert_eq!(resp.sum, 42); -} - -#[cfg(feature = "client")] -#[tokio::test] -async fn generated_client_surfaces_jsonrpc_error_on_missing_permission() { - let client = demo_client(); - - let err = client - .add(AddRequest { a: 1, b: 2 }) - .await - .expect_err("anonymous add must be rejected as a JSON-RPC error"); - - match err { - ras_transport_core::TransportError::JsonRpc { message, .. } => { - let m = message.to_lowercase(); - assert!( - m.contains("auth") || m.contains("permission"), - "expected auth/permission error, got: {message}" - ); - } - other => panic!("expected JsonRpc error variant, got: {other:?}"), - } -} - -#[cfg(feature = "client")] -#[tokio::test] -async fn generated_client_round_trips_versioned_wire_method() { - let client = demo_client(); - - let resp = client - .rename_user_v1_0_0(RenameUserV1 { - name: "Ada".to_string(), - }) - .await - .expect("legacy versioned method should round-trip via client"); - - assert_eq!( - resp, - RenameUserResponseV1 { - name: "Ada".to_string() - } - ); -} - async fn call_rpc( server: &axum_test::TestServer, method: &str, @@ -314,216 +216,11 @@ where } } -#[tokio::test] -async fn legacy_version_round_trips_through_canonical_handler() { - let server = server(); - - let resp: RenameUserResponseV1 = call_rpc( - &server, - "rename_user.v1", - json!(RenameUserV1 { - name: "Ada".to_string(), - }), - None, - ) - .await - .expect("legacy rename ok"); - - assert_eq!( - resp, - RenameUserResponseV1 { - name: "Ada".to_string() - } - ); -} - -#[tokio::test] -async fn canonical_version_uses_declared_wire_method() { - let server = server(); - - let resp: RenameUserResponseV2 = call_rpc( - &server, - "rename_user.v2", - json!(RenameUserV2 { - display_name: "Grace".to_string(), - notify: true, - }), - None, - ) - .await - .expect("canonical rename ok"); - - assert_eq!( - resp, - RenameUserResponseV2 { - display_name: "Grace".to_string(), - notified: true, - } - ); -} - -#[tokio::test] -async fn unauth_method_round_trips() { - let server = server(); - - let resp: EchoResponse = call_rpc( - &server, - "ping", - json!(EchoRequest { - msg: "hello".to_string(), - }), - None, - ) - .await - .expect("ping ok"); - - assert_eq!(resp.msg, "hello"); - assert_eq!(resp.user_id, None); -} - -#[tokio::test] -async fn optional_auth_method_anonymous_without_token() { - let server = server(); - - let resp: EchoResponse = call_rpc( - &server, - "whoami", - json!(EchoRequest { - msg: "hi".to_string() - }), - None, - ) - .await - .expect("whoami ok for anonymous"); - - assert_eq!(resp.msg, "hi"); - assert_eq!(resp.user_id, None); -} - -#[tokio::test] -async fn optional_auth_method_identifies_valid_token() { - let server = server(); - - let resp: EchoResponse = call_rpc( - &server, - "whoami", - json!(EchoRequest { - msg: "hi".to_string() - }), - Some("user-token"), - ) - .await - .expect("whoami ok for authenticated"); - - assert_eq!(resp.user_id.as_deref(), Some("user-1")); -} - -#[tokio::test] -async fn optional_auth_method_is_lenient_with_bad_token() { - let server = server(); - - // A present-but-invalid token must NOT reject an OPTIONAL_AUTH method. - let resp: EchoResponse = call_rpc( - &server, - "whoami", - json!(EchoRequest { - msg: "hi".to_string() - }), - Some("not-a-real-token"), - ) - .await - .expect("whoami stays lenient for a bad token"); - - assert_eq!(resp.user_id, None); -} - -#[tokio::test] -async fn permission_required_method_rejects_anonymous() { - let server = server(); - - let err = call_rpc::(&server, "add", json!(AddRequest { a: 2, b: 3 }), None) - .await - .expect_err("anonymous add must be rejected"); - - let s = err.to_string(); - assert!( - s.contains("Authentication") || s.contains("AUTH") || s.contains("auth"), - "expected auth-related error, got: {s}" - ); -} - -#[tokio::test] -async fn permission_required_method_rejects_wrong_perms() { - 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"), - "expected permission-related error, got: {s}" - ); -} - -#[tokio::test] -async fn permission_required_method_succeeds_with_correct_perms() { - 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 = server(); - - let resp: EchoResponse = call_rpc( - &server, - "admin_only", - json!(EchoRequest { - msg: "secret".to_string(), - }), - Some("admin-token"), - ) - .await - .expect("admin call ok"); - - assert_eq!(resp.msg, "secret"); - assert_eq!(resp.user_id.as_deref(), Some("admin-1")); -} - -#[tokio::test] -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 = server(); - - let body = serde_json::json!({ - "jsonrpc": "2.0", - "method": "ping", - "params": { "bogus": 1 }, - "id": 1, - }); - - let resp: serde_json::Value = server.post("/rpc").json(&body).await.json(); - - assert!( - resp.get("error").is_some(), - "expected error in response: {resp}" - ); - let code = resp["error"]["code"].as_i64().unwrap(); - assert_eq!(code, -32602, "expected invalid_params (-32602), got {code}"); -} +#[path = "e2e/auth.rs"] +mod auth; +#[path = "e2e/client.rs"] +mod client; +#[path = "e2e/parameters.rs"] +mod parameters; +#[path = "e2e/versioning.rs"] +mod versioning; diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e/auth.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e/auth.rs new file mode 100644 index 0000000..13759f9 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e/auth.rs @@ -0,0 +1,180 @@ +use super::*; + +#[cfg(feature = "client")] +#[tokio::test] +async fn generated_client_sends_bearer_and_succeeds_with_permission() { + let mut client = demo_client(); + client.set_bearer_token(Some("user-token")); + + let resp = client + .add(AddRequest { a: 7, b: 35 }) + .await + .expect("authenticated add should succeed"); + + assert_eq!(resp.sum, 42); +} + +#[cfg(feature = "client")] +#[tokio::test] +async fn generated_client_surfaces_jsonrpc_error_on_missing_permission() { + let client = demo_client(); + + let err = client + .add(AddRequest { a: 1, b: 2 }) + .await + .expect_err("anonymous add must be rejected as a JSON-RPC error"); + + match err { + ras_transport_core::TransportError::JsonRpc { message, .. } => { + let m = message.to_lowercase(); + assert!( + m.contains("auth") || m.contains("permission"), + "expected auth/permission error, got: {message}" + ); + } + other => panic!("expected JsonRpc error variant, got: {other:?}"), + } +} + +#[tokio::test] +async fn unauth_method_round_trips() { + let server = server(); + + let resp: EchoResponse = call_rpc( + &server, + "ping", + json!(EchoRequest { + msg: "hello".to_string(), + }), + None, + ) + .await + .expect("ping ok"); + + assert_eq!(resp.msg, "hello"); + assert_eq!(resp.user_id, None); +} + +#[tokio::test] +async fn optional_auth_method_anonymous_without_token() { + let server = server(); + + let resp: EchoResponse = call_rpc( + &server, + "whoami", + json!(EchoRequest { + msg: "hi".to_string() + }), + None, + ) + .await + .expect("whoami ok for anonymous"); + + assert_eq!(resp.msg, "hi"); + assert_eq!(resp.user_id, None); +} + +#[tokio::test] +async fn optional_auth_method_identifies_valid_token() { + let server = server(); + + let resp: EchoResponse = call_rpc( + &server, + "whoami", + json!(EchoRequest { + msg: "hi".to_string() + }), + Some("user-token"), + ) + .await + .expect("whoami ok for authenticated"); + + assert_eq!(resp.user_id.as_deref(), Some("user-1")); +} + +#[tokio::test] +async fn optional_auth_method_is_lenient_with_bad_token() { + let server = server(); + + // A present-but-invalid token must NOT reject an OPTIONAL_AUTH method. + let resp: EchoResponse = call_rpc( + &server, + "whoami", + json!(EchoRequest { + msg: "hi".to_string() + }), + Some("not-a-real-token"), + ) + .await + .expect("whoami stays lenient for a bad token"); + + assert_eq!(resp.user_id, None); +} + +#[tokio::test] +async fn permission_required_method_rejects_anonymous() { + let server = server(); + + let err = call_rpc::(&server, "add", json!(AddRequest { a: 2, b: 3 }), None) + .await + .expect_err("anonymous add must be rejected"); + + let s = err.to_string(); + assert!( + s.contains("Authentication") || s.contains("AUTH") || s.contains("auth"), + "expected auth-related error, got: {s}" + ); +} + +#[tokio::test] +async fn permission_required_method_rejects_wrong_perms() { + 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"), + "expected permission-related error, got: {s}" + ); +} + +#[tokio::test] +async fn permission_required_method_succeeds_with_correct_perms() { + 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 = server(); + + let resp: EchoResponse = call_rpc( + &server, + "admin_only", + json!(EchoRequest { + msg: "secret".to_string(), + }), + Some("admin-token"), + ) + .await + .expect("admin call ok"); + + assert_eq!(resp.msg, "secret"); + assert_eq!(resp.user_id.as_deref(), Some("admin-1")); +} diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e/client.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e/client.rs new file mode 100644 index 0000000..33af12e --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e/client.rs @@ -0,0 +1,36 @@ +use super::*; + +#[cfg(feature = "client")] +#[tokio::test] +async fn generated_client_round_trips_over_axum_transport() { + let client = demo_client(); + + let resp = client + .ping(EchoRequest { + msg: "hello-from-client".to_string(), + }) + .await + .expect("ping over transport should succeed"); + + assert_eq!(resp.msg, "hello-from-client"); + assert_eq!(resp.user_id, None); +} + +#[cfg(feature = "client")] +#[tokio::test] +async fn generated_client_timeout_variant_accepts_duration() { + let client = demo_client(); + + let resp = client + .ping_with_timeout( + EchoRequest { + msg: "timeout-client".to_string(), + }, + std::time::Duration::from_secs(1), + ) + .await + .expect("ping_with_timeout over transport should succeed"); + + assert_eq!(resp.msg, "timeout-client"); + assert_eq!(resp.user_id, None); +} diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e/parameters.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e/parameters.rs new file mode 100644 index 0000000..bbfd9d3 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e/parameters.rs @@ -0,0 +1,24 @@ +use super::*; + +#[tokio::test] +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 = server(); + + let body = serde_json::json!({ + "jsonrpc": "2.0", + "method": "ping", + "params": { "bogus": 1 }, + "id": 1, + }); + + let resp: serde_json::Value = server.post("/rpc").json(&body).await.json(); + + assert!( + resp.get("error").is_some(), + "expected error in response: {resp}" + ); + let code = resp["error"]["code"].as_i64().unwrap(); + assert_eq!(code, -32602, "expected invalid_params (-32602), got {code}"); +} diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e/versioning.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e/versioning.rs new file mode 100644 index 0000000..34c74e1 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e/versioning.rs @@ -0,0 +1,76 @@ +use super::*; + +#[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; +} + +#[cfg(feature = "client")] +#[tokio::test] +async fn generated_client_round_trips_versioned_wire_method() { + let client = demo_client(); + + let resp = client + .rename_user_v1_0_0(RenameUserV1 { + name: "Ada".to_string(), + }) + .await + .expect("legacy versioned method should round-trip via client"); + + assert_eq!( + resp, + RenameUserResponseV1 { + name: "Ada".to_string() + } + ); +} + +#[tokio::test] +async fn legacy_version_round_trips_through_canonical_handler() { + let server = server(); + + let resp: RenameUserResponseV1 = call_rpc( + &server, + "rename_user.v1", + json!(RenameUserV1 { + name: "Ada".to_string(), + }), + None, + ) + .await + .expect("legacy rename ok"); + + assert_eq!( + resp, + RenameUserResponseV1 { + name: "Ada".to_string() + } + ); +} + +#[tokio::test] +async fn canonical_version_uses_declared_wire_method() { + let server = server(); + + let resp: RenameUserResponseV2 = call_rpc( + &server, + "rename_user.v2", + json!(RenameUserV2 { + display_name: "Grace".to_string(), + notify: true, + }), + None, + ) + .await + .expect("canonical rename ok"); + + assert_eq!( + resp, + RenameUserResponseV2 { + display_name: "Grace".to_string(), + notified: true, + } + ); +} 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 90bf485..29ab26d 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,6 +1,6 @@ #[test] 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"); + let template = ras_api_explorer_assets::TEMPLATE; assert!(!template.contains("localStorage.getItem('bearer-token')")); assert!(!template.contains("localStorage.setItem('bearer-token'")); assert!(!template.contains("localStorage.removeItem('bearer-token'")); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs index a192b1a..298b95f 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs @@ -299,428 +299,11 @@ async fn make_jsonrpc_request( request.await.json() } -#[tokio::test] -async fn test_unauthorized_methods() { - let server = create_test_server(); - - // Test sign_in with valid credentials - let response = make_jsonrpc_request( - &server, - "sign_in", - json!({ - "email": "admin@test.com", - "password": "admin123" - }), - None, - ) - .await; - - assert_eq!(response["jsonrpc"], "2.0"); - assert_eq!(response["id"], 1); - assert!(response.get("error").is_none()); - - let result = &response["result"]; - assert_eq!(result["jwt"], "valid-admin-token"); - assert_eq!(result["user_id"], "admin-user"); - - // Test sign_in with invalid credentials - let response = make_jsonrpc_request( - &server, - "sign_in", - json!({ - "email": "wrong@test.com", - "password": "wrong" - }), - None, - ) - .await; - - assert!(response.get("error").is_some()); - - // Test get_public_info - let response = make_jsonrpc_request(&server, "get_public_info", json!(()), None).await; - - 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": [ - {"id": 1, "value": "test", "active": true}, - {"id": 2, "value": "test2", "active": false} - ], - "metadata": { - "version": "1.0", - "tags": ["test", "demo"] - } - }); - - 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 server = create_test_server(); - - // Test without token - should fail - let response = make_jsonrpc_request(&server, "sign_out", json!(()), None).await; - - assert!(response.get("error").is_some()); - let error = &response["error"]; - assert_eq!(error["code"], -32001); // Custom auth error code - - // Test with valid token - should succeed - let response = - 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( - &server, - "get_user_info", - json!(()), - Some("valid-user-token"), - ) - .await; - - assert!(response.get("error").is_none()); - let result = &response["result"]; - assert_eq!(result["name"], "User regular-user"); - assert_eq!(result["email"], "regular-user@test.com"); - - // Test process_data - let response = make_jsonrpc_request( - &server, - "process_data", - json!(["item1", "item2", "item3"]), - Some("valid-empty-perms-token"), - ) - .await; - - assert!(response.get("error").is_none()); - let result = &response["result"]; - assert_eq!(result["processed_count"], 3); - 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(); - let request_body = json!({ - "jsonrpc": "2.0", - "method": "get_user_info", - "params": (), - "id": 1 - }); - - // Cookie auth on a POST requires the double-submit CSRF header. - let response: Value = 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 - .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(); - 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(); - - // Test with user token (insufficient permissions) - should fail - let response = make_jsonrpc_request( - &server, - "delete_everything", - json!(()), - Some("valid-user-token"), - ) - .await; - - assert!(response.get("error").is_some()); - let error = &response["error"]; - assert_eq!(error["code"], -32002); // Insufficient permissions error - - // Test with admin token - should succeed - let response = make_jsonrpc_request( - &server, - "delete_everything", - json!(()), - Some("valid-admin-token"), - ) - .await; - - assert!(response.get("error").is_none()); - - // Test create_user with admin token - let response = make_jsonrpc_request( - &server, - "create_user", - json!({ - "name": "New User", - "email": "new@test.com", - "permissions": ["user"] - }), - Some("valid-admin-token"), - ) - .await; - - assert!(response.get("error").is_none()); - let result = &response["result"]; - assert_eq!(result["name"], "New User"); - assert_eq!(result["email"], "new@test.com"); - assert!(result["id"].as_i64().unwrap() >= 1000); -} - -#[tokio::test] -async fn test_user_permission_methods() { - let server = create_test_server(); - - // Test with empty permissions token - should fail - let response = make_jsonrpc_request( - &server, - "update_profile", - json!({ - "name": "Updated User", - "email": "updated@test.com", - "permissions": [] - }), - Some("valid-empty-perms-token"), - ) - .await; - - assert!(response.get("error").is_some()); - - // Test with user token - should succeed - let response = make_jsonrpc_request( - &server, - "update_profile", - json!({ - "name": "Updated User", - "email": "updated@test.com", - "permissions": [] - }), - Some("valid-user-token"), - ) - .await; - - assert!(response.get("error").is_none()); - let result = &response["result"]; - assert_eq!(result["name"], "Updated User"); - assert_eq!(result["id"], 456); - - // Test get_user_data with existing user - let response = make_jsonrpc_request( - &server, - "get_user_data", - json!(123), - Some("valid-user-token"), - ) - .await; - - assert!(response.get("error").is_none()); - let result = &response["result"]; - assert_eq!(result["name"], "Found User"); - - // Test get_user_data with non-existing user - let response = make_jsonrpc_request( - &server, - "get_user_data", - json!(999), - Some("valid-user-token"), - ) - .await; - - assert!(response.get("error").is_none()); - assert_eq!(response["result"], json!(null)); -} - -#[tokio::test] -async fn test_invalid_requests() { - let server = create_test_server(); - - // Test method not found - let response = make_jsonrpc_request(&server, "non_existent_method", json!(()), None).await; - - assert!(response.get("error").is_some()); - let error = &response["error"]; - assert_eq!(error["code"], -32601); // Method not found - - // Test invalid JSON-RPC format (missing jsonrpc field) - let invalid_request = json!({ - "method": "sign_in", - "params": {}, - "id": 1 - }); - - 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(&server, "sign_in", json!("invalid_params"), None).await; - - assert!(response.get("error").is_some()); -} - -#[tokio::test] -async fn test_concurrent_requests() { - let server = std::sync::Arc::new(create_test_server()); - - // Test multiple concurrent requests - let mut handles = vec![]; - - for _ in 0..10 { - let server = std::sync::Arc::clone(&server); - let handle = tokio::spawn(async move { - make_jsonrpc_request(&server, "get_public_info", json!(()), None).await - }); - handles.push(handle); - } - - // Wait for all requests to complete - let results = futures::future::join_all(handles).await; - - // All requests should succeed - for result in results { - let response = result.unwrap(); - assert_eq!(response["result"], "This is public information"); - } -} - -#[tokio::test] -async fn test_openrpc_generation() { - // Test that OpenRPC document is generated correctly - let openrpc_doc = generate_testservice_openrpc(); - - assert_eq!(openrpc_doc["openrpc"], "1.3.2"); - assert_eq!(openrpc_doc["info"]["title"], "TestService JSON-RPC API"); - - let methods = openrpc_doc["methods"].as_array().unwrap(); - assert_eq!(methods.len(), 11); // We have 11 methods defined - - // Check that unauthorized methods don't have authentication metadata - let sign_in_method = methods.iter().find(|m| m["name"] == "sign_in").unwrap(); - assert!(sign_in_method.get("x-authentication").is_none()); - - // Check that admin methods have correct permissions - let delete_method = methods - .iter() - .find(|m| m["name"] == "delete_everything") - .unwrap(); - 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 - let moderate_method = methods - .iter() - .find(|m| m["name"] == "moderate_content") - .unwrap(); - let permissions = moderate_method["x-permissions"].as_array().unwrap(); - assert_eq!(permissions.len(), 2); - assert!(permissions.contains(&json!("admin"))); - assert!(permissions.contains(&json!("moderator"))); -} - -#[cfg(feature = "reqwest")] -#[test] -fn test_client_generation() { - // Test that client generation compiles and produces valid API - let client_result = TestServiceClientBuilder::new("http://example.invalid/rpc") - .with_timeout(std::time::Duration::from_millis(1000)) - .build(); - - assert!(client_result.is_ok()); - - let mut client = client_result.unwrap(); - client.set_bearer_token(Some("test-token")); - assert_eq!(client.bearer_token(), Some("test-token")); -} +#[path = "http_integration/auth.rs"] +mod auth; +#[path = "http_integration/client.rs"] +mod client; +#[path = "http_integration/parameters.rs"] +mod parameters; +#[path = "http_integration/specs.rs"] +mod specs; diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration/auth.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration/auth.rs new file mode 100644 index 0000000..dde8998 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration/auth.rs @@ -0,0 +1,323 @@ +use super::*; + +#[tokio::test] +async fn test_unauthorized_methods() { + let server = create_test_server(); + + // Test sign_in with valid credentials + let response = make_jsonrpc_request( + &server, + "sign_in", + json!({ + "email": "admin@test.com", + "password": "admin123" + }), + None, + ) + .await; + + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], 1); + assert!(response.get("error").is_none()); + + let result = &response["result"]; + assert_eq!(result["jwt"], "valid-admin-token"); + assert_eq!(result["user_id"], "admin-user"); + + // Test sign_in with invalid credentials + let response = make_jsonrpc_request( + &server, + "sign_in", + json!({ + "email": "wrong@test.com", + "password": "wrong" + }), + None, + ) + .await; + + assert!(response.get("error").is_some()); + + // Test get_public_info + let response = make_jsonrpc_request(&server, "get_public_info", json!(()), None).await; + + 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": [ + {"id": 1, "value": "test", "active": true}, + {"id": 2, "value": "test2", "active": false} + ], + "metadata": { + "version": "1.0", + "tags": ["test", "demo"] + } + }); + + 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 server = create_test_server(); + + // Test without token - should fail + let response = make_jsonrpc_request(&server, "sign_out", json!(()), None).await; + + assert!(response.get("error").is_some()); + let error = &response["error"]; + assert_eq!(error["code"], -32001); // Custom auth error code + + // Test with valid token - should succeed + let response = + 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( + &server, + "get_user_info", + json!(()), + Some("valid-user-token"), + ) + .await; + + assert!(response.get("error").is_none()); + let result = &response["result"]; + assert_eq!(result["name"], "User regular-user"); + assert_eq!(result["email"], "regular-user@test.com"); + + // Test process_data + let response = make_jsonrpc_request( + &server, + "process_data", + json!(["item1", "item2", "item3"]), + Some("valid-empty-perms-token"), + ) + .await; + + assert!(response.get("error").is_none()); + let result = &response["result"]; + assert_eq!(result["processed_count"], 3); + 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(); + let request_body = json!({ + "jsonrpc": "2.0", + "method": "get_user_info", + "params": (), + "id": 1 + }); + + // Cookie auth on a POST requires the double-submit CSRF header. + let response: Value = 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 + .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(); + 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(); + + // Test with user token (insufficient permissions) - should fail + let response = make_jsonrpc_request( + &server, + "delete_everything", + json!(()), + Some("valid-user-token"), + ) + .await; + + assert!(response.get("error").is_some()); + let error = &response["error"]; + assert_eq!(error["code"], -32002); // Insufficient permissions error + + // Test with admin token - should succeed + let response = make_jsonrpc_request( + &server, + "delete_everything", + json!(()), + Some("valid-admin-token"), + ) + .await; + + assert!(response.get("error").is_none()); + + // Test create_user with admin token + let response = make_jsonrpc_request( + &server, + "create_user", + json!({ + "name": "New User", + "email": "new@test.com", + "permissions": ["user"] + }), + Some("valid-admin-token"), + ) + .await; + + assert!(response.get("error").is_none()); + let result = &response["result"]; + assert_eq!(result["name"], "New User"); + assert_eq!(result["email"], "new@test.com"); + assert!(result["id"].as_i64().unwrap() >= 1000); +} + +#[tokio::test] +async fn test_user_permission_methods() { + let server = create_test_server(); + + // Test with empty permissions token - should fail + let response = make_jsonrpc_request( + &server, + "update_profile", + json!({ + "name": "Updated User", + "email": "updated@test.com", + "permissions": [] + }), + Some("valid-empty-perms-token"), + ) + .await; + + assert!(response.get("error").is_some()); + + // Test with user token - should succeed + let response = make_jsonrpc_request( + &server, + "update_profile", + json!({ + "name": "Updated User", + "email": "updated@test.com", + "permissions": [] + }), + Some("valid-user-token"), + ) + .await; + + assert!(response.get("error").is_none()); + let result = &response["result"]; + assert_eq!(result["name"], "Updated User"); + assert_eq!(result["id"], 456); + + // Test get_user_data with existing user + let response = make_jsonrpc_request( + &server, + "get_user_data", + json!(123), + Some("valid-user-token"), + ) + .await; + + assert!(response.get("error").is_none()); + let result = &response["result"]; + assert_eq!(result["name"], "Found User"); + + // Test get_user_data with non-existing user + let response = make_jsonrpc_request( + &server, + "get_user_data", + json!(999), + Some("valid-user-token"), + ) + .await; + + assert!(response.get("error").is_none()); + assert_eq!(response["result"], json!(null)); +} diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration/client.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration/client.rs new file mode 100644 index 0000000..fffd61a --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration/client.rs @@ -0,0 +1,41 @@ +use super::*; + +#[tokio::test] +async fn test_concurrent_requests() { + let server = std::sync::Arc::new(create_test_server()); + + // Test multiple concurrent requests + let mut handles = vec![]; + + for _ in 0..10 { + let server = std::sync::Arc::clone(&server); + let handle = tokio::spawn(async move { + make_jsonrpc_request(&server, "get_public_info", json!(()), None).await + }); + handles.push(handle); + } + + // Wait for all requests to complete + let results = futures::future::join_all(handles).await; + + // All requests should succeed + for result in results { + let response = result.unwrap(); + assert_eq!(response["result"], "This is public information"); + } +} + +#[cfg(feature = "reqwest")] +#[test] +fn test_client_generation() { + // Test that client generation compiles and produces valid API + let client_result = TestServiceClientBuilder::new("http://example.invalid/rpc") + .with_timeout(std::time::Duration::from_millis(1000)) + .build(); + + assert!(client_result.is_ok()); + + let mut client = client_result.unwrap(); + client.set_bearer_token(Some("test-token")); + assert_eq!(client.bearer_token(), Some("test-token")); +} diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration/parameters.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration/parameters.rs new file mode 100644 index 0000000..8ffd5af --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration/parameters.rs @@ -0,0 +1,28 @@ +use super::*; + +#[tokio::test] +async fn test_invalid_requests() { + let server = create_test_server(); + + // Test method not found + let response = make_jsonrpc_request(&server, "non_existent_method", json!(()), None).await; + + assert!(response.get("error").is_some()); + let error = &response["error"]; + assert_eq!(error["code"], -32601); // Method not found + + // Test invalid JSON-RPC format (missing jsonrpc field) + let invalid_request = json!({ + "method": "sign_in", + "params": {}, + "id": 1 + }); + + 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(&server, "sign_in", json!("invalid_params"), None).await; + + assert!(response.get("error").is_some()); +} diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration/specs.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration/specs.rs new file mode 100644 index 0000000..0d07c17 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration/specs.rs @@ -0,0 +1,38 @@ +use super::*; + +#[tokio::test] +async fn test_openrpc_generation() { + // Test that OpenRPC document is generated correctly + let openrpc_doc = generate_testservice_openrpc(); + + assert_eq!(openrpc_doc["openrpc"], "1.3.2"); + assert_eq!(openrpc_doc["info"]["title"], "TestService JSON-RPC API"); + + let methods = openrpc_doc["methods"].as_array().unwrap(); + assert_eq!(methods.len(), 11); // We have 11 methods defined + + // Check that unauthorized methods don't have authentication metadata + let sign_in_method = methods.iter().find(|m| m["name"] == "sign_in").unwrap(); + assert!(sign_in_method.get("x-authentication").is_none()); + + // Check that admin methods have correct permissions + let delete_method = methods + .iter() + .find(|m| m["name"] == "delete_everything") + .unwrap(); + 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 + let moderate_method = methods + .iter() + .find(|m| m["name"] == "moderate_content") + .unwrap(); + let permissions = moderate_method["x-permissions"].as_array().unwrap(); + assert_eq!(permissions.len(), 2); + assert!(permissions.contains(&json!("admin"))); + assert!(permissions.contains(&json!("moderator"))); +} diff --git a/crates/rpc/ras-jsonrpc-macro/tests/xm_feedback_parity_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_service_contracts.rs similarity index 100% rename from crates/rpc/ras-jsonrpc-macro/tests/xm_feedback_parity_test.rs rename to crates/rpc/ras-jsonrpc-macro/tests/http_service_contracts.rs diff --git a/crates/specs/ras-api-explorer-assets/Cargo.toml b/crates/specs/ras-api-explorer-assets/Cargo.toml new file mode 100644 index 0000000..fdfc002 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "ras-api-explorer-assets" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Embedded API explorer assets shared by RAS service macros" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" diff --git a/crates/specs/ras-api-explorer-assets/README.md b/crates/specs/ras-api-explorer-assets/README.md new file mode 100644 index 0000000..c38c4b1 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/README.md @@ -0,0 +1,6 @@ +Embedded API explorer assets for the REST and JSON-RPC service macros. + +`TEMPLATE` is a self-contained HTML page assembled at compile time from the +standalone markup, stylesheet, and script files under `src/assets`. The macros +embed it and replace `CONFIG_PLACEHOLDER` with the service configuration as +JSON whose `<` characters are escaped. This crate has no runtime dependencies. diff --git a/crates/specs/ras-api-explorer-assets/src/assets/body.html b/crates/specs/ras-api-explorer-assets/src/assets/body.html new file mode 100644 index 0000000..a680a84 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/body.html @@ -0,0 +1,106 @@ + + +
+ + +
+
+
+

Select an operation

+
Choose an operation to prepare a request.
+
+
+ + +
+
+
+
+
+ Request + +
+
+
No operation selected.
+
+
+
+
+ Saved requests + +
+
+
+
+
+ + +
+
+ + + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/bootstrap.js b/crates/specs/ras-api-explorer-assets/src/assets/bootstrap.js new file mode 100644 index 0000000..58e6679 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/bootstrap.js @@ -0,0 +1,17 @@ + const CONFIG = JSON.parse(document.getElementById("ras-explorer-config").textContent); + + const METHODS = ["get", "post", "put", "patch", "delete", "head", "options"]; + const state = { + spec: null, + operations: [], + selectedId: null, + token: "", + environments: [], + activeEnvironment: 0, + saved: {}, + history: [], + lastResponse: { body: "", headers: "", request: "" }, + responseTab: "body" + }; + const storagePrefix = `ras-explorer:${CONFIG.protocol}:${CONFIG.serviceName}:${location.pathname}`; + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/events.js b/crates/specs/ras-api-explorer-assets/src/assets/events.js new file mode 100644 index 0000000..7d1c7de --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/events.js @@ -0,0 +1,118 @@ + function renderResponseOutput() { + $("response-output").textContent = state.lastResponse[state.responseTab] || ""; + } + + function saveCurrentRequest() { + const operation = activeOperation(); + const snapshot = currentRequestSnapshot(); + if (!operation || !snapshot) return; + const name = prompt("Saved request name", operation.label); + if (!name) return; + state.saved[operation.id] = state.saved[operation.id] || []; + state.saved[operation.id].unshift({ name, snapshot, createdAt: Date.now() }); + storageSet("saved", state.saved); + renderSaved(); + } + + async function loadSpec() { + $("service-name").textContent = `${CONFIG.serviceName} Explorer`; + $("service-subtitle").textContent = CONFIG.protocol === "rest" ? "REST OpenAPI" : "JSON-RPC OpenRPC"; + const response = await fetch(CONFIG.specPath, { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error(`Failed to load API specification: ${response.status}`); + state.spec = await response.json(); + state.operations = CONFIG.protocol === "rest" ? normalizeOpenApi(state.spec) : normalizeOpenRpc(state.spec); + renderOperations(); + if (state.operations.length) selectOperation(state.operations[0].id); + } + + function bindEvents() { + $("theme-toggle").addEventListener("click", () => { + const current = document.documentElement.getAttribute("data-theme"); + setTheme(current === "dark" ? "light" : "dark"); + }); + $("operation-search").addEventListener("input", renderOperations); + $("environment-select").addEventListener("change", (event) => { + state.activeEnvironment = Number(event.target.value); + storageSet("activeEnvironment", state.activeEnvironment); + renderEnvironments(); + updateRequestUrl(); + }); + $("base-url").addEventListener("input", (event) => { + state.environments[state.activeEnvironment].baseUrl = event.target.value; + storageSet("environments", state.environments); + updateRequestUrl(); + }); + $("add-environment").addEventListener("click", () => { + const name = prompt("Environment name", `Env ${state.environments.length + 1}`); + if (!name) return; + state.environments.push({ name, baseUrl: activeBaseUrl() }); + state.activeEnvironment = state.environments.length - 1; + storageSet("environments", state.environments); + storageSet("activeEnvironment", state.activeEnvironment); + renderEnvironments(); + }); + $("save-token").addEventListener("click", () => { + 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 = ""; + $("bearer-token").value = ""; + sessionStorage.removeItem(`${storagePrefix}:bearer-token`); + $("auth-state").textContent = "No token"; + }); + $("send-request").addEventListener("click", sendCurrentRequest); + $("save-request").addEventListener("click", saveCurrentRequest); + $("clear-saved").addEventListener("click", () => { + const operation = activeOperation(); + if (operation) { + state.saved[operation.id] = []; + storageSet("saved", state.saved); + renderSaved(); + } + }); + $("clear-history").addEventListener("click", () => { + state.history = []; + storageSet("history", state.history); + renderHistory(); + }); + $("copy-response").addEventListener("click", async () => { + await navigator.clipboard.writeText($("response-output").textContent); + showToast("Copied response"); + }); + document.querySelectorAll("[data-response-tab]").forEach((tab) => { + tab.addEventListener("click", () => { + state.responseTab = tab.dataset.responseTab; + document.querySelectorAll("[data-response-tab]").forEach((item) => item.classList.toggle("active", item === tab)); + renderResponseOutput(); + }); + }); + } + + document.addEventListener("DOMContentLoaded", async () => { + initializeTheme(); + state.environments = storageGet("environments", [{ name: "Default", baseUrl: CONFIG.apiBasePath || "/" }]); + state.activeEnvironment = storageGet("activeEnvironment", 0); + state.saved = storageGet("saved", {}); + state.history = storageGet("history", []); + state.token = storageGet("bearer-token", ""); + $("bearer-token").value = state.token; + $("auth-state").textContent = state.token ? "Token set" : "No token"; + bindEvents(); + renderEnvironments(); + renderHistory(); + renderSaved(); + try { + await loadSpec(); + } catch (error) { + $("operation-list").textContent = ""; + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = error.message; + $("operation-list").appendChild(empty); + showToast(error.message); + } + }); + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/explorer.css b/crates/specs/ras-api-explorer-assets/src/assets/explorer.css new file mode 100644 index 0000000..07362c8 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/explorer.css @@ -0,0 +1,432 @@ + * { box-sizing: border-box; } + :root { + color-scheme: dark; + --bg: #101114; + --panel: #17191f; + --panel-2: #20232b; + --panel-3: #292d36; + --text: #eef1f6; + --muted: #a7afbf; + --faint: #717b8f; + --border: #343946; + --accent: #4f8cff; + --accent-2: #46c2a8; + --warn: #f2b84b; + --danger: #ff6b6b; + --ok: #59d18c; + --shadow: 0 14px 40px rgb(0 0 0 / 0.32); + } + [data-theme="light"] { + color-scheme: light; + --bg: #f4f6fa; + --panel: #ffffff; + --panel-2: #f0f3f8; + --panel-3: #e5eaf2; + --text: #121722; + --muted: #4c586d; + --faint: #748096; + --border: #d9e0ec; + --accent: #1d5fd1; + --accent-2: #087f68; + --warn: #976508; + --danger: #c93333; + --ok: #167a42; + --shadow: 0 14px 34px rgb(27 39 60 / 0.16); + } + body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + button, input, select, textarea { + font: inherit; + } + button { + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + border-radius: 7px; + padding: 0.48rem 0.68rem; + cursor: pointer; + } + button:hover { border-color: var(--accent); } + button.primary { + border-color: var(--accent); + background: var(--accent); + color: white; + font-weight: 650; + } + button.ghost { background: transparent; } + button:disabled { + cursor: not-allowed; + opacity: 0.62; + } + input, select, textarea { + width: 100%; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + border-radius: 7px; + padding: 0.55rem 0.65rem; + min-width: 0; + } + textarea { + min-height: 180px; + resize: vertical; + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 12.5px; + line-height: 1.5; + } + code, pre, .mono { + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + } + .app { + display: grid; + grid-template-columns: minmax(250px, 330px) minmax(420px, 1fr) minmax(320px, 470px); + min-height: 100vh; + } + .sidebar, .workspace, .response { + min-width: 0; + border-right: 1px solid var(--border); + background: var(--panel); + } + .workspace { + background: var(--bg); + } + .response { + border-right: 0; + } + .topbar { + min-height: 68px; + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + .brand { + min-width: 0; + } + .brand h1 { + margin: 0; + font-size: 1rem; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .brand .sub { + color: var(--muted); + font-size: 0.78rem; + margin-top: 0.15rem; + } + .stack { + padding: 1rem; + display: grid; + gap: 0.75rem; + } + .field { + display: grid; + gap: 0.35rem; + } + .field label, .section-title { + color: var(--muted); + font-size: 0.74rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .row { + display: flex; + gap: 0.55rem; + align-items: center; + } + .row > * { min-width: 0; } + .row .grow { flex: 1; } + .search { + padding: 0.9rem 1rem; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + } + .list { + padding: 0.65rem; + display: grid; + gap: 0.45rem; + overflow: auto; + max-height: calc(100vh - 226px); + } + .op { + text-align: left; + width: 100%; + background: var(--panel); + border-color: var(--border); + padding: 0.7rem; + } + .op.active { + border-color: var(--accent); + box-shadow: inset 3px 0 0 var(--accent); + background: var(--panel-2); + } + .op-main { + display: flex; + align-items: center; + gap: 0.55rem; + } + .op-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 650; + } + .op-desc { + color: var(--muted); + font-size: 0.78rem; + margin-top: 0.28rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .badge { + display: inline-flex; + align-items: center; + min-height: 22px; + border-radius: 6px; + padding: 0.1rem 0.38rem; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--muted); + font-size: 0.72rem; + font-weight: 800; + white-space: nowrap; + } + .get { color: #74b9ff; } + .post { color: #59d18c; } + .put, .patch { color: #f2b84b; } + .delete { color: #ff8585; } + .lock { margin-left: auto; color: var(--warn); } + .open { margin-left: auto; color: var(--ok); } + .main-scroll, .response-scroll { + height: calc(100vh - 68px); + overflow: auto; + padding: 1rem; + } + .panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); + margin-bottom: 1rem; + } + .panel-head { + padding: 0.9rem 1rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + .panel-body { + padding: 1rem; + display: grid; + gap: 0.9rem; + } + .titleline { + min-width: 0; + } + .titleline h2 { + margin: 0; + font-size: 1.03rem; + overflow-wrap: anywhere; + } + .titleline .description { + margin: 0.25rem 0 0; + color: var(--muted); + } + .grid2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } + .params { + display: grid; + gap: 0.55rem; + } + .param-row { + display: grid; + grid-template-columns: minmax(110px, 0.85fr) minmax(160px, 1.4fr) minmax(76px, 0.5fr); + gap: 0.55rem; + align-items: center; + } + .param-row .name { + overflow-wrap: anywhere; + } + .hint { + color: var(--muted); + font-size: 0.82rem; + } + .schema-docs { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-2); + padding: 0.75rem; + display: grid; + gap: 0.6rem; + } + .schema-head { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + .schema-desc { + margin: 0; + color: var(--muted); + } + .schema-fields { + display: grid; + gap: 0.45rem; + } + .schema-field { + display: grid; + grid-template-columns: minmax(120px, 0.9fr) minmax(80px, 0.5fr) minmax(160px, 1.5fr); + gap: 0.55rem; + align-items: start; + padding-top: 0.45rem; + border-top: 1px solid var(--border); + } + .schema-field-desc { + color: var(--muted); + } + .markdown { + display: grid; + gap: 0.5rem; + } + .markdown p { + margin: 0; + white-space: pre-wrap; + } + .markdown ul { + margin: 0; + padding-left: 1.2rem; + display: grid; + gap: 0.25rem; + } + .markdown li { + padding-left: 0.15rem; + } + .markdown code { + background: var(--panel-3); + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.05rem 0.25rem; + font-size: 0.92em; + } + .markdown pre { + min-height: 0; + white-space: pre; + margin: 0; + } + .markdown pre code { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + } + .markdown a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; + } + .tabs { + display: flex; + gap: 0.45rem; + flex-wrap: wrap; + } + .tab.active { + border-color: var(--accent); + color: white; + background: var(--accent); + } + pre { + margin: 0; + min-height: 220px; + white-space: pre-wrap; + overflow: auto; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.85rem; + font-size: 12.5px; + } + .status { + display: inline-flex; + align-items: center; + border-radius: 6px; + padding: 0.16rem 0.45rem; + font-weight: 800; + font-size: 0.75rem; + border: 1px solid var(--border); + } + .status.ok { color: var(--ok); } + .status.warn { color: var(--warn); } + .status.err { color: var(--danger); } + .history-item, .saved-item { + display: grid; + gap: 0.18rem; + padding: 0.55rem; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--panel-2); + } + .history-item button, .saved-item button { + justify-self: start; + margin-top: 0.25rem; + } + .toast { + position: fixed; + right: 1rem; + bottom: 1rem; + max-width: min(420px, calc(100vw - 2rem)); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem 0.9rem; + box-shadow: var(--shadow); + opacity: 0; + transform: translateY(12px); + transition: opacity 0.18s, transform 0.18s; + pointer-events: none; + } + .toast.show { + opacity: 1; + transform: translateY(0); + } + .empty { + color: var(--muted); + border: 1px dashed var(--border); + border-radius: 8px; + padding: 1rem; + text-align: center; + } + @media (max-width: 1180px) { + .app { + grid-template-columns: minmax(240px, 310px) minmax(0, 1fr); + } + .response { + grid-column: 1 / -1; + border-top: 1px solid var(--border); + } + .response-scroll { height: auto; max-height: 60vh; } + } + @media (max-width: 760px) { + .app { + display: block; + } + .list { max-height: 42vh; } + .main-scroll, .response-scroll { height: auto; } + .grid2, .param-row, .schema-field { grid-template-columns: 1fr; } + .topbar { align-items: flex-start; } + } diff --git a/crates/specs/ras-api-explorer-assets/src/assets/forms.js b/crates/specs/ras-api-explorer-assets/src/assets/forms.js new file mode 100644 index 0000000..7638d07 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/forms.js @@ -0,0 +1,132 @@ + function renderRestForm(operation) { + const fragment = document.createDocumentFragment(); + const allParams = [ + ["Path parameters", operation.pathParams, "path"], + ["Query parameters", operation.queryParams, "query"] + ]; + allParams.forEach(([title, params, kind]) => { + if (!params.length) return; + const group = document.createElement("div"); + group.className = "field"; + const label = document.createElement("div"); + label.className = "section-title"; + label.textContent = title; + const rows = document.createElement("div"); + rows.className = "params"; + params.forEach((param) => { + const row = document.createElement("div"); + row.className = "param-row"; + const name = document.createElement("div"); + name.className = "name mono"; + name.textContent = `${param.name}${param.required ? " *" : ""}`; + const input = document.createElement("input"); + input.placeholder = param.description || param.name; + input.dataset[kind === "path" ? "pathParam" : "queryParam"] = param.name; + input.addEventListener("input", updateRequestUrl); + const type = document.createElement("span"); + type.className = "badge"; + type.textContent = schemaType(param.schema); + row.append(name, input, type); + rows.appendChild(row); + }); + group.append(label, rows); + fragment.appendChild(group); + }); + if (operation.requestSchema) { + fragment.appendChild(editorBlock("JSON body", "body-editor", jsonPretty(exampleFromSchema(operation.requestSchema)))); + const docs = renderSchemaDocs("Request schema", operation.requestSchema); + if (docs) fragment.appendChild(docs); + } + const responseDocs = renderSchemaDocs("Response schema", operation.responseSchema); + if (responseDocs) fragment.appendChild(responseDocs); + return fragment; + } + + function renderRpcForm(operation) { + const fragment = document.createDocumentFragment(); + const grid = document.createElement("div"); + grid.className = "grid2"; + const idField = document.createElement("div"); + idField.className = "field"; + const idLabel = document.createElement("label"); + idLabel.textContent = "Request ID"; + idLabel.htmlFor = "rpc-request-id"; + const idRow = document.createElement("div"); + idRow.className = "row"; + const idInput = document.createElement("input"); + idInput.id = "rpc-request-id"; + idInput.className = "grow"; + idInput.value = requestId(); + const regen = document.createElement("button"); + regen.textContent = "Regenerate"; + regen.addEventListener("click", () => idInput.value = requestId()); + idRow.append(idInput, regen); + idField.append(idLabel, idRow); + const methodField = document.createElement("div"); + methodField.className = "field"; + const methodLabel = document.createElement("label"); + methodLabel.textContent = "JSON-RPC method"; + const methodValue = document.createElement("input"); + methodValue.value = operation.label; + methodValue.readOnly = true; + methodField.append(methodLabel, methodValue); + grid.append(idField, methodField); + fragment.appendChild(grid); + if (operation.paramsSchema) { + fragment.appendChild(editorBlock("Params", "params-editor", jsonPretty(exampleFromSchema(operation.paramsSchema)))); + const docs = renderSchemaDocs("Params schema", operation.paramsSchema); + if (docs) fragment.appendChild(docs); + } else { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "This method has no params."; + fragment.appendChild(empty); + } + const responseDocs = renderSchemaDocs("Result schema", operation.responseSchema); + if (responseDocs) fragment.appendChild(responseDocs); + return fragment; + } + + function editorBlock(labelText, id, value) { + const field = document.createElement("div"); + field.className = "field"; + const label = document.createElement("label"); + label.textContent = labelText; + label.htmlFor = id; + const editor = document.createElement("textarea"); + editor.id = id; + editor.spellcheck = false; + editor.value = value; + field.append(label, editor); + return field; + } + + function renderRequestForm() { + const operation = activeOperation(); + const form = $("request-form"); + form.textContent = ""; + $("send-request").disabled = !operation; + if (!operation) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "No operation selected."; + form.appendChild(empty); + return; + } + const auth = document.createElement("div"); + auth.className = "row"; + const authBadge = document.createElement("span"); + authBadge.className = operation.authRequired ? "badge lock" : "badge open"; + authBadge.textContent = operation.authRequired ? "Authentication required" : "No authentication required"; + auth.appendChild(authBadge); + operation.permissions.forEach((permission) => { + const badge = document.createElement("span"); + badge.className = "badge"; + badge.textContent = permission; + auth.appendChild(badge); + }); + form.appendChild(auth); + form.appendChild(operation.protocol === "rest" ? renderRestForm(operation) : renderRpcForm(operation)); + updateRequestUrl(); + } + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/head.html b/crates/specs/ras-api-explorer-assets/src/assets/head.html new file mode 100644 index 0000000..00d1aa2 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/head.html @@ -0,0 +1,6 @@ + + + + + + API Explorer diff --git a/crates/specs/ras-api-explorer-assets/src/assets/markdown.js b/crates/specs/ras-api-explorer-assets/src/assets/markdown.js new file mode 100644 index 0000000..e22e1f0 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/markdown.js @@ -0,0 +1,122 @@ + function appendInlineMarkdown(parent, text) { + let index = 0; + const source = String(text || ""); + + while (index < source.length) { + if (source.startsWith("**", index)) { + const end = source.indexOf("**", index + 2); + if (end > index + 2) { + const strong = document.createElement("strong"); + appendInlineMarkdown(strong, source.slice(index + 2, end)); + parent.appendChild(strong); + index = end + 2; + continue; + } + } + + if (source[index] === "`") { + const end = source.indexOf("`", index + 1); + if (end > index + 1) { + const code = document.createElement("code"); + code.textContent = source.slice(index + 1, end); + parent.appendChild(code); + index = end + 1; + continue; + } + } + + if (source[index] === "[") { + const labelEnd = source.indexOf("]", index + 1); + const urlStart = labelEnd + 1; + if (labelEnd > index + 1 && source[urlStart] === "(") { + const urlEnd = source.indexOf(")", urlStart + 1); + const href = source.slice(urlStart + 1, urlEnd); + if (urlEnd > urlStart + 1 && isSafeMarkdownUrl(href)) { + const link = document.createElement("a"); + link.href = href; + link.target = "_blank"; + link.rel = "noreferrer noopener"; + appendInlineMarkdown(link, source.slice(index + 1, labelEnd)); + parent.appendChild(link); + index = urlEnd + 1; + continue; + } + } + } + + const next = ["**", "`", "["] + .map((token) => source.indexOf(token, index + 1)) + .filter((position) => position !== -1) + .sort((a, b) => a - b)[0] ?? source.length; + parent.appendChild(document.createTextNode(source.slice(index, next))); + index = next; + } + } + + function isSafeMarkdownUrl(href) { + try { + const url = new URL(href, window.location.href); + return url.protocol === "http:" || url.protocol === "https:"; + } catch (_) { + return false; + } + } + + function renderMarkdownInto(container, text) { + container.textContent = ""; + container.classList.add("markdown"); + + const lines = String(text || "").replace(/\r\n?/g, "\n").split("\n"); + let index = 0; + + while (index < lines.length) { + if (!lines[index].trim()) { + index += 1; + continue; + } + + if (lines[index].trimStart().startsWith("```")) { + const codeLines = []; + index += 1; + while (index < lines.length && !lines[index].trimStart().startsWith("```")) { + codeLines.push(lines[index]); + index += 1; + } + if (index < lines.length) index += 1; + + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = codeLines.join("\n"); + pre.appendChild(code); + container.appendChild(pre); + continue; + } + + if (/^\s*-\s+/.test(lines[index])) { + const list = document.createElement("ul"); + while (index < lines.length && /^\s*-\s+/.test(lines[index])) { + const item = document.createElement("li"); + appendInlineMarkdown(item, lines[index].replace(/^\s*-\s+/, "")); + list.appendChild(item); + index += 1; + } + container.appendChild(list); + continue; + } + + const paragraphLines = []; + while ( + index < lines.length + && lines[index].trim() + && !lines[index].trimStart().startsWith("```") + && !/^\s*-\s+/.test(lines[index]) + ) { + paragraphLines.push(lines[index]); + index += 1; + } + const paragraph = document.createElement("p"); + appendInlineMarkdown(paragraph, paragraphLines.join("\n")); + container.appendChild(paragraph); + } + } + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/navigation.js b/crates/specs/ras-api-explorer-assets/src/assets/navigation.js new file mode 100644 index 0000000..ec4178e --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/navigation.js @@ -0,0 +1,116 @@ + function renderOperations() { + const query = $("operation-search").value.trim().toLowerCase(); + const list = $("operation-list"); + list.textContent = ""; + state.operations + .filter((operation) => `${operation.method} ${operation.label} ${operation.summary}`.toLowerCase().includes(query)) + .forEach((operation) => { + const button = document.createElement("button"); + button.className = `op ${operation.id === state.selectedId ? "active" : ""}`; + button.type = "button"; + button.addEventListener("click", () => selectOperation(operation.id)); + + const main = document.createElement("div"); + main.className = "op-main"; + const method = document.createElement("span"); + method.className = `badge ${operation.method.toLowerCase()}`; + method.textContent = operation.method; + const name = document.createElement("span"); + name.className = "op-name mono"; + name.textContent = operation.label; + const auth = document.createElement("span"); + auth.className = operation.authRequired ? "badge lock" : "badge open"; + auth.textContent = operation.authRequired ? "Auth" : "Open"; + main.append(method, name, auth); + + const desc = document.createElement("div"); + desc.className = "op-desc"; + desc.textContent = operation.summary || operation.description || ""; + button.append(main, desc); + list.appendChild(button); + }); + if (!list.children.length) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "No matching operations."; + list.appendChild(empty); + } + } + + function renderEnvironments() { + const select = $("environment-select"); + select.textContent = ""; + state.environments.forEach((env, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = env.name; + select.appendChild(option); + }); + select.value = String(state.activeEnvironment); + $("base-url").value = activeBaseUrl(); + } + + function renderSaved() { + const container = $("saved-list"); + container.textContent = ""; + const operation = activeOperation(); + const items = operation ? (state.saved[operation.id] || []) : []; + if (!items.length) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = operation ? "No saved requests for this operation." : "Select an operation."; + container.appendChild(empty); + return; + } + items.forEach((item, index) => { + const row = document.createElement("div"); + row.className = "saved-item"; + const name = document.createElement("strong"); + name.textContent = item.name; + const time = document.createElement("span"); + time.className = "hint"; + time.textContent = new Date(item.createdAt).toLocaleString(); + const load = document.createElement("button"); + load.textContent = "Load"; + load.addEventListener("click", () => applySnapshot(item.snapshot)); + const remove = document.createElement("button"); + remove.textContent = "Remove"; + remove.addEventListener("click", () => { + state.saved[operation.id].splice(index, 1); + storageSet("saved", state.saved); + renderSaved(); + }); + const actions = document.createElement("div"); + actions.className = "row"; + actions.append(load, remove); + row.append(name, time, actions); + container.appendChild(row); + }); + } + + function renderHistory() { + const container = $("history-list"); + container.textContent = ""; + if (!state.history.length) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "Requests you send in this session appear here."; + container.appendChild(empty); + return; + } + state.history.forEach((item) => { + const row = document.createElement("div"); + row.className = "history-item"; + const title = document.createElement("strong"); + title.textContent = item.title; + const meta = document.createElement("span"); + meta.className = "hint"; + meta.textContent = `${item.status} - ${item.duration}ms - ${new Date(item.createdAt).toLocaleTimeString()}`; + const load = document.createElement("button"); + load.textContent = "Load request"; + load.addEventListener("click", () => applySnapshot(item.snapshot)); + row.append(title, meta, load); + container.appendChild(row); + }); + } + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/requests.js b/crates/specs/ras-api-explorer-assets/src/assets/requests.js new file mode 100644 index 0000000..f8ff276 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/requests.js @@ -0,0 +1,117 @@ + function selectOperation(id, rerenderList = true) { + state.selectedId = id; + const operation = activeOperation(); + $("operation-title").textContent = operation ? `${operation.method} ${operation.label}` : "Select an operation"; + renderMarkdownInto( + $("operation-description"), + operation?.description || operation?.summary || "Prepare and send a request." + ); + renderRequestForm(); + renderSaved(); + if (rerenderList) renderOperations(); + } + + function requestId() { + if (globalThis.crypto?.randomUUID) return crypto.randomUUID(); + return `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + } + + function updateRequestUrl() { + const operation = activeOperation(); + if (!operation) { + $("request-url").textContent = ""; + return; + } + $("request-url").textContent = buildRequestPreview(operation); + } + + function buildRequestPreview(operation) { + if (operation.protocol === "jsonrpc") return toAbsoluteUrl(activeBaseUrl()); + let path = operation.path; + document.querySelectorAll("[data-path-param]").forEach((input) => { + path = path.replace(`{${input.dataset.pathParam}}`, encodeURIComponent(input.value || `{${input.dataset.pathParam}}`)); + }); + const query = new URLSearchParams(); + document.querySelectorAll("[data-query-param]").forEach((input) => { + if (input.value) query.append(input.dataset.queryParam, input.value); + }); + const base = activeBaseUrl().replace(/\/$/, ""); + return toAbsoluteUrl(`${base}${path}${query.toString() ? `?${query}` : ""}`); + } + + function buildRequest(operation) { + const headers = { "Content-Type": "application/json" }; + if (state.token) headers.Authorization = `Bearer ${state.token}`; + if (operation.protocol === "rest") { + const options = { method: operation.method, headers }; + const body = $("body-editor")?.value.trim(); + if (body) options.body = JSON.stringify(JSON.parse(body)); + return { url: buildRequestPreview(operation), options, requestBody: body || "" }; + } + const payload = { + jsonrpc: "2.0", + method: operation.label, + id: $("rpc-request-id")?.value || requestId() + }; + const params = $("params-editor")?.value.trim(); + if (params) payload.params = JSON.parse(params); + return { + url: toAbsoluteUrl(activeBaseUrl()), + options: { method: "POST", headers, body: JSON.stringify(payload) }, + requestBody: JSON.stringify(payload, null, 2) + }; + } + + async function sendCurrentRequest() { + const operation = activeOperation(); + if (!operation) return; + const button = $("send-request"); + button.disabled = true; + button.textContent = "Sending"; + const started = performance.now(); + try { + const request = buildRequest(operation); + const response = await fetch(request.url, request.options); + const duration = Math.round(performance.now() - started); + const text = await response.text(); + let body = text; + try { body = JSON.parse(text); } catch (_) {} + const headers = Object.fromEntries(response.headers.entries()); + const isRpcError = operation.protocol === "jsonrpc" && body && body.error; + const statusText = `${response.status} ${response.statusText || ""}`.trim(); + state.lastResponse = { + body: jsonPretty(body), + headers: jsonPretty(headers), + request: jsonPretty({ + url: request.url, + method: request.options.method, + headers: request.options.headers, + body: request.requestBody ? JSON.parse(request.requestBody) : undefined + }) + }; + $("response-status").className = `status ${response.ok && !isRpcError ? "ok" : response.status < 500 ? "warn" : "err"}`; + $("response-status").textContent = isRpcError ? "RPC error" : statusText; + $("response-meta").textContent = `${operation.method} ${operation.label} - ${duration}ms`; + state.history.unshift({ + title: `${operation.method} ${operation.label}`, + status: isRpcError ? "RPC error" : statusText, + duration, + createdAt: Date.now(), + snapshot: currentRequestSnapshot() + }); + state.history = state.history.slice(0, 30); + storageSet("history", state.history); + renderHistory(); + renderResponseOutput(); + } catch (error) { + $("response-status").className = "status err"; + $("response-status").textContent = "Failed"; + state.lastResponse = { body: error.message, headers: "", request: "" }; + renderResponseOutput(); + showToast(error.message); + } finally { + button.disabled = false; + button.textContent = "Send"; + } + } + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/schema-model.js b/crates/specs/ras-api-explorer-assets/src/assets/schema-model.js new file mode 100644 index 0000000..64f9932 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/schema-model.js @@ -0,0 +1,48 @@ + function resolveRef(schema) { + if (!schema || !schema.$ref) return schema || null; + const prefix = "#/components/schemas/"; + if (schema.$ref.startsWith(prefix)) { + return state.spec?.components?.schemas?.[schema.$ref.slice(prefix.length)] || schema; + } + return schema; + } + + function schemaType(schema) { + const resolved = resolveRef(schema); + if (!resolved) return "any"; + if (resolved.$ref) return resolved.$ref.split("/").pop(); + if (Array.isArray(resolved.type)) return resolved.type.filter((t) => t !== "null").join(" | ") || "null"; + if (resolved.type) return resolved.nullable ? `${resolved.type}?` : resolved.type; + if (resolved.enum) return "enum"; + if (resolved.oneOf) return "oneOf"; + if (resolved.anyOf) return "anyOf"; + return "object"; + } + + function schemaTitle(schema) { + const refName = schema?.$ref?.split("/").pop(); + const resolved = resolveRef(schema); + return resolved?.title || refName || schemaType(schema); + } + + function schemaFields(schema) { + const resolved = resolveRef(schema); + const properties = resolved?.properties || {}; + const required = new Set(resolved?.required || []); + return Object.entries(properties).map(([name, prop]) => { + const propSchema = resolveRef(prop); + return { + name, + required: required.has(name), + type: schemaType(prop), + description: propSchema?.description || "" + }; + }); + } + + function schemaHasDocs(schema) { + const resolved = resolveRef(schema); + if (!resolved) return false; + return Boolean(resolved.description || schemaFields(schema).some((field) => field.description)); + } + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/schema-render.js b/crates/specs/ras-api-explorer-assets/src/assets/schema-render.js new file mode 100644 index 0000000..4144958 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/schema-render.js @@ -0,0 +1,82 @@ + function exampleFromSchema(schema, seen = new Set()) { + const resolved = resolveRef(schema); + if (!resolved) return {}; + if (resolved.$ref) { + if (seen.has(resolved.$ref)) return {}; + seen.add(resolved.$ref); + return exampleFromSchema(resolveRef(resolved), seen); + } + if (resolved.example !== undefined) return resolved.example; + if (Array.isArray(resolved.examples) && resolved.examples.length) return resolved.examples[0]; + if (resolved.default !== undefined) return resolved.default; + if (Array.isArray(resolved.enum) && resolved.enum.length) return resolved.enum[0]; + const variants = resolved.oneOf || resolved.anyOf; + if (Array.isArray(variants) && variants.length) { + return exampleFromSchema(variants.find((item) => item.type !== "null") || variants[0], seen); + } + const type = Array.isArray(resolved.type) ? resolved.type.find((item) => item !== "null") : resolved.type; + if (type === "string") return "example"; + if (type === "integer" || type === "number") return 0; + if (type === "boolean") return false; + if (type === "array") return [exampleFromSchema(resolved.items, seen)]; + if (type === "object" || resolved.properties) { + const output = {}; + Object.entries(resolved.properties || {}).forEach(([key, prop]) => { + output[key] = exampleFromSchema(prop, seen); + }); + return output; + } + return {}; + } + + function renderSchemaDocs(title, schema) { + if (!schemaHasDocs(schema)) return null; + + const resolved = resolveRef(schema); + const docs = document.createElement("div"); + docs.className = "schema-docs"; + const section = document.createElement("div"); + section.className = "section-title"; + section.textContent = title; + const head = document.createElement("div"); + head.className = "schema-head"; + const name = document.createElement("strong"); + name.textContent = schemaTitle(schema); + const type = document.createElement("span"); + type.className = "badge"; + type.textContent = schemaType(schema); + head.append(name, type); + docs.append(section, head); + + if (resolved?.description) { + const description = document.createElement("div"); + description.className = "schema-desc"; + renderMarkdownInto(description, resolved.description); + docs.appendChild(description); + } + + const fields = schemaFields(schema); + if (fields.length) { + const rows = document.createElement("div"); + rows.className = "schema-fields"; + fields.forEach((field) => { + const row = document.createElement("div"); + row.className = "schema-field"; + const fieldName = document.createElement("div"); + fieldName.className = "mono"; + fieldName.textContent = `${field.name}${field.required ? " *" : ""}`; + const fieldType = document.createElement("span"); + fieldType.className = "badge"; + fieldType.textContent = field.type; + const fieldDescription = document.createElement("div"); + fieldDescription.className = "schema-field-desc"; + renderMarkdownInto(fieldDescription, field.description || ""); + row.append(fieldName, fieldType, fieldDescription); + rows.appendChild(row); + }); + docs.appendChild(rows); + } + + return docs; + } + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/specs.js b/crates/specs/ras-api-explorer-assets/src/assets/specs.js new file mode 100644 index 0000000..a64a488 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/specs.js @@ -0,0 +1,61 @@ + function jsonPretty(value) { + if (typeof value === "string") return value; + return JSON.stringify(value, null, 2); + } + + function normalizePermissions(value) { + if (!Array.isArray(value)) return []; + if (value.every((item) => typeof item === "string")) return value; + return value.map((item) => Array.isArray(item) ? item.join(" + ") : String(item)); + } + + function normalizeOpenApi(spec) { + const operations = []; + Object.entries(spec.paths || {}).forEach(([path, pathItem]) => { + Object.entries(pathItem || {}).forEach(([method, operation]) => { + if (!METHODS.includes(method)) return; + const upper = method.toUpperCase(); + const params = operation.parameters || []; + const requestSchema = operation.requestBody?.content?.["application/json"]?.schema || null; + const response = Object.entries(operation.responses || {}).find(([code]) => code.startsWith("2")); + const responseSchema = response?.[1]?.content?.["application/json"]?.schema || null; + operations.push({ + id: `${upper} ${path}`, + protocol: "rest", + label: path, + method: upper, + path, + summary: operation.summary || `${upper} ${path}`, + description: operation.description || operation.summary || "", + authRequired: Boolean(operation.security && operation.security.length), + permissions: normalizePermissions(operation["x-permissions"]), + pathParams: params.filter((param) => param.in === "path"), + queryParams: params.filter((param) => param.in === "query"), + requestSchema, + responseSchema + }); + }); + }); + return operations; + } + + function normalizeOpenRpc(spec) { + return (spec.methods || []).map((method) => { + const auth = method["x-authentication"]; + const param = Array.isArray(method.params) ? method.params[0] : null; + return { + id: method.name, + protocol: "jsonrpc", + label: method.name, + method: "RPC", + path: CONFIG.apiBasePath, + summary: method.summary || method.name, + description: method.description || method.summary || "", + authRequired: Boolean(auth && auth.required !== false), + permissions: normalizePermissions(method["x-permissions"]), + paramsSchema: param?.schema || null, + responseSchema: method.result?.schema || null + }; + }); + } + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/state.js b/crates/specs/ras-api-explorer-assets/src/assets/state.js new file mode 100644 index 0000000..3b4f29d --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/state.js @@ -0,0 +1,57 @@ + function activeOperation() { + return state.operations.find((operation) => operation.id === state.selectedId) || null; + } + + function activeBaseUrl() { + return state.environments[state.activeEnvironment]?.baseUrl || CONFIG.apiBasePath || ""; + } + + function toAbsoluteUrl(path) { + if (/^https?:\/\//i.test(path)) return path; + const prefix = path.startsWith("/") ? path : `/${path}`; + return `${window.location.origin}${prefix}`; + } + + function currentRequestSnapshot() { + const operation = activeOperation(); + if (!operation) return null; + if (operation.protocol === "rest") { + const pathValues = {}; + document.querySelectorAll("[data-path-param]").forEach((input) => pathValues[input.dataset.pathParam] = input.value); + const queryValues = {}; + document.querySelectorAll("[data-query-param]").forEach((input) => queryValues[input.dataset.queryParam] = input.value); + return { + operationId: operation.id, + pathValues, + queryValues, + body: $("body-editor")?.value || "" + }; + } + return { + operationId: operation.id, + requestId: $("rpc-request-id")?.value || "", + params: $("params-editor")?.value || "" + }; + } + + function applySnapshot(snapshot) { + if (!snapshot) return; + selectOperation(snapshot.operationId, false); + if (snapshot.pathValues) { + Object.entries(snapshot.pathValues).forEach(([key, value]) => { + const input = document.querySelector(`[data-path-param="${CSS.escape(key)}"]`); + if (input) input.value = value; + }); + } + if (snapshot.queryValues) { + Object.entries(snapshot.queryValues).forEach(([key, value]) => { + const input = document.querySelector(`[data-query-param="${CSS.escape(key)}"]`); + if (input) input.value = value; + }); + } + if ($("body-editor") && snapshot.body !== undefined) $("body-editor").value = snapshot.body; + if ($("params-editor") && snapshot.params !== undefined) $("params-editor").value = snapshot.params; + if ($("rpc-request-id") && snapshot.requestId !== undefined) $("rpc-request-id").value = snapshot.requestId; + updateRequestUrl(); + } + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/storage.js b/crates/specs/ras-api-explorer-assets/src/assets/storage.js new file mode 100644 index 0000000..dd7b0f7 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/storage.js @@ -0,0 +1,32 @@ + const $ = (id) => document.getElementById(id); + + function storageGet(key, fallback) { + try { + const value = sessionStorage.getItem(`${storagePrefix}:${key}`); + return value ? JSON.parse(value) : fallback; + } catch (_) { + return fallback; + } + } + + function storageSet(key, value) { + sessionStorage.setItem(`${storagePrefix}:${key}`, JSON.stringify(value)); + } + + function showToast(message) { + const toast = $("toast"); + toast.textContent = message; + toast.classList.add("show"); + setTimeout(() => toast.classList.remove("show"), 2200); + } + + function setTheme(theme) { + const next = theme === "light" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + localStorage.setItem("ras-explorer-theme", next); + } + + function initializeTheme() { + setTheme(localStorage.getItem("ras-explorer-theme") || "dark"); + } + diff --git a/crates/specs/ras-api-explorer-assets/src/assets/tail.html b/crates/specs/ras-api-explorer-assets/src/assets/tail.html new file mode 100644 index 0000000..308b1d0 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/tail.html @@ -0,0 +1,2 @@ + + diff --git a/crates/specs/ras-api-explorer-assets/src/lib.rs b/crates/specs/ras-api-explorer-assets/src/lib.rs new file mode 100644 index 0000000..e4cbccf --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/lib.rs @@ -0,0 +1,120 @@ +//! Embedded API explorer shared by REST and JSON-RPC service macros. + +/// Placeholder in [`TEMPLATE`] that consumers replace with the explorer +/// configuration JSON. The JSON must have its `<` characters escaped +/// (for example as `\u003c`) so it cannot terminate the configuration script. +pub const CONFIG_PLACEHOLDER: &str = "{EXPLORER_CONFIG_JSON}"; + +/// Self-contained explorer HTML with a single [`CONFIG_PLACEHOLDER`]. +/// +/// Each asset file is a standalone document fragment: `head.html`, `body.html` +/// and `tail.html` are markup, `explorer.css` is a stylesheet, and the `*.js` +/// files are scripts. The wrapper tags live here so the fragments stay valid on +/// their own and editors can normalize their whitespace freely. Script order +/// matters: the files share one scope, and `events.js` binds handlers to +/// functions defined by the earlier files. +pub const TEMPLATE: &str = concat!( + include_str!("assets/head.html"), + " \n", + include_str!("assets/body.html"), + " \n", + include_str!("assets/tail.html"), +); + +#[cfg(test)] +mod tests { + use super::*; + + const SCRIPTS: [&str; 11] = [ + include_str!("assets/bootstrap.js"), + include_str!("assets/storage.js"), + include_str!("assets/schema-model.js"), + include_str!("assets/markdown.js"), + include_str!("assets/schema-render.js"), + include_str!("assets/specs.js"), + include_str!("assets/state.js"), + include_str!("assets/navigation.js"), + include_str!("assets/forms.js"), + include_str!("assets/requests.js"), + include_str!("assets/events.js"), + ]; + + #[test] + fn placeholder_appears_exactly_once_inside_the_config_script() { + assert_eq!(TEMPLATE.matches(CONFIG_PLACEHOLDER).count(), 1); + let expected = format!( + "" + ); + assert!(TEMPLATE.contains(&expected)); + } + + #[test] + fn template_is_a_complete_html_document() { + assert!(TEMPLATE.starts_with("\n")); + assert!(TEMPLATE.ends_with("\n")); + for (open, close) in [ + (""), + ("", ""), + ("", ""), + ] { + assert_eq!(TEMPLATE.matches(open).count(), 1, "{open}"); + assert_eq!(TEMPLATE.matches(close).count(), 1, "{close}"); + } + assert_eq!(TEMPLATE.matches("").count(), 1); + // The configuration script plus the single explorer script. + assert_eq!(TEMPLATE.matches("").count(), 2); + } + + #[test] + fn stylesheet_and_scripts_cannot_break_out_of_their_wrapper_tags() { + let css = include_str!("assets/explorer.css"); + assert!( + !css.contains(" --all-targets --all-features --locked` +and `cargo test --doc -p --all-features --locked`, with downstream suites as listed above. +Use the exact feature combinations in `.github/workflows/ci.yml` for macro and WASM validation. +The explorer suite runs with `npm --prefix tests/playwright test` after its documented setup. + +The explorer package needs more than workspace compilation. +Inspect `cargo package --list` to confirm all embedded source assets are present. +Unpack the package archives and compile the macro consumers without access to sibling source directories. +Resolve unpublished workspace dependencies through a temporary local registry or explicit test patches; +publishing is not needed for verification and is not part of this plan. + +The MR description should name the new owner, what moved, and the contract evidence. +Report any baseline test failures separately from refactor regressions. +A failed behavioral check means investigate the extraction; do not rewrite expected output merely to pass. +No feature redesigns, dependency upgrades, or broad test-framework changes are bundled +with these responsibility refactors. The existing WASM task-details CSS-token panic was +fixed in a separate checkpoint before its module extraction, with a browser regression test. diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md new file mode 100644 index 0000000..40dd70e --- /dev/null +++ b/documentation/reviews/refactor-progress.md @@ -0,0 +1,91 @@ +Single-MR responsibility refactor. Base: `84346d6` (PR #27). + +The accepted plan is executed sequentially on `refactor/responsibility-boundaries`. +Every checkpoint below passed its affected-package gate before the next extraction. +The gate includes formatting, nextest, doctests, Clippy with warnings denied, +and additional feature/consumer checks where recorded. Raw logs are in +`/tmp/ras-refactor-logs` for this workspace session. + +Baseline: workspace nextest ran 926 tests: 924 passed, two SIGSEGV failures, +one skipped. The failures were `ras-identity-session::permissions_are_frozen_into_the_token_snapshot` +and `ras-identity-local::test_duplicate_user_is_rejected`, before refactoring. +Identity work must investigate these failures before its checkpoint can pass. +REST baseline: 61/61 passed. + +| Step | Change | Verification | +| --- | --- | --- | +| 1 | REST model and parser | 61 tests, 1 doctest; Clippy; no-default/server/client macro builds; no-default/server native and client WASM `rest-api` builds. | +| 2 | REST expansion, routing, request extraction, canonical/versioned handlers | 61 tests, 1 doctest; Clippy; all three macro feature modes; server native and client WASM consumer builds. | +| 3 | JSON-RPC model and parser | 59 tests; doctests (1 pre-existing ignored example); Clippy; all macro feature modes and no-default/server/client-WASM `basic-jsonrpc-api` builds. | +| 4 | JSON-RPC builder, HTTP envelope/auth policy, method/version dispatch | 59 tests; doctests; Clippy; all macro feature modes; native-server and WASM-client consumer builds. | +| 5 | Shared explorer assets crate | Original template SHA-256 preserved; 120 macro tests; docs/Clippy/features; 11/11 browser tests (baseline also 11/11); asset and macro packages created offline, unpacked macro builds pass using local dependency patches. | +| 6 | WebSocket subscription policy/accounting and handler test organization | 112 server/macro tests including all 29 moved handler tests; docs and Clippy; chat server consumer build. Checked mutation and egress checks unchanged. | +| 7 | WebSocket handler contract, socket IO, and lifecycle configuration | 112 server/macro tests; docs and Clippy. Public handler paths re-export moved types; connection loop remains together. | +| 8 | Explorer markup, styles, rendering, state, and request assets | Assembled HTML remains byte-identical (60,172 bytes); 120 tests; docs/Clippy/macro features; 11 browser tests; packaged and unpacked macro builds. | +| 9 | File-service types, uploads, downloads, routes, and auth generation | Baseline and result: 49 tests; docs/Clippy; no-default/server/client macro checks; native and WASM API consumer checks. | +| 10 | OpenAPI schema collection/normalization and operation emission | 61 tests, doctest, Clippy/features, 11 browser tests. 64 original and extracted document samples produce the same four JSON variants: schema titles already vary with HashMap insertion order. No output policy changed. | +| 11 | OpenRPC schemas/references, examples, and methods | 59 tests, docs/Clippy/features, 11 browser tests; three baseline JSON documents equal all 64 extracted samples each. | +| 12 | HTTP credential, cookie, CSRF, and redaction policy behind the transport facade | Auth baseline 39 tests; result 208 auth/HTTP macro tests; all 24 transport tests retained under scenario owners; docs/Clippy/macro feature matrix. | +| 13 | Session config, claims, JWT signing/verification, lifecycle, and auth adapter | Before and after: 40 identity tests pass (1 existing skipped), including both original crashing cases; docs/Clippy and chat consumer build. Original full-workspace SIGSEGV cause remains unreproduced. | +| 14 | OAuth2 HTTP transport, PKCE, authorization parameters, ID-token validation, and companion tests | Baseline and result: 55 tests; all 19 moved client tests retained; docs and Clippy. Timeout construction moved into the transport owner. | +| 15 | HTTP query serialization and path encoding | Baseline and result: 36 tests; docs/Clippy; no-default build; all three generated API clients compile for WASM. Root exports preserved. | +| 16 | Observability core depends directly on `http`, not Axum | Baseline and result: 37 core/OTEL tests; docs/Clippy; depth-one dependency tree contains `http` and no Axum. | +| 17 | WebSocket client test scenarios | Baseline and result: 53 tests (18 moved client cases); doctest/Clippy; WASM build with the CI no-default/wasm feature combination. Pre-existing extra blank line formatted. | +| 18 | WebSocket client builder and message driver | 53 tests, doctest/Clippy; explicit native and WASM CI builds. Existing driver/heartbeat ordering and lock scopes retained. | +| 19 | Chat library application constructor, state, operations, auth, and persistence conversions | Baseline and result: 28 tests; docs/Clippy. Ten service methods delegate to cohesive operation owners sharing the existing state/locks. Main retains environment, tracing, listener and serving; constructor accepts explicit identity storage and development seeding and returns router/session/manager handles. | +| 20 | Chat fixtures call the production application constructor | 29 tests (28 retained + router-level authenticated WebSocket/persistence/cleanup test); docs/Clippy. Removed duplicate auth/chat implementations and health-only stand-in. Assertions now reflect existing production 201 registration, 400 malformed input, and JSON health contracts. | +| 21a | Fix existing task-details CSS-token panic before UI extraction | Seven Rust tests, Clippy, WASM bundle and browser interaction test pass. Browser baseline had panicked on one space-separated `class` token; split it into two calls. New browser test covers login failure/success, list/create/complete/delete and failed-create state. cdylib has no doctest target. | +| 21 | WASM UI app state/actions and component renderers | Seven Rust tests, Clippy, WASM bundle and Chromium interaction test pass after extraction. Native cdylib doctests are not applicable. Signal/event ownership unchanged. | +| 22 | Bidirectional wire/connection models and sender contracts/adapters | Type baseline 14 tests; result 158 type/client/server tests; docs/Clippy and WASM client build. Existing root and sender-module exports and dependencies preserved. | +| 23 | REST integration/e2e scenarios | All 61 discovered names/counts retained; 43 moved cases grouped under their original test targets; 61 tests, doctest/Clippy/features pass. | +| 24 | JSON-RPC integration/e2e scenarios | All 59 discovered names/counts retained, including conditional client cases; 27 moved cases; 59 tests, docs/Clippy/features pass. | +| 25 | File-service e2e transfer, validation, limit, and schema/client scenarios | All 49 discovered names/counts retained; 23 moved cases; 49 tests, docs/Clippy/features pass. | +| 26 | Local-identity companion tests | All 20 discovered local-identity cases retained (one existing ignored); 40 local/session tests pass, docs/Clippy pass. Provider implementation unchanged. | +| 27 | Rename history-based macro test targets to `http_service_contracts` | 120 REST/JSON-RPC tests, docs/Clippy/features pass; source files renamed without changing their cases. No external target-name references found. | + +Final verification investigation: + +- The first complete run executed 927 tests: 922 passed; four identity tests + SIGSEGVed and one session test rejected freshly created credentials. +- A fresh target directory with `CARGO_INCREMENTAL=0` still reproduced identity + failures at the default local parallelism (924 passed, three failures). +- The exact workspace-feature identity binaries pass their 40-test filtered run. + GDB under the full workload captured an invalid reference in Argon2 block XOR. +- The host kernel journal records a NULL-pointer fault in `filemap_release_folio` + and termination of `kcompactd0` during this session. Host instability is a + hypothesis, not a confirmed root cause. Kernel/debugger logs are in the raw-log directory. +- All 927 tests pass without retries at four-worker concurrency, with one existing + ignored test. Workspace doctests also pass. No skips or production crypto changes + were introduced to address the local failures. +- GitHub rejected the workflow edits during the refactor because the configured OAuth + token lacked `workflow` scope, so the WASM UI browser test was initially local-only. + The post-review follow-up re-added it to the `wasm-ui-example` CI job, and the spec + now opens and closes the task detail panel explicitly instead of relying on click + bubbling from the checkbox. +- The finish skill's `/simplify` slash command is unavailable in this runtime; + no matching installed skill or callable slash-command tool was found. + +Deferred items remain the optional TUI/OAuth2 demo cleanups, optional OpenRPC model +companion tests, and the breaking bidirectional adapter package move. + +Final project gates: + +- Hosted [workspace test job](https://github.com/JedimEmO/rust-api-stack/actions/runs/33960234279/job/101290741016) + passes all 927 tests without retries, with one existing ignored test, plus workspace doctests. +- Local workspace format, Clippy (`-D warnings`), rustdoc (`-D warnings`), mdBook, + package README/Markdown links, and cargo-deny policy checks pass. +- All 13 CI feature combinations and generated-client specification checks pass. +- Final browser checks pass: 11 explorer tests and one WASM task-flow test. +- Explorer HTML was byte-identical to the original 60,172-byte response at checkpoint 8. + The post-review follow-up moved the style and script wrapper tags out of the fragments + into the assembler so each asset is a standalone file; the served page now differs + from the original only by one blank line before the closing script tag, and unit + tests in the assets crate guard the placeholder and document structure. + The asset and both macro archives package successfully; both unpacked macros compile + against the packaged asset using local dependency patches. +- Diff review retains original whitespace in the UI's raw stylesheet string. +- The application constructor documents its validated-configuration precondition. + No unresolved implementation TODOs or temporary debugging artifacts were added. + +The default-parallel local identity failures remain a separate unresolved baseline issue; +this refactor does not claim to fix the host or cryptographic runtime. diff --git a/examples/bidirectional-chat/server/Cargo.toml b/examples/bidirectional-chat/server/Cargo.toml index 23dc525..3bf6abc 100644 --- a/examples/bidirectional-chat/server/Cargo.toml +++ b/examples/bidirectional-chat/server/Cargo.toml @@ -43,6 +43,7 @@ config = { workspace = true } [dev-dependencies] tempfile = { workspace = true } axum-test = { workspace = true } +bidirectional-chat-api = { path = "../api", version = "0.1.0", features = ["client"] } [features] default = ["server"] diff --git a/examples/bidirectional-chat/server/src/app.rs b/examples/bidirectional-chat/server/src/app.rs new file mode 100644 index 0000000..a45f04c --- /dev/null +++ b/examples/bidirectional-chat/server/src/app.rs @@ -0,0 +1,205 @@ +use crate::{ + auth::{AuthHandlers, AuthServiceImpl, ChatPermissions}, + chat::ChatServer, + config::Config, +}; +use anyhow::Result; +use axum::{Router, routing::get}; +use bidirectional_chat_api::auth::ChatAuthServiceBuilder; +use ras_identity_local::LocalUserProvider; +use ras_identity_session::{JwtAlgorithm, JwtAuthProvider, SessionConfig, SessionService}; +use ras_jsonrpc_bidirectional_server::{ + DefaultConnectionManager, WebSocketServiceBuilder, + service::{BuiltWebSocketService, websocket_handler}, +}; +use std::sync::Arc; +use tower_http::cors::CorsLayer; +use tracing::{debug, error, info}; + +/// Identity storage and optional example-account seeding for application startup. +pub struct ApplicationDependencies { + pub identity_provider: Arc, + /// Seed Alice and Bob accounts for local development. + pub seed_development_users: bool, +} + +/// The assembled router and handles for session revocation and connection management. +pub struct ChatApplication { + pub router: Router, + pub session_service: Arc, + pub connection_manager: Arc, +} + +/// Assemble the REST and WebSocket application without binding a socket. +/// +/// The supplied configuration must pass [`Config::validate`]. +pub async fn build_application( + config: &Config, + dependencies: ApplicationDependencies, +) -> Result { + // Registration and login must share identity storage. + info!("Setting up identity provider"); + let identity_provider = dependencies.identity_provider; + + // Add admin users from configuration + if config.admin.auto_create { + for admin_user in &config.admin.users { + match identity_provider + .add_user( + admin_user.username.clone(), + admin_user.password.clone(), + admin_user.email.clone(), + admin_user.display_name.clone(), + ) + .await + { + Ok(_) => info!("Created admin user: {}", admin_user.username), + Err(e) => { + // User might already exist, which is fine + debug!( + "Admin user {} might already exist: {}", + admin_user.username, e + ); + } + } + } + } + + // Add some default test users if in development mode + if dependencies.seed_development_users { + let test_users = vec![ + ( + "alice", + "alice123", + Some("alice@example.com"), + Some("Alice"), + ), + ("bob", "bob123", Some("bob@example.com"), Some("Bob")), + ]; + + for (username, password, email, display_name) in test_users { + match identity_provider + .add_user( + username.to_string(), + password.to_string(), + email.map(|s| s.to_string()), + display_name.map(|s| s.to_string()), + ) + .await + { + Ok(_) => debug!("Created test user: {}", username), + Err(e) => debug!("Test user {} might already exist: {}", username, e), + } + } + } + + // Create session service from configuration + let session_config = SessionConfig { + jwt_secret: config.auth.jwt_secret.clone(), + jwt_ttl: chrono::Duration::seconds(config.auth.jwt_ttl_seconds), + enforce_active_sessions: true, + algorithm: JwtAlgorithm::from_name(&config.auth.jwt_algorithm) + .unwrap_or(JwtAlgorithm::HS256), + iss: Some("bidirectional-chat".to_string()), + aud: Some("bidirectional-chat".to_string()), + require_iss_aud: true, + max_sessions_per_user: ras_identity_session::DEFAULT_MAX_SESSIONS_PER_USER, + }; + info!( + "Creating session service with JWT TTL: {} seconds", + config.auth.jwt_ttl_seconds + ); + let session_service = Arc::new( + SessionService::new(session_config) + .map_err(anyhow::Error::from)? + .with_permissions(Arc::new(ChatPermissions::new(config.admin.users.clone()))), + ); + + // Register the identity provider with the session service + // We need to dereference the Arc and clone the inner provider since register_provider takes Box + session_service + .register_provider(Box::new((*identity_provider).clone())) + .await; + + // Create JWT auth provider + let auth_provider = Arc::new(JwtAuthProvider::new(session_service.clone())); + + // Create connection manager + let connection_manager = Arc::new(DefaultConnectionManager::new()); + + // Create chat server with configuration + 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( + chat_server.clone(), + connection_manager.clone(), + ) + .with_auth_provider(auth_provider.clone()), + ); + + // Build WebSocket service + let ws_service = WebSocketServiceBuilder::builder() + .handler(handler) + .auth_provider(auth_provider.clone()) + .require_auth(true) + .build() + .build_with_manager(connection_manager.clone()); + + // Create auth handlers with the shared identity provider + let auth_handlers = AuthHandlers { + session_service: session_service.clone(), + identity_provider: identity_provider.clone(), + }; + + // Build REST service using the macro-generated builder + // Create auth service implementation + let auth_service_impl = AuthServiceImpl { + handlers: auth_handlers.clone(), + }; + + let auth_router = ChatAuthServiceBuilder::new(auth_service_impl) + .auth_provider(auth_provider.as_ref().clone()) + .build(); + + // Create WebSocket endpoint + type ChatServiceType = BuiltWebSocketService< + bidirectional_chat_api::ChatServiceHandler, + JwtAuthProvider, + DefaultConnectionManager, + >; + let ws_router = Router::new() + .route("/ws", get(websocket_handler::)) + .with_state(ws_service); + + // Configure CORS based on configuration + let cors_layer = if config.server.cors.allow_any_origin { + CorsLayer::permissive() + } else { + let mut cors = CorsLayer::new(); + for origin in &config.server.cors.allowed_origins { + cors = cors.allow_origin(origin.parse::().unwrap()); + } + cors + }; + + // Combine all routers + let app = Router::new() + .merge(auth_router) + .merge(ws_router) + .layer(cors_layer); + + Ok(ChatApplication { + router: app, + session_service, + connection_manager, + }) +} diff --git a/examples/bidirectional-chat/server/src/auth.rs b/examples/bidirectional-chat/server/src/auth.rs new file mode 100644 index 0000000..6c2ee90 --- /dev/null +++ b/examples/bidirectional-chat/server/src/auth.rs @@ -0,0 +1,140 @@ +use crate::config; +use bidirectional_chat_api::auth::{ + HealthResponse, LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, +}; +use chrono::Utc; +use ras_identity_core::{UserPermissions, VerifiedIdentity}; +use ras_identity_local::LocalUserProvider; +use ras_identity_session::SessionService; +use ras_rest_core::{RestError, RestResponse, RestResult}; +use serde_json::json; +use std::sync::Arc; +use tracing::{debug, info, warn}; + +#[derive(Clone)] +pub(crate) struct ChatPermissions { + admin_users: Vec, +} + +// REST API handlers +#[derive(Clone)] +pub(crate) struct AuthHandlers { + pub(crate) session_service: Arc, + pub(crate) identity_provider: Arc, +} + +impl ChatPermissions { + pub(crate) fn new(admin_users: Vec) -> Self { + Self { admin_users } + } +} + +#[async_trait::async_trait] +impl UserPermissions for ChatPermissions { + async fn get_permissions( + &self, + identity: &VerifiedIdentity, + ) -> ras_identity_core::IdentityResult> { + // Check if user is in admin configuration + for admin_user in &self.admin_users { + if admin_user.username == identity.subject { + return Ok(admin_user.permissions.clone()); + } + } + + // Default permissions for regular users + Ok(vec!["user".to_string()]) + } +} + +impl AuthHandlers { + async fn handle_login(&self, request: LoginRequest) -> RestResult { + debug!("Processing login request"); + + // Create auth payload + let provider_id = request.provider.as_deref().unwrap_or("local"); + let auth_payload = json!({ + "username": request.username, + "password": request.password, + "provider": provider_id, + }); + + // Begin session + let token = self + .session_service + .begin_session(provider_id, auth_payload) + .await + .map_err(|e| { + warn!(provider = %provider_id, "Login failed: {}", e); + RestError::unauthorized("Invalid credentials") + })?; + + // Parse token to get user info (for response) + let claims = self + .session_service + .verify_session(&token) + .await + .map_err(|e| { + warn!("Token verification failed: {}", e); + RestError::internal_server_error("Token verification failed") + })?; + + info!(user_id = %claims.sub, "User logged in successfully"); + Ok(RestResponse::ok(LoginResponse { + token, + expires_at: claims.exp, + user_id: claims.sub, + })) + } + + async fn handle_register(&self, request: RegisterRequest) -> RestResult { + debug!("Processing registration request"); + + // Add user + self.identity_provider + .add_user( + request.username.clone(), + request.password, + request.email.clone(), + request.display_name.clone(), + ) + .await + .map_err(|e| { + warn!(username = %request.username, "Registration failed: {}", e); + RestError::conflict("Username already exists") + })?; + + info!(username = %request.username, email = ?request.email, "User registered successfully"); + + Ok(RestResponse::created(RegisterResponse { + message: "User registered successfully".to_string(), + username: request.username, + display_name: request.display_name, + })) + } + + async fn handle_health(&self) -> RestResult { + Ok(RestResponse::ok(HealthResponse { + status: "OK".to_string(), + timestamp: Utc::now().to_rfc3339(), + })) + } +} +pub(crate) struct AuthServiceImpl { + pub(crate) handlers: AuthHandlers, +} + +#[async_trait::async_trait] +impl bidirectional_chat_api::auth::ChatAuthServiceTrait for AuthServiceImpl { + async fn post_auth_login(&self, request: LoginRequest) -> RestResult { + self.handlers.handle_login(request).await + } + + async fn post_auth_register(&self, request: RegisterRequest) -> RestResult { + self.handlers.handle_register(request).await + } + + async fn get_health(&self) -> RestResult { + self.handlers.handle_health().await + } +} diff --git a/examples/bidirectional-chat/server/src/chat/conversions.rs b/examples/bidirectional-chat/server/src/chat/conversions.rs new file mode 100644 index 0000000..7bf7ae7 --- /dev/null +++ b/examples/bidirectional-chat/server/src/chat/conversions.rs @@ -0,0 +1,107 @@ +use crate::persistence::PersistedUserProfile; +use bidirectional_chat_api::*; + +pub(super) 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", + } +} + +pub(super) 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", + } +} + +pub(super) 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", + } +} + +pub(super) 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, + } +} + +pub(super) 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, + } +} + +pub(super) 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, + } +} + +pub(super) 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 diff --git a/examples/bidirectional-chat/server/src/chat/messages.rs b/examples/bidirectional-chat/server/src/chat/messages.rs new file mode 100644 index 0000000..26526c0 --- /dev/null +++ b/examples/bidirectional-chat/server/src/chat/messages.rs @@ -0,0 +1,171 @@ +use super::*; + +impl ChatServer { + #[instrument(skip(self, connection_manager, _user), fields(client_id = %client_id, user = %_user.user_id))] + pub(super) async fn send_message( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: SendMessageRequest, + ) -> Result> { + debug!("Processing send_message request"); + + // Validate message length + if request.text.len() > self.config.max_message_length { + return Err(format!( + "Message too long. Maximum length is {} characters", + self.config.max_message_length + ) + .into()); + } + + // Get user session + let session = self.user_sessions.get(&client_id).ok_or_else(|| { + error!("User session not found for client {}", client_id); + "User session not found" + })?; + + let room_id = session.current_room.clone().ok_or_else(|| { + warn!("User {} not in any room", session.username); + "User not in any room" + })?; + + // Drop the session ref to avoid holding the lock + 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; + if let Some(room_typing_users) = typing_users.get_mut(&room_id) { + if room_typing_users.remove(&username).is_some() { + was_typing = true; + } + if room_typing_users.is_empty() { + typing_users.remove(&room_id); + } + } + drop(typing_users); + + // Send stop typing notification if user was typing + if was_typing { + self.broadcast_typing_notification(connection_manager, &room_id, &username, false) + .await; + } + + // Get room to find all users + let room = self.rooms.get(&room_id).ok_or_else(|| { + error!("Room {} not found", room_id); + "Room not found" + })?; + let room_users: Vec = room.users.iter().cloned().collect(); + let user_count = room.users.len(); + drop(room); + + debug!(room_id = %room_id, user_count = user_count, "Broadcasting message to room"); + + // Generate message details + let message_id = self.next_message_id().await; + let timestamp = Utc::now(); + let timestamp_str = timestamp.to_rfc3339(); + + // Create notification + let notification = MessageReceivedNotification { + message_id, + username: username.clone(), + text: request.text.clone(), + timestamp: timestamp_str.clone(), + room_id: room_id.clone(), + }; + + // Persist message to disk + let persisted_msg = PersistedMessage { + id: message_id, + room_id: room_id.clone(), + username: username.clone(), + text: request.text, + timestamp, + }; + if let Err(e) = self + .persistence + .append_message(&room_id, &persisted_msg) + .await + { + error!(message_id = message_id, room_id = %room_id, "Failed to persist message: {}", e); + } else { + debug!(message_id = message_id, "Message persisted successfully"); + } + + // Send to all users in the room + for target_username in room_users { + // Find connection ID for this username + for entry in self.user_sessions.iter() { + if entry.username == target_username { + // Send notification directly using connection manager + let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { + method: "message_received".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!(target_user = %target_username, connection_id = %entry.key(), + "Failed to send message notification: {:?}", e); + } + } + } + } + + info!(message_id = message_id, room_id = %room_id, sender = %username, + "Message sent successfully"); + Ok(SendMessageResponse { + message_id, + timestamp: timestamp_str, + }) + } + pub(super) async fn broadcast_announcement( + &self, + _client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: BroadcastAnnouncementRequest, + ) -> Result<(), Box> { + let notification = SystemAnnouncementNotification { + message: request.message, + level: request.level, + timestamp: Utc::now().to_rfc3339(), + }; + + // Send to all connected users + for entry in self.user_sessions.iter() { + let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { + method: "system_announcement".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 announcement: {:?}", e); + } + } + + let user_count = self.user_sessions.len(); + info!(user_count = user_count, "Announcement broadcast complete"); + Ok(()) + } +} diff --git a/examples/bidirectional-chat/server/src/chat/mod.rs b/examples/bidirectional-chat/server/src/chat/mod.rs new file mode 100644 index 0000000..f3e9e25 --- /dev/null +++ b/examples/bidirectional-chat/server/src/chat/mod.rs @@ -0,0 +1,300 @@ +use crate::config; +use crate::persistence::{ + PersistedCatAvatar, PersistedMessage, PersistedRoom, PersistedUserProfile, PersistenceManager, +}; +use anyhow::Result; +use bidirectional_chat_api::*; +use chrono::Utc; +use conversions::*; +use dashmap::DashMap; +use ras_auth_core::AuthenticatedUser; +use ras_jsonrpc_bidirectional_types::{ConnectionId, ConnectionManager}; +use std::time::Duration; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; +use tokio::sync::{Mutex, RwLock}; +use tokio::time::Instant; +use tracing::{debug, error, info, instrument, warn}; +use uuid::Uuid; + +mod conversions; +mod messages; +mod profiles; +mod rooms; +mod service; +mod typing; + +// Chat room state +#[derive(Debug, Clone)] +struct ChatRoom { + id: String, + name: String, + users: HashSet, // usernames + created_at: chrono::DateTime, +} + +// User session state +#[derive(Debug, Clone)] +struct UserSession { + username: String, + current_room: Option, // room_id +} + +// Typing state tracking +#[derive(Debug, Clone)] +struct TypingState { + started_at: Instant, +} + +#[derive(Debug, Clone)] +struct MessageRateLimitState { + window_start: Instant, + messages_sent: u32, +} + +#[derive(Clone)] +pub(crate) struct ChatServer { + rooms: Arc>, + user_sessions: Arc>, + 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, rate_limit_enabled = rate_limit.enabled))] + pub(crate) 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| { + error!("Failed to initialize persistence: {}", e); + e + })?; + + // Load persisted state + debug!("Loading persisted state"); + let mut state = persistence.load_state().await.map_err(|e| { + error!("Failed to load persisted state: {}", e); + e + })?; + + let server = Self { + rooms: Arc::new(DashMap::new()), + user_sessions: Arc::new(DashMap::new()), + 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 + if state.rooms.is_empty() { + info!("No rooms found in persistence, creating default rooms"); + // Create default rooms from configuration + for room_config in &config.default_rooms { + let room = ChatRoom { + id: room_config.id.clone(), + name: room_config.name.clone(), + users: HashSet::new(), + created_at: Utc::now(), + }; + server.rooms.insert(room_config.id.clone(), room.clone()); + + // Persist the room + state.rooms.insert( + room_config.id.clone(), + PersistedRoom { + id: room.id, + name: room.name, + created_at: room.created_at, + users: room.users.clone(), + }, + ); + info!( + "Created default room: {} ({})", + room_config.name, room_config.id + ); + } + + if !state.rooms.is_empty() { + server.persistence.save_state(&state).await.map_err(|e| { + error!("Failed to save initial state: {}", e); + e + })?; + } + } else { + info!("Restoring {} rooms from persistence", state.rooms.len()); + // Restore rooms from persistence (clear user lists as they're not currently connected) + for (id, persisted_room) in state.rooms { + debug!(room_id = %id, room_name = %persisted_room.name, "Restoring room"); + let room = ChatRoom { + id: persisted_room.id, + name: persisted_room.name, + users: HashSet::new(), // Clear users on restart + created_at: persisted_room.created_at, + }; + server.rooms.insert(id, room); + } + } + + Ok(server) + } + + async fn next_message_id(&self) -> u64 { + let mut counter = self.message_counter.write().await; + let id = *counter; + *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, + }) + } + + 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; + let now = Instant::now(); + let timeout = Duration::from_secs(5); + + let mut expired_users = Vec::new(); + + for (room_id, room_typing_users) in typing_users.iter_mut() { + room_typing_users.retain(|username, state| { + if now.duration_since(state.started_at) > timeout { + expired_users.push((room_id.clone(), username.clone())); + false + } else { + true + } + }); + } + + drop(typing_users); + + // Send stop typing notifications for expired users + for (room_id, username) in expired_users { + self.broadcast_typing_notification(connection_manager, &room_id, &username, false) + .await; + } + } + + // Broadcast typing notification to all users in a room + async fn broadcast_typing_notification( + &self, + connection_manager: &dyn ConnectionManager, + room_id: &str, + username: &str, + is_typing: bool, + ) { + if let Some(room) = self.rooms.get(room_id) { + let room_users: Vec = room.users.iter().cloned().collect(); + drop(room); + + let notification = if is_typing { + let notification = UserStartedTypingNotification { + username: username.to_string(), + room_id: room_id.to_string(), + }; + ras_jsonrpc_bidirectional_types::ServerNotification { + method: "user_started_typing".to_string(), + params: serde_json::to_value(¬ification).unwrap(), + metadata: None, + } + } else { + let notification = UserStoppedTypingNotification { + username: username.to_string(), + room_id: room_id.to_string(), + }; + ras_jsonrpc_bidirectional_types::ServerNotification { + method: "user_stopped_typing".to_string(), + params: serde_json::to_value(¬ification).unwrap(), + metadata: None, + } + }; + + let msg = ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification( + notification, + ); + + // Send to all users in the room except the typing user + for target_username in room_users { + if target_username != username { + for entry in self.user_sessions.iter() { + 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.key(), + "Failed to send typing notification: {:?}", e); + } + } + } + } + } + } +} + +// Implement the chat service +#[cfg(test)] +mod tests; diff --git a/examples/bidirectional-chat/server/src/chat/profiles.rs b/examples/bidirectional-chat/server/src/chat/profiles.rs new file mode 100644 index 0000000..298720a --- /dev/null +++ b/examples/bidirectional-chat/server/src/chat/profiles.rs @@ -0,0 +1,88 @@ +use super::*; + +impl ChatServer { + pub(super) async fn get_profile( + &self, + _client_id: ConnectionId, + _connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: GetProfileRequest, + ) -> Result> { + // Load current state + let state = self.persistence.load_state().await?; + + // Get profile from persistence or create default + let profile = if let Some(persisted) = state.user_profiles.get(&request.username) { + user_profile_from_persisted(persisted) + } else { + // Create default profile + UserProfile { + username: request.username.clone(), + display_name: None, + avatar: CatAvatar { + breed: CatBreed::Tabby, + color: CatColor::Orange, + expression: CatExpression::Happy, + }, + created_at: Utc::now().to_rfc3339(), + last_seen: Utc::now().to_rfc3339(), + } + }; + + Ok(GetProfileResponse { profile }) + } + pub(super) async fn update_profile( + &self, + _client_id: ConnectionId, + _connection_manager: &dyn ConnectionManager, + user: &AuthenticatedUser, + request: UpdateProfileRequest, + ) -> Result> { + // Load current state + let mut state = self.persistence.load_state().await?; + + // Get existing profile or create new one + let mut persisted_profile = state + .user_profiles + .get(&user.user_id) + .cloned() + .unwrap_or_else(|| PersistedUserProfile { + username: user.user_id.clone(), + display_name: None, + avatar: PersistedCatAvatar { + breed: "tabby".to_string(), + color: "orange".to_string(), + expression: "happy".to_string(), + }, + created_at: Utc::now(), + last_seen: Utc::now(), + }); + + // Update fields if provided + if let Some(display_name) = request.display_name { + persisted_profile.display_name = Some(display_name); + } + + if let Some(avatar) = request.avatar { + persisted_profile.avatar = PersistedCatAvatar { + breed: persisted_cat_breed(avatar.breed).to_string(), + color: persisted_cat_color(avatar.color).to_string(), + expression: persisted_cat_expression(avatar.expression).to_string(), + }; + } + + // Update last seen + persisted_profile.last_seen = Utc::now(); + + // Save to persistence + state + .user_profiles + .insert(user.user_id.clone(), persisted_profile.clone()); + self.persistence.save_state(&state).await?; + + // Convert to response + let profile = user_profile_from_persisted(&persisted_profile); + + Ok(UpdateProfileResponse { profile }) + } +} diff --git a/examples/bidirectional-chat/server/src/chat/rooms.rs b/examples/bidirectional-chat/server/src/chat/rooms.rs new file mode 100644 index 0000000..76fec93 --- /dev/null +++ b/examples/bidirectional-chat/server/src/chat/rooms.rs @@ -0,0 +1,343 @@ +use super::*; + +impl ChatServer { + #[instrument(skip(self, connection_manager, _user), fields(client_id = %client_id, user = %_user.user_id, room_name = %request.room_name))] + pub(super) async fn join_room( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: JoinRoomRequest, + ) -> Result> { + debug!("Processing join_room request"); + + // Validate room name length + if request.room_name.len() > self.config.max_room_name_length { + return Err(format!( + "Room name too long. Maximum length is {} characters", + self.config.max_room_name_length + ) + .into()); + } + + // Get or create room + let room_id = if self.rooms.contains_key(&request.room_name) { + request.room_name.clone() + } else { + // Create new room + let room_id = if request.room_name.is_empty() { + Uuid::new_v4().to_string() + } else { + request.room_name.clone() + }; + + let new_room = ChatRoom { + id: room_id.clone(), + name: request.room_name.clone(), + users: HashSet::new(), + created_at: Utc::now(), + }; + + self.rooms.insert(room_id.clone(), new_room.clone()); + + // Persist new room + let mut state = self.persistence.load_state().await.unwrap_or_default(); + state.rooms.insert( + room_id.clone(), + PersistedRoom { + id: new_room.id.clone(), + name: new_room.name.clone(), + created_at: new_room.created_at, + users: new_room.users.clone(), + }, + ); + if let Err(e) = self.persistence.save_state(&state).await { + error!(room_id = %room_id, "Failed to persist new room: {}", e); + } else { + info!(room_id = %room_id, room_name = %new_room.name, "New room created and persisted"); + } + + // Notify all users about new room + let room_info = self.get_room_info(&room_id).unwrap(); + let notification = RoomCreatedNotification { room_info }; + + // Broadcast to all connected users + for entry in self.user_sessions.iter() { + let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { + method: "room_created".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 room_created notification: {:?}", e); + } + } + + room_id + }; + + // Get user session + let mut session = self.user_sessions.get_mut(&client_id).ok_or_else(|| { + error!("User session not found for client {}", client_id); + "User session not found" + })?; + + let username = session.username.clone(); + + // Leave current room if in one + 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); + + // 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); + } + } + } + } + + // Update session + session.current_room = Some(room_id.clone()); + drop(session); + + // Add user to new room + let mut room = self.rooms.get_mut(&room_id).ok_or("Room not found")?; + + // Check user limit + if self.config.max_users_per_room > 0 && room.users.len() >= self.config.max_users_per_room + { + return Err(format!( + "Room is full. Maximum {} users allowed per room", + self.config.max_users_per_room + ) + .into()); + } + + // Get existing users before adding the new user + let existing_users: Vec = room.users.iter().cloned().collect(); + + room.users.insert(username.clone()); + let user_count = room.users.len() as u32; + let room_users: Vec = room.users.iter().cloned().collect(); + drop(room); + + // Notify users in new room + let notification = UserJoinedNotification { + username: username.clone(), + room_id: room_id.clone(), + user_count, + }; + + for target_username in room_users { + for entry in self.user_sessions.iter() { + if entry.username == target_username { + let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { + method: "user_joined".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!(target_user = %target_username, connection_id = %entry.key(), + "Failed to send message notification: {:?}", e); + } + } + } + } + + info!( + user = %username, + room_id = %room_id, + existing_users = ?existing_users, + user_count = %user_count, + "User joined room successfully" + ); + + Ok(JoinRoomResponse { + room_id, + user_count, + existing_users, + }) + } + pub(super) async fn leave_room( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: LeaveRoomRequest, + ) -> Result<(), Box> { + let mut session = self + .user_sessions + .get_mut(&client_id) + .ok_or("User session not found")?; + + // Check if user is in the requested room + if session.current_room.as_ref() != Some(&request.room_id) { + return Err("User not in the specified room".into()); + } + + let username = session.username.clone(); + let room_id_for_log = request.room_id.clone(); + session.current_room = None; + drop(session); + + // Remove user from room + if let Some(mut room) = self.rooms.get_mut(&request.room_id) { + room.users.remove(&username); + let user_count = room.users.len() as u32; + let room_users: Vec = room.users.iter().cloned().collect(); + drop(room); + + // Notify remaining users + let notification = UserLeftNotification { + username: username.clone(), + room_id: request.room_id, + user_count, + }; + + for target_username in room_users { + for entry in self.user_sessions.iter() { + if entry.username == target_username { + 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); + } + } + } + } + } + + info!(user = %username, room_id = %room_id_for_log, "User left room successfully"); + Ok(()) + } + #[instrument(skip(self, _connection_manager, _user), fields(client_id = %_client_id, user = %_user.user_id))] + pub(super) async fn list_rooms( + &self, + _client_id: ConnectionId, + _connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + _request: ListRoomsRequest, + ) -> Result> { + debug!("Processing list_rooms request"); + let rooms: Vec = self + .rooms + .iter() + .map(|entry| RoomInfo { + room_id: entry.id.clone(), + room_name: entry.name.clone(), + user_count: entry.users.len() as u32, + }) + .collect(); + + debug!(room_count = rooms.len(), "Returning room list"); + Ok(ListRoomsResponse { rooms }) + } + pub(super) async fn kick_user( + &self, + _client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: KickUserRequest, + ) -> Result> { + // Find the target user's session + let mut target_connection_id = None; + let mut target_room_id = None; + + for entry in self.user_sessions.iter() { + if entry.username == request.target_username { + target_connection_id = Some(*entry.key()); + target_room_id = entry.current_room.clone(); + break; + } + } + + 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 + && let Some(mut room) = self.rooms.get_mut(room_id) + { + room.users.remove(&request.target_username); + } + + // Send kick notification to the target user + let kick_notification = UserKickedNotification { + username: request.target_username.clone(), + reason: request.reason.clone(), + room_id: target_room_id.as_ref().cloned().unwrap_or_default(), + }; + + let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { + method: "user_kicked".to_string(), + params: serde_json::to_value(&kick_notification).unwrap(), + metadata: None, + }; + let msg = ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification( + notification_msg, + ); + if let Err(e) = connection_manager.send_to_connection(target_id, msg).await { + warn!("Failed to send kick notification to user: {:?}", e); + } + + // 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 + if let Err(e) = connection_manager.remove_connection(target_id).await { + warn!("Failed to disconnect user: {:?}", e); + } + + Ok(true) + } +} diff --git a/examples/bidirectional-chat/server/src/chat/service.rs b/examples/bidirectional-chat/server/src/chat/service.rs new file mode 100644 index 0000000..38dc7c9 --- /dev/null +++ b/examples/bidirectional-chat/server/src/chat/service.rs @@ -0,0 +1,339 @@ +use super::*; + +#[async_trait::async_trait] +impl ChatServiceService for ChatServer { + async fn send_message( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: SendMessageRequest, + ) -> Result> { + ChatServer::send_message(self, client_id, connection_manager, _user, request).await + } + + async fn join_room( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: JoinRoomRequest, + ) -> Result> { + ChatServer::join_room(self, client_id, connection_manager, _user, request).await + } + + async fn leave_room( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: LeaveRoomRequest, + ) -> Result<(), Box> { + ChatServer::leave_room(self, client_id, connection_manager, _user, request).await + } + + async fn list_rooms( + &self, + _client_id: ConnectionId, + _connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + _request: ListRoomsRequest, + ) -> Result> { + ChatServer::list_rooms(self, _client_id, _connection_manager, _user, _request).await + } + + async fn kick_user( + &self, + _client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: KickUserRequest, + ) -> Result> { + ChatServer::kick_user(self, _client_id, connection_manager, _user, request).await + } + + async fn broadcast_announcement( + &self, + _client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: BroadcastAnnouncementRequest, + ) -> Result<(), Box> { + ChatServer::broadcast_announcement(self, _client_id, connection_manager, _user, request) + .await + } + + async fn get_profile( + &self, + _client_id: ConnectionId, + _connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + request: GetProfileRequest, + ) -> Result> { + ChatServer::get_profile(self, _client_id, _connection_manager, _user, request).await + } + + async fn update_profile( + &self, + _client_id: ConnectionId, + _connection_manager: &dyn ConnectionManager, + user: &AuthenticatedUser, + request: UpdateProfileRequest, + ) -> Result> { + ChatServer::update_profile(self, _client_id, _connection_manager, user, request).await + } + + async fn start_typing( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + _request: StartTypingRequest, + ) -> Result<(), Box> { + ChatServer::start_typing(self, client_id, connection_manager, _user, _request).await + } + + async fn stop_typing( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + _request: StopTypingRequest, + ) -> Result<(), Box> { + ChatServer::stop_typing(self, client_id, connection_manager, _user, _request).await + } + + // 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, + _params: MessageReceivedNotification, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } + + async fn notify_user_joined( + &self, + _connection_id: ConnectionId, + _params: UserJoinedNotification, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } + + async fn notify_user_left( + &self, + _connection_id: ConnectionId, + _params: UserLeftNotification, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } + + async fn notify_system_announcement( + &self, + _connection_id: ConnectionId, + _params: SystemAnnouncementNotification, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } + + async fn notify_user_kicked( + &self, + _connection_id: ConnectionId, + _params: UserKickedNotification, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } + + async fn notify_room_created( + &self, + _connection_id: ConnectionId, + _params: RoomCreatedNotification, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } + + async fn notify_room_deleted( + &self, + _connection_id: ConnectionId, + _params: RoomDeletedNotification, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } + + async fn notify_user_started_typing( + &self, + _connection_id: ConnectionId, + _params: UserStartedTypingNotification, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } + + async fn notify_user_stopped_typing( + &self, + _connection_id: ConnectionId, + _params: UserStoppedTypingNotification, + ) -> ras_jsonrpc_bidirectional_types::Result<()> { + Ok(()) + } + + // Lifecycle hooks + async fn on_client_connected( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + ) -> Result<(), Box> { + info!("Client {} connected", client_id); + + // Send welcome message + let notification = SystemAnnouncementNotification { + message: "Welcome to the chat server! Please authenticate to continue.".to_string(), + level: AnnouncementLevel::Info, + timestamp: Utc::now().to_rfc3339(), + }; + + let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { + method: "system_announcement".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(client_id, msg).await { + warn!( + "Failed to send welcome message to client {}: {:?}", + client_id, e + ); + } + + Ok(()) + } + + async fn on_client_disconnected( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + ) -> Result<(), Box> { + info!("Client {} disconnected", client_id); + + // 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 + let mut typing_users = self.typing_users.lock().await; + let mut was_typing = false; + if let Some(room_typing_users) = typing_users.get_mut(&room_id) { + if room_typing_users.remove(&username).is_some() { + was_typing = true; + } + if room_typing_users.is_empty() { + typing_users.remove(&room_id); + } + } + drop(typing_users); + + // Send stop typing notification if user was typing + if was_typing { + self.broadcast_typing_notification( + connection_manager, + &room_id, + &username, + false, + ) + .await; + } + + // Remove from room + if let Some(mut room) = self.rooms.get_mut(&room_id) { + room.users.remove(&session.username); + let user_count = room.users.len() as u32; + let room_users: Vec = room.users.iter().cloned().collect(); + drop(room); + + // Notify remaining users + let notification = UserLeftNotification { + username: session.username, + room_id, + user_count, + }; + + for target_username in room_users { + for entry in self.user_sessions.iter() { + if entry.username == target_username { + 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 on disconnect: {:?}", e); + } + } + } + } + } + } + } + + Ok(()) + } + + async fn on_client_authenticated( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + user: &AuthenticatedUser, + ) -> Result<(), Box> { + info!( + "Client {} authenticated as user {}", + client_id, user.user_id + ); + + // Create user session + let session = UserSession { + username: user.user_id.clone(), + current_room: None, + }; + + self.user_sessions.insert(client_id, session); + + // Send personalized welcome + let notification = SystemAnnouncementNotification { + message: format!( + "Welcome {}, you have been successfully authenticated!", + user.user_id + ), + level: AnnouncementLevel::Info, + timestamp: Utc::now().to_rfc3339(), + }; + + let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { + method: "system_announcement".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(client_id, msg).await { + warn!( + "Failed to send welcome message to client {}: {:?}", + client_id, e + ); + } + + Ok(()) + } +} + +// Permission provider for the chat application diff --git a/examples/bidirectional-chat/server/src/chat/tests.rs b/examples/bidirectional-chat/server/src/chat/tests.rs new file mode 100644 index 0000000..43a8880 --- /dev/null +++ b/examples/bidirectional-chat/server/src/chat/tests.rs @@ -0,0 +1,956 @@ +use super::*; +use ras_jsonrpc_bidirectional_server::DefaultConnectionManager; +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 serde_json::json; +use std::collections::VecDeque; +use std::future; +use std::time::Duration; +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(outbound) = receiver.try_recv() { + messages.push(outbound.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); + // Handler errors expose a generic message; details stay in server logs. + assert_eq!(error.message, "Internal error"); + + 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); + // The rate-limit reason stays in server logs. + assert_eq!(error.message, "Internal error"); + + 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/chat/typing.rs b/examples/bidirectional-chat/server/src/chat/typing.rs new file mode 100644 index 0000000..b3748a6 --- /dev/null +++ b/examples/bidirectional-chat/server/src/chat/typing.rs @@ -0,0 +1,94 @@ +use super::*; + +impl ChatServer { + pub(super) async fn start_typing( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + _request: StartTypingRequest, + ) -> Result<(), Box> { + // Get user session + let session = self.user_sessions.get(&client_id).ok_or_else(|| { + error!("User session not found for client {}", client_id); + "User session not found" + })?; + + let username = session.username.clone(); + let room_id = session.current_room.clone().ok_or_else(|| { + warn!("User {} not in any room", session.username); + "User not in any room" + })?; + drop(session); + + // Update typing state + let mut typing_users = self.typing_users.lock().await; + let room_typing_users = typing_users + .entry(room_id.clone()) + .or_insert_with(HashMap::new); + + let is_new_typing = !room_typing_users.contains_key(&username); + room_typing_users.insert( + username.clone(), + TypingState { + started_at: Instant::now(), + }, + ); + drop(typing_users); + + // Send notification only if this is a new typing state + if is_new_typing { + self.broadcast_typing_notification(connection_manager, &room_id, &username, true) + .await; + } + + // Clean up expired typing states + self.cleanup_expired_typing_states(connection_manager).await; + + Ok(()) + } + pub(super) async fn stop_typing( + &self, + client_id: ConnectionId, + connection_manager: &dyn ConnectionManager, + _user: &AuthenticatedUser, + _request: StopTypingRequest, + ) -> Result<(), Box> { + // Get user session + let session = self.user_sessions.get(&client_id).ok_or_else(|| { + error!("User session not found for client {}", client_id); + "User session not found" + })?; + + let username = session.username.clone(); + let room_id = session.current_room.clone().ok_or_else(|| { + warn!("User {} not in any room", session.username); + "User not in any room" + })?; + drop(session); + + // Remove from typing state + let mut typing_users = self.typing_users.lock().await; + let mut should_notify = false; + + if let Some(room_typing_users) = typing_users.get_mut(&room_id) { + if room_typing_users.remove(&username).is_some() { + should_notify = true; + } + + // Clean up empty room entries + if room_typing_users.is_empty() { + typing_users.remove(&room_id); + } + } + drop(typing_users); + + // Send notification if user was typing + if should_notify { + self.broadcast_typing_notification(connection_manager, &room_id, &username, false) + .await; + } + + Ok(()) + } +} diff --git a/examples/bidirectional-chat/server/src/lib.rs b/examples/bidirectional-chat/server/src/lib.rs index 0724421..b2c843f 100644 --- a/examples/bidirectional-chat/server/src/lib.rs +++ b/examples/bidirectional-chat/server/src/lib.rs @@ -2,3 +2,8 @@ pub mod config; pub mod persistence; + +mod app; +mod auth; +mod chat; +pub use app::{ApplicationDependencies, ChatApplication, build_application}; diff --git a/examples/bidirectional-chat/server/src/main.rs b/examples/bidirectional-chat/server/src/main.rs index 1b2b503..761b72e 100644 --- a/examples/bidirectional-chat/server/src/main.rs +++ b/examples/bidirectional-chat/server/src/main.rs @@ -1,1459 +1,10 @@ -//! Bidirectional chat server example -//! -//! This example demonstrates a real-time chat server using bidirectional JSON-RPC over WebSockets. -//! Features include: -//! - Multiple chat rooms -//! - User authentication with JWT -//! - Role-based permissions (user, moderator, admin) -//! - Real-time message broadcasting -//! - System announcements -//! - User management (kick functionality) - +//! Bidirectional chat server example. use anyhow::Result; -use axum::{Router, routing::get}; -use bidirectional_chat_api::auth::{ - ChatAuthServiceBuilder, HealthResponse, LoginRequest, LoginResponse, RegisterRequest, - RegisterResponse, -}; -use bidirectional_chat_api::*; -use chrono::Utc; -use dashmap::DashMap; -use ras_auth_core::AuthenticatedUser; -use ras_identity_core::{UserPermissions, VerifiedIdentity}; +use bidirectional_chat_server::{ApplicationDependencies, build_application, config::Config}; use ras_identity_local::LocalUserProvider; -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 ras_rest_core::{RestError, RestResponse, RestResult}; -use serde_json::json; -use std::{ - collections::{HashMap, HashSet}, - sync::Arc, - time::Duration, -}; -use tokio::sync::{Mutex, RwLock}; -use tokio::time::Instant; -use tower_http::cors::CorsLayer; -use tracing::{debug, error, info, instrument, warn}; -use uuid::Uuid; - -use bidirectional_chat_server::config::{self, Config}; -use bidirectional_chat_server::persistence::{ - PersistedCatAvatar, PersistedMessage, PersistedRoom, PersistedUserProfile, PersistenceManager, -}; - -// Chat room state -#[derive(Debug, Clone)] -struct ChatRoom { - id: String, - name: String, - users: HashSet, // usernames - created_at: chrono::DateTime, -} - -// User session state -#[derive(Debug, Clone)] -struct UserSession { - username: String, - current_room: Option, // room_id -} - -// Typing state tracking -#[derive(Debug, Clone)] -struct TypingState { - 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 { - rooms: Arc>, - user_sessions: Arc>, - 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, 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| { - error!("Failed to initialize persistence: {}", e); - e - })?; - - // Load persisted state - debug!("Loading persisted state"); - let mut state = persistence.load_state().await.map_err(|e| { - error!("Failed to load persisted state: {}", e); - e - })?; - - let server = Self { - rooms: Arc::new(DashMap::new()), - user_sessions: Arc::new(DashMap::new()), - 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 - if state.rooms.is_empty() { - info!("No rooms found in persistence, creating default rooms"); - // Create default rooms from configuration - for room_config in &config.default_rooms { - let room = ChatRoom { - id: room_config.id.clone(), - name: room_config.name.clone(), - users: HashSet::new(), - created_at: Utc::now(), - }; - server.rooms.insert(room_config.id.clone(), room.clone()); - - // Persist the room - state.rooms.insert( - room_config.id.clone(), - PersistedRoom { - id: room.id, - name: room.name, - created_at: room.created_at, - users: room.users.clone(), - }, - ); - info!( - "Created default room: {} ({})", - room_config.name, room_config.id - ); - } - - if !state.rooms.is_empty() { - server.persistence.save_state(&state).await.map_err(|e| { - error!("Failed to save initial state: {}", e); - e - })?; - } - } else { - info!("Restoring {} rooms from persistence", state.rooms.len()); - // Restore rooms from persistence (clear user lists as they're not currently connected) - for (id, persisted_room) in state.rooms { - debug!(room_id = %id, room_name = %persisted_room.name, "Restoring room"); - let room = ChatRoom { - id: persisted_room.id, - name: persisted_room.name, - users: HashSet::new(), // Clear users on restart - created_at: persisted_room.created_at, - }; - server.rooms.insert(id, room); - } - } - - Ok(server) - } - - async fn next_message_id(&self) -> u64 { - let mut counter = self.message_counter.write().await; - let id = *counter; - *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, - }) - } - - 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; - let now = Instant::now(); - let timeout = Duration::from_secs(5); - - let mut expired_users = Vec::new(); - - for (room_id, room_typing_users) in typing_users.iter_mut() { - room_typing_users.retain(|username, state| { - if now.duration_since(state.started_at) > timeout { - expired_users.push((room_id.clone(), username.clone())); - false - } else { - true - } - }); - } - - drop(typing_users); - - // Send stop typing notifications for expired users - for (room_id, username) in expired_users { - self.broadcast_typing_notification(connection_manager, &room_id, &username, false) - .await; - } - } - - // Broadcast typing notification to all users in a room - async fn broadcast_typing_notification( - &self, - connection_manager: &dyn ConnectionManager, - room_id: &str, - username: &str, - is_typing: bool, - ) { - if let Some(room) = self.rooms.get(room_id) { - let room_users: Vec = room.users.iter().cloned().collect(); - drop(room); - - let notification = if is_typing { - let notification = UserStartedTypingNotification { - username: username.to_string(), - room_id: room_id.to_string(), - }; - ras_jsonrpc_bidirectional_types::ServerNotification { - method: "user_started_typing".to_string(), - params: serde_json::to_value(¬ification).unwrap(), - metadata: None, - } - } else { - let notification = UserStoppedTypingNotification { - username: username.to_string(), - room_id: room_id.to_string(), - }; - ras_jsonrpc_bidirectional_types::ServerNotification { - method: "user_stopped_typing".to_string(), - params: serde_json::to_value(¬ification).unwrap(), - metadata: None, - } - }; - - let msg = ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification( - notification, - ); - - // Send to all users in the room except the typing user - for target_username in room_users { - if target_username != username { - for entry in self.user_sessions.iter() { - 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.key(), - "Failed to send typing notification: {:?}", e); - } - } - } - } - } - } -} - -// Implement the chat service -#[async_trait::async_trait] -impl ChatServiceService for ChatServer { - #[instrument(skip(self, connection_manager, _user), fields(client_id = %client_id, user = %_user.user_id))] - async fn send_message( - &self, - client_id: ConnectionId, - connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - request: SendMessageRequest, - ) -> Result> { - debug!("Processing send_message request"); - - // Validate message length - if request.text.len() > self.config.max_message_length { - return Err(format!( - "Message too long. Maximum length is {} characters", - self.config.max_message_length - ) - .into()); - } - - // Get user session - let session = self.user_sessions.get(&client_id).ok_or_else(|| { - error!("User session not found for client {}", client_id); - "User session not found" - })?; - - let room_id = session.current_room.clone().ok_or_else(|| { - warn!("User {} not in any room", session.username); - "User not in any room" - })?; - - // Drop the session ref to avoid holding the lock - 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; - if let Some(room_typing_users) = typing_users.get_mut(&room_id) { - if room_typing_users.remove(&username).is_some() { - was_typing = true; - } - if room_typing_users.is_empty() { - typing_users.remove(&room_id); - } - } - drop(typing_users); - - // Send stop typing notification if user was typing - if was_typing { - self.broadcast_typing_notification(connection_manager, &room_id, &username, false) - .await; - } - - // Get room to find all users - let room = self.rooms.get(&room_id).ok_or_else(|| { - error!("Room {} not found", room_id); - "Room not found" - })?; - let room_users: Vec = room.users.iter().cloned().collect(); - let user_count = room.users.len(); - drop(room); - - debug!(room_id = %room_id, user_count = user_count, "Broadcasting message to room"); - - // Generate message details - let message_id = self.next_message_id().await; - let timestamp = Utc::now(); - let timestamp_str = timestamp.to_rfc3339(); - - // Create notification - let notification = MessageReceivedNotification { - message_id, - username: username.clone(), - text: request.text.clone(), - timestamp: timestamp_str.clone(), - room_id: room_id.clone(), - }; - - // Persist message to disk - let persisted_msg = PersistedMessage { - id: message_id, - room_id: room_id.clone(), - username: username.clone(), - text: request.text, - timestamp, - }; - if let Err(e) = self - .persistence - .append_message(&room_id, &persisted_msg) - .await - { - error!(message_id = message_id, room_id = %room_id, "Failed to persist message: {}", e); - } else { - debug!(message_id = message_id, "Message persisted successfully"); - } - - // Send to all users in the room - for target_username in room_users { - // Find connection ID for this username - for entry in self.user_sessions.iter() { - if entry.username == target_username { - // Send notification directly using connection manager - let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "message_received".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!(target_user = %target_username, connection_id = %entry.key(), - "Failed to send message notification: {:?}", e); - } - } - } - } - - info!(message_id = message_id, room_id = %room_id, sender = %username, - "Message sent successfully"); - Ok(SendMessageResponse { - message_id, - timestamp: timestamp_str, - }) - } - - #[instrument(skip(self, connection_manager, _user), fields(client_id = %client_id, user = %_user.user_id, room_name = %request.room_name))] - async fn join_room( - &self, - client_id: ConnectionId, - connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - request: JoinRoomRequest, - ) -> Result> { - debug!("Processing join_room request"); - - // Validate room name length - if request.room_name.len() > self.config.max_room_name_length { - return Err(format!( - "Room name too long. Maximum length is {} characters", - self.config.max_room_name_length - ) - .into()); - } - - // Get or create room - let room_id = if self.rooms.contains_key(&request.room_name) { - request.room_name.clone() - } else { - // Create new room - let room_id = if request.room_name.is_empty() { - Uuid::new_v4().to_string() - } else { - request.room_name.clone() - }; - - let new_room = ChatRoom { - id: room_id.clone(), - name: request.room_name.clone(), - users: HashSet::new(), - created_at: Utc::now(), - }; - - self.rooms.insert(room_id.clone(), new_room.clone()); - - // Persist new room - let mut state = self.persistence.load_state().await.unwrap_or_default(); - state.rooms.insert( - room_id.clone(), - PersistedRoom { - id: new_room.id.clone(), - name: new_room.name.clone(), - created_at: new_room.created_at, - users: new_room.users.clone(), - }, - ); - if let Err(e) = self.persistence.save_state(&state).await { - error!(room_id = %room_id, "Failed to persist new room: {}", e); - } else { - info!(room_id = %room_id, room_name = %new_room.name, "New room created and persisted"); - } - - // Notify all users about new room - let room_info = self.get_room_info(&room_id).unwrap(); - let notification = RoomCreatedNotification { room_info }; - - // Broadcast to all connected users - for entry in self.user_sessions.iter() { - let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "room_created".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 room_created notification: {:?}", e); - } - } - - room_id - }; - - // Get user session - let mut session = self.user_sessions.get_mut(&client_id).ok_or_else(|| { - error!("User session not found for client {}", client_id); - "User session not found" - })?; - - let username = session.username.clone(); - - // Leave current room if in one - 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); - - // 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); - } - } - } - } - - // Update session - session.current_room = Some(room_id.clone()); - drop(session); - - // Add user to new room - let mut room = self.rooms.get_mut(&room_id).ok_or("Room not found")?; - - // Check user limit - if self.config.max_users_per_room > 0 && room.users.len() >= self.config.max_users_per_room - { - return Err(format!( - "Room is full. Maximum {} users allowed per room", - self.config.max_users_per_room - ) - .into()); - } - - // Get existing users before adding the new user - let existing_users: Vec = room.users.iter().cloned().collect(); - - room.users.insert(username.clone()); - let user_count = room.users.len() as u32; - let room_users: Vec = room.users.iter().cloned().collect(); - drop(room); - - // Notify users in new room - let notification = UserJoinedNotification { - username: username.clone(), - room_id: room_id.clone(), - user_count, - }; - - for target_username in room_users { - for entry in self.user_sessions.iter() { - if entry.username == target_username { - let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "user_joined".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!(target_user = %target_username, connection_id = %entry.key(), - "Failed to send message notification: {:?}", e); - } - } - } - } - - info!( - user = %username, - room_id = %room_id, - existing_users = ?existing_users, - user_count = %user_count, - "User joined room successfully" - ); - - Ok(JoinRoomResponse { - room_id, - user_count, - existing_users, - }) - } - - async fn leave_room( - &self, - client_id: ConnectionId, - connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - request: LeaveRoomRequest, - ) -> Result<(), Box> { - let mut session = self - .user_sessions - .get_mut(&client_id) - .ok_or("User session not found")?; - - // Check if user is in the requested room - if session.current_room.as_ref() != Some(&request.room_id) { - return Err("User not in the specified room".into()); - } - - let username = session.username.clone(); - let room_id_for_log = request.room_id.clone(); - session.current_room = None; - drop(session); - - // Remove user from room - if let Some(mut room) = self.rooms.get_mut(&request.room_id) { - room.users.remove(&username); - let user_count = room.users.len() as u32; - let room_users: Vec = room.users.iter().cloned().collect(); - drop(room); - - // Notify remaining users - let notification = UserLeftNotification { - username: username.clone(), - room_id: request.room_id, - user_count, - }; - - for target_username in room_users { - for entry in self.user_sessions.iter() { - if entry.username == target_username { - 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); - } - } - } - } - } - - info!(user = %username, room_id = %room_id_for_log, "User left room successfully"); - Ok(()) - } - - #[instrument(skip(self, _connection_manager, _user), fields(client_id = %_client_id, user = %_user.user_id))] - async fn list_rooms( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - _request: ListRoomsRequest, - ) -> Result> { - debug!("Processing list_rooms request"); - let rooms: Vec = self - .rooms - .iter() - .map(|entry| RoomInfo { - room_id: entry.id.clone(), - room_name: entry.name.clone(), - user_count: entry.users.len() as u32, - }) - .collect(); - - debug!(room_count = rooms.len(), "Returning room list"); - Ok(ListRoomsResponse { rooms }) - } - - async fn kick_user( - &self, - _client_id: ConnectionId, - connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - request: KickUserRequest, - ) -> Result> { - // Find the target user's session - let mut target_connection_id = None; - let mut target_room_id = None; - - for entry in self.user_sessions.iter() { - if entry.username == request.target_username { - target_connection_id = Some(*entry.key()); - target_room_id = entry.current_room.clone(); - break; - } - } - - 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 - && let Some(mut room) = self.rooms.get_mut(room_id) - { - room.users.remove(&request.target_username); - } - - // Send kick notification to the target user - let kick_notification = UserKickedNotification { - username: request.target_username.clone(), - reason: request.reason.clone(), - room_id: target_room_id.as_ref().cloned().unwrap_or_default(), - }; - - let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "user_kicked".to_string(), - params: serde_json::to_value(&kick_notification).unwrap(), - metadata: None, - }; - let msg = ras_jsonrpc_bidirectional_types::BidirectionalMessage::ServerNotification( - notification_msg, - ); - if let Err(e) = connection_manager.send_to_connection(target_id, msg).await { - warn!("Failed to send kick notification to user: {:?}", e); - } - - // 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 - if let Err(e) = connection_manager.remove_connection(target_id).await { - warn!("Failed to disconnect user: {:?}", e); - } - - Ok(true) - } - - async fn broadcast_announcement( - &self, - _client_id: ConnectionId, - connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - request: BroadcastAnnouncementRequest, - ) -> Result<(), Box> { - let notification = SystemAnnouncementNotification { - message: request.message, - level: request.level, - timestamp: Utc::now().to_rfc3339(), - }; - - // Send to all connected users - for entry in self.user_sessions.iter() { - let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "system_announcement".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 announcement: {:?}", e); - } - } - - let user_count = self.user_sessions.len(); - info!(user_count = user_count, "Announcement broadcast complete"); - Ok(()) - } - - async fn get_profile( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - request: GetProfileRequest, - ) -> Result> { - // Load current state - let state = self.persistence.load_state().await?; - - // Get profile from persistence or create default - let profile = if let Some(persisted) = state.user_profiles.get(&request.username) { - user_profile_from_persisted(persisted) - } else { - // Create default profile - UserProfile { - username: request.username.clone(), - display_name: None, - avatar: CatAvatar { - breed: CatBreed::Tabby, - color: CatColor::Orange, - expression: CatExpression::Happy, - }, - created_at: Utc::now().to_rfc3339(), - last_seen: Utc::now().to_rfc3339(), - } - }; - - Ok(GetProfileResponse { profile }) - } - - async fn update_profile( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - user: &AuthenticatedUser, - request: UpdateProfileRequest, - ) -> Result> { - // Load current state - let mut state = self.persistence.load_state().await?; - - // Get existing profile or create new one - let mut persisted_profile = state - .user_profiles - .get(&user.user_id) - .cloned() - .unwrap_or_else(|| PersistedUserProfile { - username: user.user_id.clone(), - display_name: None, - avatar: PersistedCatAvatar { - breed: "tabby".to_string(), - color: "orange".to_string(), - expression: "happy".to_string(), - }, - created_at: Utc::now(), - last_seen: Utc::now(), - }); - - // Update fields if provided - if let Some(display_name) = request.display_name { - persisted_profile.display_name = Some(display_name); - } - - if let Some(avatar) = request.avatar { - persisted_profile.avatar = PersistedCatAvatar { - breed: persisted_cat_breed(avatar.breed).to_string(), - color: persisted_cat_color(avatar.color).to_string(), - expression: persisted_cat_expression(avatar.expression).to_string(), - }; - } - - // Update last seen - persisted_profile.last_seen = Utc::now(); - - // Save to persistence - state - .user_profiles - .insert(user.user_id.clone(), persisted_profile.clone()); - self.persistence.save_state(&state).await?; - - // Convert to response - let profile = user_profile_from_persisted(&persisted_profile); - - Ok(UpdateProfileResponse { profile }) - } - - async fn start_typing( - &self, - client_id: ConnectionId, - connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - _request: StartTypingRequest, - ) -> Result<(), Box> { - // Get user session - let session = self.user_sessions.get(&client_id).ok_or_else(|| { - error!("User session not found for client {}", client_id); - "User session not found" - })?; - - let username = session.username.clone(); - let room_id = session.current_room.clone().ok_or_else(|| { - warn!("User {} not in any room", session.username); - "User not in any room" - })?; - drop(session); - - // Update typing state - let mut typing_users = self.typing_users.lock().await; - let room_typing_users = typing_users - .entry(room_id.clone()) - .or_insert_with(HashMap::new); - - let is_new_typing = !room_typing_users.contains_key(&username); - room_typing_users.insert( - username.clone(), - TypingState { - started_at: Instant::now(), - }, - ); - drop(typing_users); - - // Send notification only if this is a new typing state - if is_new_typing { - self.broadcast_typing_notification(connection_manager, &room_id, &username, true) - .await; - } - - // Clean up expired typing states - self.cleanup_expired_typing_states(connection_manager).await; - - Ok(()) - } - - async fn stop_typing( - &self, - client_id: ConnectionId, - connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - _request: StopTypingRequest, - ) -> Result<(), Box> { - // Get user session - let session = self.user_sessions.get(&client_id).ok_or_else(|| { - error!("User session not found for client {}", client_id); - "User session not found" - })?; - - let username = session.username.clone(); - let room_id = session.current_room.clone().ok_or_else(|| { - warn!("User {} not in any room", session.username); - "User not in any room" - })?; - drop(session); - - // Remove from typing state - let mut typing_users = self.typing_users.lock().await; - let mut should_notify = false; - - if let Some(room_typing_users) = typing_users.get_mut(&room_id) { - if room_typing_users.remove(&username).is_some() { - should_notify = true; - } - - // Clean up empty room entries - if room_typing_users.is_empty() { - typing_users.remove(&room_id); - } - } - drop(typing_users); - - // Send notification if user was typing - if should_notify { - self.broadcast_typing_notification(connection_manager, &room_id, &username, false) - .await; - } - - Ok(()) - } - - // 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, - _params: MessageReceivedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_user_joined( - &self, - _connection_id: ConnectionId, - _params: UserJoinedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_user_left( - &self, - _connection_id: ConnectionId, - _params: UserLeftNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_system_announcement( - &self, - _connection_id: ConnectionId, - _params: SystemAnnouncementNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_user_kicked( - &self, - _connection_id: ConnectionId, - _params: UserKickedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_room_created( - &self, - _connection_id: ConnectionId, - _params: RoomCreatedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_room_deleted( - &self, - _connection_id: ConnectionId, - _params: RoomDeletedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_user_started_typing( - &self, - _connection_id: ConnectionId, - _params: UserStartedTypingNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_user_stopped_typing( - &self, - _connection_id: ConnectionId, - _params: UserStoppedTypingNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - // Lifecycle hooks - async fn on_client_connected( - &self, - client_id: ConnectionId, - connection_manager: &dyn ConnectionManager, - ) -> Result<(), Box> { - info!("Client {} connected", client_id); - - // Send welcome message - let notification = SystemAnnouncementNotification { - message: "Welcome to the chat server! Please authenticate to continue.".to_string(), - level: AnnouncementLevel::Info, - timestamp: Utc::now().to_rfc3339(), - }; - - let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "system_announcement".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(client_id, msg).await { - warn!( - "Failed to send welcome message to client {}: {:?}", - client_id, e - ); - } - - Ok(()) - } +use std::sync::Arc; +use tracing::{error, info}; - async fn on_client_disconnected( - &self, - client_id: ConnectionId, - connection_manager: &dyn ConnectionManager, - ) -> Result<(), Box> { - info!("Client {} disconnected", client_id); - - // 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 - let mut typing_users = self.typing_users.lock().await; - let mut was_typing = false; - if let Some(room_typing_users) = typing_users.get_mut(&room_id) { - if room_typing_users.remove(&username).is_some() { - was_typing = true; - } - if room_typing_users.is_empty() { - typing_users.remove(&room_id); - } - } - drop(typing_users); - - // Send stop typing notification if user was typing - if was_typing { - self.broadcast_typing_notification( - connection_manager, - &room_id, - &username, - false, - ) - .await; - } - - // Remove from room - if let Some(mut room) = self.rooms.get_mut(&room_id) { - room.users.remove(&session.username); - let user_count = room.users.len() as u32; - let room_users: Vec = room.users.iter().cloned().collect(); - drop(room); - - // Notify remaining users - let notification = UserLeftNotification { - username: session.username, - room_id, - user_count, - }; - - for target_username in room_users { - for entry in self.user_sessions.iter() { - if entry.username == target_username { - 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 on disconnect: {:?}", e); - } - } - } - } - } - } - } - - Ok(()) - } - - async fn on_client_authenticated( - &self, - client_id: ConnectionId, - connection_manager: &dyn ConnectionManager, - user: &AuthenticatedUser, - ) -> Result<(), Box> { - info!( - "Client {} authenticated as user {}", - client_id, user.user_id - ); - - // Create user session - let session = UserSession { - username: user.user_id.clone(), - current_room: None, - }; - - self.user_sessions.insert(client_id, session); - - // Send personalized welcome - let notification = SystemAnnouncementNotification { - message: format!( - "Welcome {}, you have been successfully authenticated!", - user.user_id - ), - level: AnnouncementLevel::Info, - timestamp: Utc::now().to_rfc3339(), - }; - - let notification_msg = ras_jsonrpc_bidirectional_types::ServerNotification { - method: "system_announcement".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(client_id, msg).await { - warn!( - "Failed to send welcome message to client {}: {:?}", - client_id, e - ); - } - - Ok(()) - } -} - -// Permission provider for the chat application -#[derive(Clone)] -struct ChatPermissions { - admin_users: Vec, -} - -// REST API handlers -#[derive(Clone)] -struct AuthHandlers { - session_service: Arc, - identity_provider: Arc, -} - -impl ChatPermissions { - fn new(admin_users: Vec) -> Self { - Self { admin_users } - } -} - -#[async_trait::async_trait] -impl UserPermissions for ChatPermissions { - async fn get_permissions( - &self, - identity: &VerifiedIdentity, - ) -> ras_identity_core::IdentityResult> { - // Check if user is in admin configuration - for admin_user in &self.admin_users { - if admin_user.username == identity.subject { - return Ok(admin_user.permissions.clone()); - } - } - - // Default permissions for regular users - Ok(vec!["user".to_string()]) - } -} - -impl AuthHandlers { - async fn handle_login(&self, request: LoginRequest) -> RestResult { - debug!("Processing login request"); - - // Create auth payload - let provider_id = request.provider.as_deref().unwrap_or("local"); - let auth_payload = json!({ - "username": request.username, - "password": request.password, - "provider": provider_id, - }); - - // Begin session - let token = self - .session_service - .begin_session(provider_id, auth_payload) - .await - .map_err(|e| { - warn!(provider = %provider_id, "Login failed: {}", e); - RestError::unauthorized("Invalid credentials") - })?; - - // Parse token to get user info (for response) - let claims = self - .session_service - .verify_session(&token) - .await - .map_err(|e| { - warn!("Token verification failed: {}", e); - RestError::internal_server_error("Token verification failed") - })?; - - info!(user_id = %claims.sub, "User logged in successfully"); - Ok(RestResponse::ok(LoginResponse { - token, - expires_at: claims.exp, - user_id: claims.sub, - })) - } - - async fn handle_register(&self, request: RegisterRequest) -> RestResult { - debug!("Processing registration request"); - - // Add user - self.identity_provider - .add_user( - request.username.clone(), - request.password, - request.email.clone(), - request.display_name.clone(), - ) - .await - .map_err(|e| { - warn!(username = %request.username, "Registration failed: {}", e); - RestError::conflict("Username already exists") - })?; - - info!(username = %request.username, email = ?request.email, "User registered successfully"); - - Ok(RestResponse::created(RegisterResponse { - message: "User registered successfully".to_string(), - username: request.username, - display_name: request.display_name, - })) - } - - async fn handle_health(&self) -> RestResult { - Ok(RestResponse::ok(HealthResponse { - status: "OK".to_string(), - timestamp: Utc::now().to_rfc3339(), - })) - } -} #[tokio::main] async fn main() -> Result<()> { // Load environment variables first (before config loading) @@ -1495,187 +46,15 @@ async fn main() -> Result<()> { info!("Starting bidirectional chat server"); info!("Configuration loaded from environment and config file"); - // Create identity provider - use Arc to share between session service and registration - info!("Setting up identity provider"); - let identity_provider = Arc::new(LocalUserProvider::new()); - - // Add admin users from configuration - if config.admin.auto_create { - for admin_user in &config.admin.users { - match identity_provider - .add_user( - admin_user.username.clone(), - admin_user.password.clone(), - admin_user.email.clone(), - admin_user.display_name.clone(), - ) - .await - { - Ok(_) => info!("Created admin user: {}", admin_user.username), - Err(e) => { - // User might already exist, which is fine - debug!( - "Admin user {} might already exist: {}", - admin_user.username, e - ); - } - } - } - } - - // Add some default test users if in development mode - if cfg!(debug_assertions) { - let test_users = vec![ - ( - "alice", - "alice123", - Some("alice@example.com"), - Some("Alice"), - ), - ("bob", "bob123", Some("bob@example.com"), Some("Bob")), - ]; - - for (username, password, email, display_name) in test_users { - match identity_provider - .add_user( - username.to_string(), - password.to_string(), - email.map(|s| s.to_string()), - display_name.map(|s| s.to_string()), - ) - .await - { - Ok(_) => debug!("Created test user: {}", username), - Err(e) => debug!("Test user {} might already exist: {}", username, e), - } - } - } - - // Create session service from configuration - let session_config = SessionConfig { - jwt_secret: config.auth.jwt_secret.clone(), - jwt_ttl: chrono::Duration::seconds(config.auth.jwt_ttl_seconds), - enforce_active_sessions: true, - algorithm: JwtAlgorithm::from_name(&config.auth.jwt_algorithm) - .unwrap_or(JwtAlgorithm::HS256), - iss: Some("bidirectional-chat".to_string()), - aud: Some("bidirectional-chat".to_string()), - require_iss_aud: true, - max_sessions_per_user: ras_identity_session::DEFAULT_MAX_SESSIONS_PER_USER, - }; - info!( - "Creating session service with JWT TTL: {} seconds", - config.auth.jwt_ttl_seconds - ); - let session_service = Arc::new( - SessionService::new(session_config) - .map_err(anyhow::Error::from)? - .with_permissions(Arc::new(ChatPermissions::new(config.admin.users.clone()))), - ); - - // Register the identity provider with the session service - // We need to dereference the Arc and clone the inner provider since register_provider takes Box - session_service - .register_provider(Box::new((*identity_provider).clone())) - .await; - - // Create JWT auth provider - let auth_provider = Arc::new(JwtAuthProvider::new(session_service.clone())); - - // Create connection manager - let connection_manager = Arc::new(DefaultConnectionManager::new()); - - // Create chat server with configuration - 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( - chat_server.clone(), - connection_manager.clone(), - ) - .with_auth_provider(auth_provider.clone()), - ); - - // Build WebSocket service - let ws_service = WebSocketServiceBuilder::builder() - .handler(handler) - .auth_provider(auth_provider.clone()) - .require_auth(true) - .build() - .build_with_manager(connection_manager); - - // Create auth handlers with the shared identity provider - let auth_handlers = AuthHandlers { - session_service: session_service.clone(), - identity_provider: identity_provider.clone(), - }; - - // Build REST service using the macro-generated builder - // Create auth service implementation - struct AuthServiceImpl { - handlers: AuthHandlers, - } - - #[async_trait::async_trait] - impl bidirectional_chat_api::auth::ChatAuthServiceTrait for AuthServiceImpl { - async fn post_auth_login(&self, request: LoginRequest) -> RestResult { - self.handlers.handle_login(request).await - } - - async fn post_auth_register( - &self, - request: RegisterRequest, - ) -> RestResult { - self.handlers.handle_register(request).await - } - - async fn get_health(&self) -> RestResult { - self.handlers.handle_health().await - } - } - - let auth_service_impl = AuthServiceImpl { - handlers: auth_handlers.clone(), - }; - - let auth_router = ChatAuthServiceBuilder::new(auth_service_impl) - .auth_provider(auth_provider.as_ref().clone()) - .build(); - - // Create WebSocket endpoint - type ChatServiceType = BuiltWebSocketService< - bidirectional_chat_api::ChatServiceHandler, - JwtAuthProvider, - DefaultConnectionManager, - >; - let ws_router = Router::new() - .route("/ws", get(websocket_handler::)) - .with_state(ws_service); - - // Configure CORS based on configuration - let cors_layer = if config.server.cors.allow_any_origin { - CorsLayer::permissive() - } else { - let mut cors = CorsLayer::new(); - for origin in &config.server.cors.allowed_origins { - cors = cors.allow_origin(origin.parse::().unwrap()); - } - cors - }; - - // Combine all routers - let app = Router::new() - .merge(auth_router) - .merge(ws_router) - .layer(cors_layer); + let application = build_application( + &config, + ApplicationDependencies { + identity_provider: Arc::new(LocalUserProvider::new()), + seed_development_users: cfg!(debug_assertions), + }, + ) + .await?; + let app = application.router; // Start server let addr = config.socket_addr(); @@ -1698,967 +77,3 @@ 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(outbound) = receiver.try_recv() { - messages.push(outbound.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); - // Handler errors expose a generic message; details stay in server logs. - assert_eq!(error.message, "Internal error"); - - 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); - // The rate-limit reason stays in server logs. - assert_eq!(error.message, "Internal error"); - - 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/tests/README.md b/examples/bidirectional-chat/server/tests/README.md index 0af3639..05038f4 100644 --- a/examples/bidirectional-chat/server/tests/README.md +++ b/examples/bidirectional-chat/server/tests/README.md @@ -22,7 +22,7 @@ Chat server auth and lifecycle tests: - **Admin Permissions**: Tests admin vs regular user permissions in JWT claims - **Concurrent Users**: Tests multiple users logging in simultaneously -### `../src/main.rs` Unit Tests +### `../src/chat/tests.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 @@ -104,11 +104,11 @@ The tests cover the following areas: ## Test Architecture -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. +The tests exercise the library application and its local state owners: +- `server_tests.rs` covers configuration and persistence, calls `build_application` for startup checks, and exercises an authenticated WebSocket session through the production router. +- `auth_lifecycle_tests.rs` calls `build_application` with explicit configuration and an in-memory identity provider, then tests production login, registration, and permission assignment. +- The `../src/chat/tests.rs` WebSocket unit tests exercise the real `ChatServer` through the generated handler, in-memory socket adapter, and in-memory connection manager. +- HTTP-only checks use `axum-test` mock transport; the router-level WebSocket check binds an ephemeral local port and verifies persisted messages and disconnect cleanup. - Both suites create temporary directories for runtime data and support concurrent test execution. ## Known Coverage Gaps diff --git a/examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs b/examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs index 52120b3..03ee86c 100644 --- a/examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs +++ b/examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs @@ -1,4 +1,4 @@ -//! Auth and lifecycle tests for a locally wired chat service fixture. +//! Auth and lifecycle tests through the production chat application. //! //! These tests cover: //! - In-memory fixture startup and health checks @@ -7,27 +7,17 @@ //! - Concurrent login handling use anyhow::Result; -use axum::{Router, http::StatusCode, routing::get}; -use bidirectional_chat_api::*; +use axum::http::StatusCode; use bidirectional_chat_server::config::{ AdminConfig, AdminUser, AuthConfig, ChatConfig, Config, LoggingConfig, RateLimitConfig, RoomConfig, ServerConfig, }; -use chrono::Utc; -use ras_auth_core::AuthenticatedUser; -use ras_identity_core::{UserPermissions, VerifiedIdentity}; +use bidirectional_chat_server::{ApplicationDependencies, build_application}; use ras_identity_local::LocalUserProvider; -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 ras_identity_session::SessionService; use serde_json::json; -use std::{collections::HashSet, sync::Arc}; +use std::sync::Arc; use tempfile::TempDir; -use tokio::sync::RwLock; -use tower_http::cors::CorsLayer; /// Test server with auth and WebSocket routers wired through in-memory transport. struct TestChatServer { @@ -92,18 +82,6 @@ impl TestChatServer { // Set up server components let identity_provider = Arc::new(LocalUserProvider::new()); - // Add admin users - for admin_user in &config.admin.users { - let _ = 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 let test_users = vec![ ("alice", "alice123", Some("alice@test.com"), Some("Alice")), @@ -127,81 +105,16 @@ impl TestChatServer { .await; } - // Create session service - let session_config = SessionConfig { - jwt_secret: config.auth.jwt_secret.clone(), - jwt_ttl: chrono::Duration::seconds(config.auth.jwt_ttl_seconds), - enforce_active_sessions: true, - algorithm: JwtAlgorithm::HS256, - iss: Some("bidirectional-chat".to_string()), - aud: Some("bidirectional-chat".to_string()), - require_iss_aud: true, - max_sessions_per_user: ras_identity_session::DEFAULT_MAX_SESSIONS_PER_USER, - }; - - let session_service = Arc::new( - SessionService::new(session_config) - .unwrap() - .with_permissions(Arc::new(TestChatPermissions::new( - config.admin.users.clone(), - ))), - ); - - session_service - .register_provider(Box::new((*identity_provider).clone())) - .await; - - // Create JWT auth provider - let auth_provider = JwtAuthProvider::new(session_service.clone()); - - // Create connection manager - let connection_manager = Arc::new(DefaultConnectionManager::new()); - - // Create chat server - let chat_server = Arc::new(ChatServer::new(config.chat.clone()).await?); - - // Create handler - let auth_provider = Arc::new(auth_provider); - let handler = Arc::new( - ChatServiceHandler::new(chat_server.clone(), connection_manager.clone()) - .with_auth_provider(auth_provider.clone()), - ); - - // Build WebSocket service - let ws_service = WebSocketServiceBuilder::builder() - .handler(handler) - .auth_provider(auth_provider) - .require_auth(true) - .build() - .build_with_manager(connection_manager); - - // Create routers - let auth_router = Router::new() - .route("/auth/login", axum::routing::post(login_handler)) - .route("/auth/register", axum::routing::post(register_handler)) - .with_state(( - Arc::clone(&session_service), - Arc::clone(&identity_provider), - chat_server, - )); - - type ChatServiceType = BuiltWebSocketService< - ChatServiceHandler, - JwtAuthProvider, - DefaultConnectionManager, - >; - let ws_router = Router::new() - .route("/ws", get(websocket_handler::)) - .with_state(ws_service); - - let health_router = Router::new().route("/health", get(|| async { "OK" })); - - // Combine all routers - let app = Router::new() - .merge(auth_router) - .merge(ws_router) - .merge(health_router) - .layer(CorsLayer::permissive()); + let application = build_application( + &config, + ApplicationDependencies { + identity_provider, + seed_development_users: false, + }, + ) + .await?; + let session_service = application.session_service; + let app = application.router; Ok(Self { server: Arc::new( @@ -236,450 +149,6 @@ impl TestChatServer { } } -// Permission provider for tests -#[derive(Clone)] -struct TestChatPermissions { - admin_users: Vec, -} - -impl TestChatPermissions { - fn new(admin_users: Vec) -> Self { - Self { admin_users } - } -} - -#[async_trait::async_trait] -impl UserPermissions for TestChatPermissions { - async fn get_permissions( - &self, - identity: &VerifiedIdentity, - ) -> ras_identity_core::IdentityResult> { - for admin_user in &self.admin_users { - if admin_user.username == identity.subject { - return Ok(admin_user.permissions.clone()); - } - } - Ok(vec!["user".to_string()]) - } -} - -// Handler implementations -async fn login_handler( - axum::extract::State((session_service, _identity_provider, _chat_server)): axum::extract::State< - (Arc, Arc, Arc), - >, - axum::Json(payload): axum::Json, -) -> Result, axum::http::StatusCode> { - let provider_id = payload - .get("provider") - .and_then(|v| v.as_str()) - .unwrap_or("local"); - - let token = session_service - .begin_session(provider_id, payload.clone()) - .await - .map_err(|_| axum::http::StatusCode::UNAUTHORIZED)?; - - let claims = session_service - .verify_session(&token) - .await - .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(axum::Json(json!({ - "token": token, - "expires_at": claims.exp, - "user_id": claims.sub, - }))) -} - -async fn register_handler( - axum::extract::State((_session_service, identity_provider, _chat_server)): axum::extract::State< - (Arc, Arc, Arc), - >, - axum::Json(payload): axum::Json, -) -> Result, axum::http::StatusCode> { - let username = payload - .get("username") - .and_then(|v| v.as_str()) - .ok_or(axum::http::StatusCode::BAD_REQUEST)?; - - let password = payload - .get("password") - .and_then(|v| v.as_str()) - .ok_or(axum::http::StatusCode::BAD_REQUEST)?; - - let email = payload - .get("email") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let display_name = payload - .get("display_name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - identity_provider - .add_user( - username.to_string(), - password.to_string(), - email.clone(), - display_name.clone(), - ) - .await - .map_err(|_| axum::http::StatusCode::CONFLICT)?; - - Ok(axum::Json(json!({ - "message": "User registered successfully", - "username": username, - "display_name": display_name, - }))) -} - -// Import ChatServer from main.rs -use bidirectional_chat_server::persistence::{PersistedRoom, PersistenceManager}; -use dashmap::DashMap; - -#[derive(Debug, Clone)] -struct ChatRoom { - id: String, - name: String, - users: HashSet, - created_at: chrono::DateTime, -} - -#[derive(Debug, Clone)] -struct UserSession; - -#[derive(Clone)] -struct ChatServer { - rooms: Arc>, - user_sessions: Arc>, - message_counter: Arc>, - persistence: Arc, -} - -impl ChatServer { - async fn new(config: ChatConfig) -> Result { - let persistence = Arc::new(PersistenceManager::new(&config.data_dir)); - persistence.init().await?; - - let mut state = persistence.load_state().await?; - - let server = Self { - rooms: Arc::new(DashMap::new()), - user_sessions: Arc::new(DashMap::new()), - message_counter: Arc::new(RwLock::new(state.next_message_id)), - persistence, - }; - - // Create default rooms - if state.rooms.is_empty() { - for room_config in &config.default_rooms { - let room = ChatRoom { - id: room_config.id.clone(), - name: room_config.name.clone(), - users: HashSet::new(), - created_at: Utc::now(), - }; - server.rooms.insert(room_config.id.clone(), room.clone()); - - state.rooms.insert( - room_config.id.clone(), - PersistedRoom { - id: room.id, - name: room.name, - created_at: room.created_at, - users: room.users.clone(), - }, - ); - } - - if !state.rooms.is_empty() { - server.persistence.save_state(&state).await?; - } - } else { - for (id, persisted_room) in state.rooms { - let room = ChatRoom { - id: persisted_room.id, - name: persisted_room.name, - users: HashSet::new(), - created_at: persisted_room.created_at, - }; - server.rooms.insert(id, room); - } - } - - Ok(server) - } - - async fn next_message_id(&self) -> u64 { - let mut counter = self.message_counter.write().await; - let id = *counter; - *counter += 1; - id - } -} - -// Minimal implementation of ChatServiceService for testing -#[async_trait::async_trait] -impl ChatServiceService for ChatServer { - async fn send_message( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - _request: SendMessageRequest, - ) -> Result> { - // Minimal implementation for testing - let message_id = self.next_message_id().await; - let timestamp = Utc::now().to_rfc3339(); - - Ok(SendMessageResponse { - message_id, - timestamp, - }) - } - - async fn join_room( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - request: JoinRoomRequest, - ) -> Result> { - // Minimal implementation for testing - let room_id = request.room_name.clone(); - let user_count = 1; - - Ok(JoinRoomResponse { - room_id, - user_count, - existing_users: vec![], - }) - } - - async fn leave_room( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - _request: LeaveRoomRequest, - ) -> Result<(), Box> { - Ok(()) - } - - async fn list_rooms( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - _request: ListRoomsRequest, - ) -> Result> { - let rooms: Vec = self - .rooms - .iter() - .map(|entry| RoomInfo { - room_id: entry.id.clone(), - room_name: entry.name.clone(), - user_count: entry.users.len() as u32, - }) - .collect(); - - Ok(ListRoomsResponse { rooms }) - } - - async fn kick_user( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - _request: KickUserRequest, - ) -> Result> { - Ok(true) - } - - async fn broadcast_announcement( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - _request: BroadcastAnnouncementRequest, - ) -> Result<(), Box> { - Ok(()) - } - - async fn get_profile( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - request: GetProfileRequest, - ) -> Result> { - // Return a default profile for testing - let profile = UserProfile { - username: request.username, - display_name: None, - avatar: CatAvatar { - breed: CatBreed::Tabby, - color: CatColor::Orange, - expression: CatExpression::Happy, - }, - created_at: Utc::now().to_rfc3339(), - last_seen: Utc::now().to_rfc3339(), - }; - - Ok(GetProfileResponse { profile }) - } - - async fn update_profile( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - user: &AuthenticatedUser, - request: UpdateProfileRequest, - ) -> Result> { - // Return updated profile for testing - let profile = UserProfile { - username: user.user_id.clone(), - display_name: request.display_name, - avatar: request.avatar.unwrap_or(CatAvatar { - breed: CatBreed::Tabby, - color: CatColor::Orange, - expression: CatExpression::Happy, - }), - created_at: Utc::now().to_rfc3339(), - last_seen: Utc::now().to_rfc3339(), - }; - - Ok(UpdateProfileResponse { profile }) - } - - // Notification methods (not used by server) - async fn notify_message_received( - &self, - _connection_id: ConnectionId, - _params: MessageReceivedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_user_joined( - &self, - _connection_id: ConnectionId, - _params: UserJoinedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_user_left( - &self, - _connection_id: ConnectionId, - _params: UserLeftNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_system_announcement( - &self, - _connection_id: ConnectionId, - _params: SystemAnnouncementNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_user_kicked( - &self, - _connection_id: ConnectionId, - _params: UserKickedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_room_created( - &self, - _connection_id: ConnectionId, - _params: RoomCreatedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_room_deleted( - &self, - _connection_id: ConnectionId, - _params: RoomDeletedNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn start_typing( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - _request: StartTypingRequest, - ) -> Result<(), Box> { - Ok(()) - } - - async fn stop_typing( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - _request: StopTypingRequest, - ) -> Result<(), Box> { - Ok(()) - } - - async fn notify_user_started_typing( - &self, - _connection_id: ConnectionId, - _params: UserStartedTypingNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - async fn notify_user_stopped_typing( - &self, - _connection_id: ConnectionId, - _params: UserStoppedTypingNotification, - ) -> ras_jsonrpc_bidirectional_types::Result<()> { - Ok(()) - } - - // Lifecycle hooks - async fn on_client_connected( - &self, - _client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - ) -> Result<(), Box> { - Ok(()) - } - - async fn on_client_disconnected( - &self, - client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - ) -> Result<(), Box> { - // Remove user session - self.user_sessions.remove(&client_id); - Ok(()) - } - - async fn on_client_authenticated( - &self, - client_id: ConnectionId, - _connection_manager: &dyn ConnectionManager, - _user: &AuthenticatedUser, - ) -> Result<(), Box> { - // Create user session - self.user_sessions.insert(client_id, UserSession); - Ok(()) - } -} - -// Tests - #[tokio::test] async fn test_server_lifecycle() -> Result<()> { let server = TestChatServer::start().await?; @@ -714,14 +183,14 @@ async fn test_user_authentication() -> Result<()> { .post("/auth/login") .json(&json!({ "username": "alice" })) .await; - missing_password.assert_status(StatusCode::UNAUTHORIZED); + missing_password.assert_status(StatusCode::BAD_REQUEST); let missing_username = server .server .post("/auth/login") .json(&json!({ "password": "alice123" })) .await; - missing_username.assert_status(StatusCode::UNAUTHORIZED); + missing_username.assert_status(StatusCode::BAD_REQUEST); server.shutdown().await; Ok(()) @@ -743,7 +212,7 @@ async fn test_user_registration() -> Result<()> { })) .await; - response.assert_status_ok(); + response.assert_status(StatusCode::CREATED); // The new user is added to the same identity provider that backs login. let token = server.login("newuser", "newpass123").await?; diff --git a/examples/bidirectional-chat/server/tests/server_tests.rs b/examples/bidirectional-chat/server/tests/server_tests.rs index 7204cf5..181406b 100644 --- a/examples/bidirectional-chat/server/tests/server_tests.rs +++ b/examples/bidirectional-chat/server/tests/server_tests.rs @@ -1,17 +1,16 @@ -//! Chat configuration, persistence, and health-router fixture tests. -//! -//! The health fixture does not construct the application server. +//! Chat configuration, persistence, and production application startup tests. use anyhow::Result; -use axum::Router; use bidirectional_chat_server::config::{ AdminConfig, AdminUser, AuthConfig, ChatConfig, Config, LoggingConfig, RateLimitConfig, RoomConfig, ServerConfig, }; +use bidirectional_chat_server::{ApplicationDependencies, build_application}; use config::{Config as FileConfig, File}; +use ras_identity_local::LocalUserProvider; use ras_identity_session::{JwtAlgorithm, SessionConfig}; +use std::sync::Arc; use tempfile::TempDir; -use tower_http::cors::CorsLayer; /// Test server instance struct TestServer { @@ -19,13 +18,16 @@ struct TestServer { } impl TestServer { - /// Start the isolated health router; application configuration is unused. - async fn start(_config: Config) -> Result { - let health_router = Router::new().route("/health", axum::routing::get(|| async { "OK" })); - - let app = Router::new() - .merge(health_router) - .layer(CorsLayer::permissive()); + async fn start(config: Config) -> Result { + let application = build_application( + &config, + ApplicationDependencies { + identity_provider: Arc::new(LocalUserProvider::new()), + seed_development_users: false, + }, + ) + .await?; + let app = application.router; Ok(Self { server: axum_test::TestServer::builder() @@ -154,7 +156,7 @@ async fn test_server_startup() -> Result<()> { let response = server.server.get("/health").await; response.assert_status_ok(); - assert_eq!(response.text(), "OK"); + assert_eq!(response.json::()["status"], "OK"); server.shutdown().await; Ok(()) @@ -378,11 +380,81 @@ async fn test_message_persistence() -> Result<()> { Ok(()) } -// Module to re-export necessary types for the tests -mod bidirectional_chat_server { - pub use bidirectional_chat_server::config; +#[tokio::test] +async fn application_router_authenticates_websocket_and_persists_messages() -> Result<()> { + use bidirectional_chat_api::{ + ChatServiceClientBuilder, JoinRoomRequest, ListRoomsRequest, SendMessageRequest, + }; + use bidirectional_chat_server::persistence::PersistenceManager; + use serde_json::json; + use std::time::Duration; - pub mod persistence { - pub use bidirectional_chat_server::persistence::*; - } + let (config, _temp_dir) = create_test_config().await?; + let application = build_application( + &config, + ApplicationDependencies { + identity_provider: Arc::new(LocalUserProvider::new()), + seed_development_users: false, + }, + ) + .await?; + let manager = application.connection_manager; + let server = axum_test::TestServer::builder() + .http_transport() + .build(application.router)?; + server + .post("/auth/register") + .json(&json!({"username": "socket-user", "password": "socket-password"})) + .await + .assert_status(axum::http::StatusCode::CREATED); + let login: serde_json::Value = server + .post("/auth/login") + .json(&json!({"username": "socket-user", "password": "socket-password"})) + .await + .json(); + let token = login["token"].as_str().unwrap().to_string(); + + // Drive the router through the generated client, the same one the TUI uses. + let http_url = server.server_address().expect("http transport address"); + let ws_url = format!( + "ws://{}:{}/ws", + http_url.host_str().unwrap(), + http_url.port().unwrap() + ); + let client = ChatServiceClientBuilder::new(ws_url) + .with_jwt_token(token) + .build() + .await?; + + tokio::time::timeout(Duration::from_secs(5), async { + client.connect().await?; + client + .join_room(JoinRoomRequest { + room_name: "general".to_string(), + }) + .await?; + client + .send_message(SendMessageRequest { + text: "persisted through application router".to_string(), + }) + .await?; + let rooms = client.list_rooms(ListRoomsRequest {}).await?; + assert!(rooms.rooms.iter().any(|room| room.room_id == "general")); + client.disconnect().await?; + while manager.connection_count() != 0 { + tokio::task::yield_now().await; + } + anyhow::Ok(()) + }) + .await??; + + let messages = PersistenceManager::new(&config.chat.data_dir) + .load_room_messages("general", None) + .await?; + assert!( + messages + .iter() + .any(|message| message.text == "persisted through application router") + ); + Ok(()) } diff --git a/examples/wasm-ui-demo/src/app/dashboard.rs b/examples/wasm-ui-demo/src/app/dashboard.rs new file mode 100644 index 0000000..53a2e72 --- /dev/null +++ b/examples/wasm-ui-demo/src/app/dashboard.rs @@ -0,0 +1,242 @@ +use super::*; + +pub(super) fn render_dashboard(app: Arc) -> Dom { + html!("div", { + .class(&*STYLES) + .style("min-height", "100vh") + .style("background", "linear-gradient(to bottom, #0a0a0a, #000000)") + .children(&mut [ + // Header + html!("nav", { + .class("glass") + .apply(|b| dwclass!(b, "sticky top-0")) + .style("z-index", "50") + .child(html!("div", { + .apply(|b| dwclass!(b, "max-w-7xl p-4")) + .style("margin", "0 auto") + .child(html!("div", { + .apply(|b| dwclass!(b, "flex justify-between")) + .style("align-items", "center") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "flex gap-3")) + .style("align-items", "center") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "w-10 h-10 rounded-lg flex justify-center")) + .style("background", "linear-gradient(to bottom right, #3b82f6, #8b5cf6)") + .style("align-items", "center") + .child(html!("span", { + .apply(|b| dwclass!(b, "font-bold text-lg")) + .style("color", "white") + .text("T") + })) + }), + html!("h1", { + .apply(|b| dwclass!(b, "text-2xl font-bold")) + .style("background", "linear-gradient(to right, #60a5fa, #a78bfa)") + .style("background-clip", "text") + .style("-webkit-background-clip", "text") + .style("color", "transparent") + .text("Task Manager") + }), + ]) + }), + + html!("button", { + .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300 rounded-lg transition-all border border-bunker-700")) + .style("padding", "0.5rem 1rem") + .style("background-color", "rgba(31, 41, 55, 0.5)") + .text("Sign Out") + .event(clone!(app => move |_: events::Click| { + App::logout(app.clone()); + })) + }), + ]) + })) + })) + }), + + // Main content + html!("main", { + .apply(|b| dwclass!(b, "max-w-7xl p-6")) + .style("margin", "0 auto") + .style("padding-top", "2rem") + .style("padding-bottom", "2rem") + .child(html!("div", { + .apply(|b| dwclass!(b, "grid gap-8")) + .style("grid-template-columns", "1fr") + .children(&mut [ + // Left column - Stats and Tasks + html!("div", { + .style("display", "flex") + .style("flex-direction", "column") + .style("gap", "1.5rem") + .children(&mut [ + // Stats + html!("div", { + .child_signal(app.stats.signal_cloned().map(|stats| { + stats.map(|s| render_stats_card(&s)) + })) + }), + + // Task list + render_task_list(app.clone()), + ]) + }), + + // Right column - Create form and selected task + html!("div", { + .style("display", "flex") + .style("flex-direction", "column") + .style("gap", "1.5rem") + .children(&mut [ + // Create task form + render_task_form(app.clone()), + + // Selected task details + html!("div", { + .child_signal(app.selected_task.signal_cloned().map(clone!(app => move |task| { + task.map(|t| { + html!("div", { + .class("glass") + .class("animate-fade-in") + .apply(|b| dwclass!(b, "rounded-2xl p-8")) + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "flex justify-between")) + .style("align-items", "center") + .style("margin-bottom", "2rem") + .children(&mut [ + html!("h3", { + .apply(|b| dwclass!(b, "text-2xl font-bold text-bunker-100")) + .text("Task Details") + }), + html!("button", { + .apply(|b| dwclass!(b, "text-bunker-400 hover:text-bunker-200 text-2xl")) + .text("×") + .event(clone!(app => move |_: events::Click| { + app.selected_task.set(None); + })) + }), + ]) + }), + + html!("div", { + .style("display", "flex") + .style("flex-direction", "column") + .style("gap", "1.5rem") + .children(&mut [ + // Title and status + html!("div", { + .children(&mut [ + html!("h4", { + .apply(|b| dwclass!(b, "text-xl font-semibold text-bunker-100")) + .style("margin-bottom", "0.5rem") + .text(&t.title) + }), + html!("p", { + .apply(|b| dwclass!(b, "text-bunker-400")) + .text(&t.description) + }), + ]) + }), + + // Meta info + html!("div", { + .apply(|b| dwclass!(b, "grid grid-cols-2 gap-4")) + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "rounded-lg p-4")) + .style("background-color", "rgba(31, 41, 55, 0.5)") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "text-xs text-bunker-500")) + .style("text-transform", "uppercase") + .style("letter-spacing", "0.05em") + .text("Task ID") + }), + html!("div", { + .apply(|b| dwclass!(b, "text-sm text-bunker-300 font-mono")) + .style("margin-top", "0.25rem") + .text(task_id_preview(&t.id)) + .attr("title", &t.id) + }), + ]) + }), + + html!("div", { + .apply(|b| dwclass!(b, "rounded-lg p-4")) + .style("background-color", "rgba(31, 41, 55, 0.5)") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "text-xs text-bunker-500")) + .style("text-transform", "uppercase") + .style("letter-spacing", "0.05em") + .text("Status") + }), + html!("div", { + .apply(|b| dwclass!(b, "text-sm font-medium")) + .style("margin-top", "0.25rem") + .apply(|b| if t.completed { + dwclass!(b, "text-apple-400") + } else { + dwclass!(b, "text-candlelight-400") + }) + .text(if t.completed { "Completed" } else { "In Progress" }) + }), + ]) + }), + + html!("div", { + .apply(|b| dwclass!(b, "rounded-lg p-4")) + .style("background-color", "rgba(31, 41, 55, 0.5)") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "text-xs text-bunker-500")) + .style("text-transform", "uppercase") + .style("letter-spacing", "0.05em") + .text("Created") + }), + html!("div", { + .apply(|b| dwclass!(b, "text-sm text-bunker-300")) + .style("margin-top", "0.25rem") + .text(timestamp_date(&t.created_at)) + }), + ]) + }), + + html!("div", { + .apply(|b| dwclass!(b, "rounded-lg p-4")) + .style("background-color", "rgba(31, 41, 55, 0.5)") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "text-xs text-bunker-500")) + .style("text-transform", "uppercase") + .style("letter-spacing", "0.05em") + .text("Updated") + }), + html!("div", { + .apply(|b| dwclass!(b, "text-sm text-bunker-300")) + .style("margin-top", "0.25rem") + .text(timestamp_date(&t.updated_at)) + }), + ]) + }), + ]) + }), + ]) + }), + ]) + }) + }) + }))) + }), + ]) + }), + ]) + })) + }), + ]) + }) +} diff --git a/examples/wasm-ui-demo/src/app/login.rs b/examples/wasm-ui-demo/src/app/login.rs new file mode 100644 index 0000000..542e5ab --- /dev/null +++ b/examples/wasm-ui-demo/src/app/login.rs @@ -0,0 +1,165 @@ +use super::*; + +pub(super) fn render_login_form(app: Arc) -> Dom { + html!("div", { + .class(&*STYLES) + .apply(|b| dwclass!(b, "flex justify-center")) + .style("background", "linear-gradient(to bottom right, #1a1a1a, #0f0f0f, #000000)") + .style("min-height", "100vh") + .style("position", "relative") + .style("overflow", "hidden") + .children(&mut [ + // Background decoration + html!("div", { + .style("position", "absolute") + .style("top", "-50%") + .style("right", "-50%") + .style("width", "200%") + .style("height", "200%") + .style("background", "radial-gradient(circle at center, rgba(59, 130, 246, 0.1) 0%, transparent 70%)") + .style("animation", "rotate 30s linear infinite") + }), + + html!("div", { + .apply(|b| dwclass!(b, "flex flex-col justify-center w-full max-w-md p-8")) + .style("position", "relative") + .style("z-index", "10") + .child(html!("div", { + .class("glass") + .apply(|b| dwclass!(b, "rounded-2xl shadow-2xl p-10")) + .children(&mut [ + html!("h2", { + .apply(|b| dwclass!(b, "text-3xl font-bold text-center")) + .style("background", "linear-gradient(to right, #60a5fa, #a78bfa)") + .style("background-clip", "text") + .style("-webkit-background-clip", "text") + .style("color", "transparent") + .style("margin-bottom", "0.5rem") + .text("Welcome Back") + }), + + html!("p", { + .apply(|b| dwclass!(b, "text-bunker-400 text-center")) + .style("margin-bottom", "2rem") + .text("Sign in to manage your tasks") + }), + + html!("div", { + .children(&mut [ + html!("div", { + .style("margin-bottom", "1.5rem") + .children(&mut [ + html!("label", { + .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300")) + .style("display", "block") + .style("margin-bottom", "0.5rem") + .text("Username") + }), + html!("input", { + .apply(|b| dwclass!(b, "w-full p-4 border border-bunker-700 rounded-lg text-bunker-100 focus:border-picton-blue-500")) + .style("background-color", "rgba(24, 24, 27, 0.5)") + .style("outline", "none") + .attr("type", "text") + .attr("placeholder", "Enter your username") + .prop_signal("value", app.username.signal_cloned()) + .event(clone!(app => move |_: events::Input| { + let elem = web_sys::window() + .unwrap() + .document() + .unwrap() + .active_element() + .unwrap() + .dyn_into::() + .unwrap(); + app.username.set(elem.value()); + })) + }), + ]) + }), + + html!("div", { + .style("margin-bottom", "2rem") + .children(&mut [ + html!("label", { + .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300")) + .style("display", "block") + .style("margin-bottom", "0.5rem") + .text("Password") + }), + html!("input", { + .apply(|b| dwclass!(b, "w-full p-4 border border-bunker-700 rounded-lg text-bunker-100 focus:border-picton-blue-500")) + .style("background-color", "rgba(24, 24, 27, 0.5)") + .style("outline", "none") + .attr("type", "password") + .attr("placeholder", "Enter your password") + .prop_signal("value", app.password.signal_cloned()) + .event(clone!(app => move |_: events::Input| { + let elem = web_sys::window() + .unwrap() + .document() + .unwrap() + .active_element() + .unwrap() + .dyn_into::() + .unwrap(); + app.password.set(elem.value()); + })) + }), + ]) + }), + + html!("div", { + .child_signal(app.login_error.signal_cloned().map(|error| { + error.map(|msg| { + html!("div", { + .apply(|b| dwclass!(b, "text-red-400 text-sm text-center border border-red-800 rounded-lg p-3")) + .style("background-color", "rgba(127, 29, 29, 0.2)") + .style("margin-bottom", "1.5rem") + .text(&msg) + }) + }) + })) + }), + + html!("button", { + .apply(|b| dwclass!(b, "w-full p-4 font-semibold rounded-lg transition-all")) + .style("color", "white") + .style_signal("background", app.is_loading.signal().map(|loading| { + if !loading { "linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)" } else { "#4b5563" } + })) + .style_signal("cursor", app.is_loading.signal().map(|loading| { + if !loading { "pointer" } else { "not-allowed" } + })) + .style("box-shadow", "0 4px 15px rgba(59, 130, 246, 0.3)") + .attr("type", "button") + .prop_signal("disabled", app.is_loading.signal()) + .text_signal(app.is_loading.signal().map(|loading| { + if loading { "Signing In..." } else { "Sign In" } + })) + .event(clone!(app => move |_: events::Click| { + App::login(app.clone()); + })) + }), + + html!("div", { + .style("margin-top", "2rem") + .apply(|b| dwclass!(b, "text-sm text-bunker-500 text-center")) + .children(&mut [ + html!("p", { + .text("Demo credentials:") + }), + html!("p", { + .apply(|b| dwclass!(b, "text-bunker-400")) + .style("margin-top", "0.25rem") + .text("user/password • admin/secret") + }), + ]) + }), + ]) + }), + ]) + })) + }), + ]) + }) +} diff --git a/examples/wasm-ui-demo/src/app/mod.rs b/examples/wasm-ui-demo/src/app/mod.rs new file mode 100644 index 0000000..e7fc9b1 --- /dev/null +++ b/examples/wasm-ui-demo/src/app/mod.rs @@ -0,0 +1,301 @@ +use dominator::{Dom, clone, events}; +use dwind::prelude::*; +use dwind_macros::dwclass; +use futures_signals::{ + signal::{Mutable, Signal, SignalExt}, + signal_vec::{MutableVec, SignalVecExt}, +}; +use std::sync::Arc; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; + +use basic_jsonrpc_api::{ + CreateTaskRequest, DashboardStats, MyServiceClient, MyServiceClientBuilder, SignInRequest, + SignInResponse, Task, TaskListResponse, TaskPriority, UpdateTaskRequest, +}; + +mod styles; +use styles::STYLES; +mod login; +use login::render_login_form; +mod statistics; +use statistics::render_stats_card; +mod task_form; +use task_form::render_task_form; +mod task_item; +use task_item::render_task_item; +mod task_list; +use task_list::render_task_list; +mod dashboard; +use dashboard::render_dashboard; + +#[derive(Clone)] +pub(super) struct App { + // Authentication state + auth_token: Mutable>, + username: Mutable, + password: Mutable, + login_error: Mutable>, + is_loading: Mutable, + + // Tasks state + tasks: MutableVec, + selected_task: Mutable>, + + // Task form state + new_task_title: Mutable, + new_task_description: Mutable, + new_task_priority: Mutable, + + // Dashboard stats + stats: Mutable>, + + // RPC client + client: MyServiceClient, +} + +impl App { + pub(super) fn new() -> Arc { + // Get the current window location to build the API URL dynamically + let window = web_sys::window().unwrap(); + let location = window.location(); + let protocol = location.protocol().unwrap(); + let host = location.host().unwrap(); + let api_url = rpc_endpoint_url(&protocol, &host); + + // Initialize the RPC client + let client = MyServiceClientBuilder::new(&api_url) + .build() + .expect("Failed to build client"); + + Arc::new(Self { + auth_token: Mutable::new(None), + username: Mutable::new(String::new()), + password: Mutable::new(String::new()), + login_error: Mutable::new(None), + is_loading: Mutable::new(false), + + tasks: MutableVec::new(), + selected_task: Mutable::new(None), + + new_task_title: Mutable::new(String::new()), + new_task_description: Mutable::new(String::new()), + new_task_priority: Mutable::new(TaskPriority::Medium), + + stats: Mutable::new(None), + + client, + }) + } + + fn is_authenticated(&self) -> impl Signal + 'static { + self.auth_token.signal_ref(|token| token.is_some()) + } + + fn login(app: Arc) { + let username = app.username.get_cloned(); + let password = app.password.get_cloned(); + + app.is_loading.set(true); + app.login_error.set(None); + + spawn_local(clone!(app => async move { + let result = app.client.sign_in(SignInRequest::WithCredentials { + username, + password, + }).await; + + app.is_loading.set(false); + + match result { + Ok(SignInResponse::Success { jwt }) => { + app.auth_token.set(Some(jwt)); + app.password.set(String::new()); + + // Load initial data after login + Self::load_tasks(app.clone()); + Self::load_stats(app.clone()); + } + Ok(SignInResponse::Failure { msg }) => { + app.login_error.set(Some(msg)); + } + Err(e) => { + app.login_error.set(Some(format!("Connection error: {}", e))); + } + } + })); + } + + fn logout(app: Arc) { + 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 _ = client.sign_out(()).await; + } + + app.auth_token.set(None); + app.tasks.lock_mut().clear(); + app.stats.set(None); + app.selected_task.set(None); + })); + } + + fn load_tasks(app: Arc) { + 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)); + + if let Ok(TaskListResponse { tasks, .. }) = client.list_tasks(()).await { + app.tasks.lock_mut().replace_cloned(tasks); + } + } + })); + } + + fn load_stats(app: Arc) { + 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)); + + if let Ok(stats) = client.get_dashboard_stats(()).await { + app.stats.set(Some(stats)); + } + } + })); + } + + fn create_task(app: Arc) { + let title = app.new_task_title.get_cloned(); + let description = app.new_task_description.get_cloned(); + let priority = app.new_task_priority.get_cloned(); + + 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)); + + if let Ok(task) = client.create_task(request).await { + app.tasks.lock_mut().push_cloned(task); + app.new_task_title.set(String::new()); + app.new_task_description.set(String::new()); + app.new_task_priority.set(TaskPriority::Medium); + + // Reload stats + Self::load_stats(app.clone()); + } + } + })); + } + + fn toggle_task_completion(app: Arc, task_id: String) { + 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)); + + // Find the task to toggle + let task_index = app.tasks.lock_ref().iter() + .position(|t| t.id == task_id); + + if let Some(index) = task_index { + 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); + + // Reload stats + Self::load_stats(app.clone()); + } + } + } + })); + } + + fn delete_task(app: Arc, task_id: String) { + 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)); + + if client.delete_task(task_id.clone()).await.is_ok() { + 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() + && selected.id == task_id + { + app.selected_task.set(None); + } + + // Reload stats + Self::load_stats(app.clone()); + } + } + })); + } +} + +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) +} + +pub(super) fn render(app: Arc) -> Dom { + html!("div", { + .child_signal(app.is_authenticated().map(clone!(app => move |authenticated| { + if authenticated { + Some(render_dashboard(app.clone())) + } else { + Some(render_login_form(app.clone())) + } + }))) + }) +} + +#[cfg(test)] +mod tests; diff --git a/examples/wasm-ui-demo/src/app/statistics.rs b/examples/wasm-ui-demo/src/app/statistics.rs new file mode 100644 index 0000000..e74b580 --- /dev/null +++ b/examples/wasm-ui-demo/src/app/statistics.rs @@ -0,0 +1,156 @@ +use super::*; + +pub(super) fn render_stats_card(stats: &DashboardStats) -> Dom { + html!("div", { + .class("glass") + .apply(|b| dwclass!(b, "rounded-2xl p-8")) + .children(&mut [ + html!("h3", { + .apply(|b| dwclass!(b, "text-2xl font-bold text-bunker-100")) + .style("margin-bottom", "2rem") + .text("Dashboard Overview") + }), + + html!("div", { + .apply(|b| dwclass!(b, "grid grid-cols-2 gap-6")) + .children(&mut [ + // Total Tasks + html!("div", { + .apply(|b| dwclass!(b, "p-6 rounded-xl")) + .style("background", "linear-gradient(to bottom right, #2563eb, #1e40af)") + .style("box-shadow", "0 8px 32px rgba(59, 130, 246, 0.2)") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "flex justify-between")) + .style("align-items", "flex-start") + .children(&mut [ + html!("div", { + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "text-3xl font-bold")) + .style("color", "white") + .text(&stats.total_tasks.to_string()) + }), + html!("div", { + .apply(|b| dwclass!(b, "text-sm text-picton-blue-200")) + .style("margin-top", "0.25rem") + .text("Total Tasks") + }), + ]) + }), + html!("div", { + .apply(|b| dwclass!(b, "text-picton-blue-300")) + .text("All") + .style("font-size", "1.5rem") + }), + ]) + }), + ]) + }), + + // Completed Tasks + html!("div", { + .apply(|b| dwclass!(b, "p-6 rounded-xl")) + .style("background", "linear-gradient(to bottom right, #16a34a, #15803d)") + .style("box-shadow", "0 8px 32px rgba(34, 197, 94, 0.2)") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "flex justify-between")) + .style("align-items", "flex-start") + .children(&mut [ + html!("div", { + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "text-3xl font-bold")) + .style("color", "white") + .text(&stats.completed_tasks.to_string()) + }), + html!("div", { + .apply(|b| dwclass!(b, "text-sm text-apple-200")) + .style("margin-top", "0.25rem") + .text("Completed") + }), + ]) + }), + html!("div", { + .apply(|b| dwclass!(b, "text-apple-300")) + .text("Done") + .style("font-size", "1.5rem") + }), + ]) + }), + ]) + }), + + // Pending Tasks + html!("div", { + .apply(|b| dwclass!(b, "p-6 rounded-xl")) + .style("background", "linear-gradient(to bottom right, #d97706, #b45309)") + .style("box-shadow", "0 8px 32px rgba(251, 191, 36, 0.2)") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "flex justify-between")) + .style("align-items", "flex-start") + .children(&mut [ + html!("div", { + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "text-3xl font-bold")) + .style("color", "white") + .text(&stats.pending_tasks.to_string()) + }), + html!("div", { + .apply(|b| dwclass!(b, "text-sm text-candlelight-200")) + .style("margin-top", "0.25rem") + .text("Pending") + }), + ]) + }), + html!("div", { + .apply(|b| dwclass!(b, "text-candlelight-300")) + .text("⏳") + .style("font-size", "1.5rem") + }), + ]) + }), + ]) + }), + + // High Priority Tasks + html!("div", { + .apply(|b| dwclass!(b, "p-6 rounded-xl")) + .style("background", "linear-gradient(to bottom right, #dc2626, #991b1b)") + .style("box-shadow", "0 8px 32px rgba(239, 68, 68, 0.2)") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "flex justify-between")) + .style("align-items", "flex-start") + .children(&mut [ + html!("div", { + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "text-3xl font-bold")) + .style("color", "white") + .text(&stats.high_priority_tasks.to_string()) + }), + html!("div", { + .apply(|b| dwclass!(b, "text-sm text-red-200")) + .style("margin-top", "0.25rem") + .text("High Priority") + }), + ]) + }), + html!("div", { + .apply(|b| dwclass!(b, "text-red-300")) + .text("High") + .style("font-size", "1.5rem") + }), + ]) + }), + ]) + }), + ]) + }), + ]) + }) +} diff --git a/examples/wasm-ui-demo/src/app/styles.rs b/examples/wasm-ui-demo/src/app/styles.rs new file mode 100644 index 0000000..69dc7ff --- /dev/null +++ b/examples/wasm-ui-demo/src/app/styles.rs @@ -0,0 +1,80 @@ +use dominator::class; +use once_cell::sync::Lazy; + +// Define styles using dominator's class! macro +pub(super) static STYLES: Lazy = Lazy::new(|| { + class! { + .raw(" + * { + box-sizing: border-box; + } + + body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #0a0a0a; + color: #e5e5e5; + line-height: 1.5; + } + + /* Custom scrollbar for dark mode */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: #1a1a1a; + } + + ::-webkit-scrollbar-thumb { + background: #404040; + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: #555; + } + + /* Glass morphism effect */ + .glass { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + } + + /* Smooth transitions */ + * { + transition: all 0.2s ease; + } + + /* Animations */ + @keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes slideIn { + from { transform: translateX(-100%); } + to { transform: translateX(0); } + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.8; } + } + + .animate-fade-in { + animation: fadeIn 0.5s ease-out; + } + + .animate-slide-in { + animation: slideIn 0.3s ease-out; + } + + .animate-pulse { + animation: pulse 2s infinite; + } + ") + } +}); diff --git a/examples/wasm-ui-demo/src/app/task_form.rs b/examples/wasm-ui-demo/src/app/task_form.rs new file mode 100644 index 0000000..3000496 --- /dev/null +++ b/examples/wasm-ui-demo/src/app/task_form.rs @@ -0,0 +1,175 @@ +use super::*; + +pub(super) fn render_task_form(app: Arc) -> Dom { + html!("div", { + .class("glass") + .apply(|b| dwclass!(b, "rounded-2xl p-8")) + .children(&mut [ + html!("h3", { + .apply(|b| dwclass!(b, "text-2xl font-bold text-bunker-100")) + .style("margin-bottom", "2rem") + .text("Create New Task") + }), + + html!("div", { + .children(&mut [ + // Title field + html!("div", { + .style("margin-bottom", "1.5rem") + .children(&mut [ + html!("label", { + .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300")) + .style("display", "block") + .style("margin-bottom", "0.5rem") + .text("Title") + }), + html!("input", { + .apply(|b| dwclass!(b, "w-full p-4 border border-bunker-700 rounded-lg text-bunker-100 focus:border-picton-blue-500 transition-all")) + .style("background-color", "rgba(24, 24, 27, 0.5)") + .style("outline", "none") + .attr("type", "text") + .attr("placeholder", "What needs to be done?") + .prop_signal("value", app.new_task_title.signal_cloned()) + .event(clone!(app => move |_: events::Input| { + let elem = web_sys::window() + .unwrap() + .document() + .unwrap() + .active_element() + .unwrap() + .dyn_into::() + .unwrap(); + app.new_task_title.set(elem.value()); + })) + }), + ]) + }), + + // Description field + html!("div", { + .style("margin-bottom", "1.5rem") + .children(&mut [ + html!("label", { + .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300")) + .style("display", "block") + .style("margin-bottom", "0.5rem") + .text("Description") + }), + html!("textarea", { + .apply(|b| dwclass!(b, "w-full p-4 border border-bunker-700 rounded-lg text-bunker-100 focus:border-picton-blue-500 transition-all")) + .style("background-color", "rgba(24, 24, 27, 0.5)") + .style("outline", "none") + .style("resize", "vertical") + .style("min-height", "80px") + .attr("placeholder", "Add more details...") + .prop_signal("value", app.new_task_description.signal_cloned()) + .event(clone!(app => move |_: events::Input| { + let elem = web_sys::window() + .unwrap() + .document() + .unwrap() + .active_element() + .unwrap() + .dyn_into::() + .unwrap(); + app.new_task_description.set(elem.value()); + })) + }), + ]) + }), + + // Priority field + html!("div", { + .style("margin-bottom", "2rem") + .children(&mut [ + html!("label", { + .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300")) + .style("display", "block") + .style("margin-bottom", "0.5rem") + .text("Priority") + }), + html!("div", { + .apply(|b| dwclass!(b, "flex gap-3")) + .children(&mut [ + html!("button", { + .apply(|b| dwclass!(b, "flex-1 p-3 text-sm font-medium rounded-lg border transition-all")) + .style_signal("background-color", app.new_task_priority.signal_cloned().map(|p| { + if matches!(p, TaskPriority::Low) { "#16a34a" } else { "#1f2937" } + })) + .style_signal("border-color", app.new_task_priority.signal_cloned().map(|p| { + if matches!(p, TaskPriority::Low) { "#16a34a" } else { "#374151" } + })) + .style_signal("color", app.new_task_priority.signal_cloned().map(|p| { + if matches!(p, TaskPriority::Low) { "white" } else { "#9ca3af" } + })) + .attr("type", "button") + .text("Low") + .event(clone!(app => move |_: events::Click| { + app.new_task_priority.set(TaskPriority::Low); + })) + }), + + html!("button", { + .apply(|b| dwclass!(b, "flex-1 p-3 text-sm font-medium rounded-lg border transition-all")) + .style_signal("background-color", app.new_task_priority.signal_cloned().map(|p| { + if matches!(p, TaskPriority::Medium) { "#d97706" } else { "#1f2937" } + })) + .style_signal("border-color", app.new_task_priority.signal_cloned().map(|p| { + if matches!(p, TaskPriority::Medium) { "#d97706" } else { "#374151" } + })) + .style_signal("color", app.new_task_priority.signal_cloned().map(|p| { + if matches!(p, TaskPriority::Medium) { "white" } else { "#9ca3af" } + })) + .attr("type", "button") + .text("Medium") + .event(clone!(app => move |_: events::Click| { + app.new_task_priority.set(TaskPriority::Medium); + })) + }), + + html!("button", { + .apply(|b| dwclass!(b, "flex-1 p-3 text-sm font-medium rounded-lg border transition-all")) + .style_signal("background-color", app.new_task_priority.signal_cloned().map(|p| { + if matches!(p, TaskPriority::High) { "#dc2626" } else { "#1f2937" } + })) + .style_signal("border-color", app.new_task_priority.signal_cloned().map(|p| { + if matches!(p, TaskPriority::High) { "#dc2626" } else { "#374151" } + })) + .style_signal("color", app.new_task_priority.signal_cloned().map(|p| { + if matches!(p, TaskPriority::High) { "white" } else { "#9ca3af" } + })) + .attr("type", "button") + .text("High") + .event(clone!(app => move |_: events::Click| { + app.new_task_priority.set(TaskPriority::High); + })) + }), + ]) + }), + ]) + }), + + html!("button", { + .apply(|b| dwclass!(b, "w-full p-4 font-semibold rounded-lg transition-all")) + .style("color", "white") + .style_signal("background", app.new_task_title.signal_ref(|t| { + if !t.is_empty() { "linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)" } else { "#374151" } + })) + .style_signal("cursor", app.new_task_title.signal_ref(|t| { + if !t.is_empty() { "pointer" } else { "not-allowed" } + })) + .style_signal("box-shadow", app.new_task_title.signal_ref(|t| { + if !t.is_empty() { "0 4px 15px rgba(59, 130, 246, 0.3)" } else { "none" } + })) + .attr("type", "button") + .prop_signal("disabled", app.new_task_title.signal_ref(|t| t.is_empty())) + .text("Create Task") + .event(clone!(app => move |_: events::Click| { + App::create_task(app.clone()); + })) + }), + ]) + }), + ]) + }) +} diff --git a/examples/wasm-ui-demo/src/app/task_item.rs b/examples/wasm-ui-demo/src/app/task_item.rs new file mode 100644 index 0000000..2bc10fc --- /dev/null +++ b/examples/wasm-ui-demo/src/app/task_item.rs @@ -0,0 +1,133 @@ +use super::*; + +pub(super) fn render_task_item(app: Arc, task: Task) -> Dom { + let task_id = task.id.clone(); + 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", "L"), + }; + + html!("div", { + .class("glass") + .apply(|b| dwclass!(b, "p-6 rounded-xl hover:shadow-2xl transition-all")) + .style("cursor", "pointer") + .style("border", "1px solid rgba(255, 255, 255, 0.1)") + .event(clone!(app, task => move |_: events::Click| { + app.selected_task.set(Some(task.clone())); + })) + .child(html!("div", { + .apply(|b| dwclass!(b, "flex gap-4")) + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "flex")) + .style("align-items", "center") + .child(html!("input" => web_sys::HtmlInputElement, { + .apply(|b| dwclass!(b, "w-5 h-5 rounded bg-bunker-800 border-bunker-600 text-picton-blue-500")) + .style("cursor", "pointer") + .attr("type", "checkbox") + .prop("checked", task.completed) + .event(clone!(app, task_id => move |e: events::Change| { + e.stop_propagation(); + App::toggle_task_completion(app.clone(), task_id.clone()); + })) + })) + }), + + html!("div", { + .apply(|b| dwclass!(b, "flex-1")) + .style("min-width", "0") + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "flex justify-between")) + .style("align-items", "flex-start") + .children(&mut [ + html!("h4", { + .apply(|b| dwclass!(b, "text-lg font-semibold text-bunker-100")) + .style_signal("text-decoration", Mutable::new(task.completed).signal().map(|completed| { + if completed { "line-through" } else { "none" } + })) + .style_signal("opacity", Mutable::new(task.completed).signal().map(|completed| { + if completed { "0.5" } else { "1" } + })) + .text(&task.title) + }), + + html!("span", { + .class(match task.priority { + TaskPriority::High => "text-red-400", + TaskPriority::Medium => "text-candlelight-400", + TaskPriority::Low => "text-apple-400", + }) + .style("background-color", match task.priority { + TaskPriority::High => "rgba(127, 29, 29, 0.2)", + TaskPriority::Medium => "rgba(180, 83, 9, 0.2)", + TaskPriority::Low => "rgba(21, 128, 61, 0.2)", + }) + .apply(|b| dwclass!(b, "rounded-full text-xs font-medium flex gap-1")) + .style("padding", "0.25rem 0.75rem") + .style("align-items", "center") + .children(&mut [ + html!("span", { + .text(priority_mark) + }), + html!("span", { + .class(match task.priority { + TaskPriority::High => "text-red-400", + TaskPriority::Medium => "text-candlelight-400", + TaskPriority::Low => "text-apple-400", + }) + .text(&format!("{:?}", task.priority)) + }), + ]) + }), + ]) + }), + + html!("p", { + .apply(|b| dwclass!(b, "text-sm text-bunker-400")) + .style("margin-top", "0.5rem") + .style_signal("opacity", Mutable::new(task.completed).signal().map(|completed| { + if completed { "0.5" } else { "1" } + })) + .text(&task.description) + }), + + html!("div", { + .apply(|b| dwclass!(b, "flex gap-4 text-xs text-bunker-500")) + .style("margin-top", "0.75rem") + .children(&mut [ + html!("span", { + .apply(|b| dwclass!(b, "flex gap-1")) + .style("align-items", "center") + .children(&mut [ + html!("span", { + .text("Created") + }), + html!("span", { + .text(timestamp_date(&task.created_at)) + }), + ]) + }), + ]) + }), + ]) + }), + + html!("button", { + .apply(|b| dwclass!(b, "text-red-400 hover:text-red-300 text-sm font-medium rounded-lg transition-all")) + .style("padding", "0.25rem 0.75rem") + .text("Delete") + .event(clone!(app, task_id => move |e: events::Click| { + e.stop_propagation(); + App::delete_task(app.clone(), task_id.clone()); + })) + }), + ]) + })) + }) +} diff --git a/examples/wasm-ui-demo/src/app/task_list.rs b/examples/wasm-ui-demo/src/app/task_list.rs new file mode 100644 index 0000000..5ebfece --- /dev/null +++ b/examples/wasm-ui-demo/src/app/task_list.rs @@ -0,0 +1,55 @@ +use super::*; + +pub(super) fn render_task_list(app: Arc) -> Dom { + html!("div", { + .class("glass") + .apply(|b| dwclass!(b, "rounded-2xl p-8")) + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "flex justify-between")) + .style("align-items", "center") + .style("margin-bottom", "2rem") + .children(&mut [ + html!("h3", { + .apply(|b| dwclass!(b, "text-2xl font-bold text-bunker-100")) + .text("Your Tasks") + }), + html!("div", { + .apply(|b| dwclass!(b, "text-sm text-bunker-400")) + .text_signal(app.tasks.signal_vec_cloned().len().map(|len| { + format!("{} task{}", len, if len == 1 { "" } else { "s" }) + })) + }), + ]) + }), + + html!("div", { + .style("display", "flex") + .style("flex-direction", "column") + .style("gap", "1rem") + .children_signal_vec(app.tasks.signal_vec_cloned() + .map(clone!(app => move |task| { + render_task_item(app.clone(), task) + }))) + }), + + // Empty state + html!("div", { + .apply(|b| dwclass!(b, "text-center")) + .style("padding", "3rem 0") + .visible_signal(app.tasks.signal_vec_cloned().len().map(|len| len == 0)) + .children(&mut [ + html!("div", { + .apply(|b| dwclass!(b, "text-2xl font-semibold text-bunker-300")) + .style("margin-bottom", "1rem") + .text("No Tasks") + }), + html!("p", { + .apply(|b| dwclass!(b, "text-bunker-400 text-lg")) + .text("No tasks yet. Create your first task!") + }), + ]) + }), + ]) + }) +} diff --git a/examples/wasm-ui-demo/src/app/tests.rs b/examples/wasm-ui-demo/src/app/tests.rs new file mode 100644 index 0000000..1091e45 --- /dev/null +++ b/examples/wasm-ui-demo/src/app/tests.rs @@ -0,0 +1,74 @@ +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é"); +} diff --git a/examples/wasm-ui-demo/src/lib.rs b/examples/wasm-ui-demo/src/lib.rs index 5362342..b4e7a3e 100644 --- a/examples/wasm-ui-demo/src/lib.rs +++ b/examples/wasm-ui-demo/src/lib.rs @@ -1,1368 +1,9 @@ #[macro_use] extern crate dominator; -use dominator::{Dom, class, clone, events}; -use dwind::prelude::*; -use dwind_macros::dwclass; -use futures_signals::{ - signal::{Mutable, Signal, SignalExt}, - signal_vec::{MutableVec, SignalVecExt}, -}; -use once_cell::sync::Lazy; -use std::sync::Arc; -use wasm_bindgen::JsCast; +mod app; +use app::{App, render}; use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::spawn_local; - -use basic_jsonrpc_api::{ - CreateTaskRequest, DashboardStats, MyServiceClient, MyServiceClientBuilder, SignInRequest, - SignInResponse, Task, TaskListResponse, TaskPriority, UpdateTaskRequest, -}; - -// Define styles using dominator's class! macro -static STYLES: Lazy = Lazy::new(|| { - class! { - .raw(" - * { - box-sizing: border-box; - } - - body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - background-color: #0a0a0a; - color: #e5e5e5; - line-height: 1.5; - } - - /* Custom scrollbar for dark mode */ - ::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - ::-webkit-scrollbar-track { - background: #1a1a1a; - } - - ::-webkit-scrollbar-thumb { - background: #404040; - border-radius: 4px; - } - - ::-webkit-scrollbar-thumb:hover { - background: #555; - } - - /* Glass morphism effect */ - .glass { - background: rgba(255, 255, 255, 0.05); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.1); - } - - /* Smooth transitions */ - * { - transition: all 0.2s ease; - } - - /* Animations */ - @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } - } - - @keyframes slideIn { - from { transform: translateX(-100%); } - to { transform: translateX(0); } - } - - @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.8; } - } - - .animate-fade-in { - animation: fadeIn 0.5s ease-out; - } - - .animate-slide-in { - animation: slideIn 0.3s ease-out; - } - - .animate-pulse { - animation: pulse 2s infinite; - } - ") - } -}); - -#[derive(Clone)] -struct App { - // Authentication state - auth_token: Mutable>, - username: Mutable, - password: Mutable, - login_error: Mutable>, - is_loading: Mutable, - - // Tasks state - tasks: MutableVec, - selected_task: Mutable>, - - // Task form state - new_task_title: Mutable, - new_task_description: Mutable, - new_task_priority: Mutable, - - // Dashboard stats - stats: Mutable>, - - // RPC client - client: MyServiceClient, -} - -impl App { - fn new() -> Arc { - // Get the current window location to build the API URL dynamically - let window = web_sys::window().unwrap(); - let location = window.location(); - let protocol = location.protocol().unwrap(); - let host = location.host().unwrap(); - let api_url = rpc_endpoint_url(&protocol, &host); - - // Initialize the RPC client - let client = MyServiceClientBuilder::new(&api_url) - .build() - .expect("Failed to build client"); - - Arc::new(Self { - auth_token: Mutable::new(None), - username: Mutable::new(String::new()), - password: Mutable::new(String::new()), - login_error: Mutable::new(None), - is_loading: Mutable::new(false), - - tasks: MutableVec::new(), - selected_task: Mutable::new(None), - - new_task_title: Mutable::new(String::new()), - new_task_description: Mutable::new(String::new()), - new_task_priority: Mutable::new(TaskPriority::Medium), - - stats: Mutable::new(None), - - client, - }) - } - - fn is_authenticated(&self) -> impl Signal + 'static { - self.auth_token.signal_ref(|token| token.is_some()) - } - - fn login(app: Arc) { - let username = app.username.get_cloned(); - let password = app.password.get_cloned(); - - app.is_loading.set(true); - app.login_error.set(None); - - spawn_local(clone!(app => async move { - let result = app.client.sign_in(SignInRequest::WithCredentials { - username, - password, - }).await; - - app.is_loading.set(false); - - match result { - Ok(SignInResponse::Success { jwt }) => { - app.auth_token.set(Some(jwt)); - app.password.set(String::new()); - - // Load initial data after login - Self::load_tasks(app.clone()); - Self::load_stats(app.clone()); - } - Ok(SignInResponse::Failure { msg }) => { - app.login_error.set(Some(msg)); - } - Err(e) => { - app.login_error.set(Some(format!("Connection error: {}", e))); - } - } - })); - } - - fn logout(app: Arc) { - 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 _ = client.sign_out(()).await; - } - - app.auth_token.set(None); - app.tasks.lock_mut().clear(); - app.stats.set(None); - app.selected_task.set(None); - })); - } - - fn load_tasks(app: Arc) { - 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)); - - if let Ok(TaskListResponse { tasks, .. }) = client.list_tasks(()).await { - app.tasks.lock_mut().replace_cloned(tasks); - } - } - })); - } - - fn load_stats(app: Arc) { - 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)); - - if let Ok(stats) = client.get_dashboard_stats(()).await { - app.stats.set(Some(stats)); - } - } - })); - } - - fn create_task(app: Arc) { - let title = app.new_task_title.get_cloned(); - let description = app.new_task_description.get_cloned(); - let priority = app.new_task_priority.get_cloned(); - - 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)); - - if let Ok(task) = client.create_task(request).await { - app.tasks.lock_mut().push_cloned(task); - app.new_task_title.set(String::new()); - app.new_task_description.set(String::new()); - app.new_task_priority.set(TaskPriority::Medium); - - // Reload stats - Self::load_stats(app.clone()); - } - } - })); - } - - fn toggle_task_completion(app: Arc, task_id: String) { - 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)); - - // Find the task to toggle - let task_index = app.tasks.lock_ref().iter() - .position(|t| t.id == task_id); - - if let Some(index) = task_index { - 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); - - // Reload stats - Self::load_stats(app.clone()); - } - } - } - })); - } - - fn delete_task(app: Arc, task_id: String) { - 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)); - - if client.delete_task(task_id.clone()).await.is_ok() { - 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() - && selected.id == task_id - { - app.selected_task.set(None); - } - - // Reload stats - Self::load_stats(app.clone()); - } - } - })); - } -} - -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) - .apply(|b| dwclass!(b, "flex justify-center")) - .style("background", "linear-gradient(to bottom right, #1a1a1a, #0f0f0f, #000000)") - .style("min-height", "100vh") - .style("position", "relative") - .style("overflow", "hidden") - .children(&mut [ - // Background decoration - html!("div", { - .style("position", "absolute") - .style("top", "-50%") - .style("right", "-50%") - .style("width", "200%") - .style("height", "200%") - .style("background", "radial-gradient(circle at center, rgba(59, 130, 246, 0.1) 0%, transparent 70%)") - .style("animation", "rotate 30s linear infinite") - }), - - html!("div", { - .apply(|b| dwclass!(b, "flex flex-col justify-center w-full max-w-md p-8")) - .style("position", "relative") - .style("z-index", "10") - .child(html!("div", { - .class("glass") - .apply(|b| dwclass!(b, "rounded-2xl shadow-2xl p-10")) - .children(&mut [ - html!("h2", { - .apply(|b| dwclass!(b, "text-3xl font-bold text-center")) - .style("background", "linear-gradient(to right, #60a5fa, #a78bfa)") - .style("background-clip", "text") - .style("-webkit-background-clip", "text") - .style("color", "transparent") - .style("margin-bottom", "0.5rem") - .text("Welcome Back") - }), - - html!("p", { - .apply(|b| dwclass!(b, "text-bunker-400 text-center")) - .style("margin-bottom", "2rem") - .text("Sign in to manage your tasks") - }), - - html!("div", { - .children(&mut [ - html!("div", { - .style("margin-bottom", "1.5rem") - .children(&mut [ - html!("label", { - .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300")) - .style("display", "block") - .style("margin-bottom", "0.5rem") - .text("Username") - }), - html!("input", { - .apply(|b| dwclass!(b, "w-full p-4 border border-bunker-700 rounded-lg text-bunker-100 focus:border-picton-blue-500")) - .style("background-color", "rgba(24, 24, 27, 0.5)") - .style("outline", "none") - .attr("type", "text") - .attr("placeholder", "Enter your username") - .prop_signal("value", app.username.signal_cloned()) - .event(clone!(app => move |_: events::Input| { - let elem = web_sys::window() - .unwrap() - .document() - .unwrap() - .active_element() - .unwrap() - .dyn_into::() - .unwrap(); - app.username.set(elem.value()); - })) - }), - ]) - }), - - html!("div", { - .style("margin-bottom", "2rem") - .children(&mut [ - html!("label", { - .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300")) - .style("display", "block") - .style("margin-bottom", "0.5rem") - .text("Password") - }), - html!("input", { - .apply(|b| dwclass!(b, "w-full p-4 border border-bunker-700 rounded-lg text-bunker-100 focus:border-picton-blue-500")) - .style("background-color", "rgba(24, 24, 27, 0.5)") - .style("outline", "none") - .attr("type", "password") - .attr("placeholder", "Enter your password") - .prop_signal("value", app.password.signal_cloned()) - .event(clone!(app => move |_: events::Input| { - let elem = web_sys::window() - .unwrap() - .document() - .unwrap() - .active_element() - .unwrap() - .dyn_into::() - .unwrap(); - app.password.set(elem.value()); - })) - }), - ]) - }), - - html!("div", { - .child_signal(app.login_error.signal_cloned().map(|error| { - error.map(|msg| { - html!("div", { - .apply(|b| dwclass!(b, "text-red-400 text-sm text-center border border-red-800 rounded-lg p-3")) - .style("background-color", "rgba(127, 29, 29, 0.2)") - .style("margin-bottom", "1.5rem") - .text(&msg) - }) - }) - })) - }), - - html!("button", { - .apply(|b| dwclass!(b, "w-full p-4 font-semibold rounded-lg transition-all")) - .style("color", "white") - .style_signal("background", app.is_loading.signal().map(|loading| { - if !loading { "linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)" } else { "#4b5563" } - })) - .style_signal("cursor", app.is_loading.signal().map(|loading| { - if !loading { "pointer" } else { "not-allowed" } - })) - .style("box-shadow", "0 4px 15px rgba(59, 130, 246, 0.3)") - .attr("type", "button") - .prop_signal("disabled", app.is_loading.signal()) - .text_signal(app.is_loading.signal().map(|loading| { - if loading { "Signing In..." } else { "Sign In" } - })) - .event(clone!(app => move |_: events::Click| { - App::login(app.clone()); - })) - }), - - html!("div", { - .style("margin-top", "2rem") - .apply(|b| dwclass!(b, "text-sm text-bunker-500 text-center")) - .children(&mut [ - html!("p", { - .text("Demo credentials:") - }), - html!("p", { - .apply(|b| dwclass!(b, "text-bunker-400")) - .style("margin-top", "0.25rem") - .text("user/password • admin/secret") - }), - ]) - }), - ]) - }), - ]) - })) - }), - ]) - }) -} - -fn render_stats_card(stats: &DashboardStats) -> Dom { - html!("div", { - .class("glass") - .apply(|b| dwclass!(b, "rounded-2xl p-8")) - .children(&mut [ - html!("h3", { - .apply(|b| dwclass!(b, "text-2xl font-bold text-bunker-100")) - .style("margin-bottom", "2rem") - .text("Dashboard Overview") - }), - - html!("div", { - .apply(|b| dwclass!(b, "grid grid-cols-2 gap-6")) - .children(&mut [ - // Total Tasks - html!("div", { - .apply(|b| dwclass!(b, "p-6 rounded-xl")) - .style("background", "linear-gradient(to bottom right, #2563eb, #1e40af)") - .style("box-shadow", "0 8px 32px rgba(59, 130, 246, 0.2)") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "flex justify-between")) - .style("align-items", "flex-start") - .children(&mut [ - html!("div", { - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "text-3xl font-bold")) - .style("color", "white") - .text(&stats.total_tasks.to_string()) - }), - html!("div", { - .apply(|b| dwclass!(b, "text-sm text-picton-blue-200")) - .style("margin-top", "0.25rem") - .text("Total Tasks") - }), - ]) - }), - html!("div", { - .apply(|b| dwclass!(b, "text-picton-blue-300")) - .text("All") - .style("font-size", "1.5rem") - }), - ]) - }), - ]) - }), - - // Completed Tasks - html!("div", { - .apply(|b| dwclass!(b, "p-6 rounded-xl")) - .style("background", "linear-gradient(to bottom right, #16a34a, #15803d)") - .style("box-shadow", "0 8px 32px rgba(34, 197, 94, 0.2)") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "flex justify-between")) - .style("align-items", "flex-start") - .children(&mut [ - html!("div", { - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "text-3xl font-bold")) - .style("color", "white") - .text(&stats.completed_tasks.to_string()) - }), - html!("div", { - .apply(|b| dwclass!(b, "text-sm text-apple-200")) - .style("margin-top", "0.25rem") - .text("Completed") - }), - ]) - }), - html!("div", { - .apply(|b| dwclass!(b, "text-apple-300")) - .text("Done") - .style("font-size", "1.5rem") - }), - ]) - }), - ]) - }), - - // Pending Tasks - html!("div", { - .apply(|b| dwclass!(b, "p-6 rounded-xl")) - .style("background", "linear-gradient(to bottom right, #d97706, #b45309)") - .style("box-shadow", "0 8px 32px rgba(251, 191, 36, 0.2)") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "flex justify-between")) - .style("align-items", "flex-start") - .children(&mut [ - html!("div", { - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "text-3xl font-bold")) - .style("color", "white") - .text(&stats.pending_tasks.to_string()) - }), - html!("div", { - .apply(|b| dwclass!(b, "text-sm text-candlelight-200")) - .style("margin-top", "0.25rem") - .text("Pending") - }), - ]) - }), - html!("div", { - .apply(|b| dwclass!(b, "text-candlelight-300")) - .text("⏳") - .style("font-size", "1.5rem") - }), - ]) - }), - ]) - }), - - // High Priority Tasks - html!("div", { - .apply(|b| dwclass!(b, "p-6 rounded-xl")) - .style("background", "linear-gradient(to bottom right, #dc2626, #991b1b)") - .style("box-shadow", "0 8px 32px rgba(239, 68, 68, 0.2)") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "flex justify-between")) - .style("align-items", "flex-start") - .children(&mut [ - html!("div", { - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "text-3xl font-bold")) - .style("color", "white") - .text(&stats.high_priority_tasks.to_string()) - }), - html!("div", { - .apply(|b| dwclass!(b, "text-sm text-red-200")) - .style("margin-top", "0.25rem") - .text("High Priority") - }), - ]) - }), - html!("div", { - .apply(|b| dwclass!(b, "text-red-300")) - .text("High") - .style("font-size", "1.5rem") - }), - ]) - }), - ]) - }), - ]) - }), - ]) - }) -} - -fn render_task_form(app: Arc) -> Dom { - html!("div", { - .class("glass") - .apply(|b| dwclass!(b, "rounded-2xl p-8")) - .children(&mut [ - html!("h3", { - .apply(|b| dwclass!(b, "text-2xl font-bold text-bunker-100")) - .style("margin-bottom", "2rem") - .text("Create New Task") - }), - - html!("div", { - .children(&mut [ - // Title field - html!("div", { - .style("margin-bottom", "1.5rem") - .children(&mut [ - html!("label", { - .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300")) - .style("display", "block") - .style("margin-bottom", "0.5rem") - .text("Title") - }), - html!("input", { - .apply(|b| dwclass!(b, "w-full p-4 border border-bunker-700 rounded-lg text-bunker-100 focus:border-picton-blue-500 transition-all")) - .style("background-color", "rgba(24, 24, 27, 0.5)") - .style("outline", "none") - .attr("type", "text") - .attr("placeholder", "What needs to be done?") - .prop_signal("value", app.new_task_title.signal_cloned()) - .event(clone!(app => move |_: events::Input| { - let elem = web_sys::window() - .unwrap() - .document() - .unwrap() - .active_element() - .unwrap() - .dyn_into::() - .unwrap(); - app.new_task_title.set(elem.value()); - })) - }), - ]) - }), - - // Description field - html!("div", { - .style("margin-bottom", "1.5rem") - .children(&mut [ - html!("label", { - .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300")) - .style("display", "block") - .style("margin-bottom", "0.5rem") - .text("Description") - }), - html!("textarea", { - .apply(|b| dwclass!(b, "w-full p-4 border border-bunker-700 rounded-lg text-bunker-100 focus:border-picton-blue-500 transition-all")) - .style("background-color", "rgba(24, 24, 27, 0.5)") - .style("outline", "none") - .style("resize", "vertical") - .style("min-height", "80px") - .attr("placeholder", "Add more details...") - .prop_signal("value", app.new_task_description.signal_cloned()) - .event(clone!(app => move |_: events::Input| { - let elem = web_sys::window() - .unwrap() - .document() - .unwrap() - .active_element() - .unwrap() - .dyn_into::() - .unwrap(); - app.new_task_description.set(elem.value()); - })) - }), - ]) - }), - - // Priority field - html!("div", { - .style("margin-bottom", "2rem") - .children(&mut [ - html!("label", { - .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300")) - .style("display", "block") - .style("margin-bottom", "0.5rem") - .text("Priority") - }), - html!("div", { - .apply(|b| dwclass!(b, "flex gap-3")) - .children(&mut [ - html!("button", { - .apply(|b| dwclass!(b, "flex-1 p-3 text-sm font-medium rounded-lg border transition-all")) - .style_signal("background-color", app.new_task_priority.signal_cloned().map(|p| { - if matches!(p, TaskPriority::Low) { "#16a34a" } else { "#1f2937" } - })) - .style_signal("border-color", app.new_task_priority.signal_cloned().map(|p| { - if matches!(p, TaskPriority::Low) { "#16a34a" } else { "#374151" } - })) - .style_signal("color", app.new_task_priority.signal_cloned().map(|p| { - if matches!(p, TaskPriority::Low) { "white" } else { "#9ca3af" } - })) - .attr("type", "button") - .text("Low") - .event(clone!(app => move |_: events::Click| { - app.new_task_priority.set(TaskPriority::Low); - })) - }), - - html!("button", { - .apply(|b| dwclass!(b, "flex-1 p-3 text-sm font-medium rounded-lg border transition-all")) - .style_signal("background-color", app.new_task_priority.signal_cloned().map(|p| { - if matches!(p, TaskPriority::Medium) { "#d97706" } else { "#1f2937" } - })) - .style_signal("border-color", app.new_task_priority.signal_cloned().map(|p| { - if matches!(p, TaskPriority::Medium) { "#d97706" } else { "#374151" } - })) - .style_signal("color", app.new_task_priority.signal_cloned().map(|p| { - if matches!(p, TaskPriority::Medium) { "white" } else { "#9ca3af" } - })) - .attr("type", "button") - .text("Medium") - .event(clone!(app => move |_: events::Click| { - app.new_task_priority.set(TaskPriority::Medium); - })) - }), - - html!("button", { - .apply(|b| dwclass!(b, "flex-1 p-3 text-sm font-medium rounded-lg border transition-all")) - .style_signal("background-color", app.new_task_priority.signal_cloned().map(|p| { - if matches!(p, TaskPriority::High) { "#dc2626" } else { "#1f2937" } - })) - .style_signal("border-color", app.new_task_priority.signal_cloned().map(|p| { - if matches!(p, TaskPriority::High) { "#dc2626" } else { "#374151" } - })) - .style_signal("color", app.new_task_priority.signal_cloned().map(|p| { - if matches!(p, TaskPriority::High) { "white" } else { "#9ca3af" } - })) - .attr("type", "button") - .text("High") - .event(clone!(app => move |_: events::Click| { - app.new_task_priority.set(TaskPriority::High); - })) - }), - ]) - }), - ]) - }), - - html!("button", { - .apply(|b| dwclass!(b, "w-full p-4 font-semibold rounded-lg transition-all")) - .style("color", "white") - .style_signal("background", app.new_task_title.signal_ref(|t| { - if !t.is_empty() { "linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)" } else { "#374151" } - })) - .style_signal("cursor", app.new_task_title.signal_ref(|t| { - if !t.is_empty() { "pointer" } else { "not-allowed" } - })) - .style_signal("box-shadow", app.new_task_title.signal_ref(|t| { - if !t.is_empty() { "0 4px 15px rgba(59, 130, 246, 0.3)" } else { "none" } - })) - .attr("type", "button") - .prop_signal("disabled", app.new_task_title.signal_ref(|t| t.is_empty())) - .text("Create Task") - .event(clone!(app => move |_: events::Click| { - App::create_task(app.clone()); - })) - }), - ]) - }), - ]) - }) -} - -fn render_task_item(app: Arc, task: Task) -> Dom { - let task_id = task.id.clone(); - 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", "L"), - }; - - html!("div", { - .class("glass") - .apply(|b| dwclass!(b, "p-6 rounded-xl hover:shadow-2xl transition-all")) - .style("cursor", "pointer") - .style("border", "1px solid rgba(255, 255, 255, 0.1)") - .event(clone!(app, task => move |_: events::Click| { - app.selected_task.set(Some(task.clone())); - })) - .child(html!("div", { - .apply(|b| dwclass!(b, "flex gap-4")) - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "flex")) - .style("align-items", "center") - .child(html!("input" => web_sys::HtmlInputElement, { - .apply(|b| dwclass!(b, "w-5 h-5 rounded bg-bunker-800 border-bunker-600 text-picton-blue-500")) - .style("cursor", "pointer") - .attr("type", "checkbox") - .prop("checked", task.completed) - .event(clone!(app, task_id => move |e: events::Change| { - e.stop_propagation(); - App::toggle_task_completion(app.clone(), task_id.clone()); - })) - })) - }), - - html!("div", { - .apply(|b| dwclass!(b, "flex-1")) - .style("min-width", "0") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "flex justify-between")) - .style("align-items", "flex-start") - .children(&mut [ - html!("h4", { - .apply(|b| dwclass!(b, "text-lg font-semibold text-bunker-100")) - .style_signal("text-decoration", Mutable::new(task.completed).signal().map(|completed| { - if completed { "line-through" } else { "none" } - })) - .style_signal("opacity", Mutable::new(task.completed).signal().map(|completed| { - if completed { "0.5" } else { "1" } - })) - .text(&task.title) - }), - - html!("span", { - .class(match task.priority { - TaskPriority::High => "text-red-400", - TaskPriority::Medium => "text-candlelight-400", - TaskPriority::Low => "text-apple-400", - }) - .style("background-color", match task.priority { - TaskPriority::High => "rgba(127, 29, 29, 0.2)", - TaskPriority::Medium => "rgba(180, 83, 9, 0.2)", - TaskPriority::Low => "rgba(21, 128, 61, 0.2)", - }) - .apply(|b| dwclass!(b, "rounded-full text-xs font-medium flex gap-1")) - .style("padding", "0.25rem 0.75rem") - .style("align-items", "center") - .children(&mut [ - html!("span", { - .text(priority_mark) - }), - html!("span", { - .class(match task.priority { - TaskPriority::High => "text-red-400", - TaskPriority::Medium => "text-candlelight-400", - TaskPriority::Low => "text-apple-400", - }) - .text(&format!("{:?}", task.priority)) - }), - ]) - }), - ]) - }), - - html!("p", { - .apply(|b| dwclass!(b, "text-sm text-bunker-400")) - .style("margin-top", "0.5rem") - .style_signal("opacity", Mutable::new(task.completed).signal().map(|completed| { - if completed { "0.5" } else { "1" } - })) - .text(&task.description) - }), - - html!("div", { - .apply(|b| dwclass!(b, "flex gap-4 text-xs text-bunker-500")) - .style("margin-top", "0.75rem") - .children(&mut [ - html!("span", { - .apply(|b| dwclass!(b, "flex gap-1")) - .style("align-items", "center") - .children(&mut [ - html!("span", { - .text("Created") - }), - html!("span", { - .text(timestamp_date(&task.created_at)) - }), - ]) - }), - ]) - }), - ]) - }), - - html!("button", { - .apply(|b| dwclass!(b, "text-red-400 hover:text-red-300 text-sm font-medium rounded-lg transition-all")) - .style("padding", "0.25rem 0.75rem") - .text("Delete") - .event(clone!(app, task_id => move |e: events::Click| { - e.stop_propagation(); - App::delete_task(app.clone(), task_id.clone()); - })) - }), - ]) - })) - }) -} - -fn render_task_list(app: Arc) -> Dom { - html!("div", { - .class("glass") - .apply(|b| dwclass!(b, "rounded-2xl p-8")) - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "flex justify-between")) - .style("align-items", "center") - .style("margin-bottom", "2rem") - .children(&mut [ - html!("h3", { - .apply(|b| dwclass!(b, "text-2xl font-bold text-bunker-100")) - .text("Your Tasks") - }), - html!("div", { - .apply(|b| dwclass!(b, "text-sm text-bunker-400")) - .text_signal(app.tasks.signal_vec_cloned().len().map(|len| { - format!("{} task{}", len, if len == 1 { "" } else { "s" }) - })) - }), - ]) - }), - - html!("div", { - .style("display", "flex") - .style("flex-direction", "column") - .style("gap", "1rem") - .children_signal_vec(app.tasks.signal_vec_cloned() - .map(clone!(app => move |task| { - render_task_item(app.clone(), task) - }))) - }), - - // Empty state - html!("div", { - .apply(|b| dwclass!(b, "text-center")) - .style("padding", "3rem 0") - .visible_signal(app.tasks.signal_vec_cloned().len().map(|len| len == 0)) - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "text-2xl font-semibold text-bunker-300")) - .style("margin-bottom", "1rem") - .text("No Tasks") - }), - html!("p", { - .apply(|b| dwclass!(b, "text-bunker-400 text-lg")) - .text("No tasks yet. Create your first task!") - }), - ]) - }), - ]) - }) -} - -fn render_dashboard(app: Arc) -> Dom { - html!("div", { - .class(&*STYLES) - .style("min-height", "100vh") - .style("background", "linear-gradient(to bottom, #0a0a0a, #000000)") - .children(&mut [ - // Header - html!("nav", { - .class("glass") - .apply(|b| dwclass!(b, "sticky top-0")) - .style("z-index", "50") - .child(html!("div", { - .apply(|b| dwclass!(b, "max-w-7xl p-4")) - .style("margin", "0 auto") - .child(html!("div", { - .apply(|b| dwclass!(b, "flex justify-between")) - .style("align-items", "center") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "flex gap-3")) - .style("align-items", "center") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "w-10 h-10 rounded-lg flex justify-center")) - .style("background", "linear-gradient(to bottom right, #3b82f6, #8b5cf6)") - .style("align-items", "center") - .child(html!("span", { - .apply(|b| dwclass!(b, "font-bold text-lg")) - .style("color", "white") - .text("T") - })) - }), - html!("h1", { - .apply(|b| dwclass!(b, "text-2xl font-bold")) - .style("background", "linear-gradient(to right, #60a5fa, #a78bfa)") - .style("background-clip", "text") - .style("-webkit-background-clip", "text") - .style("color", "transparent") - .text("Task Manager") - }), - ]) - }), - - html!("button", { - .apply(|b| dwclass!(b, "text-sm font-medium text-bunker-300 rounded-lg transition-all border border-bunker-700")) - .style("padding", "0.5rem 1rem") - .style("background-color", "rgba(31, 41, 55, 0.5)") - .text("Sign Out") - .event(clone!(app => move |_: events::Click| { - App::logout(app.clone()); - })) - }), - ]) - })) - })) - }), - - // Main content - html!("main", { - .apply(|b| dwclass!(b, "max-w-7xl p-6")) - .style("margin", "0 auto") - .style("padding-top", "2rem") - .style("padding-bottom", "2rem") - .child(html!("div", { - .apply(|b| dwclass!(b, "grid gap-8")) - .style("grid-template-columns", "1fr") - .children(&mut [ - // Left column - Stats and Tasks - html!("div", { - .style("display", "flex") - .style("flex-direction", "column") - .style("gap", "1.5rem") - .children(&mut [ - // Stats - html!("div", { - .child_signal(app.stats.signal_cloned().map(|stats| { - stats.map(|s| render_stats_card(&s)) - })) - }), - - // Task list - render_task_list(app.clone()), - ]) - }), - - // Right column - Create form and selected task - html!("div", { - .style("display", "flex") - .style("flex-direction", "column") - .style("gap", "1.5rem") - .children(&mut [ - // Create task form - render_task_form(app.clone()), - - // Selected task details - html!("div", { - .child_signal(app.selected_task.signal_cloned().map(clone!(app => move |task| { - task.map(|t| { - html!("div", { - .class("glass animate-fade-in") - .apply(|b| dwclass!(b, "rounded-2xl p-8")) - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "flex justify-between")) - .style("align-items", "center") - .style("margin-bottom", "2rem") - .children(&mut [ - html!("h3", { - .apply(|b| dwclass!(b, "text-2xl font-bold text-bunker-100")) - .text("Task Details") - }), - html!("button", { - .apply(|b| dwclass!(b, "text-bunker-400 hover:text-bunker-200 text-2xl")) - .text("×") - .event(clone!(app => move |_: events::Click| { - app.selected_task.set(None); - })) - }), - ]) - }), - - html!("div", { - .style("display", "flex") - .style("flex-direction", "column") - .style("gap", "1.5rem") - .children(&mut [ - // Title and status - html!("div", { - .children(&mut [ - html!("h4", { - .apply(|b| dwclass!(b, "text-xl font-semibold text-bunker-100")) - .style("margin-bottom", "0.5rem") - .text(&t.title) - }), - html!("p", { - .apply(|b| dwclass!(b, "text-bunker-400")) - .text(&t.description) - }), - ]) - }), - - // Meta info - html!("div", { - .apply(|b| dwclass!(b, "grid grid-cols-2 gap-4")) - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "rounded-lg p-4")) - .style("background-color", "rgba(31, 41, 55, 0.5)") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "text-xs text-bunker-500")) - .style("text-transform", "uppercase") - .style("letter-spacing", "0.05em") - .text("Task ID") - }), - html!("div", { - .apply(|b| dwclass!(b, "text-sm text-bunker-300 font-mono")) - .style("margin-top", "0.25rem") - .text(task_id_preview(&t.id)) - .attr("title", &t.id) - }), - ]) - }), - - html!("div", { - .apply(|b| dwclass!(b, "rounded-lg p-4")) - .style("background-color", "rgba(31, 41, 55, 0.5)") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "text-xs text-bunker-500")) - .style("text-transform", "uppercase") - .style("letter-spacing", "0.05em") - .text("Status") - }), - html!("div", { - .apply(|b| dwclass!(b, "text-sm font-medium")) - .style("margin-top", "0.25rem") - .apply(|b| if t.completed { - dwclass!(b, "text-apple-400") - } else { - dwclass!(b, "text-candlelight-400") - }) - .text(if t.completed { "Completed" } else { "In Progress" }) - }), - ]) - }), - - html!("div", { - .apply(|b| dwclass!(b, "rounded-lg p-4")) - .style("background-color", "rgba(31, 41, 55, 0.5)") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "text-xs text-bunker-500")) - .style("text-transform", "uppercase") - .style("letter-spacing", "0.05em") - .text("Created") - }), - html!("div", { - .apply(|b| dwclass!(b, "text-sm text-bunker-300")) - .style("margin-top", "0.25rem") - .text(timestamp_date(&t.created_at)) - }), - ]) - }), - - html!("div", { - .apply(|b| dwclass!(b, "rounded-lg p-4")) - .style("background-color", "rgba(31, 41, 55, 0.5)") - .children(&mut [ - html!("div", { - .apply(|b| dwclass!(b, "text-xs text-bunker-500")) - .style("text-transform", "uppercase") - .style("letter-spacing", "0.05em") - .text("Updated") - }), - html!("div", { - .apply(|b| dwclass!(b, "text-sm text-bunker-300")) - .style("margin-top", "0.25rem") - .text(timestamp_date(&t.updated_at)) - }), - ]) - }), - ]) - }), - ]) - }), - ]) - }) - }) - }))) - }), - ]) - }), - ]) - })) - }), - ]) - }) -} - -fn render(app: Arc) -> Dom { - html!("div", { - .child_signal(app.is_authenticated().map(clone!(app => move |authenticated| { - if authenticated { - Some(render_dashboard(app.clone())) - } else { - Some(render_login_form(app.clone())) - } - }))) - }) -} - -#[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() { diff --git a/tests/playwright/README.md b/tests/playwright/README.md index 61621cd..73e4a06 100644 --- a/tests/playwright/README.md +++ b/tests/playwright/README.md @@ -34,3 +34,18 @@ Test tokens: - `user-token` - `admin-token` + +## WASM UI interactions + +Build the UI and run its separate Chromium suite: + +```bash +npm --prefix examples/wasm-ui-demo ci +npm --prefix examples/wasm-ui-demo run build +npm --prefix tests/playwright test -- --config wasm-ui.config.ts +``` + +Run these commands from the repository root after installing the Playwright browser. +The suite serves the compiled bundle and supplies deterministic JSON-RPC responses. +It checks login failure/success, task list/create/complete/delete, failed-create state, +and browser panics. CI runs it in the WASM UI example job after building the bundle. diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 9596c52..38c960c 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -5,6 +5,7 @@ const jsonrpcPort = process.env.PLAYWRIGHT_JSONRPC_PORT ?? '3102'; export default defineConfig({ testDir: './tests', + testIgnore: 'wasm-ui.spec.ts', fullyParallel: false, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list'], ['html', { open: 'never' }]], diff --git a/tests/playwright/tests/wasm-ui.spec.ts b/tests/playwright/tests/wasm-ui.spec.ts new file mode 100644 index 0000000..49580db --- /dev/null +++ b/tests/playwright/tests/wasm-ui.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from '@playwright/test'; + +test('login, task actions, and failures preserve reactive UI state', async ({ page }) => { + const tasks: Array> = []; + const requests: Array<{ method: string; params: any }> = []; + const browserErrors: string[] = []; + let failCreate = false; + page.on('pageerror', error => browserErrors.push(error.message)); + await page.route('**/rpc', async route => { + const request = route.request().postDataJSON(); + requests.push(request); + let result: unknown; + switch (request.method) { + case 'sign_in': + result = request.params.WithCredentials.password === 'password' + ? { Success: { jwt: 'browser-test-token' } } + : { Failure: { msg: 'Invalid credentials' } }; + break; + case 'list_tasks': result = { tasks, total: tasks.length }; break; + case 'get_dashboard_stats': result = { + total_tasks: tasks.length, + completed_tasks: tasks.filter(task => task.completed).length, + pending_tasks: tasks.filter(task => !task.completed).length, + high_priority_tasks: tasks.filter(task => task.priority === 'High').length + }; break; + case 'create_task': + expect(route.request().headers().authorization).toBe('Bearer browser-test-token'); + if (failCreate) { + await route.fulfill({ json: { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'Creation failed' } } }); + return; + } + result = { ...request.params, id: `task-${tasks.length + 1}`, completed: false, + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }; + tasks.push(result as Record); + break; + case 'update_task': { + const task = tasks.find(task => task.id === request.params.id)!; + for (const [key, value] of Object.entries(request.params)) { + if (value !== null) task[key] = value; + } + result = task; + break; + } + case 'delete_task': tasks.splice(tasks.findIndex(task => task.id === request.params), 1); result = true; break; + case 'sign_out': result = null; break; + default: throw new Error(`Unexpected RPC method: ${request.method}`); + } + await route.fulfill({ json: { jsonrpc: '2.0', id: request.id, result } }); + }); + + await page.goto('/'); + await page.getByPlaceholder('Enter your username').fill('user'); + await page.getByPlaceholder('Enter your password').fill('wrong'); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await expect(page.getByText('Invalid credentials', { exact: true })).toBeVisible(); + await page.getByPlaceholder('Enter your password').fill('password'); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await expect(page.getByText('No tasks yet. Create your first task!')).toBeVisible(); + + await page.getByPlaceholder('What needs to be done?').fill('Review module boundaries'); + await page.getByPlaceholder('Add more details...').fill('Verify browser interactions'); + await page.getByRole('button', { name: 'High', exact: true }).click(); + await page.getByRole('button', { name: 'Create Task', exact: true }).click(); + await expect(page.getByText('Review module boundaries', { exact: true })).toBeVisible(); + await expect(page.getByPlaceholder('What needs to be done?')).toHaveValue(''); + + // Selecting a task renders the detail panel, which once panicked on a + // space-separated class token. Open it explicitly rather than relying on + // the checkbox click bubbling up to the card. + await page.getByText('Review module boundaries', { exact: true }).click(); + await expect(page.getByText('Task Details', { exact: true })).toBeVisible(); + // The description is shown in both the card and the detail panel. + await expect(page.getByText('Verify browser interactions', { exact: true })).toHaveCount(2); + await page.getByRole('button', { name: '×', exact: true }).click(); + await expect(page.getByText('Task Details', { exact: true })).toHaveCount(0); + await expect(page.getByText('Verify browser interactions', { exact: true })).toHaveCount(1); + + await page.getByRole('checkbox').check(); + await expect.poll(() => tasks[0]?.completed).toBe(true); + await expect(page.getByRole('checkbox')).toBeChecked(); + + failCreate = true; + await page.getByPlaceholder('What needs to be done?').fill('Keep this draft'); + await page.getByRole('button', { name: 'Create Task', exact: true }).click(); + await expect.poll(() => requests.filter(request => request.method === 'create_task').length).toBe(2); + await expect(page.getByPlaceholder('What needs to be done?')).toHaveValue('Keep this draft'); + await expect(page.getByRole('checkbox')).toHaveCount(1); + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(page.getByText('No tasks yet. Create your first task!')).toBeVisible(); + expect(requests.some(request => request.method === 'list_tasks')).toBe(true); + expect(browserErrors).toEqual([]); +}); diff --git a/tests/playwright/wasm-ui.config.ts b/tests/playwright/wasm-ui.config.ts new file mode 100644 index 0000000..030c42b --- /dev/null +++ b/tests/playwright/wasm-ui.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + testMatch: 'wasm-ui.spec.ts', + retries: 0, + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://127.0.0.1:3103', + trace: 'retain-on-failure', + screenshot: 'only-on-failure' + }, + webServer: { + command: '../../examples/wasm-ui-demo/node_modules/.bin/vite --host 127.0.0.1 --port 3103 ../../examples/wasm-ui-demo/dist', + url: 'http://127.0.0.1:3103', + reuseExistingServer: false + } +});