From 069ce099389822e2389e91e176e6902e648669ad Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 9 May 2026 11:22:49 +0200 Subject: [PATCH 1/4] Add method doc comments to API docs --- crates/rest/ras-rest-macro/src/client.rs | 9 +-- crates/rest/ras-rest-macro/src/lib.rs | 59 +++++++++++++++++++ crates/rest/ras-rest-macro/src/openapi.rs | 27 ++++++++- crates/rest/ras-rest-macro/tests/e2e.rs | 1 + .../ras-rest-macro/tests/http_integration.rs | 26 ++++++-- crates/rpc/ras-jsonrpc-macro/src/lib.rs | 59 +++++++++++++++++++ crates/rpc/ras-jsonrpc-macro/src/openrpc.rs | 25 +++++++- .../ras-jsonrpc-macro/tests/integration.rs | 20 +++++++ .../fixtures/jsonrpc-fixture/src/main.rs | 3 + .../fixtures/rest-fixture/src/main.rs | 3 + .../playwright/tests/jsonrpc-explorer.spec.ts | 7 +++ tests/playwright/tests/rest-explorer.spec.ts | 7 +++ 12 files changed, 234 insertions(+), 12 deletions(-) diff --git a/crates/rest/ras-rest-macro/src/client.rs b/crates/rest/ras-rest-macro/src/client.rs index bd2f3cd..cccdb43 100644 --- a/crates/rest/ras-rest-macro/src/client.rs +++ b/crates/rest/ras-rest-macro/src/client.rs @@ -6,11 +6,12 @@ use syn::Type; /// segment as well as fully-qualified forms like `std::option::Option` / /// `core::option::Option` — anything whose last path segment is `Option`. fn is_option_type(ty: &Type) -> bool { - if let Type::Path(type_path) = ty { - if let Some(last) = type_path.path.segments.last() { - return last.ident == "Option"; - } + if let Type::Path(type_path) = ty + && let Some(last) = type_path.path.segments.last() + { + return last.ident == "Option"; } + false } diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index d7b97eb..1e03081 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -87,6 +87,7 @@ enum OpenApiConfig { #[derive(Debug)] struct EndpointDefinition { + docs: Option, method: HttpMethod, auth: AuthRequirement, path: String, @@ -97,6 +98,29 @@ struct EndpointDefinition { handler_name: Ident, } +#[derive(Debug)] +struct DocComment { + summary: String, + description: String, +} + +impl DocComment { + fn from_lines(lines: Vec) -> Option { + let lines: Vec = lines + .into_iter() + .map(|line| line.trim().to_string()) + .collect(); + let start = lines.iter().position(|line| !line.is_empty())?; + let end = lines.iter().rposition(|line| !line.is_empty())?; + let lines = &lines[start..=end]; + + Some(Self { + summary: lines[0].clone(), + description: lines.join("\n"), + }) + } +} + #[derive(Debug)] enum HttpMethod { Get, @@ -146,6 +170,38 @@ enum AuthRequirement { WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups } +const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`"; + +fn parse_doc_comment_attrs( + attrs: Vec, + entry_kind: &str, +) -> syn::Result> { + let lines = attrs + .into_iter() + .map(|attr| parse_doc_comment_attr(attr, entry_kind)) + .collect::>>()?; + + Ok(DocComment::from_lines(lines)) +} + +fn parse_doc_comment_attr(attr: syn::Attribute, entry_kind: &str) -> syn::Result { + if !attr.path().is_ident("doc") { + return Err(syn::Error::new_spanned( + attr, + format!("Only doc comments (`/// ...`) are supported before {entry_kind} definitions"), + )); + } + + if let syn::Meta::NameValue(name_value) = &attr.meta + && let syn::Expr::Lit(expr_lit) = &name_value.value + && let syn::Lit::Str(doc_line) = &expr_lit.lit + { + return Ok(doc_line.value()); + } + + Err(syn::Error::new_spanned(attr, DOC_COMMENT_EXPECTED)) +} + impl Parse for ServiceDefinition { fn parse(input: syn::parse::ParseStream) -> syn::Result { // Parse the opening brace @@ -253,6 +309,8 @@ impl Parse for ServiceDefinition { 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")?; + // Parse HTTP method (GET, POST, PUT, DELETE, PATCH) let method_ident = input.parse::()?; let method = match method_ident.to_string().as_str() { @@ -423,6 +481,7 @@ impl Parse for EndpointDefinition { let response_type = input.parse::()?; Ok(EndpointDefinition { + docs, method, auth, path, diff --git a/crates/rest/ras-rest-macro/src/openapi.rs b/crates/rest/ras-rest-macro/src/openapi.rs index 25de7b6..7ced6df 100644 --- a/crates/rest/ras-rest-macro/src/openapi.rs +++ b/crates/rest/ras-rest-macro/src/openapi.rs @@ -148,6 +148,17 @@ pub fn generate_openapi_code( .map(|endpoint| { let method = endpoint.method.as_str(); let path = &endpoint.path; + let (summary, description) = match &endpoint.docs { + Some(docs) => { + let summary = &docs.summary; + let description = &docs.description; + ( + quote! { Some(#summary.to_string()) }, + quote! { Some(#description.to_string()) }, + ) + } + None => (quote! { None }, quote! { None }), + }; let auth_required = matches!(endpoint.auth, AuthRequirement::WithPermissions(_)); // Flatten permission groups for OpenAPI documentation let permissions = match &endpoint.auth { @@ -200,6 +211,8 @@ pub fn generate_openapi_code( #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(), @@ -217,6 +230,8 @@ pub fn generate_openapi_code( struct #endpoint_info_struct_name { method: String, path: String, + summary: Option, + description: Option, auth_required: bool, permissions: Vec, request_type_name: String, @@ -462,9 +477,17 @@ pub fn generate_openapi_code( let path_item = paths.entry(endpoint.path.clone()).or_insert_with(|| json!({})); let method_lower = endpoint.method.to_lowercase(); + let operation_summary = endpoint + .summary + .clone() + .unwrap_or_else(|| format!("{} {}", endpoint.method, endpoint.path)); + let operation_description = endpoint + .description + .clone() + .unwrap_or_else(|| format!("Handles {} requests to {}", endpoint.method, endpoint.path)); let mut operation = json!({ - "summary": format!("{} {}", endpoint.method, endpoint.path), - "description": format!("Handles {} requests to {}", endpoint.method, endpoint.path), + "summary": operation_summary, + "description": operation_description, "operationId": format!("{}_{}", method_lower, endpoint.path.replace("/", "_").replace("{", "").replace("}", "").trim_start_matches('_')), "responses": { "200": { diff --git a/crates/rest/ras-rest-macro/tests/e2e.rs b/crates/rest/ras-rest-macro/tests/e2e.rs index 2c95e29..6dbfb6b 100644 --- a/crates/rest/ras-rest-macro/tests/e2e.rs +++ b/crates/rest/ras-rest-macro/tests/e2e.rs @@ -30,6 +30,7 @@ rest_service!({ openapi: false, serve_docs: false, endpoints: [ + /// List all items. GET UNAUTHORIZED items() -> ItemsResponse, GET WITH_PERMISSIONS(["user"]) items/{id: u32}() -> Item, POST WITH_PERMISSIONS(["admin"]) items(CreateItem) -> Item, diff --git a/crates/rest/ras-rest-macro/tests/http_integration.rs b/crates/rest/ras-rest-macro/tests/http_integration.rs index 64f10eb..51bc6fc 100644 --- a/crates/rest/ras-rest-macro/tests/http_integration.rs +++ b/crates/rest/ras-rest-macro/tests/http_integration.rs @@ -117,7 +117,11 @@ rest_service!({ ui_theme: "default", endpoints: [ // User management endpoints + /// List users. + /// + /// Returns all users visible to the caller. GET UNAUTHORIZED users() -> UsersResponse, + /// Create a user. POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User, GET WITH_PERMISSIONS(["user"]) users/{id: i32}() -> User, PUT WITH_PERMISSIONS(["admin"]) users/{id: i32}(UpdateUserRequest) -> User, @@ -952,13 +956,25 @@ async fn test_path_parameters() { #[tokio::test] async fn test_openapi_generation() { - // Test that OpenAPI document generation works - // Note: This tests compilation and basic structure, actual OpenAPI document - // generation would be tested separately let _ = TestRestServiceBuilder::new(TestRestServiceImpl); - // The fact that this compiles means the REST service macro generated the builder correctly - // with OpenAPI configuration enabled + let openapi_doc = generate_testrestservice_openapi(); + assert_eq!(openapi_doc["openapi"], "3.0.3"); + + let get_users = &openapi_doc["paths"]["/users"]["get"]; + assert_eq!(get_users["summary"], "List users."); + assert_eq!( + get_users["description"], + "List users.\n\nReturns all users visible to the caller." + ); + + let post_users = &openapi_doc["paths"]["/users"]["post"]; + assert_eq!(post_users["summary"], "Create a user."); + assert_eq!(post_users["description"], "Create a user."); + + let health = &openapi_doc["paths"]["/health"]["get"]; + assert_eq!(health["summary"], "GET /health"); + assert_eq!(health["description"], "Handles GET requests to /health"); } #[tokio::test] diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index e862fe0..c5faed9 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -44,18 +44,74 @@ enum ExplorerConfig { #[derive(Debug)] struct MethodDefinition { + docs: Option, auth: AuthRequirement, name: Ident, request_type: Type, response_type: Type, } +#[derive(Debug)] +struct DocComment { + summary: String, + description: String, +} + +impl DocComment { + fn from_lines(lines: Vec) -> Option { + let lines: Vec = lines + .into_iter() + .map(|line| line.trim().to_string()) + .collect(); + let start = lines.iter().position(|line| !line.is_empty())?; + let end = lines.iter().rposition(|line| !line.is_empty())?; + let lines = &lines[start..=end]; + + Some(Self { + summary: lines[0].clone(), + description: lines.join("\n"), + }) + } +} + #[derive(Debug)] enum AuthRequirement { Unauthorized, WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups } +const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`"; + +fn parse_doc_comment_attrs( + attrs: Vec, + entry_kind: &str, +) -> syn::Result> { + let lines = attrs + .into_iter() + .map(|attr| parse_doc_comment_attr(attr, entry_kind)) + .collect::>>()?; + + Ok(DocComment::from_lines(lines)) +} + +fn parse_doc_comment_attr(attr: syn::Attribute, entry_kind: &str) -> syn::Result { + if !attr.path().is_ident("doc") { + return Err(syn::Error::new_spanned( + attr, + format!("Only doc comments (`/// ...`) are supported before {entry_kind} definitions"), + )); + } + + if let syn::Meta::NameValue(name_value) = &attr.meta + && let syn::Expr::Lit(expr_lit) = &name_value.value + && let syn::Lit::Str(doc_line) = &expr_lit.lit + { + return Ok(doc_line.value()); + } + + Err(syn::Error::new_spanned(attr, DOC_COMMENT_EXPECTED)) +} + impl Parse for ServiceDefinition { fn parse(input: syn::parse::ParseStream) -> syn::Result { // Parse the opening brace @@ -150,6 +206,8 @@ impl Parse for ServiceDefinition { impl Parse for MethodDefinition { fn parse(input: syn::parse::ParseStream) -> syn::Result { + let docs = parse_doc_comment_attrs(input.call(syn::Attribute::parse_outer)?, "method")?; + // Parse auth requirement (UNAUTHORIZED or WITH_PERMISSIONS([...])) let auth = if input.peek(syn::Ident) { let auth_ident = input.parse::()?; @@ -225,6 +283,7 @@ impl Parse for MethodDefinition { let response_type = input.parse::()?; Ok(MethodDefinition { + docs, auth, name, request_type, diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs index 652f6f2..815df2d 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs @@ -157,10 +157,23 @@ pub fn generate_openrpc_code( let request_type = &method.request_type; let response_type = &method.response_type; + let (summary, description) = match &method.docs { + Some(docs) => { + let summary = &docs.summary; + let description = &docs.description; + ( + quote! { Some(#summary.to_string()) }, + quote! { Some(#description.to_string()) }, + ) + } + None => (quote! { None }, quote! { None }), + }; 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(), @@ -174,6 +187,8 @@ pub fn generate_openrpc_code( #[derive(serde::Serialize)] struct #method_info_struct_name { name: String, + summary: Option, + description: Option, auth_required: bool, permissions: Vec, request_type_name: String, @@ -386,10 +401,14 @@ pub fn generate_openrpc_code( // Sanitize the response type name for schema reference let sanitized_response_type = method.response_type_name.replace(" ", ""); + let method_summary = method + .summary + .clone() + .unwrap_or_else(|| format!("Calls the {} method", method.name)); let mut method_obj = json!({ "name": method.name, - "summary": format!("Calls the {} method", method.name), + "summary": method_summary, "params": params, "result": { "name": "result", @@ -405,6 +424,10 @@ pub fn generate_openrpc_code( // Add extensions to the method object if let Some(obj) = method_obj.as_object_mut() { + if let Some(description) = &method.description { + obj.insert("description".to_string(), json!(description)); + } + for (key, value) in extensions { obj.insert(key, value); } diff --git a/crates/rpc/ras-jsonrpc-macro/tests/integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/integration.rs index 64e8267..50f388d 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/integration.rs @@ -54,6 +54,7 @@ mod basic_service { jsonrpc_service!({ service_name: MyService, methods: [ + /// Sign in with user credentials. UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, WITH_PERMISSIONS(["admin"]) create_user(CreateUserRequest) -> User, WITH_PERMISSIONS([]) get_profile(()) -> User, @@ -104,7 +105,11 @@ mod openrpc_service { service_name: OpenRpcService, openrpc: true, methods: [ + /// Sign in with user credentials. UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, + /// Create a user account. + /// + /// Requires administrator permissions and returns the created user. WITH_PERMISSIONS(["admin"]) create_user(CreateUserRequest) -> User, WITH_PERMISSIONS([]) sign_out(()) -> (), ] @@ -163,11 +168,26 @@ async fn test_openrpc_generation() { // Check sign_in method (unauthorized) let sign_in_method = methods.iter().find(|m| m["name"] == "sign_in").unwrap(); assert!(sign_in_method.get("x-authentication").is_none()); + assert_eq!(sign_in_method["summary"], "Sign in with user credentials."); + assert_eq!( + sign_in_method["description"], + "Sign in with user credentials." + ); // Check create_user method (requires admin permission) let create_user_method = methods.iter().find(|m| m["name"] == "create_user").unwrap(); assert_eq!(create_user_method["x-authentication"]["required"], true); assert_eq!(create_user_method["x-permissions"][0], "admin"); + assert_eq!(create_user_method["summary"], "Create a user account."); + assert_eq!( + create_user_method["description"], + "Create a user account.\n\nRequires administrator permissions and returns the created user." + ); + + // Check undocumented fallback summary remains generated + let sign_out_method = methods.iter().find(|m| m["name"] == "sign_out").unwrap(); + assert_eq!(sign_out_method["summary"], "Calls the sign_out method"); + assert!(sign_out_method.get("description").is_none()); // Test writing to file assert!(generate_openrpcservice_openrpc_to_file().is_ok()); diff --git a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs index a690edb..433b18d 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs +++ b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs @@ -41,6 +41,9 @@ jsonrpc_service!({ openrpc: true, explorer: true, methods: [ + /// Echo a ping message. + /// + /// Used by explorer tests to verify OpenRPC method docs render. UNAUTHORIZED ping(PingRequest) -> PingResponse, UNAUTHORIZED no_params(()) -> String, WITH_PERMISSIONS(["admin"]) create_widget(CreateWidgetRequest) -> Widget, diff --git a/tests/playwright/fixtures/rest-fixture/src/main.rs b/tests/playwright/fixtures/rest-fixture/src/main.rs index b97c40d..13eeca3 100644 --- a/tests/playwright/fixtures/rest-fixture/src/main.rs +++ b/tests/playwright/fixtures/rest-fixture/src/main.rs @@ -44,6 +44,9 @@ rest_service!({ serve_docs: true, docs_path: "/docs", endpoints: [ + /// Check fixture service health. + /// + /// Used by explorer tests to verify OpenAPI operation docs render. GET UNAUTHORIZED health() -> HealthResponse, GET UNAUTHORIZED widgets/{id: String}() -> Widget, GET UNAUTHORIZED search/widgets ? q: String & limit: Option () -> WidgetsResponse, diff --git a/tests/playwright/tests/jsonrpc-explorer.spec.ts b/tests/playwright/tests/jsonrpc-explorer.spec.ts index d10376e..cdbf186 100644 --- a/tests/playwright/tests/jsonrpc-explorer.spec.ts +++ b/tests/playwright/tests/jsonrpc-explorer.spec.ts @@ -22,8 +22,15 @@ test.describe('JSON-RPC API explorer', () => { 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('.op').filter({ hasText: 'ping' })).toContainText('Echo a ping message.'); await expect(page.locator('#operation-list')).toContainText('create_widget'); await expect(page.locator('#operation-list')).toContainText('current_profile'); + + await selectMethod(page, 'ping'); + await expect(page.locator('#operation-description')).toContainText('Echo a ping message.'); + await expect(page.locator('#operation-description')).toContainText( + 'Used by explorer tests to verify OpenRPC method docs render.' + ); }); test('searches methods and switches params editor without stale UI', async ({ page }) => { diff --git a/tests/playwright/tests/rest-explorer.spec.ts b/tests/playwright/tests/rest-explorer.spec.ts index d42a983..3d272f4 100644 --- a/tests/playwright/tests/rest-explorer.spec.ts +++ b/tests/playwright/tests/rest-explorer.spec.ts @@ -22,8 +22,15 @@ test.describe('REST API explorer', () => { 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('.op').filter({ hasText: '/health' })).toContainText('Check fixture service health.'); await expect(page.locator('#operation-list')).toContainText('/widgets'); await expect(page.locator('#operation-list')).toContainText('/search/widgets'); + + await selectOperation(page, 'GET', '/health'); + await expect(page.locator('#operation-description')).toContainText('Check fixture service health.'); + await expect(page.locator('#operation-description')).toContainText( + 'Used by explorer tests to verify OpenAPI operation docs render.' + ); }); test('searches operations and switches request forms without stale UI', async ({ page }) => { From 13ba8b8bbe4afe1afb3ee7e64e1e4e9e0989a2ce Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 9 May 2026 11:38:59 +0200 Subject: [PATCH 2/4] Show schema docs in API explorer --- .../src/api_explorer_template.html | 123 +++++++++++++++++- .../fixtures/jsonrpc-fixture/src/main.rs | 4 + .../fixtures/rest-fixture/src/main.rs | 2 + .../playwright/tests/jsonrpc-explorer.spec.ts | 6 + tests/playwright/tests/rest-explorer.spec.ts | 3 + 5 files changed, 137 insertions(+), 1 deletion(-) 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 6b19b82..0c1f5e5 100644 --- a/crates/rest/ras-rest-macro/src/api_explorer_template.html +++ b/crates/rest/ras-rest-macro/src/api_explorer_template.html @@ -274,6 +274,41 @@ color: var(--muted); font-size: 0.82rem; } + .schema-docs { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-2); + padding: 0.75rem; + display: grid; + gap: 0.6rem; + } + .schema-head { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + .schema-desc { + margin: 0; + color: var(--muted); + white-space: pre-wrap; + } + .schema-fields { + display: grid; + gap: 0.45rem; + } + .schema-field { + display: grid; + grid-template-columns: minmax(120px, 0.9fr) minmax(80px, 0.5fr) minmax(160px, 1.5fr); + gap: 0.55rem; + align-items: start; + padding-top: 0.45rem; + border-top: 1px solid var(--border); + } + .schema-field-desc { + color: var(--muted); + white-space: pre-wrap; + } .tabs { display: flex; gap: 0.45rem; @@ -361,7 +396,7 @@ } .list { max-height: 42vh; } .main-scroll, .response-scroll { height: auto; } - .grid2, .param-row { grid-template-columns: 1fr; } + .grid2, .param-row, .schema-field { grid-template-columns: 1fr; } .topbar { align-items: flex-start; } } @@ -542,6 +577,33 @@

Response

return "object"; } + function schemaTitle(schema) { + const refName = schema?.$ref?.split("/").pop(); + const resolved = resolveRef(schema); + return resolved?.title || refName || schemaType(schema); + } + + function schemaFields(schema) { + const resolved = resolveRef(schema); + const properties = resolved?.properties || {}; + const required = new Set(resolved?.required || []); + return Object.entries(properties).map(([name, prop]) => { + const propSchema = resolveRef(prop); + return { + name, + required: required.has(name), + type: schemaType(prop), + description: propSchema?.description || "" + }; + }); + } + + function schemaHasDocs(schema) { + const resolved = resolveRef(schema); + if (!resolved) return false; + return Boolean(resolved.description || schemaFields(schema).some((field) => field.description)); + } + function exampleFromSchema(schema, seen = new Set()) { const resolved = resolveRef(schema); if (!resolved) return {}; @@ -573,6 +635,57 @@

Response

return {}; } + function renderSchemaDocs(title, schema) { + if (!schemaHasDocs(schema)) return null; + + const resolved = resolveRef(schema); + const docs = document.createElement("div"); + docs.className = "schema-docs"; + const section = document.createElement("div"); + section.className = "section-title"; + section.textContent = title; + const head = document.createElement("div"); + head.className = "schema-head"; + const name = document.createElement("strong"); + name.textContent = schemaTitle(schema); + const type = document.createElement("span"); + type.className = "badge"; + type.textContent = schemaType(schema); + head.append(name, type); + docs.append(section, head); + + if (resolved?.description) { + const description = document.createElement("p"); + description.className = "schema-desc"; + description.textContent = resolved.description; + docs.appendChild(description); + } + + const fields = schemaFields(schema); + if (fields.length) { + const rows = document.createElement("div"); + rows.className = "schema-fields"; + fields.forEach((field) => { + const row = document.createElement("div"); + row.className = "schema-field"; + const fieldName = document.createElement("div"); + fieldName.className = "mono"; + fieldName.textContent = `${field.name}${field.required ? " *" : ""}`; + const fieldType = document.createElement("span"); + fieldType.className = "badge"; + fieldType.textContent = field.type; + const fieldDescription = document.createElement("div"); + fieldDescription.className = "schema-field-desc"; + fieldDescription.textContent = field.description || ""; + row.append(fieldName, fieldType, fieldDescription); + rows.appendChild(row); + }); + docs.appendChild(rows); + } + + return docs; + } + function jsonPretty(value) { if (typeof value === "string") return value; return JSON.stringify(value, null, 2); @@ -843,7 +956,11 @@

Response

}); if (operation.requestSchema) { fragment.appendChild(editorBlock("JSON body", "body-editor", jsonPretty(exampleFromSchema(operation.requestSchema)))); + const docs = renderSchemaDocs("Request schema", operation.requestSchema); + if (docs) fragment.appendChild(docs); } + const responseDocs = renderSchemaDocs("Response schema", operation.responseSchema); + if (responseDocs) fragment.appendChild(responseDocs); return fragment; } @@ -879,12 +996,16 @@

Response

fragment.appendChild(grid); if (operation.paramsSchema) { fragment.appendChild(editorBlock("Params", "params-editor", jsonPretty(exampleFromSchema(operation.paramsSchema)))); + const docs = renderSchemaDocs("Params schema", operation.paramsSchema); + if (docs) fragment.appendChild(docs); } else { const empty = document.createElement("div"); empty.className = "empty"; empty.textContent = "This method has no params."; fragment.appendChild(empty); } + const responseDocs = renderSchemaDocs("Result schema", operation.responseSchema); + if (responseDocs) fragment.appendChild(responseDocs); return fragment; } diff --git a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs index 433b18d..015b0f4 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs +++ b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs @@ -7,13 +7,17 @@ use ras_jsonrpc_macro::jsonrpc_service; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// Request payload for the ping method. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PingRequest { + /// Message echoed by the fixture service. pub message: String, } +/// Response returned by the ping method. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PingResponse { + /// Message returned from the fixture service. pub message: String, } diff --git a/tests/playwright/fixtures/rest-fixture/src/main.rs b/tests/playwright/fixtures/rest-fixture/src/main.rs index 13eeca3..5ab820e 100644 --- a/tests/playwright/fixtures/rest-fixture/src/main.rs +++ b/tests/playwright/fixtures/rest-fixture/src/main.rs @@ -7,8 +7,10 @@ use ras_rest_macro::rest_service; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// Health status returned by the fixture service. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct HealthResponse { + /// Current health state. pub status: String, } diff --git a/tests/playwright/tests/jsonrpc-explorer.spec.ts b/tests/playwright/tests/jsonrpc-explorer.spec.ts index cdbf186..7918741 100644 --- a/tests/playwright/tests/jsonrpc-explorer.spec.ts +++ b/tests/playwright/tests/jsonrpc-explorer.spec.ts @@ -31,6 +31,12 @@ test.describe('JSON-RPC API explorer', () => { await expect(page.locator('#operation-description')).toContainText( 'Used by explorer tests to verify OpenRPC method docs render.' ); + await expect(page.locator('#request-form')).toContainText('Params schema'); + await expect(page.locator('#request-form')).toContainText('Request payload for the ping method.'); + await expect(page.locator('#request-form')).toContainText('Message echoed by the fixture service.'); + await expect(page.locator('#request-form')).toContainText('Result schema'); + await expect(page.locator('#request-form')).toContainText('Response returned by the ping method.'); + await expect(page.locator('#request-form')).toContainText('Message returned from the fixture service.'); }); test('searches methods and switches params editor without stale UI', async ({ page }) => { diff --git a/tests/playwright/tests/rest-explorer.spec.ts b/tests/playwright/tests/rest-explorer.spec.ts index 3d272f4..596991d 100644 --- a/tests/playwright/tests/rest-explorer.spec.ts +++ b/tests/playwright/tests/rest-explorer.spec.ts @@ -31,6 +31,9 @@ test.describe('REST API explorer', () => { await expect(page.locator('#operation-description')).toContainText( 'Used by explorer tests to verify OpenAPI operation docs render.' ); + await expect(page.locator('#request-form')).toContainText('Response schema'); + await expect(page.locator('#request-form')).toContainText('Health status returned by the fixture service.'); + await expect(page.locator('#request-form')).toContainText('Current health state.'); }); test('searches operations and switches request forms without stale UI', async ({ page }) => { From 20e82d1c23b2c64bd855ebd061be2a6988a0182f Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 9 May 2026 11:46:41 +0200 Subject: [PATCH 3/4] Render markdown docs in API explorer --- .../src/api_explorer_template.html | 177 +++++++++++++++++- .../fixtures/jsonrpc-fixture/src/main.rs | 20 +- .../fixtures/rest-fixture/src/main.rs | 18 +- .../playwright/tests/jsonrpc-explorer.spec.ts | 23 ++- tests/playwright/tests/rest-explorer.spec.ts | 26 ++- 5 files changed, 244 insertions(+), 20 deletions(-) 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 0c1f5e5..16f68b8 100644 --- a/crates/rest/ras-rest-macro/src/api_explorer_template.html +++ b/crates/rest/ras-rest-macro/src/api_explorer_template.html @@ -248,7 +248,7 @@ font-size: 1.03rem; overflow-wrap: anywhere; } - .titleline p { + .titleline .description { margin: 0.25rem 0 0; color: var(--muted); } @@ -291,7 +291,6 @@ .schema-desc { margin: 0; color: var(--muted); - white-space: pre-wrap; } .schema-fields { display: grid; @@ -307,8 +306,47 @@ } .schema-field-desc { color: var(--muted); + } + .markdown { + display: grid; + gap: 0.5rem; + } + .markdown p { + margin: 0; white-space: pre-wrap; } + .markdown ul { + margin: 0; + padding-left: 1.2rem; + display: grid; + gap: 0.25rem; + } + .markdown li { + padding-left: 0.15rem; + } + .markdown code { + background: var(--panel-3); + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.05rem 0.25rem; + font-size: 0.92em; + } + .markdown pre { + min-height: 0; + white-space: pre; + margin: 0; + } + .markdown pre code { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + } + .markdown a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; + } .tabs { display: flex; gap: 0.45rem; @@ -443,7 +481,7 @@

API Explorer

Select an operation

-

Choose an operation to prepare a request.

+
Choose an operation to prepare a request.
@@ -604,6 +642,128 @@

Response

return Boolean(resolved.description || schemaFields(schema).some((field) => field.description)); } + function appendInlineMarkdown(parent, text) { + let index = 0; + const source = String(text || ""); + + while (index < source.length) { + if (source.startsWith("**", index)) { + const end = source.indexOf("**", index + 2); + if (end > index + 2) { + const strong = document.createElement("strong"); + appendInlineMarkdown(strong, source.slice(index + 2, end)); + parent.appendChild(strong); + index = end + 2; + continue; + } + } + + if (source[index] === "`") { + const end = source.indexOf("`", index + 1); + if (end > index + 1) { + const code = document.createElement("code"); + code.textContent = source.slice(index + 1, end); + parent.appendChild(code); + index = end + 1; + continue; + } + } + + if (source[index] === "[") { + const labelEnd = source.indexOf("]", index + 1); + const urlStart = labelEnd + 1; + if (labelEnd > index + 1 && source[urlStart] === "(") { + const urlEnd = source.indexOf(")", urlStart + 1); + const href = source.slice(urlStart + 1, urlEnd); + if (urlEnd > urlStart + 1 && isSafeMarkdownUrl(href)) { + const link = document.createElement("a"); + link.href = href; + link.target = "_blank"; + link.rel = "noreferrer noopener"; + appendInlineMarkdown(link, source.slice(index + 1, labelEnd)); + parent.appendChild(link); + index = urlEnd + 1; + continue; + } + } + } + + const next = ["**", "`", "["] + .map((token) => source.indexOf(token, index + 1)) + .filter((position) => position !== -1) + .sort((a, b) => a - b)[0] ?? source.length; + parent.appendChild(document.createTextNode(source.slice(index, next))); + index = next; + } + } + + function isSafeMarkdownUrl(href) { + try { + const url = new URL(href, window.location.href); + return url.protocol === "http:" || url.protocol === "https:"; + } catch (_) { + return false; + } + } + + function renderMarkdownInto(container, text) { + container.textContent = ""; + container.classList.add("markdown"); + + const lines = String(text || "").replace(/\r\n?/g, "\n").split("\n"); + let index = 0; + + while (index < lines.length) { + if (!lines[index].trim()) { + index += 1; + continue; + } + + if (lines[index].trimStart().startsWith("```")) { + const codeLines = []; + index += 1; + while (index < lines.length && !lines[index].trimStart().startsWith("```")) { + codeLines.push(lines[index]); + index += 1; + } + if (index < lines.length) index += 1; + + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = codeLines.join("\n"); + pre.appendChild(code); + container.appendChild(pre); + continue; + } + + if (/^\s*-\s+/.test(lines[index])) { + const list = document.createElement("ul"); + while (index < lines.length && /^\s*-\s+/.test(lines[index])) { + const item = document.createElement("li"); + appendInlineMarkdown(item, lines[index].replace(/^\s*-\s+/, "")); + list.appendChild(item); + index += 1; + } + container.appendChild(list); + continue; + } + + const paragraphLines = []; + while ( + index < lines.length + && lines[index].trim() + && !lines[index].trimStart().startsWith("```") + && !/^\s*-\s+/.test(lines[index]) + ) { + paragraphLines.push(lines[index]); + index += 1; + } + const paragraph = document.createElement("p"); + appendInlineMarkdown(paragraph, paragraphLines.join("\n")); + container.appendChild(paragraph); + } + } + function exampleFromSchema(schema, seen = new Set()) { const resolved = resolveRef(schema); if (!resolved) return {}; @@ -655,9 +815,9 @@

Response

docs.append(section, head); if (resolved?.description) { - const description = document.createElement("p"); + const description = document.createElement("div"); description.className = "schema-desc"; - description.textContent = resolved.description; + renderMarkdownInto(description, resolved.description); docs.appendChild(description); } @@ -676,7 +836,7 @@

Response

fieldType.textContent = field.type; const fieldDescription = document.createElement("div"); fieldDescription.className = "schema-field-desc"; - fieldDescription.textContent = field.description || ""; + renderMarkdownInto(fieldDescription, field.description || ""); row.append(fieldName, fieldType, fieldDescription); rows.appendChild(row); }); @@ -1056,7 +1216,10 @@

Response

state.selectedId = id; const operation = activeOperation(); $("operation-title").textContent = operation ? `${operation.method} ${operation.label}` : "Select an operation"; - $("operation-description").textContent = operation?.description || operation?.summary || "Prepare and send a request."; + renderMarkdownInto( + $("operation-description"), + operation?.description || operation?.summary || "Prepare and send a request." + ); renderRequestForm(); renderSaved(); if (rerenderList) renderOperations(); diff --git a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs index 015b0f4..a6e243b 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs +++ b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs @@ -7,10 +7,13 @@ use ras_jsonrpc_macro::jsonrpc_service; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// Request payload for the ping method. +/// Request payload for the `ping` method. +/// +/// **Schema docs** should render with Markdown. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PingRequest { /// Message echoed by the fixture service. + /// This line must stay on a new line. pub message: String, } @@ -45,9 +48,20 @@ jsonrpc_service!({ openrpc: true, explorer: true, methods: [ - /// Echo a ping message. + /// Echo a `PingRequest` message. /// - /// Used by explorer tests to verify OpenRPC method docs render. + /// **Use this in tests.** + /// - Confirms list rendering + /// - Preserves list items + /// + /// Line one + /// Line two + /// + /// ```json + /// {"message":"hello"} + /// ``` + /// + /// See [Rust API Stack](https://example.com/docs). UNAUTHORIZED ping(PingRequest) -> PingResponse, UNAUTHORIZED no_params(()) -> String, WITH_PERMISSIONS(["admin"]) create_widget(CreateWidgetRequest) -> Widget, diff --git a/tests/playwright/fixtures/rest-fixture/src/main.rs b/tests/playwright/fixtures/rest-fixture/src/main.rs index 5ab820e..332394e 100644 --- a/tests/playwright/fixtures/rest-fixture/src/main.rs +++ b/tests/playwright/fixtures/rest-fixture/src/main.rs @@ -8,9 +8,12 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Health status returned by the fixture service. +/// +/// **Schema docs** should render for REST. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct HealthResponse { /// Current health state. + /// This field description keeps its line break. pub status: String, } @@ -46,9 +49,20 @@ rest_service!({ serve_docs: true, docs_path: "/docs", endpoints: [ - /// Check fixture service health. + /// Check fixture `health`. /// - /// Used by explorer tests to verify OpenAPI operation docs render. + /// **REST docs** support Markdown. + /// - Shows operation details + /// - Preserves line breaks + /// + /// Alpha line + /// Beta line + /// + /// ```json + /// {"status":"ok"} + /// ``` + /// + /// See [REST docs](https://example.com/rest). GET UNAUTHORIZED health() -> HealthResponse, GET UNAUTHORIZED widgets/{id: String}() -> Widget, GET UNAUTHORIZED search/widgets ? q: String & limit: Option () -> WidgetsResponse, diff --git a/tests/playwright/tests/jsonrpc-explorer.spec.ts b/tests/playwright/tests/jsonrpc-explorer.spec.ts index 7918741..97a0ccc 100644 --- a/tests/playwright/tests/jsonrpc-explorer.spec.ts +++ b/tests/playwright/tests/jsonrpc-explorer.spec.ts @@ -22,18 +22,33 @@ test.describe('JSON-RPC API explorer', () => { 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('.op').filter({ hasText: 'ping' })).toContainText('Echo a ping message.'); + await expect(page.locator('.op').filter({ hasText: 'ping' })).toContainText('Echo a `PingRequest` message.'); await expect(page.locator('#operation-list')).toContainText('create_widget'); await expect(page.locator('#operation-list')).toContainText('current_profile'); await selectMethod(page, 'ping'); - await expect(page.locator('#operation-description')).toContainText('Echo a ping message.'); - await expect(page.locator('#operation-description')).toContainText( - 'Used by explorer tests to verify OpenRPC method docs render.' + await expect(page.locator('#operation-description p code')).toContainText('PingRequest'); + await expect(page.locator('#operation-description strong')).toContainText('Use this in tests.'); + await expect(page.locator('#operation-description li')).toContainText(['Confirms list rendering', 'Preserves list items']); + await expect(page.locator('#operation-description pre code')).toContainText('{"message":"hello"}'); + await expect(page.locator('#operation-description a').filter({ hasText: 'Rust API Stack' })).toHaveAttribute( + 'href', + 'https://example.com/docs' ); + const descriptionText = await page.locator('#operation-description').evaluate((el) => el.textContent ?? ''); + expect(descriptionText).toContain('Line one\nLine two'); + await expect(page.locator('#request-form')).toContainText('Params schema'); await expect(page.locator('#request-form')).toContainText('Request payload for the ping method.'); + await expect(page.locator('#request-form .schema-desc strong')).toContainText('Schema docs'); await expect(page.locator('#request-form')).toContainText('Message echoed by the fixture service.'); + const messageDocText = await page + .locator('.schema-field') + .filter({ hasText: 'Message echoed by the fixture service.' }) + .locator('.schema-field-desc') + .first() + .evaluate((el) => el.textContent ?? ''); + expect(messageDocText).toContain('Message echoed by the fixture service.\nThis line must stay on a new line.'); await expect(page.locator('#request-form')).toContainText('Result schema'); await expect(page.locator('#request-form')).toContainText('Response returned by the ping method.'); await expect(page.locator('#request-form')).toContainText('Message returned from the fixture service.'); diff --git a/tests/playwright/tests/rest-explorer.spec.ts b/tests/playwright/tests/rest-explorer.spec.ts index 596991d..43e60c8 100644 --- a/tests/playwright/tests/rest-explorer.spec.ts +++ b/tests/playwright/tests/rest-explorer.spec.ts @@ -22,18 +22,36 @@ test.describe('REST API explorer', () => { 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('.op').filter({ hasText: '/health' })).toContainText('Check fixture service health.'); + 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 selectOperation(page, 'GET', '/health'); - await expect(page.locator('#operation-description')).toContainText('Check fixture service health.'); - await expect(page.locator('#operation-description')).toContainText( - 'Used by explorer tests to verify OpenAPI operation docs render.' + await expect(page.locator('#operation-description p code')).toContainText('health'); + await expect(page.locator('#operation-description strong')).toContainText('REST docs'); + await expect(page.locator('#operation-description li')).toContainText([ + 'Shows operation details', + 'Preserves line breaks' + ]); + await expect(page.locator('#operation-description pre code')).toContainText('{"status":"ok"}'); + await expect(page.locator('#operation-description a').filter({ hasText: 'REST docs' })).toHaveAttribute( + 'href', + 'https://example.com/rest' ); + const descriptionText = await page.locator('#operation-description').evaluate((el) => el.textContent ?? ''); + expect(descriptionText).toContain('Alpha line\nBeta line'); + await expect(page.locator('#request-form')).toContainText('Response schema'); await expect(page.locator('#request-form')).toContainText('Health status returned by the fixture service.'); + await expect(page.locator('#request-form .schema-desc strong')).toContainText('Schema docs'); await expect(page.locator('#request-form')).toContainText('Current health state.'); + const statusDocText = await page + .locator('.schema-field') + .filter({ hasText: 'Current health state.' }) + .locator('.schema-field-desc') + .first() + .evaluate((el) => el.textContent ?? ''); + expect(statusDocText).toContain('Current health state.\nThis field description keeps its line break.'); }); test('searches operations and switches request forms without stale UI', async ({ page }) => { From b57d601ad011a9efca646fd66a2c0cc57894c078 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 9 May 2026 13:01:47 +0200 Subject: [PATCH 4/4] Document versioning and changelog policy --- CHANGELOG.md | 17 ++++++++++++- Cargo.lock | 4 +-- VERSIONING.md | 34 +++++++++++++++++++++++++ crates/rest/ras-rest-macro/Cargo.toml | 4 +-- crates/rpc/ras-jsonrpc-macro/Cargo.toml | 2 +- 5 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 VERSIONING.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f5bffd..75dd378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added - 2026-05-09 +- Established repository versioning and changelog policy in `VERSIONING.md`. +- Added doc-comment support for generated API documentation: + - `ras-jsonrpc-macro` now maps `///` comments on JSON-RPC methods into OpenRPC `summary` and `description`. + - `ras-rest-macro` now maps `///` comments on REST endpoints into OpenAPI operation `summary` and `description`. +- Enhanced the API explorer to render documentation from generated specs: + - Shows operation/method docs for both REST and JSON-RPC. + - Shows schema/type and field descriptions produced by `schemars::JsonSchema`. + - Renders a safe dependency-free Markdown subset for paragraphs, line breaks, bold, inline code, fenced code blocks, lists, and HTTP(S) links. +- Added Playwright e2e coverage for REST and JSON-RPC explorer documentation rendering. + +### Changed - 2026-05-09 +- Bumped `ras-jsonrpc-macro` from `0.1.1` to `0.1.2`. +- Bumped `ras-rest-macro` from `0.1.0` to `0.1.1`. + ### Added - 2025-01-14 - Cat avatar system for bidirectional chat users - Unique ASCII art cat avatars generated from username hashes @@ -471,4 +486,4 @@ All notable changes to this project will be documented in this file. - Created rust-jsonrpc-macro procedural macro crate foundation - Added .gitignore for Rust and IDE artifacts - Configured MCP integration with Context7 for enhanced documentation -- Added CLAUDE.md for AI-assisted development guidance \ No newline at end of file +- Added CLAUDE.md for AI-assisted development guidance diff --git a/Cargo.lock b/Cargo.lock index bd7eaef..6d9f191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3382,7 +3382,7 @@ dependencies = [ [[package]] name = "ras-jsonrpc-macro" -version = "0.1.1" +version = "0.1.2" dependencies = [ "async-trait", "axum", @@ -3458,7 +3458,7 @@ dependencies = [ [[package]] name = "ras-rest-macro" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "axum", diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..6f657f3 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,34 @@ +# Versioning and Changelog Policy + +This workspace uses independent crate versions. Bump only the publishable crates +whose public API, generated output, runtime behavior, or documented behavior +changed. + +## Versioning + +- Follow SemVer for crates at `1.0.0` and later. +- While crates are `0.x`, use patch bumps for backward-compatible fixes, + additive behavior, docs, and tooling improvements. +- While crates are `0.x`, use minor bumps for breaking public API changes or + generated-code contract changes. +- Do not version-bump examples, fixtures, or crates marked `publish = false` + unless their version is meaningful to a downstream release process. +- Keep `Cargo.lock` aligned with any package version changes. + +## Changelog + +- Every user-facing change must add an entry under `CHANGELOG.md` -> + `[Unreleased]`. +- Group entries by `Added`, `Changed`, `Fixed`, `Documentation`, or + `Maintenance`. +- Include the affected crate names when a change is scoped to specific crates. +- Mention version bumps in the same changelog entry set. + +## Release Checklist + +1. Move relevant `[Unreleased]` entries into a dated release section. +2. Confirm publishable crate versions match the release contents. +3. Run the relevant crate tests and any affected e2e suites. +4. Commit `Cargo.toml`, `Cargo.lock`, `CHANGELOG.md`, and release notes together. +5. Tag releases using crate-aware tags when publishing one crate, for example + `ras-rest-macro-v0.1.1`. diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index 820c33c..ed4dd3a 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.1.0" +version = "0.1.1" edition = "2024" description = "Procedural macro for type-safe REST APIs with auth integration and OpenAPI document generation" license = "MIT OR Apache-2.0" @@ -57,4 +57,4 @@ criterion = { workspace = true, features = ["async_tokio"] } [[bench]] name = "dispatch" -harness = false \ No newline at end of file +harness = false diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index a7630eb..cd560b2 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.1" +version = "0.1.2" edition = "2024" description = "Procedural macro for type-safe JSON-RPC interfaces with auth integration and OpenRPC document generation" license = "MIT OR Apache-2.0"