From e2da9bfaec16c4c1dbe1115ef15dd7d0debad4c8 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Apr 2026 16:34:53 +0200 Subject: [PATCH 1/3] Improve API explorer UI and add Playwright e2e --- .github/workflows/ci.yml | 50 + Cargo.lock | 36 + Cargo.toml | 1 + .../src/api_explorer_template.html | 1169 ++++++++++++++++ .../rest/ras-rest-macro/src/static_hosting.rs | 1197 +--------------- .../ras-rest-macro/tests/http_integration.rs | 26 + .../tests/xss_protection_test.rs | 12 +- .../src/api_explorer_template.html | 439 ++++++ .../src/jsonrpc_explorer_template.html | 1212 ----------------- .../ras-jsonrpc-macro/src/static_hosting.rs | 27 +- .../ras-jsonrpc-macro/tests/explorer_test.rs | 7 +- .../tests/explorer_token_storage_test.rs | 6 +- tests/playwright/README.md | 28 + .../fixtures/jsonrpc-fixture/Cargo.toml | 23 + .../fixtures/jsonrpc-fixture/src/main.rs | 105 ++ .../fixtures/rest-fixture/Cargo.toml | 25 + .../fixtures/rest-fixture/src/main.rs | 146 ++ tests/playwright/package-lock.json | 78 ++ tests/playwright/package.json | 13 + tests/playwright/playwright.config.ts | 37 + .../playwright/tests/jsonrpc-explorer.spec.ts | 113 ++ tests/playwright/tests/rest-explorer.spec.ts | 120 ++ 22 files changed, 2486 insertions(+), 2384 deletions(-) create mode 100644 crates/rest/ras-rest-macro/src/api_explorer_template.html create mode 100644 crates/rpc/ras-jsonrpc-macro/src/api_explorer_template.html delete mode 100644 crates/rpc/ras-jsonrpc-macro/src/jsonrpc_explorer_template.html create mode 100644 tests/playwright/README.md create mode 100644 tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml create mode 100644 tests/playwright/fixtures/jsonrpc-fixture/src/main.rs create mode 100644 tests/playwright/fixtures/rest-fixture/Cargo.toml create mode 100644 tests/playwright/fixtures/rest-fixture/src/main.rs create mode 100644 tests/playwright/package-lock.json create mode 100644 tests/playwright/package.json create mode 100644 tests/playwright/playwright.config.ts create mode 100644 tests/playwright/tests/jsonrpc-explorer.spec.ts create mode 100644 tests/playwright/tests/rest-explorer.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10ae2fe..1db7e0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,56 @@ jobs: - name: Run tests run: cargo test --workspace --all-features -- --nocapture --test-threads=4 + playwright: + name: Playwright E2E + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: tests/playwright/package-lock.json + + - name: Install Playwright package + working-directory: tests/playwright + run: npm ci + + - name: Install Playwright browsers + working-directory: tests/playwright + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + working-directory: tests/playwright + run: npm test + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: tests/playwright/playwright-report + if-no-files-found: ignore + retention-days: 7 + + - name: Upload Playwright test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + path: tests/playwright/test-results + if-no-files-found: ignore + retention-days: 7 + coverage: name: Coverage report runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 03834dc..bd7eaef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2947,6 +2947,42 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "playwright-jsonrpc-fixture" +version = "0.0.0" +dependencies = [ + "anyhow", + "axum", + "ras-auth-core", + "ras-jsonrpc-core", + "ras-jsonrpc-macro", + "ras-jsonrpc-types", + "reqwest", + "schemars", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "playwright-rest-fixture" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "axum-extra", + "ras-auth-core", + "ras-rest-core", + "ras-rest-macro", + "reqwest", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "plotters" version = "0.3.7" diff --git a/Cargo.toml b/Cargo.toml index a4bf3cd..89b5a72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "examples/rest-wasm-example/rest-api", "examples/rest-wasm-example/rest-backend", "examples/wasm-ui-demo", + "tests/playwright/fixtures/*", ] resolver = "3" diff --git a/crates/rest/ras-rest-macro/src/api_explorer_template.html b/crates/rest/ras-rest-macro/src/api_explorer_template.html new file mode 100644 index 0000000..fdd8730 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/api_explorer_template.html @@ -0,0 +1,1169 @@ + + + + + + API Explorer + + + +
+ + +
+
+
+

Select an operation

+

Choose an operation to prepare a request.

+
+
+ + +
+
+
+
+
+ Request + +
+
+
No operation selected.
+
+
+
+
+ Saved requests + +
+
+
+
+
+ + +
+
+ + + + diff --git a/crates/rest/ras-rest-macro/src/static_hosting.rs b/crates/rest/ras-rest-macro/src/static_hosting.rs index 5d3306f..239cc0d 100644 --- a/crates/rest/ras-rest-macro/src/static_hosting.rs +++ b/crates/rest/ras-rest-macro/src/static_hosting.rs @@ -1,20 +1,17 @@ -//! Static file hosting module for REST services -//! -//! This module provides functionality to serve static files, particularly for API documentation -//! and OpenAPI spec hosting. +//! Static API explorer hosting for REST services use crate::ServiceDefinition; use proc_macro2::TokenStream; use quote::quote; -/// Configuration for static file hosting +/// Configuration for static file hosting. #[derive(Debug, Clone)] pub struct StaticHostingConfig { - /// Whether to enable static hosting + /// Whether to enable static hosting. pub serve_docs: bool, - /// URL path for documentation (default "/docs") + /// URL path for documentation (default "/docs"). pub docs_path: String, - /// UI theme selection (default "default") + /// UI theme selection retained for macro compatibility. pub ui_theme: String, } @@ -28,7 +25,7 @@ impl Default for StaticHostingConfig { } } -/// Generates static file serving routes code +/// Generates static API explorer handler code. pub fn generate_static_hosting_code( service_def: &ServiceDefinition, static_config: &StaticHostingConfig, @@ -37,1167 +34,49 @@ pub fn generate_static_hosting_code( return quote! {}; } + const TEMPLATE_CONTENT: &str = include_str!("api_explorer_template.html"); + let service_name = &service_def.service_name; - let base_path = &service_def.base_path; - let docs_path = &static_config.docs_path; - let ui_theme = &static_config.ui_theme; + let base_path = service_def.base_path.trim_end_matches('/').to_string(); + let docs_path = ensure_leading_slash(&static_config.docs_path); + let openapi_route = format!("{}/openapi.json", docs_path.trim_end_matches('/')); + let spec_path = join_paths(&base_path, &openapi_route); + let api_base_path = if base_path.is_empty() { + "/".to_string() + } else { + base_path + }; let openapi_fn_name = quote::format_ident!( "generate_{}_openapi", service_name.to_string().to_lowercase() ); - let docs_handler_name = quote::format_ident!("{}_docs_handler", service_name.to_string().to_lowercase()); + let template_lit = syn::LitStr::new(TEMPLATE_CONTENT, proc_macro2::Span::call_site()); quote! { #[cfg(feature = "server")] - // Handler for serving the documentation index async fn #docs_handler_name() -> ::axum::response::Html { - let openapi_spec = #openapi_fn_name(); - let spec_json = ::serde_json::to_string_pretty(&openapi_spec) - .unwrap_or_else(|_| "{}".to_string()); - - let html_content = generate_docs_html(&spec_json, #ui_theme, #base_path, #docs_path); - ::axum::response::Html(html_content) - } - - #[cfg(feature = "server")] - // Generate HTML content for the API explorer page - fn generate_docs_html(openapi_spec: &str, theme: &str, base_path: &str, docs_path: &str) -> String { - format!( - r#" - - - - - {} - REST API Explorer - - - - - - -
- - - - -
- -
-

{} API Explorer

-
- -
-
+ const TEMPLATE: &str = #template_lit; - -
-
-
- GET -

Select an endpoint

-
-
- Choose an endpoint from the sidebar to get started -
-
+ let html = TEMPLATE + .replace("{SERVICE_NAME_JSON}", &::serde_json::to_string(stringify!(#service_name)).unwrap_or_else(|_| "\"API\"".to_string())) + .replace("{PROTOCOL_JSON}", &::serde_json::to_string("rest").unwrap_or_else(|_| "\"rest\"".to_string())) + .replace("{SPEC_PATH_JSON}", &::serde_json::to_string(#spec_path).unwrap_or_else(|_| "\"/openapi.json\"".to_string())) + .replace("{API_BASE_PATH_JSON}", &::serde_json::to_string(#api_base_path).unwrap_or_else(|_| "\"/\"".to_string())); -
-

Select an endpoint to see the request form

-
-
- - - -
-
- - - -"#, - stringify!(#service_name), - stringify!(#service_name), - "./docs/openapi.json", - #base_path - ) + ::axum::response::Html(html) } #[cfg(feature = "server")] - // Generate OpenAPI JSON endpoint handler async fn openapi_json_handler() -> ::axum::Json<::serde_json::Value> { ::axum::Json(#openapi_fn_name()) } } } -/// Generates route registrations for static hosting +/// Generates route registrations for static hosting. pub fn generate_static_routes( service_def: &ServiceDefinition, static_config: &StaticHostingConfig, @@ -1206,8 +85,8 @@ pub fn generate_static_routes( return quote! {}; } - let docs_path = &static_config.docs_path; - let openapi_path = format!("{}/openapi.json", docs_path); + let docs_path = ensure_leading_slash(&static_config.docs_path); + let openapi_path = format!("{}/openapi.json", docs_path.trim_end_matches('/')); let docs_handler_name = quote::format_ident!( "{}_docs_handler", service_def.service_name.to_string().to_lowercase() @@ -1216,10 +95,28 @@ pub fn generate_static_routes( quote! { #[cfg(feature = "server")] { - // Register static hosting routes router = router .route(#docs_path, ::axum::routing::get(#docs_handler_name)) .route(#openapi_path, ::axum::routing::get(openapi_json_handler)); } } } + +fn ensure_leading_slash(path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + } +} + +fn join_paths(base: &str, path: &str) -> String { + let base = base.trim_end_matches('/'); + let path = ensure_leading_slash(path); + + if base.is_empty() { + path + } else { + format!("{base}{path}") + } +} diff --git a/crates/rest/ras-rest-macro/tests/http_integration.rs b/crates/rest/ras-rest-macro/tests/http_integration.rs index c2b53fb..ebe908a 100644 --- a/crates/rest/ras-rest-macro/tests/http_integration.rs +++ b/crates/rest/ras-rest-macro/tests/http_integration.rs @@ -468,6 +468,32 @@ async fn make_rest_request( request_builder.send().await } +#[tokio::test] +async fn test_docs_explorer_routes_generated() { + let (base_url, _handle) = create_rest_test_server().await; + + let docs_response = reqwest::get(format!("{}/api/v1/docs", base_url)) + .await + .unwrap(); + assert_eq!(docs_response.status(), 200); + + let docs = docs_response.text().await.unwrap(); + assert!(docs.contains("\"TestRestService\"")); + assert!(docs.contains("\"rest\"")); + assert!(docs.contains("/api/v1/docs/openapi.json")); + assert!(docs.contains("Bearer token")); + assert!(docs.contains("Saved requests")); + + let spec_response = reqwest::get(format!("{}/api/v1/docs/openapi.json", base_url)) + .await + .unwrap(); + assert_eq!(spec_response.status(), 200); + + let spec: serde_json::Value = spec_response.json().await.unwrap(); + assert_eq!(spec["info"]["title"], "TestRestService REST API"); + assert!(spec["paths"].is_object()); +} + #[tokio::test] async fn test_unauthorized_endpoints() { let (base_url, _handle) = create_rest_test_server().await; diff --git a/crates/rest/ras-rest-macro/tests/xss_protection_test.rs b/crates/rest/ras-rest-macro/tests/xss_protection_test.rs index c1b1784..7ffdb1e 100644 --- a/crates/rest/ras-rest-macro/tests/xss_protection_test.rs +++ b/crates/rest/ras-rest-macro/tests/xss_protection_test.rs @@ -19,11 +19,13 @@ fn test_xss_protection_in_generated_html() { #[test] fn test_generated_docs_do_not_store_jwt_in_local_storage() { - let source = include_str!("../src/static_hosting.rs"); - assert!(!source.contains("localStorage.getItem('jwt-token')")); - assert!(!source.contains("localStorage.setItem('jwt-token'")); - assert!(!source.contains("localStorage.removeItem('jwt-token'")); - assert!(source.contains("sessionStorage.setItem('jwt-token'")); + let template = include_str!("../src/api_explorer_template.html"); + assert!(!template.contains("localStorage.getItem('jwt-token')")); + assert!(!template.contains("localStorage.setItem('jwt-token'")); + assert!(!template.contains("localStorage.removeItem('jwt-token'")); + assert!(!template.contains("localStorage.setItem(`${storagePrefix}:bearer-token`")); + assert!(template.contains("sessionStorage.setItem(`${storagePrefix}:${key}`")); + assert!(template.contains("localStorage.setItem(\"ras-explorer-theme\"")); } fn escape_html(unsafe_str: &str) -> String { diff --git a/crates/rpc/ras-jsonrpc-macro/src/api_explorer_template.html b/crates/rpc/ras-jsonrpc-macro/src/api_explorer_template.html new file mode 100644 index 0000000..558eab4 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/api_explorer_template.html @@ -0,0 +1,439 @@ + + + + + + API Explorer + + + +
+ +
+
+
+

Select an operation

+

Choose an operation to prepare a request.

+
+
+ + +
+
+
+
+
+ Request + +
+
+
No operation selected.
+
+
+
+
+ Saved requests + +
+
+
+
+
+ +
+
+ + + diff --git a/crates/rpc/ras-jsonrpc-macro/src/jsonrpc_explorer_template.html b/crates/rpc/ras-jsonrpc-macro/src/jsonrpc_explorer_template.html deleted file mode 100644 index 9d0eb48..0000000 --- a/crates/rpc/ras-jsonrpc-macro/src/jsonrpc_explorer_template.html +++ /dev/null @@ -1,1212 +0,0 @@ - - - - - - {SERVICE_NAME} - JSON-RPC API Explorer - - - - - - -
- - - - -
- -
-

{SERVICE_NAME} JSON-RPC Explorer

-
- -
-
- - -
-
-
-

Select a method

-
-
- Choose a method from the sidebar to get started -
-
- -
-

Select a method to see the request form

-
-
- - - -
-
- - -
- - - - diff --git a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs index 60b6b9d..f2eb4b6 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs @@ -29,12 +29,9 @@ pub fn generate_static_hosting_code( return TokenStream::new(); } - // Load the template content at macro compile time - const TEMPLATE_CONTENT: &str = include_str!("jsonrpc_explorer_template.html"); + const TEMPLATE_CONTENT: &str = include_str!("api_explorer_template.html"); let explorer_path_suffix = &config.explorer_path; - // Use path relative to explorer directory - let openrpc_path_js = "explorer/openrpc.json".to_string(); let service_name_str = service_name.to_string(); let service_name_lower = service_name_str.to_lowercase(); let openrpc_fn_name_str = ["generate_", &service_name_lower, "_openrpc"].concat(); @@ -55,17 +52,21 @@ pub fn generate_static_hosting_code( let serve_explorer = { let base_path = base_path.to_string(); - move || async move { - // Template is embedded at macro expansion time - const TEMPLATE: &str = #template_lit; + let openrpc_path = openrpc_path.clone(); + move || { + let base_path = base_path.clone(); + let openrpc_path = openrpc_path.clone(); + async move { + const TEMPLATE: &str = #template_lit; - // Replace placeholders - let html = TEMPLATE - .replace("{SERVICE_NAME}", #service_name_str) - .replace("{OPENRPC_PATH}", &#openrpc_path_js) - .replace("{RPC_BASE_PATH}", &base_path); + let html = TEMPLATE + .replace("{SERVICE_NAME_JSON}", &::serde_json::to_string(#service_name_str).unwrap_or_else(|_| "\"API\"".to_string())) + .replace("{PROTOCOL_JSON}", &::serde_json::to_string("jsonrpc").unwrap_or_else(|_| "\"jsonrpc\"".to_string())) + .replace("{SPEC_PATH_JSON}", &::serde_json::to_string(&openrpc_path).unwrap_or_else(|_| "\"openrpc.json\"".to_string())) + .replace("{API_BASE_PATH_JSON}", &::serde_json::to_string(&base_path).unwrap_or_else(|_| "\"/\"".to_string())); - Html(html) + Html(html) + } } }; diff --git a/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs index 28f04a4..4d0c0d1 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs @@ -61,8 +61,11 @@ mod tests { assert_eq!(response.status(), 200); let content = response.text().await.unwrap(); - assert!(content.contains("UserService JSON-RPC Explorer")); - assert!(content.contains("Authentication")); + assert!(content.contains("\"UserService\"")); + assert!(content.contains("Bearer token")); + assert!(content.contains("Saved requests")); + assert!(content.contains("History")); + assert!(content.contains("\"jsonrpc\"")); // Test that the OpenRPC document is accessible let response = reqwest::get(format!("http://{}/explorer/openrpc.json", addr)) diff --git a/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs index cd5704a..5c92e87 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs @@ -1,8 +1,10 @@ #[test] fn test_generated_explorer_does_not_store_jwt_in_local_storage() { - let template = include_str!("../src/jsonrpc_explorer_template.html"); + let template = include_str!("../src/api_explorer_template.html"); assert!(!template.contains("localStorage.getItem('jwt-token')")); assert!(!template.contains("localStorage.setItem('jwt-token'")); assert!(!template.contains("localStorage.removeItem('jwt-token'")); - assert!(template.contains("sessionStorage.setItem('jwt-token'")); + assert!(!template.contains("localStorage.setItem(`${storagePrefix}:bearer-token`")); + assert!(template.contains("sessionStorage.setItem(`${storagePrefix}:${key}`")); + assert!(template.contains("localStorage.setItem(\"ras-explorer-theme\"")); } diff --git a/tests/playwright/README.md b/tests/playwright/README.md new file mode 100644 index 0000000..e23142c --- /dev/null +++ b/tests/playwright/README.md @@ -0,0 +1,28 @@ +# 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. + +## Local setup + +```bash +npm --prefix tests/playwright install +npm --prefix tests/playwright run install:browsers +npm --prefix tests/playwright test +``` + +For headed debugging: + +```bash +npm --prefix tests/playwright run test:headed +``` + +The fixtures use: + +- REST: `http://127.0.0.1:3101/api/v1/docs` +- JSON-RPC: `http://127.0.0.1:3102/rpc/explorer` + +Test tokens: + +- `user-token` +- `admin-token` diff --git a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml new file mode 100644 index 0000000..84ce56e --- /dev/null +++ b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "playwright-jsonrpc-fixture" +version = "0.0.0" +edition = "2024" +publish = false + +[features] +default = ["server"] +server = ["ras-jsonrpc-macro/server"] +client = ["ras-jsonrpc-macro/client"] + +[dependencies] +anyhow = { workspace = true } +axum = { workspace = true } +ras-auth-core = { path = "../../../../crates/core/ras-auth-core" } +ras-jsonrpc-core = { path = "../../../../crates/rpc/ras-jsonrpc-core" } +ras-jsonrpc-macro = { path = "../../../../crates/rpc/ras-jsonrpc-macro" } +ras-jsonrpc-types = { path = "../../../../crates/rpc/ras-jsonrpc-types" } +reqwest = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs new file mode 100644 index 0000000..18fa869 --- /dev/null +++ b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs @@ -0,0 +1,105 @@ +use std::collections::HashSet; + +use anyhow::Result; +use axum::Router; +use ras_jsonrpc_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use ras_jsonrpc_macro::jsonrpc_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PingRequest { + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PingResponse { + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateWidgetRequest { + pub name: String, + pub owner: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct Widget { + pub id: String, + pub name: String, + pub owner: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ProfileResponse { + pub user_id: String, + pub permissions: Vec, +} + +jsonrpc_service!({ + service_name: ExplorerRpcFixture, + openrpc: true, + explorer: true, + methods: [ + UNAUTHORIZED ping(PingRequest) -> PingResponse, + UNAUTHORIZED no_params(()) -> String, + WITH_PERMISSIONS(["admin"]) create_widget(CreateWidgetRequest) -> Widget, + WITH_PERMISSIONS(["user"]) current_profile(()) -> ProfileResponse, + ] +}); + +struct FixtureAuthProvider; + +impl AuthProvider for FixtureAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + let (user_id, permissions) = match token.as_str() { + "user-token" => ("user-1", vec!["user"]), + "admin-token" => ("admin-1", vec!["user", "admin"]), + _ => return Err(AuthError::InvalidToken), + }; + + Ok(AuthenticatedUser { + user_id: user_id.to_string(), + permissions: permissions + .into_iter() + .map(str::to_string) + .collect::>(), + metadata: None, + }) + }) + } +} + +#[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), + }) + }) + .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, + }) + }) + .current_profile_handler(|user, _request| async move { + Ok(ProfileResponse { + user_id: user.user_id.clone(), + permissions: user.permissions.iter().cloned().collect(), + }) + }) + .build() + .expect("fixture JSON-RPC service should build"); + + let app = Router::new().merge(rpc_router); + let listener = tokio::net::TcpListener::bind("127.0.0.1:3102").await?; + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/tests/playwright/fixtures/rest-fixture/Cargo.toml b/tests/playwright/fixtures/rest-fixture/Cargo.toml new file mode 100644 index 0000000..7254c85 --- /dev/null +++ b/tests/playwright/fixtures/rest-fixture/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "playwright-rest-fixture" +version = "0.0.0" +edition = "2024" +publish = false + +[features] +default = ["server"] +server = ["ras-rest-macro/server"] +client = ["ras-rest-macro/client"] + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +axum-extra = { workspace = true } +ras-auth-core = { path = "../../../../crates/core/ras-auth-core" } +ras-rest-core = { path = "../../../../crates/rest/ras-rest-core" } +ras-rest-macro = { path = "../../../../crates/rest/ras-rest-macro" } +reqwest = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/tests/playwright/fixtures/rest-fixture/src/main.rs b/tests/playwright/fixtures/rest-fixture/src/main.rs new file mode 100644 index 0000000..253fa84 --- /dev/null +++ b/tests/playwright/fixtures/rest-fixture/src/main.rs @@ -0,0 +1,146 @@ +use std::collections::HashSet; + +use anyhow::Result; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use ras_rest_core::{RestResponse, RestResult}; +use ras_rest_macro::rest_service; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct HealthResponse { + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct Widget { + pub id: String, + pub name: String, + pub owner: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct WidgetsResponse { + pub widgets: Vec, + pub total: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateWidgetRequest { + pub name: String, + pub owner: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ProfileResponse { + pub user_id: String, + pub permissions: Vec, +} + +rest_service!({ + service_name: ExplorerRestFixture, + base_path: "/api/v1", + openapi: true, + serve_docs: true, + docs_path: "/docs", + endpoints: [ + GET UNAUTHORIZED health() -> HealthResponse, + GET UNAUTHORIZED widgets/{id: String}() -> Widget, + GET UNAUTHORIZED search/widgets ? q: String & limit: Option () -> WidgetsResponse, + POST WITH_PERMISSIONS(["admin"]) widgets(CreateWidgetRequest) -> Widget, + GET WITH_PERMISSIONS(["user"]) profile() -> ProfileResponse, + ] +}); + +struct FixtureAuthProvider; + +impl AuthProvider for FixtureAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + let (user_id, permissions) = match token.as_str() { + "user-token" => ("user-1", vec!["user"]), + "admin-token" => ("admin-1", vec!["user", "admin"]), + _ => return Err(AuthError::InvalidToken), + }; + + Ok(AuthenticatedUser { + user_id: user_id.to_string(), + permissions: permissions + .into_iter() + .map(str::to_string) + .collect::>(), + metadata: None, + }) + }) + } +} + +struct FixtureService; + +#[async_trait::async_trait] +impl ExplorerRestFixtureTrait for FixtureService { + async fn get_health(&self) -> RestResult { + Ok(RestResponse::ok(HealthResponse { + status: "ok".to_string(), + })) + } + + async fn get_widgets_by_id(&self, id: String) -> RestResult { + Ok(RestResponse::ok(Widget { + id, + name: "Fixture Widget".to_string(), + owner: "public".to_string(), + })) + } + + async fn get_search_widgets( + &self, + q: String, + limit: Option, + ) -> RestResult { + let count = limit.unwrap_or(2).min(5) as usize; + let widgets = (0..count) + .map(|idx| Widget { + id: format!("widget-{idx}"), + name: format!("{q}-{idx}"), + owner: "search".to_string(), + }) + .collect::>(); + + Ok(RestResponse::ok(WidgetsResponse { + total: widgets.len(), + widgets, + })) + } + + async fn post_widgets( + &self, + _user: &AuthenticatedUser, + request: CreateWidgetRequest, + ) -> RestResult { + Ok(RestResponse::created(Widget { + id: "created-widget".to_string(), + name: request.name, + owner: request.owner, + })) + } + + async fn get_profile(&self, user: &AuthenticatedUser) -> RestResult { + Ok(RestResponse::ok(ProfileResponse { + user_id: user.user_id.clone(), + permissions: user.permissions.iter().cloned().collect(), + })) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let app = ExplorerRestFixtureBuilder::new(FixtureService) + .auth_provider(FixtureAuthProvider) + .build(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:3101").await?; + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/tests/playwright/package-lock.json b/tests/playwright/package-lock.json new file mode 100644 index 0000000..5794c72 --- /dev/null +++ b/tests/playwright/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "ras-api-explorer-e2e", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ras-api-explorer-e2e", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "1.58.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tests/playwright/package.json b/tests/playwright/package.json new file mode 100644 index 0000000..f933c92 --- /dev/null +++ b/tests/playwright/package.json @@ -0,0 +1,13 @@ +{ + "name": "ras-api-explorer-e2e", + "version": "0.0.0", + "private": true, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "install:browsers": "playwright install --with-deps chromium" + }, + "devDependencies": { + "@playwright/test": "1.58.2" + } +} diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts new file mode 100644 index 0000000..2d66581 --- /dev/null +++ b/tests/playwright/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list'], ['html', { open: 'never' }]], + use: { + baseURL: 'http://127.0.0.1', + launchOptions: { + slowMo: Number(process.env.PLAYWRIGHT_SLOW_MO ?? 0) + }, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ], + webServer: [ + { + command: 'cargo run -p playwright-rest-fixture', + url: 'http://127.0.0.1:3101/api/v1/docs/openapi.json', + reuseExistingServer: !process.env.CI, + timeout: 120_000 + }, + { + command: 'cargo run -p playwright-jsonrpc-fixture', + url: 'http://127.0.0.1:3102/rpc/explorer/openrpc.json', + reuseExistingServer: !process.env.CI, + timeout: 120_000 + } + ] +}); diff --git a/tests/playwright/tests/jsonrpc-explorer.spec.ts b/tests/playwright/tests/jsonrpc-explorer.spec.ts new file mode 100644 index 0000000..b90e54c --- /dev/null +++ b/tests/playwright/tests/jsonrpc-explorer.spec.ts @@ -0,0 +1,113 @@ +import { expect, test, type Page } from '@playwright/test'; + +const RPC_URL = 'http://127.0.0.1:3102/rpc/explorer'; + +async function selectMethod(page: Page, name: string) { + await page.locator('.op').filter({ hasText: name }).click(); +} + +async function send(page: Page) { + await page.locator('#send-request').click(); +} + +test.describe('JSON-RPC API explorer', () => { + test.beforeEach(async ({ page }) => { + await page.goto(RPC_URL); + await expect(page.locator('#service-name')).toContainText('ExplorerRpcFixture Explorer'); + await expect(page.locator('#operation-list .op').first()).toBeVisible(); + }); + + test('loads in dark mode and renders OpenRPC methods', async ({ page }) => { + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(page.locator('#service-subtitle')).toContainText('JSON-RPC OpenRPC'); + await expect(page.locator('#operation-list')).toContainText('ping'); + await expect(page.locator('#operation-list')).toContainText('create_widget'); + await expect(page.locator('#operation-list')).toContainText('current_profile'); + }); + + test('searches methods and switches params editor without stale UI', async ({ page }) => { + await page.locator('#operation-search').fill('create'); + await expect(page.locator('.op').filter({ hasText: 'create_widget' })).toBeVisible(); + await expect(page.locator('.op').filter({ hasText: 'ping' })).toHaveCount(0); + + await page.locator('#operation-search').fill(''); + await selectMethod(page, 'ping'); + await expect(page.locator('#params-editor')).toBeVisible(); + await page.locator('#params-editor').fill(JSON.stringify({ message: 'hello' }, null, 2)); + await expect(page.locator('#request-url')).toHaveText('http://127.0.0.1:3102/rpc'); + + await selectMethod(page, 'no_params'); + await expect(page.locator('#params-editor')).toHaveCount(0); + await expect(page.locator('#request-form')).toContainText('This method has no params.'); + + await selectMethod(page, 'create_widget'); + await expect(page.locator('#params-editor')).toBeVisible(); + await expect(page.locator('#params-editor')).not.toHaveValue(/hello/); + }); + + test('sends public and authenticated JSON-RPC requests', async ({ page }) => { + await selectMethod(page, 'ping'); + await page.locator('#params-editor').fill(JSON.stringify({ message: 'browser' }, null, 2)); + const originalRequestId = await page.locator('#rpc-request-id').inputValue(); + await page.getByRole('button', { name: 'Regenerate' }).click(); + await expect(page.locator('#rpc-request-id')).not.toHaveValue(originalRequestId); + + await send(page); + await expect(page.locator('#response-status')).toContainText('200'); + await expect(page.locator('#response-output')).toContainText('pong: browser'); + await expect(page.locator('#history-list')).toContainText('RPC ping'); + + await selectMethod(page, 'create_widget'); + await page.locator('#params-editor').fill(JSON.stringify({ name: 'RPC Widget', owner: 'playwright' }, null, 2)); + await send(page); + await expect(page.locator('#response-status')).toContainText('RPC error'); + await expect(page.locator('#response-output')).toContainText('Authentication'); + + await page.locator('#jwt-token').fill('admin-token'); + await page.locator('#save-token').click(); + await expect(page.locator('#auth-state')).toContainText('Token set'); + await send(page); + await expect(page.locator('#response-status')).toContainText('200'); + await expect(page.locator('#response-output')).toContainText('rpc-created-widget'); + await expect(page.locator('#response-output')).toContainText('RPC Widget'); + + await page.locator('[data-response-tab="request"]').click(); + await expect(page.locator('#response-output')).toContainText('create_widget'); + await expect(page.locator('#response-output')).toContainText('RPC Widget'); + }); + + test('saves JSON-RPC requests, restores history, and keeps tokens out of localStorage', async ({ page }) => { + await selectMethod(page, 'create_widget'); + await page.locator('#jwt-token').fill('admin-token'); + await page.locator('#save-token').click(); + await page.locator('#params-editor').fill(JSON.stringify({ name: 'Saved RPC', owner: 'saved-owner' }, null, 2)); + + page.once('dialog', async (dialog) => { + await dialog.accept('rpc saved request'); + }); + await page.locator('#save-request').click(); + await expect(page.locator('#saved-list')).toContainText('rpc saved request'); + + await page.locator('#params-editor').fill(JSON.stringify({ name: 'Changed RPC', owner: 'changed' }, null, 2)); + await page.locator('#saved-list').getByRole('button', { name: 'Load' }).click(); + await expect(page.locator('#params-editor')).toHaveValue(/Saved RPC/); + + await send(page); + await expect(page.locator('#history-list')).toContainText('RPC create_widget'); + await page.locator('#params-editor').fill(JSON.stringify({ name: 'After History', owner: 'changed' }, null, 2)); + await page.locator('#history-list').getByRole('button', { name: 'Load request' }).first().click(); + await expect(page.locator('#params-editor')).toHaveValue(/Saved RPC/); + + await page.reload(); + await expect(page.locator('#service-name')).toContainText('ExplorerRpcFixture Explorer'); + await selectMethod(page, 'create_widget'); + await expect(page.locator('#saved-list')).toContainText('rpc saved request'); + await expect(page.locator('#history-list')).toContainText('RPC create_widget'); + + const localStorageValues = await page.evaluate(() => Object.values(localStorage).join('\n')); + expect(localStorageValues).not.toContain('admin-token'); + expect(localStorageValues).not.toContain('user-token'); + const sessionStorageValues = await page.evaluate(() => Object.values(sessionStorage).join('\n')); + expect(sessionStorageValues).toContain('admin-token'); + }); +}); diff --git a/tests/playwright/tests/rest-explorer.spec.ts b/tests/playwright/tests/rest-explorer.spec.ts new file mode 100644 index 0000000..b3361d2 --- /dev/null +++ b/tests/playwright/tests/rest-explorer.spec.ts @@ -0,0 +1,120 @@ +import { expect, test, type Page } from '@playwright/test'; + +const REST_URL = 'http://127.0.0.1:3101/api/v1/docs'; + +async function selectOperation(page: Page, method: string, path: string) { + await page.locator('.op').filter({ hasText: method }).filter({ hasText: path }).click(); +} + +async function send(page: Page) { + await page.locator('#send-request').click(); +} + +test.describe('REST API explorer', () => { + test.beforeEach(async ({ page }) => { + await page.goto(REST_URL); + await expect(page.locator('#service-name')).toContainText('ExplorerRestFixture Explorer'); + await expect(page.locator('#operation-list .op').first()).toBeVisible(); + }); + + test('loads in dark mode and renders OpenAPI operations', async ({ page }) => { + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(page.locator('#service-subtitle')).toContainText('REST OpenAPI'); + await expect(page.locator('#operation-list')).toContainText('/health'); + await expect(page.locator('#operation-list')).toContainText('/widgets'); + await expect(page.locator('#operation-list')).toContainText('/search/widgets'); + }); + + test('searches operations and switches request forms without stale UI', async ({ page }) => { + await page.locator('#operation-search').fill('search'); + await expect(page.locator('.op').filter({ hasText: '/search/widgets' })).toBeVisible(); + await expect(page.locator('.op').filter({ hasText: '/health' })).toHaveCount(0); + + await page.locator('#operation-search').fill(''); + await selectOperation(page, 'GET', '/search/widgets'); + await page.locator('[data-query-param="q"]').fill('alpha'); + await page.locator('[data-query-param="limit"]').fill('2'); + await expect(page.locator('#request-url')).toContainText('/api/v1/search/widgets'); + await expect(page.locator('#request-url')).toContainText('q=alpha'); + await expect(page.locator('#request-url')).toContainText('limit=2'); + + await selectOperation(page, 'GET', '/widgets/{id}'); + await expect(page.locator('[data-query-param="q"]')).toHaveCount(0); + await page.locator('[data-path-param="id"]').fill('widget-123'); + await expect(page.locator('#request-url')).toContainText('/api/v1/widgets/widget-123'); + + await selectOperation(page, 'GET', '/health'); + await expect(page.locator('#body-editor')).toHaveCount(0); + await expect(page.locator('[data-path-param="id"]')).toHaveCount(0); + }); + + test('sends public and authenticated requests, then records history', async ({ page }) => { + await selectOperation(page, 'GET', '/health'); + await send(page); + await expect(page.locator('#response-status')).toContainText('200'); + await expect(page.locator('#response-output')).toContainText('"status": "ok"'); + await expect(page.locator('#history-list')).toContainText('GET /health'); + + await selectOperation(page, 'POST', '/widgets'); + await page.locator('#body-editor').fill(JSON.stringify({ name: 'Created From UI', owner: 'playwright' }, null, 2)); + await send(page); + await expect(page.locator('#response-status')).toContainText('401'); + + await page.locator('#jwt-token').fill('admin-token'); + await page.locator('#save-token').click(); + await expect(page.locator('#auth-state')).toContainText('Token set'); + await send(page); + await expect(page.locator('#response-status')).toContainText('201'); + await expect(page.locator('#response-output')).toContainText('created-widget'); + await expect(page.locator('#response-output')).toContainText('Created From UI'); + + await page.locator('[data-response-tab="headers"]').click(); + await expect(page.locator('#response-output')).toContainText('content-type'); + await page.locator('[data-response-tab="request"]').click(); + await expect(page.locator('#response-output')).toContainText('Created From UI'); + }); + + test('saves requests, restores history, and keeps tokens out of localStorage', async ({ page }) => { + await selectOperation(page, 'POST', '/widgets'); + await page.locator('#jwt-token').fill('admin-token'); + await page.locator('#save-token').click(); + await page.locator('#body-editor').fill(JSON.stringify({ name: 'Saved Body', owner: 'saved-owner' }, null, 2)); + + page.once('dialog', async (dialog) => { + await dialog.accept('rest saved request'); + }); + await page.locator('#save-request').click(); + await expect(page.locator('#saved-list')).toContainText('rest saved request'); + + await page.locator('#body-editor').fill(JSON.stringify({ name: 'Changed Body', owner: 'changed' }, null, 2)); + await page.locator('#saved-list').getByRole('button', { name: 'Load' }).click(); + await expect(page.locator('#body-editor')).toHaveValue(/Saved Body/); + + await send(page); + await expect(page.locator('#history-list')).toContainText('POST /widgets'); + await page.locator('#body-editor').fill(JSON.stringify({ name: 'After History', owner: 'changed' }, null, 2)); + await page.locator('#history-list').getByRole('button', { name: 'Load request' }).first().click(); + await expect(page.locator('#body-editor')).toHaveValue(/Saved Body/); + + await page.reload(); + await expect(page.locator('#service-name')).toContainText('ExplorerRestFixture Explorer'); + await selectOperation(page, 'POST', '/widgets'); + await expect(page.locator('#saved-list')).toContainText('rest saved request'); + await expect(page.locator('#history-list')).toContainText('POST /widgets'); + + const localStorageValues = await page.evaluate(() => Object.values(localStorage).join('\n')); + expect(localStorageValues).not.toContain('admin-token'); + expect(localStorageValues).not.toContain('user-token'); + const sessionStorageValues = await page.evaluate(() => Object.values(sessionStorage).join('\n')); + expect(sessionStorageValues).toContain('admin-token'); + }); + + test('persists theme preference in localStorage', async ({ page }) => { + await page.locator('#theme-toggle').click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + const theme = await page.evaluate(() => localStorage.getItem('ras-explorer-theme')); + expect(theme).toBe('light'); + }); +}); From d6b621df4419d42638f169a7bf0766d35fe16062 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Apr 2026 20:55:57 +0200 Subject: [PATCH 2/3] Address API explorer review feedback --- .../src/api_explorer_template.html | 14 +- .../rest/ras-rest-macro/src/static_hosting.rs | 26 +- .../ras-rest-macro/tests/http_integration.rs | 4 +- .../src/api_explorer_template.html | 439 ------------------ .../ras-jsonrpc-macro/src/static_hosting.rs | 33 +- .../ras-jsonrpc-macro/tests/explorer_test.rs | 6 +- .../tests/explorer_token_storage_test.rs | 2 +- tests/playwright/README.md | 6 + .../fixtures/jsonrpc-fixture/src/main.rs | 4 +- .../fixtures/rest-fixture/src/main.rs | 4 +- tests/playwright/playwright.config.ts | 15 +- .../playwright/tests/jsonrpc-explorer.spec.ts | 14 +- tests/playwright/tests/rest-explorer.spec.ts | 15 +- 13 files changed, 95 insertions(+), 487 deletions(-) delete mode 100644 crates/rpc/ras-jsonrpc-macro/src/api_explorer_template.html diff --git a/crates/rest/ras-rest-macro/src/api_explorer_template.html b/crates/rest/ras-rest-macro/src/api_explorer_template.html index fdd8730..6b19b82 100644 --- a/crates/rest/ras-rest-macro/src/api_explorer_template.html +++ b/crates/rest/ras-rest-macro/src/api_explorer_template.html @@ -459,7 +459,7 @@

Response

- History + History (last 30)
@@ -469,13 +469,10 @@

Response

+ + - - diff --git a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs index f2eb4b6..5dc0807 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs @@ -29,7 +29,8 @@ pub fn generate_static_hosting_code( return TokenStream::new(); } - const TEMPLATE_CONTENT: &str = include_str!("api_explorer_template.html"); + const TEMPLATE_CONTENT: &str = + include_str!("../../../rest/ras-rest-macro/src/api_explorer_template.html"); let explorer_path_suffix = &config.explorer_path; let service_name_str = service_name.to_string(); @@ -50,22 +51,26 @@ pub fn generate_static_hosting_code( let explorer_path = format!("{}{}", base_path, #explorer_path_suffix); let openrpc_path = format!("{}/openrpc.json", &explorer_path); + let explorer_html = { + const TEMPLATE: &str = #template_lit; + let config_json = ::serde_json::json!({ + "serviceName": #service_name_str, + "protocol": "jsonrpc", + "specPath": &openrpc_path, + "apiBasePath": base_path + }) + .to_string() + .replace("<", "\\u003c"); + + ::std::sync::Arc::new(TEMPLATE.replace("{EXPLORER_CONFIG_JSON}", &config_json)) + }; + let serve_explorer = { - let base_path = base_path.to_string(); - let openrpc_path = openrpc_path.clone(); + let explorer_html = explorer_html.clone(); move || { - let base_path = base_path.clone(); - let openrpc_path = openrpc_path.clone(); + let explorer_html = explorer_html.clone(); async move { - const TEMPLATE: &str = #template_lit; - - let html = TEMPLATE - .replace("{SERVICE_NAME_JSON}", &::serde_json::to_string(#service_name_str).unwrap_or_else(|_| "\"API\"".to_string())) - .replace("{PROTOCOL_JSON}", &::serde_json::to_string("jsonrpc").unwrap_or_else(|_| "\"jsonrpc\"".to_string())) - .replace("{SPEC_PATH_JSON}", &::serde_json::to_string(&openrpc_path).unwrap_or_else(|_| "\"openrpc.json\"".to_string())) - .replace("{API_BASE_PATH_JSON}", &::serde_json::to_string(&base_path).unwrap_or_else(|_| "\"/\"".to_string())); - - Html(html) + Html((*explorer_html).clone()) } } }; diff --git a/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs index 4d0c0d1..31ebd6d 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs @@ -62,9 +62,9 @@ mod tests { let content = response.text().await.unwrap(); assert!(content.contains("\"UserService\"")); - assert!(content.contains("Bearer token")); - assert!(content.contains("Saved requests")); - assert!(content.contains("History")); + assert!(content.contains("id=\"jwt-token\"")); + assert!(content.contains("id=\"saved-list\"")); + assert!(content.contains("id=\"history-list\"")); assert!(content.contains("\"jsonrpc\"")); // Test that the OpenRPC document is accessible diff --git a/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs index 5c92e87..6d51852 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs @@ -1,6 +1,6 @@ #[test] fn test_generated_explorer_does_not_store_jwt_in_local_storage() { - let template = include_str!("../src/api_explorer_template.html"); + let template = include_str!("../../../rest/ras-rest-macro/src/api_explorer_template.html"); assert!(!template.contains("localStorage.getItem('jwt-token')")); assert!(!template.contains("localStorage.setItem('jwt-token'")); assert!(!template.contains("localStorage.removeItem('jwt-token'")); diff --git a/tests/playwright/README.md b/tests/playwright/README.md index e23142c..5510af7 100644 --- a/tests/playwright/README.md +++ b/tests/playwright/README.md @@ -22,6 +22,12 @@ The fixtures use: - REST: `http://127.0.0.1:3101/api/v1/docs` - JSON-RPC: `http://127.0.0.1:3102/rpc/explorer` +To avoid local port collisions, override the ports before running the suite: + +```bash +PLAYWRIGHT_REST_PORT=3201 PLAYWRIGHT_JSONRPC_PORT=3202 npm --prefix tests/playwright test +``` + Test tokens: - `user-token` diff --git a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs index 18fa869..a690edb 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs +++ b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs @@ -98,7 +98,9 @@ async fn main() -> Result<()> { .expect("fixture JSON-RPC service should build"); let app = Router::new().merge(rpc_router); - let listener = tokio::net::TcpListener::bind("127.0.0.1:3102").await?; + let bind_addr = + std::env::var("PLAYWRIGHT_JSONRPC_ADDR").unwrap_or_else(|_| "127.0.0.1:3102".to_string()); + let listener = tokio::net::TcpListener::bind(&bind_addr).await?; axum::serve(listener, app).await?; Ok(()) diff --git a/tests/playwright/fixtures/rest-fixture/src/main.rs b/tests/playwright/fixtures/rest-fixture/src/main.rs index 253fa84..b97c40d 100644 --- a/tests/playwright/fixtures/rest-fixture/src/main.rs +++ b/tests/playwright/fixtures/rest-fixture/src/main.rs @@ -139,7 +139,9 @@ async fn main() -> Result<()> { .auth_provider(FixtureAuthProvider) .build(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:3101").await?; + let bind_addr = + std::env::var("PLAYWRIGHT_REST_ADDR").unwrap_or_else(|_| "127.0.0.1:3101".to_string()); + let listener = tokio::net::TcpListener::bind(&bind_addr).await?; axum::serve(listener, app).await?; Ok(()) diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 2d66581..0126e22 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -1,5 +1,8 @@ import { defineConfig, devices } from '@playwright/test'; +const restPort = process.env.PLAYWRIGHT_REST_PORT ?? '3101'; +const jsonrpcPort = process.env.PLAYWRIGHT_JSONRPC_PORT ?? '3102'; + export default defineConfig({ testDir: './tests', fullyParallel: false, @@ -22,16 +25,16 @@ export default defineConfig({ ], webServer: [ { - command: 'cargo run -p playwright-rest-fixture', - url: 'http://127.0.0.1:3101/api/v1/docs/openapi.json', + command: `PLAYWRIGHT_REST_ADDR=127.0.0.1:${restPort} cargo run -p playwright-rest-fixture`, + url: `http://127.0.0.1:${restPort}/api/v1/docs/openapi.json`, reuseExistingServer: !process.env.CI, - timeout: 120_000 + timeout: 240_000 }, { - command: 'cargo run -p playwright-jsonrpc-fixture', - url: 'http://127.0.0.1:3102/rpc/explorer/openrpc.json', + command: `PLAYWRIGHT_JSONRPC_ADDR=127.0.0.1:${jsonrpcPort} cargo run -p playwright-jsonrpc-fixture`, + url: `http://127.0.0.1:${jsonrpcPort}/rpc/explorer/openrpc.json`, reuseExistingServer: !process.env.CI, - timeout: 120_000 + timeout: 240_000 } ] }); diff --git a/tests/playwright/tests/jsonrpc-explorer.spec.ts b/tests/playwright/tests/jsonrpc-explorer.spec.ts index b90e54c..d10376e 100644 --- a/tests/playwright/tests/jsonrpc-explorer.spec.ts +++ b/tests/playwright/tests/jsonrpc-explorer.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; -const RPC_URL = 'http://127.0.0.1:3102/rpc/explorer'; +const RPC_PORT = process.env.PLAYWRIGHT_JSONRPC_PORT ?? '3102'; +const RPC_URL = `http://127.0.0.1:${RPC_PORT}/rpc/explorer`; async function selectMethod(page: Page, name: string) { await page.locator('.op').filter({ hasText: name }).click(); @@ -34,7 +35,7 @@ test.describe('JSON-RPC API explorer', () => { await selectMethod(page, 'ping'); await expect(page.locator('#params-editor')).toBeVisible(); await page.locator('#params-editor').fill(JSON.stringify({ message: 'hello' }, null, 2)); - await expect(page.locator('#request-url')).toHaveText('http://127.0.0.1:3102/rpc'); + await expect(page.locator('#request-url')).toHaveText(`http://127.0.0.1:${RPC_PORT}/rpc`); await selectMethod(page, 'no_params'); await expect(page.locator('#params-editor')).toHaveCount(0); @@ -110,4 +111,13 @@ test.describe('JSON-RPC API explorer', () => { const sessionStorageValues = await page.evaluate(() => Object.values(sessionStorage).join('\n')); expect(sessionStorageValues).toContain('admin-token'); }); + + test('persists theme preference in localStorage', async ({ page }) => { + await page.locator('#theme-toggle').click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + const theme = await page.evaluate(() => localStorage.getItem('ras-explorer-theme')); + expect(theme).toBe('light'); + }); }); diff --git a/tests/playwright/tests/rest-explorer.spec.ts b/tests/playwright/tests/rest-explorer.spec.ts index b3361d2..d42a983 100644 --- a/tests/playwright/tests/rest-explorer.spec.ts +++ b/tests/playwright/tests/rest-explorer.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; -const REST_URL = 'http://127.0.0.1:3101/api/v1/docs'; +const REST_PORT = process.env.PLAYWRIGHT_REST_PORT ?? '3101'; +const REST_URL = `http://127.0.0.1:${REST_PORT}/api/v1/docs`; async function selectOperation(page: Page, method: string, path: string) { await page.locator('.op').filter({ hasText: method }).filter({ hasText: path }).click(); @@ -74,6 +75,18 @@ test.describe('REST API explorer', () => { await expect(page.locator('#response-output')).toContainText('Created From UI'); }); + test('shows permission denied responses for insufficient token permissions', async ({ page }) => { + await selectOperation(page, 'POST', '/widgets'); + await page.locator('#body-editor').fill(JSON.stringify({ name: 'Denied Widget', owner: 'playwright' }, null, 2)); + await page.locator('#jwt-token').fill('user-token'); + await page.locator('#save-token').click(); + await expect(page.locator('#auth-state')).toContainText('Token set'); + + await send(page); + await expect(page.locator('#response-status')).toContainText('403'); + await expect(page.locator('#history-list')).toContainText('403'); + }); + test('saves requests, restores history, and keeps tokens out of localStorage', async ({ page }) => { await selectOperation(page, 'POST', '/widgets'); await page.locator('#jwt-token').fill('admin-token'); From 67a632ba22983b6b2102fe1cb8f4f3862a9ce52c Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Apr 2026 21:13:43 +0200 Subject: [PATCH 3/3] Fix benchmark workflow bench target invocation --- .github/workflows/bench.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index f2f8d51..ead5953 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -30,18 +30,19 @@ jobs: run: | set -euo pipefail mkdir -p bench-output - for crate in \ - ras-jsonrpc-macro \ - ras-rest-macro \ - ras-file-macro \ - ras-jsonrpc-bidirectional-macro + while read -r crate bench do - echo "::group::$crate" - cargo bench -p "$crate" -- \ + echo "::group::$crate/$bench" + cargo bench -p "$crate" --bench "$bench" -- \ --warm-up-time 1 --measurement-time 3 \ - | tee "bench-output/$crate.txt" + | tee "bench-output/$crate-$bench.txt" echo "::endgroup::" - done + done <<'BENCHES' + ras-jsonrpc-macro dispatch + ras-rest-macro dispatch + ras-file-macro streaming + ras-jsonrpc-bidirectional-macro roundtrip + BENCHES - name: Upload bench artifacts if: always() uses: actions/upload-artifact@v4