diff --git a/.gitignore b/.gitignore index b1f95c6..1793a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ target dist node_modules +playwright-report # Environment files and secrets .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db9149..d33f59b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,27 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added - 2026-05-10 +- Added `ras-version-core` `0.1.0` with the shared `VersionMigration` trait for opt-in API compatibility migrations. +- `ras-jsonrpc-macro`: Added opt-in versioned JSON-RPC methods. Legacy wire methods can migrate legacy requests into canonical request types, call the canonical trait method, and migrate canonical responses back to legacy response types. +- `ras-rest-macro`: Added opt-in versioned REST endpoints. Legacy routes can migrate generated request-part structs into canonical request parts before invoking the canonical service method, then migrate response bodies back to legacy response types. +- `ras-jsonrpc-macro` and `ras-rest-macro`: Generated clients and OpenRPC/OpenAPI specs now include versioned compatibility methods/routes when configured. +- Added REST and JSON-RPC Playwright explorer coverage for versioned compatibility routes and wire methods. + ### Fixed - 2026-05-10 - `ras-rest-macro`: Generated REST clients now serialize query parameters through reqwest's serde-backed query path, support repeated-key `Vec` and `Option>` query params, and honor serde-renamed enum values without requiring `Display`. Fixes #3. ### Changed - 2026-05-10 +- `ras-jsonrpc-macro`: Generated service setup now matches REST's trait-backed model. Users implement the generated service trait and pass the implementation to `ServiceBuilder::new(service)`, with `.base_url(...)` for custom JSON-RPC route paths. +- Bumped `ras-jsonrpc-macro` from `0.1.2` to `0.2.0` because the generated JSON-RPC server setup changed from handler setters to a required service trait implementation. +- Bumped `ras-jsonrpc-core` from `0.1.1` to `0.1.2` for the additive `VersionMigration` re-export. +- Bumped `ras-rest-core` from `0.1.0` to `0.1.1` for the additive `VersionMigration` re-export. +- Bumped `ras-rest-macro` from `0.2.0` to `0.2.1` for additive versioned endpoint/client/spec generation. - Bumped `ras-rest-macro` from `0.1.1` to `0.2.0` because generated client query params now use serde serialization instead of `Display`/`ToString`. +### Documentation - 2026-05-10 +- Updated JSON-RPC, REST, identity, observability, example, and Playwright documentation for trait-backed service setup, current auth syntax, current crate names, and versioned API migration examples. + ### Added - 2026-05-09 - Established repository versioning and changelog policy in `VERSIONING.md`. - Added doc-comment support for generated API documentation: diff --git a/Cargo.lock b/Cargo.lock index a0c96f7..f3f678e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3371,10 +3371,11 @@ dependencies = [ [[package]] name = "ras-jsonrpc-core" -version = "0.1.1" +version = "0.1.2" dependencies = [ "ras-auth-core", "ras-jsonrpc-types", + "ras-version-core", "serde", "serde_json", "thiserror 2.0.12", @@ -3382,7 +3383,7 @@ dependencies = [ [[package]] name = "ras-jsonrpc-macro" -version = "0.1.2" +version = "0.2.0" dependencies = [ "async-trait", "axum", @@ -3449,16 +3450,17 @@ dependencies = [ [[package]] name = "ras-rest-core" -version = "0.1.0" +version = "0.1.1" dependencies = [ "ras-auth-core", + "ras-version-core", "serde", "thiserror 2.0.12", ] [[package]] name = "ras-rest-macro" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "axum", @@ -3497,6 +3499,10 @@ dependencies = [ "tokio", ] +[[package]] +name = "ras-version-core" +version = "0.1.0" + [[package]] name = "ratatui" version = "0.29.0" diff --git a/README.md b/README.md index 5b056bd..7b8c06a 100644 --- a/README.md +++ b/README.md @@ -100,18 +100,21 @@ jsonrpc_service!({ // Implement the generated trait struct TaskServiceImpl { /* ... */ } -#[async_trait::async_trait] -impl TaskServiceHandler for TaskServiceImpl { - async fn sign_in(&self, request: SignInRequest) -> JsonRpcResult { +impl TaskServiceTrait for TaskServiceImpl { + async fn sign_in( + &self, + request: SignInRequest, + ) -> Result> { // Your implementation } // ... other methods } // Use with the builder -let service = TaskService::builder() +let router = TaskServiceBuilder::new(TaskServiceImpl { /* ... */ }) + .base_url("/rpc") .auth_provider(JwtAuthProvider::new()) - .build(Arc::new(TaskServiceImpl { /* ... */ })); + .build()?; ``` ### Type-Safe REST APIs @@ -278,10 +281,10 @@ use ras_observability_otel::standard_setup; let otel = standard_setup("my-service")?; // Use with service builders -let service = MyServiceBuilder::new(impl) +let service = MyServiceBuilder::new(MyServiceImpl::new()) .with_usage_tracker(otel.usage_tracker()) .with_method_duration_tracker(otel.duration_tracker()) - .build(); + .build()?; // Metrics available at /metrics endpoint ``` diff --git a/crates/core/ras-version-core/Cargo.toml b/crates/core/ras-version-core/Cargo.toml new file mode 100644 index 0000000..b16a842 --- /dev/null +++ b/crates/core/ras-version-core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ras-version-core" +version = "0.1.0" +edition = "2024" +description = "Core traits for versioned API migrations in Rust Agent Stack" +license = "MIT OR Apache-2.0" +repository = "https://github.com/example/rust-agent-stack" +homepage = "https://github.com/example/rust-agent-stack" + +[dependencies] diff --git a/crates/core/ras-version-core/src/lib.rs b/crates/core/ras-version-core/src/lib.rs new file mode 100644 index 0000000..527e9fa --- /dev/null +++ b/crates/core/ras-version-core/src/lib.rs @@ -0,0 +1,14 @@ +//! Core traits for versioned API migrations. + +/// Converts one API version type into another. +/// +/// Service macros use this trait for opt-in compatibility paths where a legacy +/// request is upgraded into the canonical request type, and the canonical +/// response is downgraded back into the legacy response type. +pub trait VersionMigration { + /// Error returned when a version migration cannot be performed. + type Error: std::fmt::Display + Send + Sync + 'static; + + /// Convert `value` from one API version type into another. + fn migrate(value: From) -> Result; +} diff --git a/crates/identity/ras-identity-session/README.md b/crates/identity/ras-identity-session/README.md index d303019..10be123 100644 --- a/crates/identity/ras-identity-session/README.md +++ b/crates/identity/ras-identity-session/README.md @@ -119,15 +119,41 @@ Example JWT payload: ```rust use ras_jsonrpc_macro::jsonrpc_service; +use ras_identity_session::JwtAuthProvider; jsonrpc_service!({ service_name: MyService, - auth_provider: JwtAuthProvider, methods: [ WITH_PERMISSIONS(["read"]) get_data(GetRequest) -> GetResponse, WITH_PERMISSIONS(["write"]) update_data(UpdateRequest) -> UpdateResponse, ] }); + +struct MyServiceImpl; + +impl MyServiceTrait for MyServiceImpl { + async fn get_data( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + request: GetRequest, + ) -> Result> { + // Load data for `user`. + } + + async fn update_data( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + request: UpdateRequest, + ) -> Result> { + // Update data for `user`. + } +} + +let auth_provider = JwtAuthProvider::new(session_service.clone()); +let router = MyServiceBuilder::new(MyServiceImpl) + .base_url("/rpc") + .auth_provider(auth_provider) + .build()?; ``` ### With WebSocket Authentication @@ -161,4 +187,4 @@ The session service integrates with bidirectional JSON-RPC WebSocket services, s - **Secret**: JWT signing secret (required) - **TTL**: Token time-to-live in seconds - **Algorithm**: JWT signing algorithm (default: HS256) -- **Refresh**: Enable/disable refresh token support (experimental) \ No newline at end of file +- **Refresh**: Enable/disable refresh token support (experimental) diff --git a/crates/observability/ras-observability-otel/README.md b/crates/observability/ras-observability-otel/README.md index bf868d8..d9539f4 100644 --- a/crates/observability/ras-observability-otel/README.md +++ b/crates/observability/ras-observability-otel/README.md @@ -44,10 +44,15 @@ let usage_tracker = { } }; -// Add to your service -MyServiceBuilder::new() +// REST service builders take the trait implementation. +MyServiceBuilder::new(MyServiceImpl::new()) .with_usage_tracker(usage_tracker) .build() + +// JSON-RPC service builders also take the trait implementation. +MyRpcServiceBuilder::new(MyRpcServiceImpl::new()) + .with_usage_tracker(rpc_usage_tracker) + .build() ``` ## Metrics Exposed @@ -80,4 +85,4 @@ See the `examples/` directory for: cargo run --example simple_usage -p ras-observability-otel # Then visit http://localhost:3000/metrics -``` \ No newline at end of file +``` diff --git a/crates/rest/ras-rest-core/Cargo.toml b/crates/rest/ras-rest-core/Cargo.toml index 29fe2de..6d6952a 100644 --- a/crates/rest/ras-rest-core/Cargo.toml +++ b/crates/rest/ras-rest-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-rest-core" -version = "0.1.0" +version = "0.1.1" edition = "2024" description = "Core types and traits for REST services in Rust Agent Stack" license = "MIT OR Apache-2.0" @@ -10,4 +10,5 @@ homepage = "https://github.com/example/rust-agent-stack" [dependencies] serde = { workspace = true } thiserror = { workspace = true } -ras-auth-core = { path = "../../core/ras-auth-core" } \ No newline at end of file +ras-auth-core = { path = "../../core/ras-auth-core" } +ras-version-core = { path = "../../core/ras-version-core" } diff --git a/crates/rest/ras-rest-core/src/lib.rs b/crates/rest/ras-rest-core/src/lib.rs index 47bab98..f50ce64 100644 --- a/crates/rest/ras-rest-core/src/lib.rs +++ b/crates/rest/ras-rest-core/src/lib.rs @@ -8,6 +8,7 @@ use thiserror::Error; // Re-export authentication types for convenience pub use ras_auth_core::{AuthError, AuthProvider, AuthResult, AuthenticatedUser}; +pub use ras_version_core::*; /// Result type for REST handlers that allows explicit HTTP status codes. pub type RestResult = Result, RestError>; diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index b00eada..2496094 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-rest-macro" -version = "0.2.0" +version = "0.2.1" edition = "2024" description = "Procedural macro for type-safe REST APIs with auth integration and OpenAPI document generation" license = "MIT OR Apache-2.0" diff --git a/crates/rest/ras-rest-macro/README.md b/crates/rest/ras-rest-macro/README.md index 7c101b4..2fae8c5 100644 --- a/crates/rest/ras-rest-macro/README.md +++ b/crates/rest/ras-rest-macro/README.md @@ -5,8 +5,9 @@ A procedural macro for creating type-safe REST APIs with authentication integrat ## Features - **Type-safe REST endpoints**: Generate axum-based REST services from macro definitions -- **Authentication integration**: Seamless integration with `ras-jsonrpc-core::AuthProvider` +- **Authentication integration**: Seamless integration with `ras-auth-core::AuthProvider` - **Permission-based access control**: Support for role-based authorization +- **Versioned endpoints**: Optional request/response migrations for legacy routes - **OpenAPI 3.0 generation**: Automatic OpenAPI documentation using schemars - **HTTP methods**: Support for GET, POST, PUT, DELETE, PATCH - **Path parameters**: Type-safe path parameter extraction @@ -49,26 +50,48 @@ rest_service!({ // The macro generates: // - UserServiceTrait: A trait with async methods for each endpoint -// - UserServiceBuilder: A builder for configuring handlers and auth providers +// - UserServiceBuilder: A builder for configuring the service implementation and auth provider ``` ### Service Configuration ```rust -let service = UserServiceBuilder::new() +use ras_auth_core::AuthenticatedUser; +use ras_rest_core::{RestResponse, RestResult}; + +struct UserServiceImpl; + +#[async_trait::async_trait] +impl UserServiceTrait for UserServiceImpl { + async fn get_users(&self) -> RestResult> { + Ok(RestResponse::ok(vec![])) + } + + async fn post_users( + &self, + _user: &AuthenticatedUser, + request: CreateUserRequest, + ) -> RestResult { + Ok(RestResponse::created(User { + id: 1, + name: request.name, + email: request.email, + })) + } + + async fn get_users_by_id(&self, _user: &AuthenticatedUser, id: i32) -> RestResult { + Ok(RestResponse::ok(User { + id, + name: "John".to_string(), + email: "john@example.com".to_string(), + })) + } + + // ... implement other endpoints +} + +let service = UserServiceBuilder::new(UserServiceImpl) .auth_provider(my_auth_provider) - .get_users_handler(|_| async { - // Return list of users - Ok(vec![]) - }) - .post_users_handler(|user, request| async { - // Create new user with admin permissions - Ok(User { id: 1, name: request.name, email: request.email }) - }) - .get_users_by_id_handler(|user, id| async { - // Get user by ID with user permissions - Ok(User { id, name: "John".to_string(), email: "john@example.com".to_string() }) - }) .build(); // Use with axum @@ -131,23 +154,102 @@ GET WITH_PERMISSIONS(["user"]) users/{id: i32}() -> User, GET UNAUTHORIZED posts/{user_id: i32}/comments/{comment_id: String}() -> Comment, ``` +### Versioned Endpoints + +Versioning is opt-in. The canonical endpoint is handled by the generated trait method, and each legacy route is migrated into the canonical request parts before the service implementation is called. + +```rust +#[derive(Serialize, Deserialize, JsonSchema)] +struct RenameUserV1 { + name: String, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +struct RenameUserV2 { + display_name: String, + notify: bool, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +struct RenameUserResponseV1 { + name: String, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +struct RenameUserResponseV2 { + display_name: String, + notified: bool, +} + +rest_service!({ + service_name: UserService, + base_path: "/api", + endpoints: [ + POST UNAUTHORIZED v2/users/{id: i32}/rename(RenameUserV2) -> RenameUserResponseV2 { + version: v2, + versions: [ + v1 { + path: v1/users/{id: i32}/rename, + body: RenameUserV1, + response: RenameUserResponseV1, + migration: RenameUserCompat, + }, + ], + }, + ] +}); + +struct RenameUserCompat; + +impl ras_rest_core::VersionMigration< + UserServicePostV2UsersByIdRenameV1Request, + UserServicePostV2UsersByIdRenameV2Request, +> for RenameUserCompat { + type Error = std::convert::Infallible; + + fn migrate( + value: UserServicePostV2UsersByIdRenameV1Request, + ) -> Result { + Ok(UserServicePostV2UsersByIdRenameV2Request { + path: UserServicePostV2UsersByIdRenameV2Path { id: value.path.id }, + query: UserServicePostV2UsersByIdRenameV2Query {}, + body: RenameUserV2 { + display_name: value.body.name, + notify: false, + }, + }) + } +} + +impl ras_rest_core::VersionMigration + for RenameUserCompat +{ + type Error = std::convert::Infallible; + + fn migrate(value: RenameUserResponseV2) -> Result { + Ok(RenameUserResponseV1 { + name: value.display_name, + }) + } +} +``` + ## Authentication Integration -The macro integrates with `rust-jsonrpc-core::AuthProvider` for authentication: +The macro integrates with `ras-auth-core::AuthProvider` for authentication: ```rust -use rust_jsonrpc_core::AuthProvider; +use ras_auth_core::{AuthFuture, AuthProvider}; struct MyAuthProvider; -#[async_trait::async_trait] impl AuthProvider for MyAuthProvider { fn authenticate(&self, token: String) -> AuthFuture<'_> { // Validate JWT token and return authenticated user } } -let service = UserServiceBuilder::new() +let service = UserServiceBuilder::new(UserServiceImpl) .auth_provider(MyAuthProvider) .build(); ``` @@ -188,4 +290,4 @@ let app = axum::Router::new() - Authentication requirements in OpenAPI security schemes - Permission metadata as OpenAPI extensions - Path parameters and request/response schemas -- Standard HTTP error responses \ No newline at end of file +- Standard HTTP error responses diff --git a/crates/rest/ras-rest-macro/src/client.rs b/crates/rest/ras-rest-macro/src/client.rs index 33c5360..c6d7941 100644 --- a/crates/rest/ras-rest-macro/src/client.rs +++ b/crates/rest/ras-rest-macro/src/client.rs @@ -47,12 +47,15 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok let base_path = &service_def.base_path; // Generate client methods - let client_methods = service_def.endpoints.iter().map(generate_client_method); + let client_methods = service_def + .endpoints + .iter() + .flat_map(generate_client_methods_for_endpoint); let client_methods_with_timeout = service_def .endpoints .iter() - .map(generate_client_method_with_timeout); + .flat_map(generate_client_methods_with_timeout_for_endpoint); let output = quote! { #[cfg(feature = "client")] @@ -170,16 +173,92 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok output } -/// Generate a client method for the REST service -fn generate_client_method(endpoint: &EndpointDefinition) -> proc_macro2::TokenStream { - let method_name = &endpoint.handler_name; +fn handler_name_for_path(method: &HttpMethod, path: &str) -> syn::Ident { + let method_str = method.as_str().to_lowercase(); + let mut parts = Vec::new(); + + for segment in path.trim_start_matches('/').split('/') { + if segment.starts_with('{') && segment.ends_with('}') { + let inner = &segment[1..segment.len() - 1]; + let name = inner.split(':').next().unwrap_or(inner).trim(); + parts.push(format!("by_{name}")); + } else if !segment.is_empty() { + parts.push(segment.to_string()); + } + } + + syn::parse_str::(&format!("{}_{}", method_str, parts.join("_"))) + .expect("generated REST client method name must be a valid Rust identifier") +} +fn generate_client_methods_for_endpoint( + endpoint: &EndpointDefinition, +) -> Vec { + let mut methods = vec![generate_client_method( + &endpoint.handler_name, + &endpoint.path_params, + &endpoint.query_params, + endpoint.request_type.as_ref(), + &endpoint.response_type, + )]; + + methods.extend(endpoint.versions.iter().map(|version| { + let method_name = handler_name_for_path(&endpoint.method, &version.path); + generate_client_method( + &method_name, + &version.path_params, + &version.query_params, + version.request_type.as_ref(), + &version.response_type, + ) + })); + + methods +} + +fn generate_client_methods_with_timeout_for_endpoint( + endpoint: &EndpointDefinition, +) -> Vec { + let mut methods = vec![generate_client_method_with_timeout( + &endpoint.handler_name, + &endpoint.method, + &endpoint.path, + &endpoint.path_params, + &endpoint.query_params, + endpoint.request_type.as_ref(), + &endpoint.response_type, + )]; + + methods.extend(endpoint.versions.iter().map(|version| { + let method_name = handler_name_for_path(&endpoint.method, &version.path); + generate_client_method_with_timeout( + &method_name, + &endpoint.method, + &version.path, + &version.path_params, + &version.query_params, + version.request_type.as_ref(), + &version.response_type, + ) + })); + + methods +} + +/// Generate a client method for the REST service +fn generate_client_method( + method_name: &syn::Ident, + path_params: &[crate::PathParam], + query_params: &[crate::QueryParam], + request_type: Option<&Type>, + response_type: &Type, +) -> proc_macro2::TokenStream { // Build function parameters and call arguments let mut params = Vec::new(); let mut call_args = Vec::new(); // Add path parameters - for path_param in endpoint.path_params.iter() { + for path_param in path_params.iter() { let param_name = &path_param.name; let param_type = &path_param.param_type; params.push(quote! { #param_name: #param_type }); @@ -187,7 +266,7 @@ fn generate_client_method(endpoint: &EndpointDefinition) -> proc_macro2::TokenSt } // Add query parameters (mirroring the macro syntax order: path → query → body). - for query_param in endpoint.query_params.iter() { + for query_param in query_params.iter() { let param_name = &query_param.name; let param_type = &query_param.param_type; params.push(quote! { #param_name: #param_type }); @@ -195,13 +274,11 @@ fn generate_client_method(endpoint: &EndpointDefinition) -> proc_macro2::TokenSt } // Add request body parameter if present - if endpoint.request_type.is_some() { - let request_type = endpoint.request_type.as_ref().unwrap(); + if let Some(request_type) = request_type { params.push(quote! { body: #request_type }); call_args.push(quote! { body }); } - let response_type = &endpoint.response_type; let method_name_with_timeout = quote::format_ident!("{}_with_timeout", method_name); quote! { @@ -213,10 +290,17 @@ fn generate_client_method(endpoint: &EndpointDefinition) -> proc_macro2::TokenSt } /// Generate a client method with timeout for the REST service -fn generate_client_method_with_timeout(endpoint: &EndpointDefinition) -> proc_macro2::TokenStream { - let method_name = &endpoint.handler_name; +fn generate_client_method_with_timeout( + method_name: &syn::Ident, + method: &HttpMethod, + path: &str, + path_params: &[crate::PathParam], + query_params: &[crate::QueryParam], + request_type: Option<&Type>, + response_type: &Type, +) -> proc_macro2::TokenStream { let method_name_with_timeout = quote::format_ident!("{}_with_timeout", method_name); - let http_method = match endpoint.method { + let http_method = match method { HttpMethod::Get => quote! { reqwest::Method::GET }, HttpMethod::Post => quote! { reqwest::Method::POST }, HttpMethod::Put => quote! { reqwest::Method::PUT }, @@ -224,8 +308,6 @@ fn generate_client_method_with_timeout(endpoint: &EndpointDefinition) -> proc_ma HttpMethod::Patch => quote! { reqwest::Method::PATCH }, }; - let path = &endpoint.path; - // Build function parameters let mut params = Vec::new(); let mut path_substitutions = Vec::new(); @@ -236,7 +318,7 @@ fn generate_client_method_with_timeout(endpoint: &EndpointDefinition) -> proc_ma }; // Add path parameters - for path_param in endpoint.path_params.iter() { + for path_param in path_params.iter() { let param_name = &path_param.name; let param_type = &path_param.param_type; params.push(quote! { #param_name: #param_type }); @@ -261,10 +343,10 @@ fn generate_client_method_with_timeout(endpoint: &EndpointDefinition) -> proc_ma // `Option` params are skipped when `None`. Values are serialized by // reqwest's serde-backed `.query()` helper so enum serde renames and other // query wire formats stay aligned with server-side extraction. - let query_handling = if endpoint.query_params.is_empty() { + let query_handling = if query_params.is_empty() { quote! {} } else { - let query_serializers = endpoint.query_params.iter().map(|qp| { + let query_serializers = query_params.iter().map(|qp| { let param_name = &qp.name; let param_str = qp.name.to_string(); if option_vec_inner_type(&qp.param_type).is_some() { @@ -300,14 +382,14 @@ fn generate_client_method_with_timeout(endpoint: &EndpointDefinition) -> proc_ma // Add query parameters to the function signature (after path params, // before the body — matches macro syntax order). - for query_param in endpoint.query_params.iter() { + for query_param in query_params.iter() { let param_name = &query_param.name; let param_type = &query_param.param_type; params.push(quote! { #param_name: #param_type }); } // Add request body parameter if present - let request_body_handling = if let Some(request_type) = &endpoint.request_type { + let request_body_handling = if let Some(request_type) = request_type { params.push(quote! { body: #request_type }); quote! { request_builder = request_builder.json(&body); @@ -316,8 +398,6 @@ fn generate_client_method_with_timeout(endpoint: &EndpointDefinition) -> proc_ma quote! {} }; - let response_type = &endpoint.response_type; - // Check if response type is unit type () let is_unit_type = quote!(#response_type).to_string() == "()"; diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index bea986e..e7af9cc 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -96,6 +96,19 @@ struct EndpointDefinition { request_type: Option, response_type: Type, handler_name: Ident, + version: Option, + versions: Vec, +} + +#[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)] @@ -121,7 +134,7 @@ impl DocComment { } } -#[derive(Debug)] +#[derive(Debug, Clone)] enum HttpMethod { Get, Post, @@ -152,13 +165,13 @@ impl HttpMethod { } } -#[derive(Debug)] +#[derive(Debug, Clone)] struct PathParam { name: Ident, param_type: Type, } -#[derive(Debug)] +#[derive(Debug, Clone)] struct QueryParam { name: Ident, param_type: Type, @@ -172,6 +185,14 @@ enum AuthRequirement { 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, @@ -307,6 +328,86 @@ impl Parse for ServiceDefinition { } } +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")?; @@ -390,72 +491,13 @@ impl Parse for EndpointDefinition { }; // Parse path with potential path parameters (e.g., users/{id: String}/posts/{post_id: i32}) - let mut path_segments = Vec::new(); - let mut path_params = Vec::new(); - let mut handler_name_parts = Vec::new(); - - // First segment is always the base path segment - let first_segment = input.parse::()?; - path_segments.push(first_segment.to_string()); - handler_name_parts.push(first_segment.to_string()); - - // Parse additional path segments - while input.peek(Token![/]) { - let _ = input.parse::()?; - - if input.peek(syn::token::Brace) { - // Parse path parameter {name: Type} - 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 { - // Regular path segment - let segment = input.parse::()?; - path_segments.push(segment.to_string()); - handler_name_parts.push(segment.to_string()); - } - } - - let path = format!("/{}", path_segments.join("/")); + let (path, path_params, handler_name_parts) = parse_endpoint_path(input)?; // Parse query parameters if present (? param1:Type & param2:Type) let mut query_params = Vec::new(); if input.peek(Token![?]) { let _ = input.parse::()?; - - // Parse first query parameter - let param_name = input.parse::()?; - let _ = input.parse::()?; - let param_type = input.parse::()?; - query_params.push(QueryParam { - name: param_name, - param_type, - }); - - // Parse additional query parameters separated by & - while input.peek(Token![&]) - && !input.peek2(syn::token::Paren) - && !input.peek2(Token![->]) - { - let _ = input.parse::()?; - let param_name = input.parse::()?; - let _ = input.parse::()?; - let param_type = input.parse::()?; - query_params.push(QueryParam { - name: param_name, - param_type, - }); - } + query_params = parse_query_params(input)?; } // Generate handler name based on method and path @@ -480,6 +522,47 @@ impl Parse for EndpointDefinition { let _ = input.parse::]>()?; let response_type = input.parse::()?; + let mut version = 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)?); + } + "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 or versions", + )); + } + } + + if content.peek(Token![,]) { + let _ = content.parse::()?; + } + } + } + Ok(EndpointDefinition { docs, method, @@ -490,6 +573,79 @@ impl Parse for EndpointDefinition { request_type, response_type, handler_name, + version, + versions, + }) + } +} + +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") + })?, }) } } @@ -562,82 +718,39 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result = service_def - .endpoints - .iter() - .enumerate() - .map(|(idx, endpoint)| { - if !endpoint.query_params.is_empty() { - let struct_name = quote::format_ident!("QueryParams{}", idx); - let fields = endpoint.query_params.iter().map(|param| { - let name = ¶m.name; - let param_type = ¶m.param_type; - quote! { pub #name: #param_type } - }); - quote! { - #[derive(serde::Deserialize)] - #[allow(dead_code)] - pub(super) struct #struct_name { - #(#fields),* - } - } - } else { - quote! {} - } - }) - .collect(); - - // Generate route registration - let route_registrations = service_def.endpoints.iter().enumerate().map(|(idx, endpoint)| { - let method_routing = endpoint.method.as_axum_method(); - let path = &endpoint.path; - let handler_name = &endpoint.handler_name; - let permission_groups = match &endpoint.auth { - AuthRequirement::Unauthorized => Vec::new(), - AuthRequirement::WithPermissions(groups) => groups.clone(), - }; - let method_str = endpoint.method.as_str(); + let request_part_structs = generate_rest_request_part_structs(&service_def); - // Generate the axum handler based on endpoint configuration using the idx for the QueryParams struct - let axum_handler = generate_axum_handler(endpoint, idx); - let handler_body = generate_handler_body(endpoint, handler_name, method_str, path, idx); + let mut query_structs: Vec = Vec::new(); + let mut route_registrations: Vec = Vec::new(); + let mut route_idx = 0usize; - // Generate permission groups code for quote - let permission_groups_code = 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> } - }; + 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, + )); + route_idx += 1; - quote! { - { - let service = self.service.clone(); - let auth_provider = self.auth_provider.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 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 { - #handler_body - } - } - })); - } + 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, + )); + route_idx += 1; } - }); + } // Generate static hosting route registration - only if docs are enabled let static_routes = if service_def.static_hosting.serve_docs { @@ -686,6 +799,9 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result #builder_name { /// Create a new builder with the service implementation @@ -754,16 +870,603 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result proc_macro2::TokenStream { +fn rest_permission_groups_code(auth: &AuthRequirement) -> proc_macro2::TokenStream { + let permission_groups = match auth { + AuthRequirement::Unauthorized => 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)] + #[allow(dead_code)] + pub(super) struct #struct_name { + #(#fields),* + } + } +} + +fn generate_canonical_route_registration( + endpoint: &EndpointDefinition, + query_struct_name: &Ident, +) -> 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 axum_handler = generate_axum_handler( + &endpoint.path_params, + &endpoint.query_params, + endpoint.request_type.as_ref(), + query_struct_name, + ); + let handler_body = generate_handler_body(endpoint, handler_name, method_str, path); + let permission_groups_code = rest_permission_groups_code(&endpoint.auth); + + quote! { + { + let service = self.service.clone(); + let auth_provider = self.auth_provider.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 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 { + #handler_body + } + } + })); + } + } +} + +fn generate_legacy_route_registration( + service_name: &Ident, + endpoint: &EndpointDefinition, + version: &EndpointVersionDefinition, + query_struct_name: &Ident, +) -> proc_macro2::TokenStream { + let method_routing = endpoint.method.as_axum_method(); + let path = &version.path; + let axum_handler = generate_axum_handler( + &version.path_params, + &version.query_params, + version.request_type.as_ref(), + query_struct_name, + ); + let handler_body = generate_legacy_handler_body(service_name, endpoint, version); + let permission_groups_code = rest_permission_groups_code(&endpoint.auth); + + quote! { + { + let service = self.service.clone(); + let auth_provider = self.auth_provider.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 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 { + #handler_body + } + } + })); + } + } +} + +fn generate_legacy_handler_body( + service_name: &Ident, + endpoint: &EndpointDefinition, + version: &EndpointVersionDefinition, +) -> proc_macro2::TokenStream { + let handler_name = &endpoint.handler_name; + let method = endpoint.method.as_str(); + let path = &version.path; + 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); + + let json_handling = if version.request_type.is_some() { + quote! { + let body = match body_result { + Ok(json) => json.0, + Err(_) => { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": "Invalid JSON" + })) + ).into_response(); + }, + }; + } + } else { + quote! {} + }; + + match &endpoint.auth { + AuthRequirement::Unauthorized => quote! { + #json_handling + + if let Some(tracker) = &with_usage_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) => { + 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(); + }, + }; + ( + status_code, + axum::Json(body) + ).into_response() + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + 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::WithPermissions(_) => { + canonical_args.insert(0, quote! { &user }); + + quote! { + #json_handling + + let token = match headers + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + { + Some(token) => token, + None => { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(serde_json::json!({ + "error": "Missing or invalid Authorization header" + })) + ).into_response(); + }, + }; + + let user = match &auth_provider { + Some(provider) => match provider.authenticate(token.to_string()).await { + Ok(user) => user, + Err(_) => { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(serde_json::json!({ + "error": "Authentication failed" + })) + ).into_response(); + }, + }, + None => { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ + "error": "No auth provider configured" + })) + ).into_response(); + }, + }; + + let has_non_empty_groups = required_permission_groups.iter().any(|g| !g.is_empty()); + if has_non_empty_groups { + let mut has_permission = false; + + for permission_group in &required_permission_groups { + if permission_group.is_empty() { + has_permission = true; + break; + } else { + let group_result = auth_provider.as_ref().unwrap().check_permissions(&user, permission_group); + if group_result.is_ok() { + has_permission = true; + break; + } + } + } + + if !has_permission { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::FORBIDDEN, + axum::Json(serde_json::json!({ + "error": "Insufficient permissions" + })) + ).into_response(); + } + } + + if let Some(tracker) = &with_usage_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) => { + 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(); + }, + }; + ( + status_code, + axum::Json(body) + ).into_response() + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + 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 + } + } + } +} + +fn generate_axum_handler( + path_params: &[PathParam], + query_params: &[QueryParam], + request_type: Option<&Type>, + query_struct_name: &Ident, +) -> proc_macro2::TokenStream { let mut extractors = Vec::new(); // Always add headers extraction for tracking purposes extractors.push(quote! { headers: axum::http::HeaderMap }); // Add path parameter extractors - if !endpoint.path_params.is_empty() { - let path_param_types = endpoint.path_params.iter().map(|param| ¶m.param_type); - if endpoint.path_params.len() == 1 { + if !path_params.is_empty() { + let path_param_types = path_params.iter().map(|param| ¶m.param_type); + if path_params.len() == 1 { extractors.push(quote! { axum::extract::Path(path_params): axum::extract::Path<#(#path_param_types)*> }); } else { extractors.push(quote! { axum::extract::Path(path_params): axum::extract::Path<(#(#path_param_types),*)> }); @@ -771,15 +1474,14 @@ fn generate_axum_handler(endpoint: &EndpointDefinition, idx: usize) -> proc_macr } // Add query parameter extractors - if !endpoint.query_params.is_empty() { - let struct_name = quote::format_ident!("QueryParams{}", idx); + if !query_params.is_empty() { extractors.push(quote! { - ::axum_extra::extract::Query(query_params): ::axum_extra::extract::Query + ::axum_extra::extract::Query(query_params): ::axum_extra::extract::Query }); } // Add request body extractor if present - use Result to handle JSON parsing errors - if endpoint.request_type.is_some() { + if request_type.is_some() { extractors.push(quote! { body_result: Result, axum::extract::rejection::JsonRejection> }); } @@ -793,7 +1495,6 @@ fn generate_handler_body( handler_name: &Ident, method: &str, path: &str, - _idx: usize, ) -> proc_macro2::TokenStream { // Handle authentication if required match &endpoint.auth { diff --git a/crates/rest/ras-rest-macro/src/openapi.rs b/crates/rest/ras-rest-macro/src/openapi.rs index 7ced6df..16ddcca 100644 --- a/crates/rest/ras-rest-macro/src/openapi.rs +++ b/crates/rest/ras-rest-macro/src/openapi.rs @@ -64,6 +64,29 @@ pub fn generate_openapi_code( 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)); + } + } } // Helper function to sanitize type names for OpenAPI component names @@ -145,9 +168,14 @@ pub fn generate_openapi_code( let endpoint_infos: Vec = service_def .endpoints .iter() - .map(|endpoint| { + .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; @@ -207,7 +235,7 @@ pub fn generate_openapi_code( }) .collect(); - quote! { + let mut infos = vec![quote! { #endpoint_info_struct_name { method: #method.to_string(), path: #path.to_string(), @@ -219,8 +247,78 @@ pub fn generate_openapi_code( 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 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, + permissions: vec![#(#permissions.to_string()),*], + 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(); @@ -238,6 +336,9 @@ pub fn generate_openapi_code( 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 @@ -550,6 +651,15 @@ pub fn generate_openapi_code( 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); + } + // Add request body for non-GET methods if endpoint.method != "GET" && endpoint.request_type_name != "Unit" { operation["requestBody"] = json!({ @@ -648,6 +758,25 @@ pub fn generate_schema_impl_checks(service_def: &ServiceDefinition) -> TokenStre 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 diff --git a/crates/rest/ras-rest-macro/tests/e2e.rs b/crates/rest/ras-rest-macro/tests/e2e.rs index dfbb710..9b69cac 100644 --- a/crates/rest/ras-rest-macro/tests/e2e.rs +++ b/crates/rest/ras-rest-macro/tests/e2e.rs @@ -24,6 +24,29 @@ struct ItemsResponse { items: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +struct RenameItemV1 { + name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +struct RenameItemV2 { + display_name: String, + notify: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] +struct RenamedItemV1 { + name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] +struct RenamedItemV2 { + id: u32, + display_name: String, + notified: bool, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)] enum SortOrder { #[serde(rename = "asc")] @@ -47,9 +70,57 @@ rest_service!({ GET UNAUTHORIZED sorted ? order: SortOrder () -> ItemsResponse, POST WITH_PERMISSIONS(["admin"]) items/batch ? notify: bool (CreateItem) -> Item, GET WITH_PERMISSIONS(["user"]) items/{id: u32}/related ? tag: Option () -> ItemsResponse, + POST UNAUTHORIZED v2/items/{id: u32}/rename ? notify: bool (RenameItemV2) -> RenamedItemV2 { + version: v2, + versions: [ + v1 { + path: v1/items/{id: u32}/rename, + query: [notify: Option], + body: RenameItemV1, + response: RenamedItemV1, + migration: RenameItemCompat, + }, + ], + }, ] }); +struct RenameItemCompat; + +impl + ras_rest_core::VersionMigration< + DemoPostV2ItemsByIdRenameV1Request, + DemoPostV2ItemsByIdRenameV2Request, + > for RenameItemCompat +{ + type Error = std::convert::Infallible; + + fn migrate( + value: DemoPostV2ItemsByIdRenameV1Request, + ) -> Result { + Ok(DemoPostV2ItemsByIdRenameV2Request { + path: DemoPostV2ItemsByIdRenameV2Path { id: value.path.id }, + query: DemoPostV2ItemsByIdRenameV2Query { + notify: value.query.notify.unwrap_or(false), + }, + body: RenameItemV2 { + display_name: value.body.name, + notify: value.query.notify.unwrap_or(false), + }, + }) + } +} + +impl ras_rest_core::VersionMigration for RenameItemCompat { + type Error = std::convert::Infallible; + + fn migrate(value: RenamedItemV2) -> Result { + Ok(RenamedItemV1 { + name: value.display_name, + }) + } +} + struct DemoImpl; #[async_trait::async_trait] @@ -170,6 +241,19 @@ impl DemoTrait for DemoImpl { }], })) } + + async fn post_v2_items_by_id_rename( + &self, + id: u32, + notify: bool, + request: RenameItemV2, + ) -> RestResult { + Ok(RestResponse::ok(RenamedItemV2 { + id, + display_name: request.display_name, + notified: notify || request.notify, + })) + } } fn router() -> axum::Router { @@ -191,6 +275,57 @@ async fn unauth_get_round_trips() { assert_eq!(resp.items[0].name, "alpha"); } +#[tokio::test] +async fn legacy_rest_version_round_trips_through_canonical_handler() { + let server = spawn_http(router()); + let base = server.server_address().unwrap().to_string(); + + let resp = client(&base) + .post_v1_items_by_id_rename( + 7, + Some(true), + RenameItemV1 { + name: "renamed".to_string(), + }, + ) + .await + .expect("legacy rename ok"); + + assert_eq!( + resp, + RenamedItemV1 { + name: "renamed".to_string() + } + ); +} + +#[tokio::test] +async fn canonical_rest_version_uses_v2_path_and_types() { + let server = spawn_http(router()); + let base = server.server_address().unwrap().to_string(); + + let resp = client(&base) + .post_v2_items_by_id_rename( + 8, + false, + RenameItemV2 { + display_name: "canonical".to_string(), + notify: true, + }, + ) + .await + .expect("canonical rename ok"); + + 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 server = spawn_http(router()); diff --git a/crates/rpc/ras-jsonrpc-core/Cargo.toml b/crates/rpc/ras-jsonrpc-core/Cargo.toml index 53c435a..fc3e2c2 100644 --- a/crates/rpc/ras-jsonrpc-core/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-core/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "ras-jsonrpc-core" -version = "0.1.1" +version = "0.1.2" edition = "2024" -description = "Core types and traits for the rust-jsonrpc crate family" +description = "Core types and traits for the ras-jsonrpc crate family" license = "MIT OR Apache-2.0" repository = "https://github.com/example/rust-agent-stack" homepage = "https://github.com/example/rust-agent-stack" @@ -12,4 +12,5 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } ras-jsonrpc-types = { path = "../ras-jsonrpc-types" } -ras-auth-core = { path = "../../core/ras-auth-core" } \ No newline at end of file +ras-auth-core = { path = "../../core/ras-auth-core" } +ras-version-core = { path = "../../core/ras-version-core" } diff --git a/crates/rpc/ras-jsonrpc-core/README.md b/crates/rpc/ras-jsonrpc-core/README.md index 3ddfb58..932e206 100644 --- a/crates/rpc/ras-jsonrpc-core/README.md +++ b/crates/rpc/ras-jsonrpc-core/README.md @@ -4,7 +4,7 @@ Core authentication and authorization traits for JSON-RPC services. ## Overview -This crate provides the foundational authentication and authorization traits used by the `ras-jsonrpc-macro` procedural macro to generate type-safe JSON-RPC services with axum integration. It defines the `AuthProvider` trait that enables flexible authentication mechanisms while maintaining a consistent interface. +This crate provides the foundational authentication, authorization, and version migration traits used by the `ras-jsonrpc-macro` procedural macro to generate type-safe JSON-RPC services with axum integration. It defines the `AuthProvider` trait that enables flexible authentication mechanisms while maintaining a consistent interface. ## Features @@ -13,6 +13,7 @@ This crate provides the foundational authentication and authorization traits use - ✅ **Flexible Auth Providers**: Support for JWT, API keys, or custom authentication - ✅ **Comprehensive Error Handling**: Detailed error types for all authentication scenarios - ✅ **Extension Traits**: Optional authentication helpers +- ✅ **Version Migration**: Re-exports `VersionMigration` for opt-in API compatibility paths - ✅ **Integration Ready**: Re-exports JSON-RPC types for convenience ## Usage @@ -21,13 +22,13 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -rust-jsonrpc-core = "0.1.0" +ras-jsonrpc-core = "0.1.2" ``` ### Implementing an Auth Provider ```rust -use rust_jsonrpc_core::{AuthProvider, AuthenticatedUser, AuthFuture, AuthError}; +use ras_jsonrpc_core::{AuthProvider, AuthenticatedUser, AuthFuture, AuthError}; use std::collections::HashSet; struct JwtAuthProvider { @@ -60,7 +61,7 @@ impl AuthProvider for JwtAuthProvider { ### Using with Permissions ```rust -use rust_jsonrpc_core::{AuthProvider, AuthProviderExt}; +use ras_jsonrpc_core::{AuthProvider, AuthProviderExt}; async fn example_usage() { let auth_provider = JwtAuthProvider::new("secret".to_string()); @@ -114,7 +115,7 @@ let user = auth_provider.authenticate_and_authorize( The crate provides comprehensive error handling: ```rust -use rust_jsonrpc_core::AuthError; +use ras_jsonrpc_core::AuthError; match auth_result { Err(AuthError::InvalidToken) => { @@ -165,12 +166,12 @@ pub struct AuthenticatedUser { - `AuthResult` - Result type for authentication operations - `AuthFuture<'a, T>` - Boxed future for async authentication -## Integration with rust-jsonrpc-macro +## Integration with ras-jsonrpc-macro -This crate is designed to work with the `rust-jsonrpc-macro` procedural macro: +This crate is designed to work with the `ras-jsonrpc-macro` procedural macro: ```rust -use rust_jsonrpc_macro::jsonrpc_service; +use ras_jsonrpc_macro::jsonrpc_service; jsonrpc_service!({ service_name: MyService, @@ -181,10 +182,69 @@ jsonrpc_service!({ ] }); -// Use with the generated builder -let service = MyServiceBuilder::new("/api") +struct MyServiceImpl; + +impl MyServiceTrait for MyServiceImpl { + async fn sign_in( + &self, + request: SignInRequest, + ) -> Result> { + // Validate credentials and issue a token. + } + + async fn get_profile( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + _request: (), + ) -> Result> { + // Load the authenticated user's profile. + } + + async fn delete_user( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + request: UserId, + ) -> Result<(), Box> { + // Only users with the admin permission can reach here. + } +} + +// Use with the generated builder. The JSON-RPC route defaults to `/rpc`. +let service = MyServiceBuilder::new(MyServiceImpl) + .base_url("/api/rpc") .auth_provider(JwtAuthProvider::new("secret")) - .build(); + .build()?; +``` + +### Version Migrations + +The macro uses `VersionMigration` for opt-in legacy compatibility. A legacy JSON-RPC method can migrate its request into the canonical request type, call the canonical service method, then migrate the canonical response back to the legacy response type. + +```rust +use ras_jsonrpc_core::VersionMigration; + +struct RenameCompat; + +impl VersionMigration for RenameCompat { + type Error = std::convert::Infallible; + + fn migrate(value: RenameUserV1) -> Result { + Ok(RenameUserV2 { + display_name: value.name, + notify: false, + }) + } +} + +impl VersionMigration for RenameCompat { + type Error = std::convert::Infallible; + + fn migrate(value: RenameUserResponseV2) -> Result { + Ok(RenameUserResponseV1 { + name: value.display_name, + }) + } +} ``` ## Example Auth Providers @@ -211,12 +271,12 @@ See the [`examples/`](../../examples/) directory for complete implementations. ## Re-exports -For convenience, this crate re-exports all types from `rust-jsonrpc-types`: +For convenience, this crate re-exports all types from `ras-jsonrpc-types`: ```rust -use rust_jsonrpc_core::{JsonRpcRequest, JsonRpcResponse, JsonRpcError}; +use ras_jsonrpc_core::{JsonRpcRequest, JsonRpcResponse, JsonRpcError}; ``` ## License -This project is licensed under the MIT License. \ No newline at end of file +This project is licensed under the MIT License. diff --git a/crates/rpc/ras-jsonrpc-core/src/lib.rs b/crates/rpc/ras-jsonrpc-core/src/lib.rs index f28568c..a13936e 100644 --- a/crates/rpc/ras-jsonrpc-core/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-core/src/lib.rs @@ -9,3 +9,6 @@ pub use ras_auth_core::*; // Re-export JSON-RPC types for convenience pub use ras_jsonrpc_types::*; + +// Re-export version migration traits for generated compatibility dispatch. +pub use ras_version_core::*; diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index cd560b2..b4f8603 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-jsonrpc-macro" -version = "0.1.2" +version = "0.2.0" edition = "2024" description = "Procedural macro for type-safe JSON-RPC interfaces with auth integration and OpenRPC document generation" license = "MIT OR Apache-2.0" diff --git a/crates/rpc/ras-jsonrpc-macro/README.md b/crates/rpc/ras-jsonrpc-macro/README.md index 6635c41..6c1edc6 100644 --- a/crates/rpc/ras-jsonrpc-macro/README.md +++ b/crates/rpc/ras-jsonrpc-macro/README.md @@ -12,7 +12,8 @@ This crate provides the `jsonrpc_service!` procedural macro that generates type- - ✅ **Authentication Integration**: Built-in support for `UNAUTHORIZED` and `WITH_PERMISSIONS` methods - ✅ **Type Safety**: Compile-time validation of request/response types - ✅ **Axum Integration**: Generates standard axum `Router` for easy composition -- ✅ **Builder Pattern**: Ergonomic service configuration using the `bon` crate +- ✅ **Trait-Based Service Wiring**: Implement one generated trait and pass it to the service builder +- ✅ **Versioned Methods**: Optional request/response migrations for legacy wire methods - ✅ **Async Support**: Full async/await support throughout - ✅ **JSON-RPC 2.0 Compliant**: Complete protocol compliance with proper error handling - ✅ **OpenRPC Document Generation**: Automatic API documentation generation @@ -23,8 +24,8 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -ras-jsonrpc-macro = "0.1.0" -ras-jsonrpc-core = "0.1.0" # For AuthProvider trait +ras-jsonrpc-macro = "0.2.0" +ras-jsonrpc-core = "0.1.2" # For AuthProvider and VersionMigration traits axum = "0.8" # For web server integration serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.0", features = ["full"] } @@ -101,33 +102,50 @@ impl AuthProvider for MyAuthProvider { ```rust use axum::{Router, routing::get}; +struct MyServiceImpl; + +impl MyServiceTrait for MyServiceImpl { + async fn sign_in( + &self, + _request: SignInRequest, + ) -> Result> { + Ok(SignInResponse { + jwt: "valid_user_token".to_string(), + user_id: "123".to_string(), + }) + } + + async fn get_profile( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + _request: (), + ) -> Result> { + Ok(UserProfile { + name: format!("User {}", user.user_id), + email: "user@example.com".to_string(), + }) + } + + async fn delete_user( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + user_id: UserId, + ) -> Result<(), Box> { + println!("Admin {} deleting user {:?}", user.user_id, user_id); + Ok(()) + } +} + #[tokio::main] async fn main() { let app = Router::new() .route("/", get(|| async { "Hello, World!" })) - .nest("/api", - MyServiceBuilder::new("/rpc") + .nest("/api", + MyServiceBuilder::new(MyServiceImpl) + .base_url("/rpc") .auth_provider(MyAuthProvider) - .sign_in_handler(|request| async move { - // Validate credentials - Ok(SignInResponse { - jwt: "valid_user_token".to_string(), - user_id: "123".to_string(), - }) - }) - .get_profile_handler(|user, _request| async move { - // User is already authenticated and authorized - Ok(UserProfile { - name: format!("User {}", user.user_id), - email: "user@example.com".to_string(), - }) - }) - .delete_user_handler(|user, user_id| async move { - // User is authenticated and has "admin" permission - println!("Admin {} deleting user {:?}", user.user_id, user_id); - Ok(()) - }) .build() + .expect("service should build") ); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); @@ -157,7 +175,7 @@ jsonrpc_service!({ UNAUTHORIZED method_name(RequestType) -> ResponseType, ``` - No authentication required -- Handler signature: `Fn(RequestType) -> Future>` +- Trait method signature: `fn method(&self, RequestType) -> impl Future> + Send` #### Permission-Based Methods ```rust @@ -165,7 +183,7 @@ WITH_PERMISSIONS(["perm1", "perm2"]) method_name(RequestType) -> ResponseType, ``` - Requires valid authentication - Checks for specified permissions -- Handler signature: `Fn(AuthenticatedUser, RequestType) -> Future>` +- Trait method signature: `fn method(&self, &AuthenticatedUser, RequestType) -> impl Future> + Send` #### Empty Permissions (Any Valid Token) ```rust @@ -173,7 +191,7 @@ WITH_PERMISSIONS([]) method_name(RequestType) -> ResponseType, ``` - Requires valid authentication - No specific permissions required -- Handler signature: `Fn(AuthenticatedUser, RequestType) -> Future>` +- Trait method signature: `fn method(&self, &AuthenticatedUser, RequestType) -> impl Future> + Send` ## Generated Code @@ -181,15 +199,19 @@ The macro generates: ### Service Builder ```rust -pub struct MyServiceBuilder { +pub trait MyServiceTrait: Send + Sync + 'static { + // One method per JSON-RPC method definition. +} + +pub struct MyServiceBuilder { // Internal fields... } -impl MyServiceBuilder { - pub fn new(base_url: impl Into) -> Self { /* ... */ } +impl MyServiceBuilder { + pub fn new(service: T) -> Self { /* ... */ } + pub fn base_url(self, base_url: impl Into) -> Self { /* ... */ } pub fn auth_provider(self, provider: T) -> Self { /* ... */ } - pub fn method_name_handler(self, handler: F) -> Self { /* ... */ } - pub fn build(self) -> axum::Router { /* ... */ } + pub fn build(self) -> Result { /* ... */ } } ``` @@ -199,6 +221,80 @@ impl MyServiceBuilder { - Permission validation - Error handling with proper JSON-RPC error codes +## Versioned Methods + +Versioning is opt-in. By default, the Rust method name is also the JSON-RPC wire method. Add a method block when you need a canonical wire name and one or more legacy compatibility methods. + +```rust +#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +struct RenameUserV1 { + name: String, +} + +#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +struct RenameUserV2 { + display_name: String, + notify: bool, +} + +#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +struct RenameUserResponseV1 { + name: String, +} + +#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +struct RenameUserResponseV2 { + display_name: String, + notified: bool, +} + +struct RenameUserCompat; + +impl ras_jsonrpc_core::VersionMigration for RenameUserCompat { + type Error = std::convert::Infallible; + + fn migrate(value: RenameUserV1) -> Result { + Ok(RenameUserV2 { + display_name: value.name, + notify: false, + }) + } +} + +impl ras_jsonrpc_core::VersionMigration + for RenameUserCompat +{ + type Error = std::convert::Infallible; + + fn migrate(value: RenameUserResponseV2) -> Result { + Ok(RenameUserResponseV1 { + name: value.display_name, + }) + } +} + +jsonrpc_service!({ + service_name: UserService, + openrpc: true, + methods: [ + UNAUTHORIZED rename_user(RenameUserV2) -> RenameUserResponseV2 { + version: v2, + wire: "rename_user.v2", + versions: [ + v1 { + wire: "rename_user.v1", + request: RenameUserV1, + response: RenameUserResponseV1, + migration: RenameUserCompat, + }, + ], + }, + ] +}); +``` + +The generated server accepts both `rename_user.v2` and `rename_user.v1`. The generated Rust client exposes `rename_user(...)` for the canonical method and `rename_user_v1(...)` for the legacy method. + ## Authentication Flow ### 1. Token Extraction @@ -279,6 +375,7 @@ The macro generates comprehensive error handling: - **Authentication Required**: Missing/invalid token (-32001) - **Insufficient Permissions**: Missing permissions (-32002) - **Internal Errors**: Handler errors (-32603) +- **Migration Errors**: Legacy request migration failures are invalid params (-32602); legacy response migration failures are internal errors (-32603) ## OpenRPC Document Generation @@ -345,6 +442,7 @@ The generated OpenRPC document includes: - **Method specifications**: Name, parameters, results - **JSON Schemas**: Complete type definitions with descriptions - **Authentication metadata**: `x-authentication` and `x-permissions` extensions for each method +- **Version metadata**: `x-ras-version`, `x-ras-canonical-version`, and `x-ras-canonical-method` extensions for versioned methods ### Example @@ -407,7 +505,6 @@ This crate works seamlessly with: - [`ras-jsonrpc-core`](../ras-jsonrpc-core) - Authentication traits and types - [`ras-jsonrpc-types`](../ras-jsonrpc-types) - JSON-RPC protocol types - [`axum`](https://crates.io/crates/axum) - Web framework -- [`bon`](https://crates.io/crates/bon) - Builder pattern generation ## Examples @@ -419,4 +516,4 @@ See the [`examples/`](../../examples/) directory for complete working examples: ## License -This project is licensed under the MIT License. \ No newline at end of file +This project is licensed under the MIT License. diff --git a/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs b/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs index ff87b8f..c7049c2 100644 --- a/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs +++ b/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs @@ -28,10 +28,22 @@ jsonrpc_service!({ ] }); +struct BenchSvcImpl; + +impl BenchSvcTrait for BenchSvcImpl { + async fn add( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + req: AddRequest, + ) -> Result> { + Ok(AddResponse { sum: req.a + req.b }) + } +} + fn build_router() -> axum::Router { - BenchSvcBuilder::new("/rpc") + BenchSvcBuilder::new(BenchSvcImpl) + .base_url("/rpc") .auth_provider(MockAuthProvider::default()) - .add_handler(|_user, req: AddRequest| async move { Ok(AddResponse { sum: req.a + req.b }) }) .build() .expect("router build") } diff --git a/crates/rpc/ras-jsonrpc-macro/examples/comprehensive_demo.rs b/crates/rpc/ras-jsonrpc-macro/examples/comprehensive_demo.rs index 36e868c..eb262ef 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/comprehensive_demo.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/comprehensive_demo.rs @@ -125,66 +125,134 @@ impl AuthProvider for DemoAuthProvider { } } +struct BasicServiceImpl; + +impl basic_service::BasicServiceTrait for BasicServiceImpl { + async fn health_check( + &self, + _request: (), + ) -> Result> { + Ok(StatusResponse { + success: true, + message: "Service is healthy".to_string(), + }) + } + + async fn get_user( + &self, + _user: &AuthenticatedUser, + request: UserRequest, + ) -> Result> { + Ok(UserResponse { + id: "user-123".to_string(), + username: request.username, + }) + } +} + +struct ApiServiceImpl; + +impl api_service::ApiServiceTrait for ApiServiceImpl { + async fn register( + &self, + request: UserRequest, + ) -> Result> { + Ok(UserResponse { + id: "new-user-456".to_string(), + username: request.username, + }) + } + + async fn authenticated_ping( + &self, + _user: &AuthenticatedUser, + _request: (), + ) -> Result> { + Ok(StatusResponse { + success: true, + message: "Pong!".to_string(), + }) + } + + async fn get_profile( + &self, + user: &AuthenticatedUser, + _request: (), + ) -> Result> { + Ok(UserResponse { + id: user.user_id.clone(), + username: "profile_user".to_string(), + }) + } + + async fn update_profile( + &self, + _user: &AuthenticatedUser, + request: UserRequest, + ) -> Result> { + Ok(UserResponse { + id: "updated-user".to_string(), + username: request.username, + }) + } + + async fn admin_action( + &self, + _user: &AuthenticatedUser, + action: AdminAction, + ) -> Result> { + Ok(StatusResponse { + success: true, + message: format!( + "Admin action {} on {} executed", + action.action, action.target + ), + }) + } +} + +struct DocumentedServiceImpl; + +impl documented_service::DocumentedServiceTrait for DocumentedServiceImpl { + async fn status( + &self, + _request: (), + ) -> Result> { + Ok(StatusResponse { + success: true, + message: "Service is operational".to_string(), + }) + } + + async fn process_request( + &self, + _user: &AuthenticatedUser, + request: UserRequest, + ) -> Result> { + Ok(UserResponse { + id: "processed-789".to_string(), + username: request.username, + }) + } +} + fn main() { println!("=== Comprehensive JSON-RPC Service Demo ===\n"); // Test basic service (no OpenRPC) println!("1. Basic Service (no OpenRPC):"); - let basic_builder = basic_service::BasicServiceBuilder::new("/basic") - .auth_provider(DemoAuthProvider) - .health_check_handler(|_| async move { - Ok(StatusResponse { - success: true, - message: "Service is healthy".to_string(), - }) - }) - .get_user_handler(|_user, request| async move { - Ok(UserResponse { - id: "user-123".to_string(), - username: request.username, - }) - }); + let basic_builder = basic_service::BasicServiceBuilder::new(BasicServiceImpl) + .base_url("/basic") + .auth_provider(DemoAuthProvider); let _basic_router = basic_builder.build().expect("Failed to build BasicService"); println!(" ✓ BasicService compiled successfully"); println!(" ✓ No OpenRPC functions generated\n"); // Test API service with default OpenRPC println!("2. API Service (OpenRPC enabled, default path):"); - let api_builder = api_service::ApiServiceBuilder::new("/api/v1") - .auth_provider(DemoAuthProvider) - .register_handler(|request| async move { - Ok(UserResponse { - id: "new-user-456".to_string(), - username: request.username, - }) - }) - .authenticated_ping_handler(|_user, _| async move { - Ok(StatusResponse { - success: true, - message: "Pong!".to_string(), - }) - }) - .get_profile_handler(|user, _| async move { - Ok(UserResponse { - id: user.user_id.clone(), - username: "profile_user".to_string(), - }) - }) - .update_profile_handler(|_user, request| async move { - Ok(UserResponse { - id: "updated-user".to_string(), - username: request.username, - }) - }) - .admin_action_handler(|_user, action| async move { - Ok(StatusResponse { - success: true, - message: format!( - "Admin action {} on {} executed", - action.action, action.target - ), - }) - }); + let api_builder = api_service::ApiServiceBuilder::new(ApiServiceImpl) + .base_url("/api/v1") + .auth_provider(DemoAuthProvider); let _api_router = api_builder.build().expect("Failed to build ApiService"); // Generate OpenRPC document @@ -207,20 +275,9 @@ fn main() { // Test documented service with custom OpenRPC path println!("3. Documented Service (OpenRPC enabled, custom path):"); - let doc_builder = documented_service::DocumentedServiceBuilder::new("/docs/api") - .auth_provider(DemoAuthProvider) - .status_handler(|_| async move { - Ok(StatusResponse { - success: true, - message: "Service is operational".to_string(), - }) - }) - .process_request_handler(|_user, request| async move { - Ok(UserResponse { - id: "processed-789".to_string(), - username: request.username, - }) - }); + let doc_builder = documented_service::DocumentedServiceBuilder::new(DocumentedServiceImpl) + .base_url("/docs/api") + .auth_provider(DemoAuthProvider); let _doc_router = doc_builder .build() .expect("Failed to build DocumentedService"); diff --git a/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs b/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs index 180cd42..4d20dcd 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs @@ -210,21 +210,10 @@ async fn main() { // Create service let auth_provider = MockAuthProvider; - // Build the service using the builder pattern - let builder = UserManagementServiceBuilder::new("/api") - .auth_provider(auth_provider) - .create_user_handler(|req| { - let service = UserManagementServiceImpl; - async move { service.create_user(req).await } - }) - .get_user_handler(|user, req| { - let service = UserManagementServiceImpl; - async move { service.get_user(&user, req).await } - }) - .search_users_handler(|user, req| { - let service = UserManagementServiceImpl; - async move { service.search_users(&user, req).await } - }); + // Build the service using the trait-backed builder + let builder = UserManagementServiceBuilder::new(UserManagementServiceImpl) + .base_url("/api") + .auth_provider(auth_provider); // Create router with explorer let app = builder.build().expect("Failed to build app"); diff --git a/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs b/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs index 4dec1af..274cf72 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs @@ -1,5 +1,5 @@ -//! Example demonstrating the handler validation feature -//! This example shows what happens when you try to build a service without configuring all handlers +//! Example demonstrating the trait-based JSON-RPC service setup. +//! All methods must be implemented by the generated trait. use ras_jsonrpc_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; use serde::{Deserialize, Serialize}; @@ -49,54 +49,43 @@ mod calculator_service { WITH_PERMISSIONS(["user"]) divide(CalculateRequest) -> CalculateResponse, ] }); -} - -fn main() { - use calculator_service::*; - println!("=== JSON-RPC Service Handler Validation Demo ===\n"); + pub struct CalculatorServiceImpl; - println!("This example demonstrates the handler validation feature."); - println!("The service builder will panic if not all handlers are configured.\n"); - - // Uncomment the following code to see the panic in action: - /* - println!("1. Attempting to build service with only 'add' and 'subtract' handlers configured..."); - - let incomplete_builder = CalculatorServiceBuilder::new("/api/calc") - .auth_provider(DemoAuthProvider) - .add_handler(|req| async move { - Ok(CalculateResponse { result: req.a + req.b }) - }) - .subtract_handler(|req| async move { - Ok(CalculateResponse { result: req.a - req.b }) - }); - // Note: multiply_handler and divide_handler are NOT configured! - - // This will panic with: "Cannot build service: the following handlers are not configured: multiply, divide" - let _router = incomplete_builder.build().expect("This should fail!"); - */ - - println!("Building service with ALL handlers configured..."); - - let complete_builder = CalculatorServiceBuilder::new("/api/calc") - .auth_provider(DemoAuthProvider) - .add_handler(|req| async move { + impl CalculatorServiceTrait for CalculatorServiceImpl { + async fn add( + &self, + req: CalculateRequest, + ) -> Result> { Ok(CalculateResponse { result: req.a + req.b, }) - }) - .subtract_handler(|req| async move { + } + + async fn subtract( + &self, + req: CalculateRequest, + ) -> Result> { Ok(CalculateResponse { result: req.a - req.b, }) - }) - .multiply_handler(|_user, req| async move { + } + + async fn multiply( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + req: CalculateRequest, + ) -> Result> { Ok(CalculateResponse { result: req.a * req.b, }) - }) - .divide_handler(|_user, req| async move { + } + + async fn divide( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + req: CalculateRequest, + ) -> Result> { if req.b == 0 { Err("Division by zero".into()) } else { @@ -104,7 +93,20 @@ fn main() { result: req.a / req.b, }) } - }); + } + } +} + +fn main() { + use calculator_service::*; + + println!("=== JSON-RPC Service Trait Demo ===\n"); + + println!("Building service from a complete trait implementation..."); + + let complete_builder = CalculatorServiceBuilder::new(CalculatorServiceImpl) + .base_url("/api/calc") + .auth_provider(DemoAuthProvider); // This should succeed let _router = complete_builder @@ -113,10 +115,7 @@ fn main() { println!("✓ Build succeeded! All handlers are configured."); println!("\nSummary:"); - println!("- The JSON-RPC service builder now validates that all handlers are configured"); - println!("- If any handler is missing, build() will panic with a helpful error message"); - println!("- This ensures that services are fully configured before deployment"); - println!("- The error message lists exactly which handlers are missing"); - - println!("\nTo see the panic behavior, uncomment the code block in the source file."); + println!("- The JSON-RPC service builder accepts a generated trait implementation"); + println!("- Missing methods are now compile-time trait implementation errors"); + println!("- The builder still configures route path, auth, and observability hooks"); } diff --git a/crates/rpc/ras-jsonrpc-macro/examples/usage.rs b/crates/rpc/ras-jsonrpc-macro/examples/usage.rs index 8e61741..eebc0da 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/usage.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/usage.rs @@ -42,27 +42,46 @@ jsonrpc_service!({ ] }); +struct MyServiceImpl; + +impl MyServiceTrait for MyServiceImpl { + async fn sign_in( + &self, + request: SignInRequest, + ) -> Result> { + println!("Handling sign_in: {:?}", request); + Ok(SignInResponse { + jwt: "generated-jwt-token".to_string(), + user_id: "user-123".to_string(), + }) + } + + async fn sign_out( + &self, + user: &AuthenticatedUser, + _request: (), + ) -> Result<(), Box> { + println!("User {} signing out", user.user_id); + Ok(()) + } + + async fn delete_everything( + &self, + user: &AuthenticatedUser, + _request: (), + ) -> Result<(), Box> { + println!("User {} deleting everything (admin action)", user.user_id); + Ok(()) + } +} + #[tokio::main] async fn main() { println!("Building JSON-RPC service with the generated macro..."); - let _router = MyServiceBuilder::new("/api/v1") + let _router = MyServiceBuilder::new(MyServiceImpl) + .base_url("/api/v1") .auth_provider(MyAuthProvider) - .sign_in_handler(|request| async move { - println!("Handling sign_in: {:?}", request); - Ok(SignInResponse { - jwt: "generated-jwt-token".to_string(), - user_id: "user-123".to_string(), - }) - }) - .sign_out_handler(|user, _request| async move { - println!("User {} signing out", user.user_id); - Ok(()) - }) - .delete_everything_handler(|user, _request| async move { - println!("User {} deleting everything (admin action)", user.user_id); - Ok(()) - }) .build() .expect("Failed to build router"); diff --git a/crates/rpc/ras-jsonrpc-macro/src/client.rs b/crates/rpc/ras-jsonrpc-macro/src/client.rs index aafb57d..8ee8327 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/client.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/client.rs @@ -8,12 +8,15 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok let client_builder_name = quote::format_ident!("{}ClientBuilder", service_name); // Generate client methods - let client_methods = service_def.methods.iter().map(generate_client_method); + let client_methods = service_def + .methods + .iter() + .flat_map(generate_client_methods_for_method); let client_methods_with_timeout = service_def .methods .iter() - .map(generate_client_method_with_timeout); + .flat_map(generate_client_methods_with_timeout_for_method); let output = quote! { /// Generated client for the JSON-RPC service @@ -143,13 +146,65 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok output } -/// Generate a client method for the JSON-RPC service -fn generate_client_method(method: &MethodDefinition) -> proc_macro2::TokenStream { - let method_name = &method.name; - let method_str = method_name.to_string(); - let request_type = &method.request_type; - let response_type = &method.response_type; +fn method_wire_name(method: &MethodDefinition) -> String { + method + .wire_name + .clone() + .unwrap_or_else(|| method.name.to_string()) +} +/// Generate client methods for the JSON-RPC service. +fn generate_client_methods_for_method(method: &MethodDefinition) -> Vec { + let mut methods = vec![generate_client_method( + &method.name, + method_wire_name(method), + &method.request_type, + &method.response_type, + )]; + + methods.extend(method.versions.iter().map(|version| { + let method_name = quote::format_ident!("{}_{}", method.name, version.version); + generate_client_method( + &method_name, + version.wire_name.clone(), + &version.request_type, + &version.response_type, + ) + })); + + methods +} + +fn generate_client_methods_with_timeout_for_method( + method: &MethodDefinition, +) -> Vec { + let mut methods = vec![generate_client_method_with_timeout( + &method.name, + method_wire_name(method), + &method.request_type, + &method.response_type, + )]; + + methods.extend(method.versions.iter().map(|version| { + let method_name = quote::format_ident!("{}_{}", method.name, version.version); + generate_client_method_with_timeout( + &method_name, + version.wire_name.clone(), + &version.request_type, + &version.response_type, + ) + })); + + methods +} + +/// Generate a client method for the JSON-RPC service +fn generate_client_method( + method_name: &syn::Ident, + method_str: String, + request_type: &syn::Type, + response_type: &syn::Type, +) -> proc_macro2::TokenStream { quote! { /// Call the #method_name method pub async fn #method_name(&self, params: #request_type) -> Result<#response_type, Box> { @@ -159,12 +214,13 @@ fn generate_client_method(method: &MethodDefinition) -> proc_macro2::TokenStream } /// Generate a client method with timeout for the JSON-RPC service -fn generate_client_method_with_timeout(method: &MethodDefinition) -> proc_macro2::TokenStream { - let method_name = &method.name; +fn generate_client_method_with_timeout( + method_name: &syn::Ident, + method_str: String, + request_type: &syn::Type, + response_type: &syn::Type, +) -> proc_macro2::TokenStream { let method_name_with_timeout = quote::format_ident!("{}_with_timeout", method_name); - let method_str = method_name.to_string(); - let request_type = &method.request_type; - let response_type = &method.response_type; quote! { /// Call the #method_name method with a custom timeout diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index c5faed9..7cc27d1 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -49,6 +49,18 @@ struct MethodDefinition { 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)] @@ -82,6 +94,14 @@ enum AuthRequirement { 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, @@ -282,12 +302,118 @@ impl Parse for MethodDefinition { 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") + })?, }) } } @@ -406,311 +532,60 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt match &method.auth { AuthRequirement::Unauthorized => { quote! { - async fn #method_name(&self, request: #request_type) -> Result<#response_type, Box>; + fn #method_name(&self, request: #request_type) -> impl std::future::Future>> + Send; } } AuthRequirement::WithPermissions(_) => { quote! { - async fn #method_name(&self, user: &ras_jsonrpc_core::AuthenticatedUser, request: #request_type) -> Result<#response_type, Box>; + fn #method_name(&self, user: &ras_jsonrpc_core::AuthenticatedUser, request: #request_type) -> impl std::future::Future>> + Send; } } } }); - // Generate builder struct and implementation - let builder_fields = service_def.methods.iter().map(|method| { - let method_name = &method.name; - let field_name = quote::format_ident!("{}_handler", method_name); - let request_type = &method.request_type; - let response_type = &method.response_type; - - match &method.auth { - AuthRequirement::Unauthorized => { - quote! { - #field_name: Option std::pin::Pin>> + Send>> + Send + Sync>>, - } - } - AuthRequirement::WithPermissions(_) => { - quote! { - #field_name: Option std::pin::Pin>> + Send>> + Send + Sync>>, - } - } - } - }); - - let builder_setters = service_def.methods.iter().map(|method| { - let method_name = &method.name; - let setter_name = quote::format_ident!("{}_handler", method_name); - let field_name = quote::format_ident!("{}_handler", method_name); - let request_type = &method.request_type; - let response_type = &method.response_type; - - match &method.auth { - AuthRequirement::Unauthorized => { - quote! { - pub fn #setter_name(mut self, handler: F) -> Self - where - F: Fn(#request_type) -> Fut + Send + Sync + 'static, - Fut: std::future::Future>> + Send + 'static, - { - self.#field_name = Some(Box::new(move |req| Box::pin(handler(req)))); - self - } - } - } - AuthRequirement::WithPermissions(_) => { - quote! { - pub fn #setter_name(mut self, handler: F) -> Self - where - F: Fn(ras_jsonrpc_core::AuthenticatedUser, #request_type) -> Fut + Send + Sync + 'static, - Fut: std::future::Future>> + Send + 'static, - { - self.#field_name = Some(Box::new(move |user, req| Box::pin(handler(user, req)))); - self - } - } - } - } - }); - - // Generate field initializations for the constructor - let field_inits = service_def.methods.iter().map(|method| { - let field_name = quote::format_ident!("{}_handler", method.name); - quote! { #field_name: None } - }); - - // Generate handler validation checks for the build method - let handler_validations = service_def.methods.iter().map(|method| { - let method_name = &method.name; - let field_name = quote::format_ident!("{}_handler", method_name); - let method_str = method_name.to_string(); - - quote! { - if self.#field_name.is_none() { - missing_handlers.push(#method_str); - } - } - }); - // Generate method dispatch logic for the JSON-RPC handler - let method_dispatch = service_def.methods.iter().map(|method| { - let method_name = &method.name; - let method_str = method_name.to_string(); - let field_name = quote::format_ident!("{}_handler", method_name); - let request_type = &method.request_type; - let permission_groups = match &method.auth { - AuthRequirement::Unauthorized => Vec::new(), - AuthRequirement::WithPermissions(groups) => groups.clone(), - }; - - match &method.auth { - AuthRequirement::Unauthorized => { - quote! { - #method_str => { - if let Some(handler) = &self.#field_name { - // Parse parameters - let params: #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() - ), - } - }; - - // Call handler with duration tracking - let start_time = std::time::Instant::now(); - let handler_result = handler(params).await; - let duration = start_time.elapsed(); - - // Track method duration if configured - if let Some(duration_tracker) = &self.method_duration_tracker { - duration_tracker(#method_str, None, 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() - ), - } - } else { - ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::method_not_found(&#method_str), - request.id.clone() - ) - } - } - } - } - AuthRequirement::WithPermissions(_) => { - // Generate permission groups code for quote - let permission_groups_code = 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> } - }; - - quote! { - #method_str => { - if let Some(handler) = &self.#field_name { - // Check if user is authenticated - let user = match &authenticated_user { - Some(u) => u, - None => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::authentication_required(), - request.id.clone() - ), - }; - - // Check permissions - AND within groups, OR between groups - let required_permission_groups: Vec> = #permission_groups_code; - // Only check permissions if we have non-empty groups - let has_non_empty_groups = required_permission_groups.iter().any(|g| !g.is_empty()); - if has_non_empty_groups { - let mut has_permission = false; - - // Check each permission group (OR logic between groups) - for permission_group in &required_permission_groups { - // Check if user has ALL permissions in this group (AND logic within group) - if permission_group.is_empty() { - // Empty group means any authenticated user can access - has_permission = true; - break; - } else { - // Check if user has all permissions in this group - let group_result = self.auth_provider - .as_ref() - .unwrap() - .check_permissions(user, permission_group); - if group_result.is_ok() { - has_permission = true; - break; - } - } - } - - if !has_permission { - // Find the first non-empty group for error reporting - let first_group = required_permission_groups.iter() - .find(|g| !g.is_empty()) - .cloned() - .unwrap_or_default(); - return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::insufficient_permissions( - first_group, - user.permissions.iter().cloned().collect() - ), - request.id.clone() - ); - } - } - - // Parse parameters - let params: #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() - ), - } - }; - - // Call handler with duration tracking - let start_time = std::time::Instant::now(); - let handler_result = handler(user.clone(), params).await; - let duration = start_time.elapsed(); - - // Track method duration if configured - if let Some(duration_tracker) = &self.method_duration_tracker { - duration_tracker(#method_str, Some(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() - ), - } - } else { - ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::method_not_found(&#method_str), - request.id.clone() - ) - } - } - } - } - } - }); + let method_dispatch = service_def + .methods + .iter() + .flat_map(generate_jsonrpc_method_dispatches); quote! { /// Generated service trait - pub trait #service_trait_name { + pub trait #service_trait_name: Send + Sync + 'static { #(#trait_methods)* } /// Generated builder for the JSON-RPC service - pub struct #builder_name { + pub struct #builder_name { base_url: String, + service: std::sync::Arc, auth_provider: Option>, 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>>, - #(#builder_fields)* } - impl #builder_name { - /// Create a new builder with the base URL - pub fn new(base_url: impl Into) -> Self { + 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: base_url.into(), + base_url: "/rpc".to_string(), + service: std::sync::Arc::new(service), auth_provider: None, usage_tracker: None, method_duration_tracker: None, - #(#field_inits,)* } } + /// 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: T) -> Self { + pub fn auth_provider(mut self, provider: A) -> Self { self.auth_provider = Some(Box::new(provider)); self } @@ -741,21 +616,8 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt self } - #(#builder_setters)* - /// Build the axum router for the JSON-RPC service pub fn build(self) -> Result { - // Validate that all handlers are configured - let mut missing_handlers = Vec::new(); - #(#handler_validations)* - - if !missing_handlers.is_empty() { - return Err(format!( - "Cannot build service: the following handlers are not configured: {}", - missing_handlers.join(", ") - )); - } - let base_url = self.base_url.clone(); let service = std::sync::Arc::new(self); @@ -854,3 +716,241 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt } } } + +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 => 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::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() + ), + }; + + let required_permission_groups: Vec> = #permission_groups_code; + let has_non_empty_groups = required_permission_groups.iter().any(|g| !g.is_empty()); + if has_non_empty_groups { + let mut has_permission = false; + + for permission_group in &required_permission_groups { + if permission_group.is_empty() { + has_permission = true; + break; + } else { + let group_result = self.auth_provider + .as_ref() + .unwrap() + .check_permissions(user, permission_group); + if group_result.is_ok() { + has_permission = true; + break; + } + } + } + + if !has_permission { + let first_group = required_permission_groups.iter() + .find(|g| !g.is_empty()) + .cloned() + .unwrap_or_default(); + return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::insufficient_permissions( + first_group, + user.permissions.iter().cloned().collect() + ), + 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::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::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 index 815df2d..4254daa 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs @@ -64,6 +64,16 @@ pub fn generate_openrpc_code( 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)); + } } // Generate schema generation functions @@ -143,8 +153,16 @@ pub fn generate_openrpc_code( let method_infos: Vec = service_def .methods .iter() - .map(|method| { - let method_name = method.name.to_string(); + .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(_)); // Flatten permission groups for OpenRPC documentation let permissions = match &method.auth { @@ -169,17 +187,51 @@ pub fn generate_openrpc_code( None => (quote! { None }, quote! { None }), }; - quote! { + let mut infos = vec![quote! { #method_info_struct_name { - name: #method_name.to_string(), + name: #canonical_method_name.to_string(), summary: #summary, description: #description, auth_required: #auth_required, permissions: vec![#(#permissions.to_string()),*], 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 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, + permissions: vec![#(#permissions.to_string()),*], + 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(); @@ -193,6 +245,9 @@ pub fn generate_openrpc_code( permissions: 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 @@ -367,6 +422,15 @@ pub fn generate_openrpc_code( } } + 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)); + } + // Generate example pairing for the method let mut examples = vec![]; if method.request_type_name != "()" { @@ -515,6 +579,14 @@ pub fn generate_schema_impl_checks(service_def: &ServiceDefinition) -> TokenStre 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 diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs index 8652a56..2f0ae22 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs @@ -29,32 +29,122 @@ struct AddResponse { sum: i64, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct RenameUserV1 { + name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct RenameUserV2 { + display_name: String, + notify: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct RenameUserResponseV1 { + name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct RenameUserResponseV2 { + display_name: String, + notified: bool, +} + +struct RenameUserCompat; + +impl ras_jsonrpc_core::VersionMigration for RenameUserCompat { + type Error = std::convert::Infallible; + + fn migrate(value: RenameUserV1) -> Result { + Ok(RenameUserV2 { + display_name: value.name, + notify: false, + }) + } +} + +impl ras_jsonrpc_core::VersionMigration + for RenameUserCompat +{ + type Error = std::convert::Infallible; + + fn migrate(value: RenameUserResponseV2) -> Result { + Ok(RenameUserResponseV1 { + name: value.display_name, + }) + } +} + jsonrpc_service!({ service_name: Demo, openrpc: false, methods: [ UNAUTHORIZED ping(EchoRequest) -> EchoResponse, + UNAUTHORIZED rename_user(RenameUserV2) -> RenameUserResponseV2 { + version: v2, + wire: "rename_user.v2", + versions: [ + v1 { + wire: "rename_user.v1", + request: RenameUserV1, + response: RenameUserResponseV1, + migration: RenameUserCompat, + }, + ], + }, WITH_PERMISSIONS(["user"]) add(AddRequest) -> AddResponse, WITH_PERMISSIONS(["admin"]) admin_only(EchoRequest) -> EchoResponse, ] }); -fn router() -> axum::Router { - DemoBuilder::new("/rpc") - .auth_provider(MockAuthProvider::default()) - .ping_handler(|req: EchoRequest| async move { - Ok(EchoResponse { - msg: req.msg, - user_id: None, - }) +struct DemoImpl; + +impl DemoTrait for DemoImpl { + async fn ping( + &self, + req: EchoRequest, + ) -> Result> { + Ok(EchoResponse { + msg: req.msg, + user_id: None, + }) + } + + async fn add( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + req: AddRequest, + ) -> Result> { + Ok(AddResponse { sum: req.a + req.b }) + } + + async fn rename_user( + &self, + req: RenameUserV2, + ) -> Result> { + Ok(RenameUserResponseV2 { + display_name: req.display_name, + notified: req.notify, }) - .add_handler(|_user, req: AddRequest| async move { Ok(AddResponse { sum: req.a + req.b }) }) - .admin_only_handler(|user, req: EchoRequest| async move { - Ok(EchoResponse { - msg: req.msg, - user_id: Some(user.user_id), - }) + } + + async fn admin_only( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + req: EchoRequest, + ) -> Result> { + Ok(EchoResponse { + msg: req.msg, + user_id: Some(user.user_id.clone()), }) + } +} + +fn router() -> axum::Router { + DemoBuilder::new(DemoImpl) + .base_url("/rpc") + .auth_provider(MockAuthProvider::default()) .build() .expect("build router") } @@ -66,6 +156,48 @@ fn client(url: String) -> DemoClient { .expect("client build") } +#[tokio::test] +async fn legacy_version_round_trips_through_canonical_handler() { + let server = spawn_http(router()); + let url = server.server_url("/rpc").expect("server url").to_string(); + + let resp = client(url) + .rename_user_v1(RenameUserV1 { + name: "Ada".to_string(), + }) + .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 = spawn_http(router()); + let url = server.server_url("/rpc").expect("server url").to_string(); + + let resp = client(url) + .rename_user(RenameUserV2 { + display_name: "Grace".to_string(), + notify: true, + }) + .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 = spawn_http(router()); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs index 31f6bf3..6aedeb7 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs @@ -50,18 +50,8 @@ mod tests { } async fn setup_test_server() -> (SocketAddr, Router) { - let service = std::sync::Arc::new(TestServiceImpl); - let service_clone = service.clone(); - - let router = TestServiceBuilder::new("/api/rpc") - .test_internal_error_handler(move |req| { - let service = service.clone(); - async move { service.test_internal_error(req).await } - }) - .test_auth_error_handler(move |user, req| { - let service = service_clone.clone(); - async move { service.test_auth_error(&user, req).await } - }) + let router = TestServiceBuilder::new(TestServiceImpl) + .base_url("/api/rpc") .build() .expect("Failed to build router"); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs index 3d7b370..4af21c9 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs @@ -127,6 +127,131 @@ jsonrpc_service!({ ] }); +struct TestServiceImpl; + +impl TestServiceTrait for TestServiceImpl { + async fn sign_in( + &self, + request: SignInRequest, + ) -> Result> { + if request.email == "admin@test.com" && request.password == "admin123" { + Ok(SignInResponse { + jwt: "valid-admin-token".to_string(), + user_id: "admin-user".to_string(), + }) + } else if request.email == "user@test.com" && request.password == "user123" { + Ok(SignInResponse { + jwt: "valid-user-token".to_string(), + user_id: "regular-user".to_string(), + }) + } else { + Err("Invalid credentials".into()) + } + } + + async fn get_public_info( + &self, + _request: (), + ) -> Result> { + Ok("This is public information".to_string()) + } + + async fn echo_complex( + &self, + request: ComplexRequest, + ) -> Result> { + Ok(request) + } + + async fn sign_out( + &self, + _user: &AuthenticatedUser, + _request: (), + ) -> Result<(), Box> { + Ok(()) + } + + async fn get_user_info( + &self, + user: &AuthenticatedUser, + _request: (), + ) -> Result> { + Ok(User { + id: Some(123), + name: format!("User {}", user.user_id), + email: format!("{}@test.com", user.user_id), + permissions: user.permissions.iter().cloned().collect(), + }) + } + + async fn process_data( + &self, + _user: &AuthenticatedUser, + data: Vec, + ) -> Result> { + Ok(ProcessingResult { + processed_count: data.len(), + errors: vec![], + success: true, + }) + } + + async fn delete_everything( + &self, + _user: &AuthenticatedUser, + _request: (), + ) -> Result<(), Box> { + Ok(()) + } + + async fn create_user( + &self, + _user: &AuthenticatedUser, + request: CreateUserRequest, + ) -> Result> { + Ok(User { + id: Some(rand::thread_rng().gen_range(1000..9999)), + name: request.name, + email: request.email, + permissions: request.permissions, + }) + } + + async fn moderate_content( + &self, + _user: &AuthenticatedUser, + content: String, + ) -> Result> { + Ok(!content.contains("spam")) + } + + async fn update_profile( + &self, + _user: &AuthenticatedUser, + mut user: User, + ) -> Result> { + user.id = Some(456); + Ok(user) + } + + async fn get_user_data( + &self, + _user: &AuthenticatedUser, + user_id: i32, + ) -> Result, Box> { + if user_id == 123 { + Ok(Some(User { + id: Some(user_id), + name: "Found User".to_string(), + email: "found@test.com".to_string(), + permissions: vec!["user".to_string()], + })) + } else { + Ok(None) + } + } +} + async fn create_test_server() -> (String, tokio::task::JoinHandle<()>) { let tokio_listener = TokioTcpListener::bind("127.0.0.1:0") .await @@ -136,72 +261,9 @@ async fn create_test_server() -> (String, tokio::task::JoinHandle<()>) { .expect("Failed to get local addr"); let base_url = format!("http://127.0.0.1:{}", addr.port()); - let builder = TestServiceBuilder::new("/rpc") - .auth_provider(TestAuthProvider::new()) - // UNAUTHORIZED handlers - .sign_in_handler(|request| async move { - // Simulate authentication logic - if request.email == "admin@test.com" && request.password == "admin123" { - Ok(SignInResponse { - jwt: "valid-admin-token".to_string(), - user_id: "admin-user".to_string(), - }) - } else if request.email == "user@test.com" && request.password == "user123" { - Ok(SignInResponse { - jwt: "valid-user-token".to_string(), - user_id: "regular-user".to_string(), - }) - } else { - Err("Invalid credentials".into()) - } - }) - .get_public_info_handler(|_| async move { Ok("This is public information".to_string()) }) - .echo_complex_handler(|request| async move { Ok(request) }) - // WITH_PERMISSIONS([]) handlers - .sign_out_handler(|_user, _| async move { Ok(()) }) - .get_user_info_handler(|user, _| async move { - Ok(User { - id: Some(123), - name: format!("User {}", user.user_id), - email: format!("{}@test.com", user.user_id), - permissions: user.permissions.into_iter().collect(), - }) - }) - .process_data_handler(|_user, data| async move { - Ok(ProcessingResult { - processed_count: data.len(), - errors: vec![], - success: true, - }) - }) - // WITH_PERMISSIONS(["admin"]) handlers - .delete_everything_handler(|_user, _| async move { Ok(()) }) - .create_user_handler(|_user, request| async move { - Ok(User { - id: Some(rand::thread_rng().gen_range(1000..9999)), - name: request.name, - email: request.email, - permissions: request.permissions, - }) - }) - .moderate_content_handler(|_user, content| async move { Ok(!content.contains("spam")) }) - // WITH_PERMISSIONS(["user"]) handlers - .update_profile_handler(|_user, mut user| async move { - user.id = Some(456); - Ok(user) - }) - .get_user_data_handler(|_user, user_id| async move { - if user_id == 123 { - Ok(Some(User { - id: Some(user_id), - name: "Found User".to_string(), - email: "found@test.com".to_string(), - permissions: vec!["user".to_string()], - })) - } else { - Ok(None) - } - }); + let builder = TestServiceBuilder::new(TestServiceImpl) + .base_url("/rpc") + .auth_provider(TestAuthProvider::new()); let app = builder.build().expect("Failed to build app"); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs index 723479f..e1e6358 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs @@ -119,19 +119,17 @@ async fn make_jsonrpc_request( app.oneshot(request).await.unwrap() } -#[tokio::test] -async fn test_authentication_required_returns_401() { - let app = TestServiceBuilder::new("/rpc") +fn test_app() -> Router { + TestServiceBuilder::new(TestServiceImpl) + .base_url("/rpc") .auth_provider(MockAuthProvider) - .public_method_handler(|req| async move { TestServiceImpl.public_method(req).await }) - .user_method_handler( - |user, req| async move { TestServiceImpl.user_method(&user, req).await }, - ) - .admin_method_handler( - |user, req| async move { TestServiceImpl.admin_method(&user, req).await }, - ) .build() - .expect("Failed to build router"); + .expect("Failed to build router") +} + +#[tokio::test] +async fn test_authentication_required_returns_401() { + let app = test_app(); // Test: No auth header for protected method should return 401 let response = make_jsonrpc_request( @@ -155,17 +153,7 @@ async fn test_authentication_required_returns_401() { #[tokio::test] async fn test_insufficient_permissions_returns_403() { - let app = TestServiceBuilder::new("/rpc") - .auth_provider(MockAuthProvider) - .public_method_handler(|req| async move { TestServiceImpl.public_method(req).await }) - .user_method_handler( - |user, req| async move { TestServiceImpl.user_method(&user, req).await }, - ) - .admin_method_handler( - |user, req| async move { TestServiceImpl.admin_method(&user, req).await }, - ) - .build() - .expect("Failed to build router"); + let app = test_app(); // Test: User token trying to access admin method should return 403 let response = make_jsonrpc_request( @@ -189,17 +177,7 @@ async fn test_insufficient_permissions_returns_403() { #[tokio::test] async fn test_invalid_token_returns_401() { - let app = TestServiceBuilder::new("/rpc") - .auth_provider(MockAuthProvider) - .public_method_handler(|req| async move { TestServiceImpl.public_method(req).await }) - .user_method_handler( - |user, req| async move { TestServiceImpl.user_method(&user, req).await }, - ) - .admin_method_handler( - |user, req| async move { TestServiceImpl.admin_method(&user, req).await }, - ) - .build() - .expect("Failed to build router"); + let app = test_app(); // Test: Invalid token should return 401 let response = make_jsonrpc_request( @@ -223,17 +201,7 @@ async fn test_invalid_token_returns_401() { #[tokio::test] async fn test_successful_auth_returns_200() { - let app = TestServiceBuilder::new("/rpc") - .auth_provider(MockAuthProvider) - .public_method_handler(|req| async move { TestServiceImpl.public_method(req).await }) - .user_method_handler( - |user, req| async move { TestServiceImpl.user_method(&user, req).await }, - ) - .admin_method_handler( - |user, req| async move { TestServiceImpl.admin_method(&user, req).await }, - ) - .build() - .expect("Failed to build router"); + let app = test_app(); // Test: Valid user token accessing user method should return 200 let response = make_jsonrpc_request( @@ -257,17 +225,7 @@ async fn test_successful_auth_returns_200() { #[tokio::test] async fn test_token_expired_returns_401() { - let app = TestServiceBuilder::new("/rpc") - .auth_provider(MockAuthProvider) - .public_method_handler(|req| async move { TestServiceImpl.public_method(req).await }) - .user_method_handler( - |user, req| async move { TestServiceImpl.user_method(&user, req).await }, - ) - .admin_method_handler( - |user, req| async move { TestServiceImpl.admin_method(&user, req).await }, - ) - .build() - .expect("Failed to build router"); + let app = test_app(); // Test: Expired token should return 401 let response = make_jsonrpc_request( diff --git a/crates/rpc/ras-jsonrpc-macro/tests/integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/integration.rs index 50f388d..57fdc6f 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/integration.rs @@ -60,35 +60,54 @@ mod basic_service { WITH_PERMISSIONS([]) get_profile(()) -> User, ] }); -} -#[tokio::test] -async fn test_macro_generates_code() { - use basic_service::*; + pub struct MyServiceImpl; - // Create a service builder - let builder = MyServiceBuilder::new("/api/v1") - .auth_provider(TestAuthProvider) - .sign_in_handler(|_request| async move { + impl MyServiceTrait for MyServiceImpl { + async fn sign_in( + &self, + _request: SignInRequest, + ) -> Result> { Ok(SignInResponse { jwt: "test-jwt".to_string(), user_id: "123".to_string(), }) - }) - .create_user_handler(|_user, request| async move { + } + + async fn create_user( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + request: CreateUserRequest, + ) -> Result> { Ok(User { id: "new-id".to_string(), name: request.name, role: request.role, }) - }) - .get_profile_handler(|user, _request| async move { + } + + async fn get_profile( + &self, + user: &ras_jsonrpc_core::AuthenticatedUser, + _request: (), + ) -> Result> { Ok(User { id: user.user_id.clone(), name: "Test User".to_string(), role: "user".to_string(), }) - }); + } + } +} + +#[tokio::test] +async fn test_macro_generates_code() { + use basic_service::*; + + // Create a service builder + let builder = MyServiceBuilder::new(MyServiceImpl) + .base_url("/api/v1") + .auth_provider(TestAuthProvider); // Build the router (this ensures all generated code compiles) let _router = builder.build().expect("Failed to build router"); @@ -114,6 +133,40 @@ mod openrpc_service { WITH_PERMISSIONS([]) sign_out(()) -> (), ] }); + + pub struct OpenRpcServiceImpl; + + impl OpenRpcServiceTrait for OpenRpcServiceImpl { + async fn sign_in( + &self, + _request: SignInRequest, + ) -> Result> { + Ok(SignInResponse { + jwt: "test-jwt".to_string(), + user_id: "123".to_string(), + }) + } + + async fn create_user( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + request: CreateUserRequest, + ) -> Result> { + Ok(User { + id: "new-id".to_string(), + name: request.name, + role: request.role, + }) + } + + async fn sign_out( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + _request: (), + ) -> Result<(), Box> { + Ok(()) + } + } } // Generate a service with custom OpenRPC output path @@ -129,6 +182,28 @@ mod custom_path_service { WITH_PERMISSIONS(["admin"]) delete_everything(()) -> (), ] }); + + pub struct CustomPathServiceImpl; + + impl CustomPathServiceTrait for CustomPathServiceImpl { + async fn sign_in( + &self, + _request: SignInRequest, + ) -> Result> { + Ok(SignInResponse { + jwt: "test-jwt".to_string(), + user_id: "123".to_string(), + }) + } + + async fn delete_everything( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + _request: (), + ) -> Result<(), Box> { + Ok(()) + } + } } #[tokio::test] @@ -136,22 +211,9 @@ async fn test_openrpc_generation() { use openrpc_service::*; // Create a service builder with OpenRPC enabled - let builder = OpenRpcServiceBuilder::new("/api/v1") - .auth_provider(TestAuthProvider) - .sign_in_handler(|_request| async move { - Ok(SignInResponse { - jwt: "test-jwt".to_string(), - user_id: "123".to_string(), - }) - }) - .create_user_handler(|_user, request| async move { - Ok(User { - id: "new-id".to_string(), - name: request.name, - role: request.role, - }) - }) - .sign_out_handler(|_user, _request| async move { Ok(()) }); + let builder = OpenRpcServiceBuilder::new(OpenRpcServiceImpl) + .base_url("/api/v1") + .auth_provider(TestAuthProvider); // Build the router let _router = builder.build().expect("Failed to build router"); @@ -200,15 +262,9 @@ async fn test_custom_openrpc_path() { use custom_path_service::*; // Create a service builder - let builder = CustomPathServiceBuilder::new("/api/v2") - .auth_provider(TestAuthProvider) - .sign_in_handler(|_request| async move { - Ok(SignInResponse { - jwt: "test-jwt".to_string(), - user_id: "123".to_string(), - }) - }) - .delete_everything_handler(|_user, _request| async move { Ok(()) }); + let builder = CustomPathServiceBuilder::new(CustomPathServiceImpl) + .base_url("/api/v2") + .auth_provider(TestAuthProvider); // Build the router let _router = builder.build().expect("Failed to build router"); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/missing_handler_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/missing_handler_test.rs index 8873245..0b02f9c 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/missing_handler_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/missing_handler_test.rs @@ -44,71 +44,48 @@ mod test_service { WITH_PERMISSIONS([]) method_three(TestRequest) -> TestResponse, ] }); -} -#[test] -fn test_error_on_missing_handlers() { - use test_service::*; + pub struct TestServiceImpl; - // Create a service builder with only one handler configured - let builder = TestServiceBuilder::new("/api") - .auth_provider(TestAuthProvider) - .method_one_handler(|_request| async move { + impl TestServiceTrait for TestServiceImpl { + async fn method_one( + &self, + _request: TestRequest, + ) -> Result> { Ok(TestResponse { result: "one".to_string(), }) - }); - // Note: method_two_handler and method_three_handler are NOT configured - - // This should return an error with missing handlers - let result = builder.build(); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - "Cannot build service: the following handlers are not configured: method_two, method_three" - ); -} - -#[test] -fn test_error_on_all_handlers_missing() { - use test_service::*; + } - // Create a service builder with no handlers configured - let builder = TestServiceBuilder::new("/api").auth_provider(TestAuthProvider); - - // This should return an error with all handlers missing - let result = builder.build(); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - "Cannot build service: the following handlers are not configured: method_one, method_two, method_three" - ); -} - -#[test] -fn test_success_with_all_handlers_configured() { - use test_service::*; - - // Create a service builder with all handlers configured - let builder = TestServiceBuilder::new("/api") - .auth_provider(TestAuthProvider) - .method_one_handler(|_request| async move { - Ok(TestResponse { - result: "one".to_string(), - }) - }) - .method_two_handler(|_user, _request| async move { + async fn method_two( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + _request: TestRequest, + ) -> Result> { Ok(TestResponse { result: "two".to_string(), }) - }) - .method_three_handler(|_user, _request| async move { + } + + async fn method_three( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + _request: TestRequest, + ) -> Result> { Ok(TestResponse { result: "three".to_string(), }) - }); + } + } +} + +#[test] +fn test_trait_based_builder_builds_when_trait_is_complete() { + use test_service::*; - // This should succeed + let builder = TestServiceBuilder::new(TestServiceImpl) + .base_url("/api") + .auth_provider(TestAuthProvider); let result = builder.build(); assert!(result.is_ok()); } diff --git a/crates/rpc/ras-jsonrpc-types/README.md b/crates/rpc/ras-jsonrpc-types/README.md index d45e042..f40f095 100644 --- a/crates/rpc/ras-jsonrpc-types/README.md +++ b/crates/rpc/ras-jsonrpc-types/README.md @@ -20,13 +20,13 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -rust-jsonrpc-types = "0.1.0" +ras-jsonrpc-types = "0.1.0" ``` ### Basic Types ```rust -use rust_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse, JsonRpcError}; +use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse, JsonRpcError}; // Create a request let request = JsonRpcRequest::new( @@ -51,7 +51,7 @@ let error_response = JsonRpcResponse::error( ### Error Handling ```rust -use rust_jsonrpc_types::{JsonRpcError, error_codes}; +use ras_jsonrpc_types::{JsonRpcError, error_codes}; // Standard JSON-RPC errors let parse_error = JsonRpcError::parse_error(); @@ -123,10 +123,10 @@ The crate provides all standard JSON-RPC 2.0 error codes plus extension codes fo This crate is designed to work seamlessly with: -- [`rust-jsonrpc-core`](../rust-jsonrpc-core) - Authentication and authorization traits -- [`rust-jsonrpc-macro`](../rust-jsonrpc-macro) - Procedural macros for service generation +- [`ras-jsonrpc-core`](../ras-jsonrpc-core) - Authentication and authorization traits +- [`ras-jsonrpc-macro`](../ras-jsonrpc-macro) - Procedural macros for service generation - Any JSON-RPC client or server implementation ## License -This project is licensed under the MIT License. \ No newline at end of file +This project is licensed under the MIT License. diff --git a/documentation/ras-identity.md b/documentation/ras-identity.md index c84bde9..907e12a 100644 --- a/documentation/ras-identity.md +++ b/documentation/ras-identity.md @@ -264,8 +264,8 @@ jsonrpc_service!({ // Public method UNAUTHORIZED get_status(()) -> Status, - // Requires authentication - AUTHENTICATED get_profile(()) -> UserProfile, + // Requires authentication but no specific permission + WITH_PERMISSIONS([]) get_profile(()) -> UserProfile, // Requires specific permissions WITH_PERMISSIONS(["admin"]) delete_user(DeleteUserRequest) -> (), @@ -275,21 +275,20 @@ jsonrpc_service!({ // Implement service struct MyApiServiceImpl; -#[async_trait] -impl MyApiService for MyApiServiceImpl { +impl MyApiServiceTrait for MyApiServiceImpl { async fn get_status(&self) -> Result { Ok(Status { healthy: true }) } - async fn get_profile(&self, user: AuthenticatedUser) -> Result { + async fn get_profile(&self, user: &AuthenticatedUser, _request: ()) -> Result { // Access user.user_id, user.permissions, etc. Ok(UserProfile { - id: user.user_id, - permissions: user.permissions.into_iter().collect(), + id: user.user_id.clone(), + permissions: user.permissions.iter().cloned().collect(), }) } - async fn delete_user(&self, _user: AuthenticatedUser, req: DeleteUserRequest) -> Result<(), Error> { + async fn delete_user(&self, _user: &AuthenticatedUser, req: DeleteUserRequest) -> Result<(), Error> { // Only users with "admin" permission can reach here Ok(()) } @@ -304,8 +303,9 @@ let service = MyApiServiceImpl; let app = Router::new() .nest("/api", MyApiServiceBuilder::new(service) - .with_auth_provider(Arc::new(jwt_auth)) - .build() + .base_url("/rpc") + .auth_provider(jwt_auth) + .build()? ); ``` @@ -319,13 +319,13 @@ rest_service!({ base_path: "/api/v1", endpoints: [ // Public endpoint - UNAUTHORIZED GET "/health" health_check() -> HealthResponse, + GET UNAUTHORIZED health() -> HealthResponse, - // Authenticated endpoint - AUTHENTICATED GET "/me" get_current_user() -> UserResponse, + // Authenticated endpoint with no specific permission + GET WITH_PERMISSIONS([]) me() -> UserResponse, // Permission-protected endpoint - WITH_PERMISSIONS(["admin"]) DELETE "/users/:id" delete_user(PathParam) -> (), + DELETE WITH_PERMISSIONS(["admin"]) users/{id: String}() -> (), ] }); ``` @@ -348,7 +348,7 @@ jsonrpc_service!({ service_name: TodoService, methods: [ UNAUTHORIZED health_check(()) -> HealthStatus, - AUTHENTICATED list_todos(()) -> Vec, + WITH_PERMISSIONS([]) list_todos(()) -> Vec, WITH_PERMISSIONS(["user"]) create_todo(CreateTodoRequest) -> Todo, WITH_PERMISSIONS(["admin"]) delete_all_todos(()) -> (), ] @@ -402,8 +402,9 @@ async fn main() -> anyhow::Result<()> { let todo_service = TodoServiceImpl::new(); let api_router = TodoServiceBuilder::new(todo_service) - .with_auth_provider(Arc::new(jwt_auth)) - .build(); + .base_url("/rpc") + .auth_provider(jwt_auth) + .build()?; // 6. Combine everything let app = Router::new() @@ -513,7 +514,7 @@ mod tests { 3. **Permission denied errors** - Verify your `UserPermissions` implementation returns expected permissions - Check the method annotation matches required permissions - - Use `AUTHENTICATED` for methods that only need login, not specific permissions + - Use `WITH_PERMISSIONS([])` for methods that only need login, not specific permissions 4. **OAuth2 redirect issues** - Ensure redirect URLs are correctly configured in provider settings @@ -581,4 +582,4 @@ The RAS identity system provides a robust foundation for authentication in your For more examples, check out: - `/examples/google-oauth-example/` - Complete OAuth2 integration - `/examples/basic-jsonrpc-service/` - JSON-RPC with authentication -- `/examples/bidirectional-chat/` - WebSocket authentication \ No newline at end of file +- `/examples/bidirectional-chat/` - WebSocket authentication diff --git a/documentation/ras-observability.md b/documentation/ras-observability.md index 2b0140e..dc5751c 100644 --- a/documentation/ras-observability.md +++ b/documentation/ras-observability.md @@ -85,16 +85,24 @@ jsonrpc_service!({ // Implement the service struct MyServiceImpl; -impl MyService for MyServiceImpl { - async fn health(&self, _ctx: CallContext, _params: ()) -> Result { +impl MyServiceTrait for MyServiceImpl { + async fn health(&self, _params: ()) -> Result> { Ok("healthy".to_string()) } - - async fn create_user(&self, _ctx: CallContext, req: CreateUserRequest) -> Result { + + async fn create_user( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + req: CreateUserRequest, + ) -> Result> { // Your implementation } - - async fn delete_user(&self, _ctx: CallContext, req: DeleteUserRequest) -> Result { + + async fn delete_user( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + req: DeleteUserRequest, + ) -> Result> { // Your implementation } } @@ -105,12 +113,15 @@ async fn main() -> Result<(), Box> { let otel = OtelSetupBuilder::new("my-jsonrpc-service").build()?; // Build your service with observability hooks - let rpc_router = MyServiceBuilder::new("/rpc") + let rpc_router = MyServiceBuilder::new(MyServiceImpl) + .base_url("/rpc") .with_usage_tracker({ let usage_tracker = otel.usage_tracker(); move |headers, user, payload| { let context = RequestContext::jsonrpc(payload.method.clone()); let usage_tracker = usage_tracker.clone(); + let headers = headers.clone(); + let user = user.cloned(); async move { usage_tracker .track_request(&headers, user.as_ref(), &context) @@ -123,6 +134,7 @@ async fn main() -> Result<(), Box> { move |method, user, duration| { let context = RequestContext::jsonrpc(method.to_string()); let duration_tracker = duration_tracker.clone(); + let user = user.cloned(); async move { duration_tracker .track_duration(&context, user.as_ref(), duration) @@ -130,7 +142,7 @@ async fn main() -> Result<(), Box> { } } }) - .build(); + .build()?; // Combine with metrics endpoint let app = Router::new() @@ -158,9 +170,9 @@ rest_service!({ service_name: UserService, base_path: "/api/v1", endpoints: [ - UNAUTHORIZED GET "/health" health() -> HealthResponse, - WITH_PERMISSIONS(["user"]) GET "/users/:id" get_user(PathParams) -> User, - WITH_PERMISSIONS(["admin"]) DELETE "/users/:id" delete_user(PathParams) -> DeleteResponse, + GET UNAUTHORIZED health() -> HealthResponse, + GET WITH_PERMISSIONS(["user"]) users/{id: UserId}() -> User, + DELETE WITH_PERMISSIONS(["admin"]) users/{id: UserId}() -> DeleteResponse, ] }); @@ -178,6 +190,8 @@ async fn main() -> Result<(), Box> { move |headers, user, method, path| { let context = RequestContext::rest(method, path); let usage_tracker = usage_tracker.clone(); + let headers = headers.clone(); + let user = user.cloned(); async move { usage_tracker .track_request(&headers, user.as_ref(), &context) @@ -190,6 +204,7 @@ async fn main() -> Result<(), Box> { move |method, path, user, duration| { let context = RequestContext::rest(method, path); let duration_tracker = duration_tracker.clone(); + let user = user.cloned(); async move { duration_tracker .track_duration(&context, user.as_ref(), duration) @@ -444,4 +459,4 @@ ras-observability-core = { path = "../crates/core/ras-observability-core" } ras-observability-otel = { path = "../crates/observability/ras-observability-otel" } ``` -The observability system is designed to be lightweight with minimal dependencies while providing production-ready metrics for your RAS stack applications. \ No newline at end of file +The observability system is designed to be lightweight with minimal dependencies while providing production-ready metrics for your RAS stack applications. diff --git a/documentation/ras-rest-macro.md b/documentation/ras-rest-macro.md index 3dea6a2..add0686 100644 --- a/documentation/ras-rest-macro.md +++ b/documentation/ras-rest-macro.md @@ -9,12 +9,13 @@ The `ras-rest-macro` crate provides a powerful procedural macro for building typ 3. [Basic Usage](#basic-usage) 4. [Macro Syntax](#macro-syntax) 5. [Authentication & Authorization](#authentication--authorization) -6. [Generated Code](#generated-code) -7. [TypeScript Client Usage](#typescript-client-usage) -8. [OpenAPI Documentation](#openapi-documentation) -9. [Error Handling](#error-handling) -10. [Advanced Features](#advanced-features) -11. [Complete Example](#complete-example) +6. [Versioned Endpoints](#versioned-endpoints) +7. [Generated Code](#generated-code) +8. [TypeScript Client Usage](#typescript-client-usage) +9. [OpenAPI Documentation](#openapi-documentation) +10. [Error Handling](#error-handling) +11. [Advanced Features](#advanced-features) +12. [Complete Example](#complete-example) ## Overview @@ -24,6 +25,7 @@ The `rest_service!` macro generates: - Native Rust client with async/await support - OpenAPI 3.0 specification for TypeScript client generation - Built-in Swagger UI hosting (optional) +- Optional compatibility routes that migrate legacy request/response shapes ## Installation @@ -31,8 +33,8 @@ Add to your `Cargo.toml`: ```toml [dependencies] -ras-rest-macro = "0.2.0" -ras-rest-core = "0.1.0" +ras-rest-macro = "0.2.1" +ras-rest-core = "0.1.1" ras-auth-core = "0.1.0" # For authentication serde = { version = "1.0", features = ["derive"] } schemars = "0.8" # Required for OpenAPI generation @@ -245,6 +247,89 @@ Use OR logic between permission groups and AND logic within groups: WITH_PERMISSIONS(["admin"] | ["moderator", "editor"]) ``` +## Versioned Endpoints + +Versioned endpoints are opt-in. The canonical route stays implemented by the generated service trait. Each legacy route declares its own path, body, response, and migration type. The generated server migrates legacy request parts into the canonical request parts, calls the canonical service method, then migrates the response body back to the legacy response type. + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetV1 { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetV2 { + pub display_name: String, + pub notify: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetResponseV1 { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetResponseV2 { + pub display_name: String, + pub notified: bool, +} + +rest_service!({ + service_name: WidgetService, + base_path: "/api", + openapi: true, + endpoints: [ + POST UNAUTHORIZED v2/widgets/{id: String}/rename(RenameWidgetV2) -> RenameWidgetResponseV2 { + version: v2, + versions: [ + v1 { + path: v1/widgets/{id: String}/rename, + body: RenameWidgetV1, + response: RenameWidgetResponseV1, + migration: RenameWidgetCompat, + }, + ], + }, + ] +}); + +struct RenameWidgetCompat; + +impl ras_rest_core::VersionMigration< + WidgetServicePostV2WidgetsByIdRenameV1Request, + WidgetServicePostV2WidgetsByIdRenameV2Request, +> for RenameWidgetCompat { + type Error = std::convert::Infallible; + + fn migrate( + value: WidgetServicePostV2WidgetsByIdRenameV1Request, + ) -> Result { + Ok(WidgetServicePostV2WidgetsByIdRenameV2Request { + path: WidgetServicePostV2WidgetsByIdRenameV2Path { id: value.path.id }, + query: WidgetServicePostV2WidgetsByIdRenameV2Query {}, + body: RenameWidgetV2 { + display_name: value.body.name, + notify: false, + }, + }) + } +} + +impl ras_rest_core::VersionMigration + for RenameWidgetCompat +{ + type Error = std::convert::Infallible; + + fn migrate(value: RenameWidgetResponseV2) -> Result { + Ok(RenameWidgetResponseV1 { + name: value.display_name, + }) + } +} +``` + +OpenAPI output includes both canonical and legacy paths. Versioned operations include `x-ras-version`, `x-ras-canonical-version`, and `x-ras-canonical-path` extensions. + ## Generated Code The macro generates several components: diff --git a/examples/basic-jsonrpc/service/README.md b/examples/basic-jsonrpc/service/README.md index 0bf1a27..0a62d62 100644 --- a/examples/basic-jsonrpc/service/README.md +++ b/examples/basic-jsonrpc/service/README.md @@ -39,10 +39,10 @@ cargo run -p basic-jsonrpc-service The service will start on `http://localhost:3000` with the following endpoints: -- **JSON-RPC endpoint**: http://localhost:3000/api/rpc -- **JSON-RPC Explorer**: http://localhost:3000/api/explorer +- **JSON-RPC endpoint**: http://localhost:3000/rpc +- **JSON-RPC Explorer**: http://localhost:3000/rpc/explorer - **Prometheus metrics**: http://localhost:3000/metrics -- **OpenRPC Document**: http://localhost:3000/api/explorer/openrpc.json +- **OpenRPC Document**: http://localhost:3000/rpc/explorer/openrpc.json ## Configuration @@ -111,7 +111,7 @@ The collector will scrape metrics from `http://localhost:3000/metrics` and forwa **Admin User:** ```bash -curl -X POST http://localhost:3000/api/rpc \ +curl -X POST http://localhost:3000/rpc \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", @@ -142,7 +142,7 @@ curl -X POST http://localhost:3000/api/rpc \ ### 2. Make Authenticated Requests ```bash -curl -X POST http://localhost:3000/api/rpc \ +curl -X POST http://localhost:3000/rpc \ -H "Content-Type: application/json" \ -H "Authorization: Bearer admin_token" \ -d '{ @@ -245,4 +245,4 @@ metrics.custom_operations.add(1, &[ - **Sampling**: Consider implementing adaptive sampling for high-traffic services - **Resource Attributes**: Add more service metadata (version, environment, etc.) - **Error Handling**: Implement proper error tracking metrics -- **Performance**: The metrics collection adds minimal overhead to request processing \ No newline at end of file +- **Performance**: The metrics collection adds minimal overhead to request processing diff --git a/examples/basic-jsonrpc/service/src/main.rs b/examples/basic-jsonrpc/service/src/main.rs index 94b2bba..50c263d 100644 --- a/examples/basic-jsonrpc/service/src/main.rs +++ b/examples/basic-jsonrpc/service/src/main.rs @@ -1,7 +1,8 @@ use axum::Router; use basic_jsonrpc_api::{ - CreateTaskRequest, DashboardStats, MyServiceBuilder, SignInRequest, SignInResponse, Task, - TaskListResponse, TaskPriority, UpdateProfileRequest, UpdateTaskRequest, UserProfile, + CreateTaskRequest, DashboardStats, MyServiceBuilder, MyServiceTrait, SignInRequest, + SignInResponse, Task, TaskListResponse, TaskPriority, UpdateProfileRequest, UpdateTaskRequest, + UserProfile, }; use chrono::Utc; use ras_jsonrpc_core::{AuthFuture, AuthProvider, AuthenticatedUser}; @@ -137,6 +138,145 @@ impl TaskStorage { } } +struct MyServiceImpl { + storage: Arc, +} + +impl MyServiceTrait for MyServiceImpl { + async fn sign_in( + &self, + request: SignInRequest, + ) -> Result> { + println!("{request:?}"); + match request { + SignInRequest::WithCredentials { username, password } => { + if username == "admin" && password == "secret" { + Ok(SignInResponse::Success { + jwt: "admin_token".to_string(), + }) + } else if username == "user" && password == "password" { + Ok(SignInResponse::Success { + jwt: "valid_token".to_string(), + }) + } else { + Ok(SignInResponse::Failure { + msg: "Invalid credentials".to_string(), + }) + } + } + } + } + + async fn sign_out( + &self, + user: &AuthenticatedUser, + _request: (), + ) -> Result<(), Box> { + info!("User {} signed out", user.user_id); + Ok(()) + } + + async fn delete_everything( + &self, + user: &AuthenticatedUser, + _request: (), + ) -> Result<(), Box> { + tracing::warn!("Admin {} is deleting everything!", user.user_id); + Ok(()) + } + + async fn list_tasks( + &self, + _user: &AuthenticatedUser, + _request: (), + ) -> Result> { + Ok(self.storage.list_tasks()) + } + + async fn create_task( + &self, + _user: &AuthenticatedUser, + request: CreateTaskRequest, + ) -> Result> { + Ok(self.storage.create_task(request)) + } + + async fn update_task( + &self, + _user: &AuthenticatedUser, + request: UpdateTaskRequest, + ) -> Result> { + self.storage.update_task(request).ok_or_else(|| { + Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Task not found", + )) as Box + }) + } + + async fn delete_task( + &self, + _user: &AuthenticatedUser, + id: String, + ) -> Result> { + Ok(self.storage.delete_task(id)) + } + + async fn get_task( + &self, + _user: &AuthenticatedUser, + id: String, + ) -> Result, Box> { + Ok(self.storage.get_task(id)) + } + + async fn get_profile( + &self, + user: &AuthenticatedUser, + _request: (), + ) -> Result> { + Ok(UserProfile { + username: if user.user_id == "admin123" { + "admin" + } else { + "user" + } + .to_string(), + email: format!("{}@example.com", user.user_id), + permissions: user.permissions.iter().cloned().collect(), + created_at: "2024-01-01T00:00:00Z".to_string(), + }) + } + + async fn update_profile( + &self, + user: &AuthenticatedUser, + request: UpdateProfileRequest, + ) -> Result> { + Ok(UserProfile { + username: if user.user_id == "admin123" { + "admin" + } else { + "user" + } + .to_string(), + email: request + .email + .unwrap_or_else(|| format!("{}@example.com", user.user_id)), + permissions: user.permissions.iter().cloned().collect(), + created_at: "2024-01-01T00:00:00Z".to_string(), + }) + } + + async fn get_dashboard_stats( + &self, + _user: &AuthenticatedUser, + _request: (), + ) -> Result> { + Ok(self.storage.get_stats()) + } +} + #[tokio::main] async fn main() { tracing_subscriber::fmt::init(); @@ -157,172 +297,59 @@ async fn main() { // Initialize task storage let task_storage = Arc::new(TaskStorage::new()); - let rpc_router = MyServiceBuilder::new("/rpc") - .with_usage_tracker({ - let usage_tracker = otel.usage_tracker(); - move |headers, user, payload| { - let method = payload.method.clone(); - let context = RequestContext::jsonrpc(method); - let usage_tracker = usage_tracker.clone(); - let headers_clone = headers.clone(); - let user_clone = user.cloned(); - - async move { - // Log the request - match &user_clone { - Some(u) => { - info!( - "RPC call: method={}, user={}, permissions={:?}", - context.method, u.user_id, u.permissions, - ); - } - None => { - info!("RPC call: method={}, user=anonymous", context.method,); - } - } + let rpc_router = MyServiceBuilder::new(MyServiceImpl { + storage: task_storage.clone(), + }) + .base_url("/rpc") + .with_usage_tracker({ + let usage_tracker = otel.usage_tracker(); + move |headers, user, payload| { + let method = payload.method.clone(); + let context = RequestContext::jsonrpc(method); + let usage_tracker = usage_tracker.clone(); + let headers_clone = headers.clone(); + let user_clone = user.cloned(); - // Track the request - usage_tracker - .track_request(&headers_clone, user_clone.as_ref(), &context) - .await; - } - } - }) - .with_method_duration_tracker({ - let duration_tracker = otel.method_duration_tracker(); - move |method: &str, - user: Option<&ras_jsonrpc_core::AuthenticatedUser>, - duration: std::time::Duration| { - let context = RequestContext::jsonrpc(method.to_string()); - let duration_tracker = duration_tracker.clone(); - let user_clone = user.cloned(); - - async move { - duration_tracker - .track_duration(&context, user_clone.as_ref(), duration) - .await; - } - } - }) - .auth_provider(MyAuthProvider) - .sign_in_handler({ - move |request| async move { - println!("{request:?}"); - match request { - SignInRequest::WithCredentials { username, password } => { - if username == "admin" && password == "secret" { - Ok(SignInResponse::Success { - jwt: "admin_token".to_string(), - }) - } else if username == "user" && password == "password" { - Ok(SignInResponse::Success { - jwt: "valid_token".to_string(), - }) - } else { - Ok(SignInResponse::Failure { - msg: "Invalid credentials".to_string(), - }) - } - } - } - } - }) - .sign_out_handler({ - move |user, _request| async move { - info!("User {} signed out", user.user_id); - Ok(()) - } - }) - .delete_everything_handler(|user, _request| async move { - tracing::warn!("Admin {} is deleting everything!", user.user_id); - Ok(()) - }) - .create_task_handler({ - let storage = task_storage.clone(); - move |_user, request| { - let storage = storage.clone(); - async move { - let task = storage.create_task(request); - Ok(task) - } - } - }) - .update_task_handler({ - let storage = task_storage.clone(); - move |_user, request| { - let storage = storage.clone(); - async move { - storage.update_task(request).ok_or_else(|| { - Box::new(std::io::Error::new( - std::io::ErrorKind::NotFound, - "Task not found", - )) as Box - }) - } - } - }) - .delete_task_handler({ - let storage = task_storage.clone(); - move |_user, id| { - let storage = storage.clone(); - async move { Ok(storage.delete_task(id)) } - } - }) - .get_task_handler({ - let storage = task_storage.clone(); - move |_user, id| { - let storage = storage.clone(); - async move { Ok(storage.get_task(id)) } - } - }) - .list_tasks_handler({ - let storage = task_storage.clone(); - move |_user, _request| { - let storage = storage.clone(); - async move { Ok(storage.list_tasks()) } - } - }) - .get_profile_handler({ - move |user, _request| async move { - Ok(UserProfile { - username: if user.user_id == "admin123" { - "admin" - } else { - "user" + async move { + // Log the request + match &user_clone { + Some(u) => { + info!( + "RPC call: method={}, user={}, permissions={:?}", + context.method, u.user_id, u.permissions, + ); } - .to_string(), - email: format!("{}@example.com", user.user_id), - permissions: user.permissions.iter().cloned().collect(), - created_at: "2024-01-01T00:00:00Z".to_string(), - }) - } - }) - .update_profile_handler({ - move |user, request| async move { - Ok(UserProfile { - username: if user.user_id == "admin123" { - "admin" - } else { - "user" + None => { + info!("RPC call: method={}, user=anonymous", context.method,); } - .to_string(), - email: request - .email - .unwrap_or_else(|| format!("{}@example.com", user.user_id)), - permissions: user.permissions.iter().cloned().collect(), - created_at: "2024-01-01T00:00:00Z".to_string(), - }) + } + + // Track the request + usage_tracker + .track_request(&headers_clone, user_clone.as_ref(), &context) + .await; } - }) - .get_dashboard_stats_handler({ - let storage = task_storage.clone(); - move |_user, _request| { - let storage = storage.clone(); - async move { Ok(storage.get_stats()) } + } + }) + .with_method_duration_tracker({ + let duration_tracker = otel.method_duration_tracker(); + move |method: &str, + user: Option<&ras_jsonrpc_core::AuthenticatedUser>, + duration: std::time::Duration| { + let context = RequestContext::jsonrpc(method.to_string()); + let duration_tracker = duration_tracker.clone(); + let user_clone = user.cloned(); + + async move { + duration_tracker + .track_duration(&context, user_clone.as_ref(), duration) + .await; } - }) - .build() - .expect("Failed to build JSON-RPC router"); + } + }) + .auth_provider(MyAuthProvider) + .build() + .expect("Failed to build JSON-RPC router"); // Create the main app with metrics endpoint let app = Router::new().merge(rpc_router).merge(otel.metrics_router()); diff --git a/examples/oauth2-demo/server/README.md b/examples/oauth2-demo/server/README.md index b3b75ba..aed60b7 100644 --- a/examples/oauth2-demo/server/README.md +++ b/examples/oauth2-demo/server/README.md @@ -232,7 +232,7 @@ examples/google-oauth-example/ - **rust-identity-oauth2**: OAuth2 provider implementation - **rust-identity-session**: JWT session management -- **rust-jsonrpc-macro**: Type-safe JSON-RPC service generation +- **ras-jsonrpc-macro**: Type-safe JSON-RPC service generation - **axum**: Web framework for HTTP handling - **tower-http**: Middleware for CORS and static files @@ -319,4 +319,4 @@ This example is part of the Rust Agent Stack project. See the main project READM ## License -This example follows the same license as the Rust Agent Stack project. \ No newline at end of file +This example follows the same license as the Rust Agent Stack project. diff --git a/examples/oauth2-demo/server/src/service.rs b/examples/oauth2-demo/server/src/service.rs index f3c9235..682478c 100644 --- a/examples/oauth2-demo/server/src/service.rs +++ b/examples/oauth2-demo/server/src/service.rs @@ -1,112 +1,131 @@ use axum::Router; use oauth2_demo_api::*; -use ras_jsonrpc_core::AuthProvider; +use ras_jsonrpc_core::{AuthProvider, AuthenticatedUser}; use tracing::info; -/// Create the API router with all the service handlers -pub fn create_api_router( - auth_provider: A, -) -> Router<()> { - GoogleOAuth2ServiceBuilder::new("/rpc") - .auth_provider(auth_provider) - .get_user_info_handler(|user, _request| async move { - info!("User {} requested user info", user.user_id); +struct GoogleOAuth2ServiceImpl; - Ok(GetUserInfoResponse { - user_id: user.user_id, - permissions: user.permissions.into_iter().collect(), - metadata: user.metadata, - }) +impl GoogleOAuth2ServiceTrait for GoogleOAuth2ServiceImpl { + async fn get_user_info( + &self, + user: &AuthenticatedUser, + _request: GetUserInfoRequest, + ) -> Result> { + info!("User {} requested user info", user.user_id); + + Ok(GetUserInfoResponse { + user_id: user.user_id.clone(), + permissions: user.permissions.iter().cloned().collect(), + metadata: user.metadata.clone(), }) - .list_documents_handler(|user, request| async move { - info!("User {} requested document list", user.user_id); - - let limit = request.limit.unwrap_or(10).min(100); - let offset = request.offset.unwrap_or(0); - - // Simulate document data - let documents = vec![ - DocumentInfo { - id: "doc_1".to_string(), - title: "Getting Started with OAuth2".to_string(), - created_at: "2024-01-15T10:30:00Z".to_string(), - tags: vec!["oauth2".to_string(), "authentication".to_string()], - }, - DocumentInfo { - id: "doc_2".to_string(), - title: "Advanced JSON-RPC Patterns".to_string(), - created_at: "2024-01-16T14:20:00Z".to_string(), - tags: vec!["jsonrpc".to_string(), "patterns".to_string()], - }, - DocumentInfo { - id: "doc_3".to_string(), - title: "Rust Identity Management".to_string(), - created_at: "2024-01-17T09:15:00Z".to_string(), - tags: vec!["rust".to_string(), "identity".to_string()], - }, - ]; - - // Apply pagination - let total = documents.len() as u32; - let paginated_docs = documents - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect(); - - Ok(ListDocumentsResponse { - documents: paginated_docs, - total, - }) + } + + async fn list_documents( + &self, + user: &AuthenticatedUser, + request: ListDocumentsRequest, + ) -> Result> { + info!("User {} requested document list", user.user_id); + + let limit = request.limit.unwrap_or(10).min(100); + let offset = request.offset.unwrap_or(0); + + let documents = vec![ + DocumentInfo { + id: "doc_1".to_string(), + title: "Getting Started with OAuth2".to_string(), + created_at: "2024-01-15T10:30:00Z".to_string(), + tags: vec!["oauth2".to_string(), "authentication".to_string()], + }, + DocumentInfo { + id: "doc_2".to_string(), + title: "Advanced JSON-RPC Patterns".to_string(), + created_at: "2024-01-16T14:20:00Z".to_string(), + tags: vec!["jsonrpc".to_string(), "patterns".to_string()], + }, + DocumentInfo { + id: "doc_3".to_string(), + title: "Rust Identity Management".to_string(), + created_at: "2024-01-17T09:15:00Z".to_string(), + tags: vec!["rust".to_string(), "identity".to_string()], + }, + ]; + + let total = documents.len() as u32; + let paginated_docs = documents + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + + Ok(ListDocumentsResponse { + documents: paginated_docs, + total, }) - .create_document_handler(|user, request| async move { - info!("User {} creating document: {}", user.user_id, request.title); + } - // Simulate document creation - let document_id = format!("doc_{}", uuid::Uuid::new_v4()); - let created_at = chrono::Utc::now().to_rfc3339(); + async fn create_document( + &self, + user: &AuthenticatedUser, + request: CreateDocumentRequest, + ) -> Result> { + info!("User {} creating document: {}", user.user_id, request.title); - Ok(CreateDocumentResponse { - document_id, - created_at, - }) - }) - .delete_document_handler(|user, request| async move { - info!( - "Admin {} deleting document: {}", - user.user_id, request.document_id - ); - - // Simulate document deletion - if request.document_id.starts_with("doc_") { - Ok(DeleteDocumentResponse { - success: true, - message: format!("Document {} deleted successfully", request.document_id), - }) - } else { - Ok(DeleteDocumentResponse { - success: false, - message: "Document not found".to_string(), - }) - } + Ok(CreateDocumentResponse { + document_id: format!("doc_{}", uuid::Uuid::new_v4()), + created_at: chrono::Utc::now().to_rfc3339(), }) - .get_system_status_handler(|user, _request| async move { - info!("System admin {} requested system status", user.user_id); + } + + async fn delete_document( + &self, + user: &AuthenticatedUser, + request: DeleteDocumentRequest, + ) -> Result> { + info!( + "Admin {} deleting document: {}", + user.user_id, request.document_id + ); + + if request.document_id.starts_with("doc_") { + Ok(DeleteDocumentResponse { + success: true, + message: format!("Document {} deleted successfully", request.document_id), + }) + } else { + Ok(DeleteDocumentResponse { + success: false, + message: "Document not found".to_string(), + }) + } + } + + async fn get_system_status( + &self, + user: &AuthenticatedUser, + _request: GetSystemStatusRequest, + ) -> Result> { + info!("System admin {} requested system status", user.user_id); - // Simulate system status - let status = SystemStatus { + Ok(GetSystemStatusResponse { + status: SystemStatus { uptime_seconds: 12345, memory_usage_mb: 256, active_sessions: 42, version: "1.0.0-example".to_string(), - }; - - Ok(GetSystemStatusResponse { status }) + }, }) - .get_beta_features_handler(|user, _request| async move { - info!("Beta user {} requested beta features", user.user_id); + } + + async fn get_beta_features( + &self, + user: &AuthenticatedUser, + _request: GetBetaFeaturesRequest, + ) -> Result> { + info!("Beta user {} requested beta features", user.user_id); - let features = vec![ + Ok(GetBetaFeaturesResponse { + features: vec![ BetaFeature { name: "Advanced Analytics".to_string(), description: "Enhanced analytics dashboard with real-time metrics".to_string(), @@ -122,10 +141,18 @@ pub fn create_api_router( description: "Real-time collaborative document editing".to_string(), enabled: false, }, - ]; - - Ok(GetBetaFeaturesResponse { features }) + ], }) + } +} + +/// Create the API router with all the service handlers +pub fn create_api_router( + auth_provider: A, +) -> Router<()> { + GoogleOAuth2ServiceBuilder::new(GoogleOAuth2ServiceImpl) + .base_url("/rpc") + .auth_provider(auth_provider) .build() .expect("Failed to build GoogleOAuth2Service") } diff --git a/tests/playwright/README.md b/tests/playwright/README.md index 5510af7..e4d1759 100644 --- a/tests/playwright/README.md +++ b/tests/playwright/README.md @@ -1,7 +1,9 @@ # API Explorer Playwright Tests These tests exercise the generated REST and JSON-RPC API explorers in a real -browser. Dedicated Rust fixture servers are started by Playwright. +browser. Dedicated Rust fixture servers are started by Playwright. The fixtures +include canonical and legacy versioned API entries so the explorer covers both +current and compatibility routes. ## Local setup diff --git a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs index a6e243b..1bfa8cf 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs +++ b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs @@ -43,6 +43,28 @@ pub struct ProfileResponse { pub permissions: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetV1 { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetV2 { + pub display_name: String, + pub notify: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetResponseV1 { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetResponseV2 { + pub display_name: String, + pub notified: bool, +} + jsonrpc_service!({ service_name: ExplorerRpcFixture, openrpc: true, @@ -64,11 +86,48 @@ jsonrpc_service!({ /// See [Rust API Stack](https://example.com/docs). UNAUTHORIZED ping(PingRequest) -> PingResponse, UNAUTHORIZED no_params(()) -> String, + UNAUTHORIZED rename_widget(RenameWidgetV2) -> RenameWidgetResponseV2 { + version: v2, + wire: "rename_widget.v2", + versions: [ + v1 { + wire: "rename_widget.v1", + request: RenameWidgetV1, + response: RenameWidgetResponseV1, + migration: RenameWidgetCompat, + }, + ], + }, WITH_PERMISSIONS(["admin"]) create_widget(CreateWidgetRequest) -> Widget, WITH_PERMISSIONS(["user"]) current_profile(()) -> ProfileResponse, ] }); +struct RenameWidgetCompat; + +impl ras_jsonrpc_core::VersionMigration for RenameWidgetCompat { + type Error = std::convert::Infallible; + + fn migrate(value: RenameWidgetV1) -> Result { + Ok(RenameWidgetV2 { + display_name: value.name, + notify: false, + }) + } +} + +impl ras_jsonrpc_core::VersionMigration + for RenameWidgetCompat +{ + type Error = std::convert::Infallible; + + fn migrate(value: RenameWidgetResponseV2) -> Result { + Ok(RenameWidgetResponseV1 { + name: value.display_name, + }) + } +} + struct FixtureAuthProvider; impl AuthProvider for FixtureAuthProvider { @@ -92,29 +151,64 @@ impl AuthProvider for FixtureAuthProvider { } } -#[tokio::main] -async fn main() -> Result<()> { - let rpc_router = ExplorerRpcFixtureBuilder::new("/rpc") - .auth_provider(FixtureAuthProvider) - .ping_handler(|request| async move { - Ok(PingResponse { - message: format!("pong: {}", request.message), - }) +struct ExplorerRpcFixtureImpl; + +impl ExplorerRpcFixtureTrait for ExplorerRpcFixtureImpl { + async fn ping( + &self, + request: PingRequest, + ) -> std::result::Result> { + Ok(PingResponse { + message: format!("pong: {}", request.message), }) - .no_params_handler(|_request| async move { Ok("no params ok".to_string()) }) - .create_widget_handler(|_user, request| async move { - Ok(Widget { - id: "rpc-created-widget".to_string(), - name: request.name, - owner: request.owner, - }) + } + + async fn no_params( + &self, + _request: (), + ) -> std::result::Result> { + Ok("no params ok".to_string()) + } + + async fn rename_widget( + &self, + request: RenameWidgetV2, + ) -> std::result::Result> { + Ok(RenameWidgetResponseV2 { + display_name: request.display_name, + notified: request.notify, }) - .current_profile_handler(|user, _request| async move { - Ok(ProfileResponse { - user_id: user.user_id.clone(), - permissions: user.permissions.iter().cloned().collect(), - }) + } + + async fn create_widget( + &self, + _user: &AuthenticatedUser, + request: CreateWidgetRequest, + ) -> std::result::Result> { + Ok(Widget { + id: "rpc-created-widget".to_string(), + name: request.name, + owner: request.owner, + }) + } + + async fn current_profile( + &self, + user: &AuthenticatedUser, + _request: (), + ) -> std::result::Result> { + Ok(ProfileResponse { + user_id: user.user_id.clone(), + permissions: user.permissions.iter().cloned().collect(), }) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let rpc_router = ExplorerRpcFixtureBuilder::new(ExplorerRpcFixtureImpl) + .base_url("/rpc") + .auth_provider(FixtureAuthProvider) .build() .expect("fixture JSON-RPC service should build"); diff --git a/tests/playwright/fixtures/rest-fixture/src/main.rs b/tests/playwright/fixtures/rest-fixture/src/main.rs index 332394e..0d1966a 100644 --- a/tests/playwright/fixtures/rest-fixture/src/main.rs +++ b/tests/playwright/fixtures/rest-fixture/src/main.rs @@ -42,6 +42,22 @@ pub struct ProfileResponse { pub permissions: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetV1 { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetV2 { + pub display_name: String, + pub notify: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RenameWidgetResponseV1 { + pub name: String, +} + rest_service!({ service_name: ExplorerRestFixture, base_path: "/api/v1", @@ -66,11 +82,54 @@ rest_service!({ GET UNAUTHORIZED health() -> HealthResponse, GET UNAUTHORIZED widgets/{id: String}() -> Widget, GET UNAUTHORIZED search/widgets ? q: String & limit: Option () -> WidgetsResponse, + POST UNAUTHORIZED v2/widgets/{id: String}/rename(RenameWidgetV2) -> Widget { + version: v2, + versions: [ + v1 { + path: v1/widgets/{id: String}/rename, + body: RenameWidgetV1, + response: RenameWidgetResponseV1, + migration: RenameWidgetCompat, + }, + ], + }, POST WITH_PERMISSIONS(["admin"]) widgets(CreateWidgetRequest) -> Widget, GET WITH_PERMISSIONS(["user"]) profile() -> ProfileResponse, ] }); +struct RenameWidgetCompat; + +impl + ras_rest_core::VersionMigration< + ExplorerRestFixturePostV2WidgetsByIdRenameV1Request, + ExplorerRestFixturePostV2WidgetsByIdRenameV2Request, + > for RenameWidgetCompat +{ + type Error = std::convert::Infallible; + + fn migrate( + value: ExplorerRestFixturePostV2WidgetsByIdRenameV1Request, + ) -> Result { + Ok(ExplorerRestFixturePostV2WidgetsByIdRenameV2Request { + path: ExplorerRestFixturePostV2WidgetsByIdRenameV2Path { id: value.path.id }, + query: ExplorerRestFixturePostV2WidgetsByIdRenameV2Query {}, + body: RenameWidgetV2 { + display_name: value.body.name, + notify: false, + }, + }) + } +} + +impl ras_rest_core::VersionMigration for RenameWidgetCompat { + type Error = std::convert::Infallible; + + fn migrate(value: Widget) -> Result { + Ok(RenameWidgetResponseV1 { name: value.name }) + } +} + struct FixtureAuthProvider; impl AuthProvider for FixtureAuthProvider { @@ -132,6 +191,18 @@ impl ExplorerRestFixtureTrait for FixtureService { })) } + async fn post_v2_widgets_by_id_rename( + &self, + id: String, + request: RenameWidgetV2, + ) -> RestResult { + Ok(RestResponse::ok(Widget { + id, + name: request.display_name, + owner: if request.notify { "notified" } else { "silent" }.to_string(), + })) + } + async fn post_widgets( &self, _user: &AuthenticatedUser, diff --git a/tests/playwright/tests/jsonrpc-explorer.spec.ts b/tests/playwright/tests/jsonrpc-explorer.spec.ts index 97a0ccc..eb8bc75 100644 --- a/tests/playwright/tests/jsonrpc-explorer.spec.ts +++ b/tests/playwright/tests/jsonrpc-explorer.spec.ts @@ -23,6 +23,8 @@ test.describe('JSON-RPC API explorer', () => { await expect(page.locator('#service-subtitle')).toContainText('JSON-RPC OpenRPC'); await expect(page.locator('#operation-list')).toContainText('ping'); await expect(page.locator('.op').filter({ hasText: 'ping' })).toContainText('Echo a `PingRequest` message.'); + await expect(page.locator('#operation-list')).toContainText('rename_widget.v2'); + await expect(page.locator('#operation-list')).toContainText('rename_widget.v1'); await expect(page.locator('#operation-list')).toContainText('create_widget'); await expect(page.locator('#operation-list')).toContainText('current_profile'); @@ -86,6 +88,14 @@ test.describe('JSON-RPC API explorer', () => { await expect(page.locator('#response-output')).toContainText('pong: browser'); await expect(page.locator('#history-list')).toContainText('RPC ping'); + await selectMethod(page, 'rename_widget.v1'); + await page.locator('#params-editor').fill(JSON.stringify({ name: 'Legacy Widget' }, null, 2)); + await send(page); + await expect(page.locator('#response-status')).toContainText('200'); + await expect(page.locator('#response-output')).toContainText('Legacy Widget'); + await expect(page.locator('#response-output')).not.toContainText('notified'); + await expect(page.locator('#history-list')).toContainText('RPC rename_widget.v1'); + await selectMethod(page, 'create_widget'); await page.locator('#params-editor').fill(JSON.stringify({ name: 'RPC Widget', owner: 'playwright' }, null, 2)); await send(page); diff --git a/tests/playwright/tests/rest-explorer.spec.ts b/tests/playwright/tests/rest-explorer.spec.ts index 43e60c8..c5a3d45 100644 --- a/tests/playwright/tests/rest-explorer.spec.ts +++ b/tests/playwright/tests/rest-explorer.spec.ts @@ -3,8 +3,12 @@ import { expect, test, type Page } from '@playwright/test'; const REST_PORT = process.env.PLAYWRIGHT_REST_PORT ?? '3101'; const REST_URL = `http://127.0.0.1:${REST_PORT}/api/v1/docs`; +function escapeRegex(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + async function selectOperation(page: Page, method: string, path: string) { - await page.locator('.op').filter({ hasText: method }).filter({ hasText: path }).click(); + await page.getByRole('button', { name: new RegExp(`^${method}\\s+${escapeRegex(path)}(?:\\s|$)`) }).click(); } async function send(page: Page) { @@ -25,6 +29,8 @@ test.describe('REST API explorer', () => { await expect(page.locator('.op').filter({ hasText: '/health' })).toContainText('Check fixture `health`.'); await expect(page.locator('#operation-list')).toContainText('/widgets'); await expect(page.locator('#operation-list')).toContainText('/search/widgets'); + await expect(page.locator('#operation-list')).toContainText('/v2/widgets/{id}/rename'); + await expect(page.locator('#operation-list')).toContainText('/v1/widgets/{id}/rename'); await selectOperation(page, 'GET', '/health'); await expect(page.locator('#operation-description p code')).toContainText('health'); @@ -84,6 +90,14 @@ test.describe('REST API explorer', () => { await expect(page.locator('#response-output')).toContainText('"status": "ok"'); await expect(page.locator('#history-list')).toContainText('GET /health'); + await selectOperation(page, 'POST', '/v1/widgets/{id}/rename'); + await page.locator('[data-path-param="id"]').fill('legacy-widget'); + await page.locator('#body-editor').fill(JSON.stringify({ name: 'Legacy REST Widget' }, null, 2)); + await send(page); + await expect(page.locator('#response-status')).toContainText('200'); + await expect(page.locator('#response-output')).toContainText('Legacy REST Widget'); + await expect(page.locator('#history-list')).toContainText('POST /v1/widgets/{id}/rename'); + await selectOperation(page, 'POST', '/widgets'); await page.locator('#body-editor').fill(JSON.stringify({ name: 'Created From UI', owner: 'playwright' }, null, 2)); await send(page);