From a13b6e81efa46ea04fe509fb45f7b5a91f3f0d71 Mon Sep 17 00:00:00 2001 From: Hxphsts Date: Tue, 8 Apr 2025 22:50:20 +0200 Subject: [PATCH 1/6] Enhance discovery with I/O JSON schemas --- Cargo.toml | 3 + examples/schema.rs | 64 ++++++++++++++++ macros/src/gen.rs | 32 +++++++- src/lib.rs | 77 +++++++++++++++++++ src/serde.rs | 186 +++++++++++++++++++++++++++++++++++++++++++++ tests/schema.rs | 154 +++++++++++++++++++++++++++++++++++++ 6 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 examples/schema.rs create mode 100644 tests/schema.rs diff --git a/Cargo.toml b/Cargo.toml index 37d70d1..8d33902 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ default = ["http_server", "rand", "uuid", "tracing-span-filter"] hyper = ["dep:hyper", "http-body-util", "restate-sdk-shared-core/http"] http_server = ["hyper", "hyper/server", "hyper/http2", "hyper-util", "tokio/net", "tokio/signal", "tokio/macros"] tracing-span-filter = ["dep:tracing-subscriber"] +schemars = ["dep:schemars"] [dependencies] bytes = "1.10" @@ -30,6 +31,7 @@ rand = { version = "0.9", optional = true } regress = "0.10" restate-sdk-macros = { version = "0.4", path = "macros" } restate-sdk-shared-core = { version = "0.3.0", features = ["request_identity", "sha2_random_seed", "http"] } +schemars = { version = "1.0.0-alpha.17", optional = true } serde = "1.0" serde_json = "1.0" thiserror = "2.0" @@ -44,6 +46,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "registry"] } trybuild = "1.0" reqwest = { version = "0.12", features = ["json"] } rand = "0.9" +schemars = "1.0.0-alpha.17" [build-dependencies] jsonptr = "0.5.1" diff --git a/examples/schema.rs b/examples/schema.rs new file mode 100644 index 0000000..f6765be --- /dev/null +++ b/examples/schema.rs @@ -0,0 +1,64 @@ +use restate_sdk::prelude::*; +/// Run with auto-generated schemas for `Json` using `schemars`: +/// cargo run --example schema --features schemars +/// +/// Run with primitive schemas only: +/// cargo run --example schema +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +#[derive(Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +struct Product { + id: String, + name: String, + price_cents: u32, +} + +#[restate_sdk::service] +trait CatalogService { + async fn get_product_by_id(product_id: String) -> Result, HandlerError>; + async fn save_product(product: Json) -> Result; + async fn is_in_stock(product_id: String) -> Result; +} + +struct CatalogServiceImpl; + +impl CatalogService for CatalogServiceImpl { + async fn get_product_by_id( + &self, + ctx: Context<'_>, + product_id: String, + ) -> Result, HandlerError> { + ctx.sleep(Duration::from_millis(50)).await?; + Ok(Json(Product { + id: product_id, + name: "Sample Product".to_string(), + price_cents: 1995, + })) + } + + async fn save_product( + &self, + _ctx: Context<'_>, + product: Json, + ) -> Result { + Ok(product.0.id) + } + + async fn is_in_stock( + &self, + _ctx: Context<'_>, + product_id: String, + ) -> Result { + Ok(!product_id.contains("out-of-stock")) + } +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + HttpServer::new(Endpoint::builder().bind(CatalogServiceImpl.serve()).build()) + .listen_and_serve("0.0.0.0:9080".parse().unwrap()) + .await; +} diff --git a/macros/src/gen.rs b/macros/src/gen.rs index a882b4d..5ce7716 100644 --- a/macros/src/gen.rs +++ b/macros/src/gen.rs @@ -194,11 +194,39 @@ impl<'a> ServiceGenerator<'a> { quote! { None } }; + let input_schema = match &handler.arg { + Some(PatType { ty, .. }) => { + quote! { + Some(::restate_sdk::discovery::InputPayload { + content_type: Some(<#ty as ::restate_sdk::serde::WithContentType>::content_type().to_string()), + json_schema: Some(<#ty as ::restate_sdk::serde::WithSchema>::generate_schema()), + required: Some(true), + }) + } + } + None => quote! { + Some(::restate_sdk::discovery::InputPayload { + content_type: Some(<() as ::restate_sdk::serde::WithContentType>::content_type().to_string()), + json_schema: Some(<() as ::restate_sdk::serde::WithSchema>::generate_schema()), + required: Some(false), + }) + } + }; + + let output_ok = &handler.output_ok; + let output_schema = quote! { + Some(::restate_sdk::discovery::OutputPayload { + content_type: Some(<#output_ok as ::restate_sdk::serde::WithContentType>::content_type().to_string()), + json_schema: Some(<#output_ok as ::restate_sdk::serde::WithSchema>::generate_schema()), + set_content_type_if_empty: Some(false), + }) + }; + quote! { ::restate_sdk::discovery::Handler { name: ::restate_sdk::discovery::HandlerName::try_from(#handler_literal).expect("Handler name valid"), - input: None, - output: None, + input: #input_schema, + output: #output_schema, ty: #handler_ty, } } diff --git a/src/lib.rs b/src/lib.rs index 533e192..c0d42a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,7 @@ //! - [Error Handling][crate::errors]: Restate retries failures infinitely. Use `TerminalError` to stop retries. //! - [Serialization][crate::serde]: The SDK serializes results to send them to the Server. //! - [Serving][crate::http_server]: Start an HTTP server to expose services. +//! - [JSON Schema Generation][crate::serde::WithSchema]: Automatically generate JSON Schemas for better discovery. //! //! # SDK Overview //! @@ -504,6 +505,82 @@ pub use restate_sdk_macros::object; /// For more details, check the [`service` macro](macro@crate::service) documentation. pub use restate_sdk_macros::workflow; +/// ### JSON Schema Generation +/// +/// The SDK provides three approaches for generating JSON Schemas for handler inputs and outputs: +/// +/// #### 1. Primitive Types +/// +/// Primitive types (like `String`, `u32`, `bool`) have built-in schema implementations +/// that work automatically without additional code: +/// +/// ```rust +/// use restate_sdk::prelude::*; +/// +/// #[restate_sdk::service] +/// trait SimpleService { +/// async fn greet(name: String) -> HandlerResult; +/// } +/// ``` +/// +/// #### 2. Using Json with schemars +/// +/// For complex types wrapped in `Json`, you need to add the `schemars` feature and derive `JsonSchema`: +/// +/// ```rust +/// use restate_sdk::prelude::*; +/// +/// #[derive(serde::Serialize, serde::Deserialize)] +/// #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// struct User { +/// name: String, +/// age: u32, +/// } +/// +/// #[restate_sdk::service] +/// trait UserService { +/// async fn register(user: Json) -> HandlerResult>; +/// } +/// ``` +/// +/// To enable rich schema generation with `Json`, add the `schemars` feature to your dependency: +/// +/// ```toml +/// [dependencies] +/// restate-sdk = { version = "0.3", features = ["schemars"] } +/// schemars = "1.0.0-alpha.17" +/// ``` +/// +/// #### 3. Custom Implementation +/// +/// You can also implement the `WithSchema` trait directly for your types to provide +/// custom schemas without relying on the `schemars` feature: +/// +/// ```rust +/// use restate_sdk::serde::{WithSchema, WithContentType, Serialize, Deserialize}; +/// +/// #[derive(serde::Serialize, serde::Deserialize)] +/// struct User { +/// name: String, +/// age: u32, +/// } +/// +/// // Implement WithSchema directly +/// impl WithSchema for User { +/// fn generate_schema() -> serde_json::Value { +/// serde_json::json!({ +/// "type": "object", +/// "properties": { +/// "name": {"type": "string"}, +/// "age": {"type": "integer", "minimum": 0} +/// }, +/// "required": ["name", "age"] +/// }) +/// } +/// } +/// ``` +/// +/// /// Prelude contains all the useful imports you need to get started with Restate. pub mod prelude { #[cfg(feature = "http_server")] diff --git a/src/serde.rs b/src/serde.rs index 2967a0b..2e9d93d 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -204,3 +204,189 @@ impl Default for Json { Self(T::default()) } } + +impl WithContentType for Json { + fn content_type() -> &'static str { + APPLICATION_JSON + } +} + +// -- Schema Generation + +/// Trait encapsulating JSON Schema information for the given serializer/deserializer. +/// +/// This trait allows types to provide JSON Schema information that can be used for +/// documentation, validation, and client generation. +/// +/// ## Behavior with `schemars` Feature Flag +/// +/// When the `schemars` feature is enabled, implementations for complex types use +/// the `schemars` crate to automatically generate rich, JSON Schema 2020-12 conforming schemas. +/// When the feature is disabled, primitive types still provide basic schemas, +/// but complex types return empty schemas, unless manually implemented. +/// +/// ## Example Implementation: +/// +/// ```rust +/// use serde_json::json; +/// use restate_sdk::serde::WithSchema; +/// +/// struct MyType { /* ... */ } +/// +/// impl WithSchema for MyType { +/// fn generate_schema() -> serde_json::Value { +/// // Generate and return a JSON Schema +/// json!({ +/// "type": "object", +/// "properties": { +/// "field1": { "type": "string" }, +/// "field2": { "type": "number" } +/// }, +/// "required": ["field1"] +/// }) +/// } +/// } +/// ``` +pub trait WithSchema { + /// Generate a JSON Schema for this type. + /// + /// Returns a JSON value representing the schema for this type. When the `schemars` + /// feature is enabled, this returns an auto-generated JSON Schema 2020-12 conforming schema. When the feature is disabled, + /// this returns an empty schema for complex types, but basic schemas for primitives. + fn generate_schema() -> serde_json::Value; +} + +// Helper function to create an empty schema +fn empty_schema() -> serde_json::Value { + serde_json::json!({}) +} + +// Basic implementations for primitive types (always available) + +impl WithSchema for () { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "null" }) + } +} + +impl WithSchema for String { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "string" }) + } +} + +impl WithSchema for bool { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "boolean" }) + } +} + +impl WithSchema for u8 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "integer", "minimum": 0, "maximum": 255 }) + } +} + +impl WithSchema for u16 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "integer", "minimum": 0, "maximum": 65535 }) + } +} + +impl WithSchema for u32 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "integer", "minimum": 0, "maximum": 4_294_967_295u64 }) + } +} + +impl WithSchema for u64 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "integer", "minimum": 0 }) + } +} + +impl WithSchema for u128 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "integer", "minimum": 0 }) + } +} + +impl WithSchema for i8 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "integer", "minimum": -128, "maximum": 127 }) + } +} + +impl WithSchema for i16 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "integer", "minimum": -32768, "maximum": 32767 }) + } +} + +impl WithSchema for i32 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }) + } +} + +impl WithSchema for i64 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "integer" }) + } +} + +impl WithSchema for i128 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "integer" }) + } +} + +impl WithSchema for f32 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "number" }) + } +} + +impl WithSchema for f64 { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "number" }) + } +} + +impl WithSchema for Vec { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "string", "format": "byte" }) + } +} + +impl WithSchema for Bytes { + fn generate_schema() -> serde_json::Value { + serde_json::json!({ "type": "string", "format": "byte" }) + } +} + +impl WithSchema for Option { + fn generate_schema() -> serde_json::Value { + T::generate_schema() + } +} + +// When schemars is disabled - works with any T +#[cfg(not(feature = "schemars"))] +impl WithSchema for Json { + fn generate_schema() -> serde_json::Value { + empty_schema() // Empty schema returns "accept all */*" + } +} + +// When schemars is enabled - requires T: JsonSchema +#[cfg(feature = "schemars")] +impl WithSchema for Json { + fn generate_schema() -> serde_json::Value { + let schema = schemars::schema_for!(T); + serde_json::to_value(schema).unwrap_or_else(|e| { + tracing::debug!("Failed to convert schema to JSON: {}", e); + empty_schema() + }) + } +} diff --git a/tests/schema.rs b/tests/schema.rs new file mode 100644 index 0000000..9baeb97 --- /dev/null +++ b/tests/schema.rs @@ -0,0 +1,154 @@ +use restate_sdk::prelude::*; +use restate_sdk::serde::{Json, WithSchema}; +use restate_sdk::service::Discoverable; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[cfg(feature = "schemars")] +use schemars::JsonSchema; + +#[derive(Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(JsonSchema))] +struct TestUser { + name: String, + age: u32, +} + +#[derive(Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(JsonSchema))] +struct Person { + name: String, + age: u32, + address: Address, +} + +#[derive(Serialize, Deserialize, Default)] +#[cfg_attr(feature = "schemars", derive(JsonSchema))] +struct Address { + street: String, + city: String, +} + +#[restate_sdk::service] +trait SchemaTestService { + async fn string_handler(input: String) -> HandlerResult; + async fn no_input_handler() -> HandlerResult; + async fn json_handler(input: Json) -> HandlerResult>; + async fn complex_handler(input: Json) -> HandlerResult>>; +} + +struct SchemaTestServiceImpl; + +impl SchemaTestService for SchemaTestServiceImpl { + async fn string_handler(&self, _ctx: Context<'_>, _input: String) -> HandlerResult { + Ok(42) + } + async fn no_input_handler(&self, _ctx: Context<'_>) -> HandlerResult { + Ok("No input".to_string()) + } + async fn json_handler( + &self, + _ctx: Context<'_>, + input: Json, + ) -> HandlerResult> { + Ok(input) + } + async fn complex_handler( + &self, + _ctx: Context<'_>, + input: Json, + ) -> HandlerResult>> { + Ok(Json(HashMap::from([("original".to_string(), input.0)]))) + } +} + +#[test] +fn schema_discovery_and_validation() { + let discovery = ServeSchemaTestService::::discover(); + assert_eq!(discovery.name.to_string(), "SchemaTestService"); + assert_eq!(discovery.handlers.len(), 4); + + for handler in &discovery.handlers { + let input = handler + .input + .as_ref() + .expect("Handler should have input schema"); + let output = handler + .output + .as_ref() + .expect("Handler should have output schema"); + let input_schema = input + .json_schema + .as_ref() + .expect("Input schema should exist"); + let output_schema = output + .json_schema + .as_ref() + .expect("Output schema should exist"); + + match handler.name.to_string().as_str() { + "string_handler" => { + assert_eq!( + input_schema.get("type").and_then(|v| v.as_str()), + Some("string") + ); + assert_eq!( + output_schema.get("type").and_then(|v| v.as_str()), + Some("integer") + ); + } + "no_input_handler" => { + assert_eq!( + input_schema.get("type").and_then(|v| v.as_str()), + Some("null") + ); + assert_eq!( + output_schema.get("type").and_then(|v| v.as_str()), + Some("string") + ); + } + "json_handler" => { + #[cfg(feature = "schemars")] + { + let obj = input_schema + .as_object() + .expect("Schema should be an object"); + assert!( + obj.contains_key("properties"), + "Json schema should have properties" + ); + assert!(obj["properties"]["name"]["type"] == "string"); + assert!(obj["properties"]["age"]["type"] == "integer"); + } + #[cfg(not(feature = "schemars"))] + assert_eq!(input_schema, &serde_json::json!({})); + } + "complex_handler" => { + #[cfg(feature = "schemars")] + { + let obj = input_schema + .as_object() + .expect("Schema should be an object"); + assert!(obj.contains_key("properties") || obj.contains_key("$ref")); + let props = obj.get("properties").or_else(|| obj.get("$ref")).unwrap(); + assert!(props.is_object(), "Complex schema should define structure"); + } + #[cfg(not(feature = "schemars"))] + assert_eq!(input_schema, &serde_json::json!({})); + } + _ => unreachable!("Unexpected handler"), + } + } +} + +#[test] +fn schema_generation() { + let string_schema = ::generate_schema(); + assert_eq!(string_schema["type"], "string"); + + let json_schema = as WithSchema>::generate_schema(); + #[cfg(feature = "schemars")] + assert!(json_schema["properties"]["name"]["type"] == "string"); + #[cfg(not(feature = "schemars"))] + assert_eq!(json_schema, serde_json::json!({})); +} From c417ec7cf5278e87cd278aef556836d46ffb4a26 Mon Sep 17 00:00:00 2001 From: Hxphsts Date: Wed, 9 Apr 2025 23:41:02 +0200 Subject: [PATCH 2/6] Implemented empty schema specs for inputs/outputs, improved documentation organization --- macros/src/gen.rs | 27 ++++++---- src/lib.rs | 79 +--------------------------- src/serde.rs | 88 ++++++++++++++++++++++++------- tests/schema.rs | 128 ++++++++++++++++++++++++++++------------------ 4 files changed, 167 insertions(+), 155 deletions(-) diff --git a/macros/src/gen.rs b/macros/src/gen.rs index 5ce7716..751f224 100644 --- a/macros/src/gen.rs +++ b/macros/src/gen.rs @@ -206,20 +206,29 @@ impl<'a> ServiceGenerator<'a> { } None => quote! { Some(::restate_sdk::discovery::InputPayload { - content_type: Some(<() as ::restate_sdk::serde::WithContentType>::content_type().to_string()), - json_schema: Some(<() as ::restate_sdk::serde::WithSchema>::generate_schema()), - required: Some(false), + content_type: None, + json_schema: None, + required: None, }) } }; let output_ok = &handler.output_ok; - let output_schema = quote! { - Some(::restate_sdk::discovery::OutputPayload { - content_type: Some(<#output_ok as ::restate_sdk::serde::WithContentType>::content_type().to_string()), - json_schema: Some(<#output_ok as ::restate_sdk::serde::WithSchema>::generate_schema()), - set_content_type_if_empty: Some(false), - }) + let output_schema = match output_ok { + syn::Type::Tuple(tuple) if tuple.elems.is_empty() => quote! { + Some(::restate_sdk::discovery::OutputPayload { + content_type: None, + json_schema: None, + set_content_type_if_empty: Some(false), + }) + }, + _ => quote! { + Some(::restate_sdk::discovery::OutputPayload { + content_type: Some(<#output_ok as ::restate_sdk::serde::WithContentType>::content_type().to_string()), + json_schema: Some(<#output_ok as ::restate_sdk::serde::WithSchema>::generate_schema()), + set_content_type_if_empty: Some(false), + }) + } }; quote! { diff --git a/src/lib.rs b/src/lib.rs index c0d42a4..e62fe22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,9 +27,8 @@ //! - [Scheduling & Timers][crate::context::ContextTimers]: Let a handler pause for a certain amount of time. Restate durably tracks the timer across failures. //! - [Awakeables][crate::context::ContextAwakeables]: Durable Futures to wait for events and the completion of external tasks. //! - [Error Handling][crate::errors]: Restate retries failures infinitely. Use `TerminalError` to stop retries. -//! - [Serialization][crate::serde]: The SDK serializes results to send them to the Server. +//! - [Serialization][crate::serde]: The SDK serializes results to send them to the Server. Includes [Schema Generation][crate::serde::WithSchema] for API documentation & discovery. //! - [Serving][crate::http_server]: Start an HTTP server to expose services. -//! - [JSON Schema Generation][crate::serde::WithSchema]: Automatically generate JSON Schemas for better discovery. //! //! # SDK Overview //! @@ -505,82 +504,6 @@ pub use restate_sdk_macros::object; /// For more details, check the [`service` macro](macro@crate::service) documentation. pub use restate_sdk_macros::workflow; -/// ### JSON Schema Generation -/// -/// The SDK provides three approaches for generating JSON Schemas for handler inputs and outputs: -/// -/// #### 1. Primitive Types -/// -/// Primitive types (like `String`, `u32`, `bool`) have built-in schema implementations -/// that work automatically without additional code: -/// -/// ```rust -/// use restate_sdk::prelude::*; -/// -/// #[restate_sdk::service] -/// trait SimpleService { -/// async fn greet(name: String) -> HandlerResult; -/// } -/// ``` -/// -/// #### 2. Using Json with schemars -/// -/// For complex types wrapped in `Json`, you need to add the `schemars` feature and derive `JsonSchema`: -/// -/// ```rust -/// use restate_sdk::prelude::*; -/// -/// #[derive(serde::Serialize, serde::Deserialize)] -/// #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// struct User { -/// name: String, -/// age: u32, -/// } -/// -/// #[restate_sdk::service] -/// trait UserService { -/// async fn register(user: Json) -> HandlerResult>; -/// } -/// ``` -/// -/// To enable rich schema generation with `Json`, add the `schemars` feature to your dependency: -/// -/// ```toml -/// [dependencies] -/// restate-sdk = { version = "0.3", features = ["schemars"] } -/// schemars = "1.0.0-alpha.17" -/// ``` -/// -/// #### 3. Custom Implementation -/// -/// You can also implement the `WithSchema` trait directly for your types to provide -/// custom schemas without relying on the `schemars` feature: -/// -/// ```rust -/// use restate_sdk::serde::{WithSchema, WithContentType, Serialize, Deserialize}; -/// -/// #[derive(serde::Serialize, serde::Deserialize)] -/// struct User { -/// name: String, -/// age: u32, -/// } -/// -/// // Implement WithSchema directly -/// impl WithSchema for User { -/// fn generate_schema() -> serde_json::Value { -/// serde_json::json!({ -/// "type": "object", -/// "properties": { -/// "name": {"type": "string"}, -/// "age": {"type": "integer", "minimum": 0} -/// }, -/// "required": ["name", "age"] -/// }) -/// } -/// } -/// ``` -/// -/// /// Prelude contains all the useful imports you need to get started with Restate. pub mod prelude { #[cfg(feature = "http_server")] diff --git a/src/serde.rs b/src/serde.rs index 2e9d93d..ed02d52 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -213,40 +213,92 @@ impl WithContentType for Json { // -- Schema Generation -/// Trait encapsulating JSON Schema information for the given serializer/deserializer. +/// ## JSON Schema Generation /// -/// This trait allows types to provide JSON Schema information that can be used for -/// documentation, validation, and client generation. +/// The SDK provides three approaches for generating JSON Schemas for handler inputs and outputs: /// -/// ## Behavior with `schemars` Feature Flag +/// ### 1. Primitive Types /// -/// When the `schemars` feature is enabled, implementations for complex types use -/// the `schemars` crate to automatically generate rich, JSON Schema 2020-12 conforming schemas. -/// When the feature is disabled, primitive types still provide basic schemas, -/// but complex types return empty schemas, unless manually implemented. +/// Primitive types (like `String`, `u32`, `bool`) have built-in schema implementations +/// that work automatically without additional code: +/// +/// ```rust +/// use restate_sdk::prelude::*; /// -/// ## Example Implementation: +/// #[restate_sdk::service] +/// trait SimpleService { +/// async fn greet(name: String) -> HandlerResult; +/// } +/// ``` +/// +/// ### 2. Using Json with schemars +/// +/// For complex types wrapped in `Json`, you need to add the `schemars` feature and derive `JsonSchema`: /// /// ```rust -/// use serde_json::json; -/// use restate_sdk::serde::WithSchema; +/// use restate_sdk::prelude::*; +/// +/// #[derive(serde::Serialize, serde::Deserialize)] +/// #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// struct User { +/// name: String, +/// age: u32, +/// } /// -/// struct MyType { /* ... */ } +/// #[restate_sdk::service] +/// trait UserService { +/// async fn register(user: Json) -> HandlerResult>; +/// } +/// ``` /// -/// impl WithSchema for MyType { +/// To enable rich schema generation with `Json`, add the `schemars` feature to your dependency: +/// +/// ```toml +/// [dependencies] +/// restate-sdk = { version = "0.3", features = ["schemars"] } +/// schemars = "1.0.0-alpha.17" +/// ``` +/// +/// ### 3. Custom Implementation +/// +/// You can also implement the `WithSchema` trait directly for your types to provide +/// custom schemas without relying on the `schemars` feature: +/// +/// ```rust +/// use restate_sdk::serde::{WithSchema, WithContentType, Serialize, Deserialize}; +/// +/// #[derive(serde::Serialize, serde::Deserialize)] +/// struct User { +/// name: String, +/// age: u32, +/// } +/// +/// // Implement WithSchema directly +/// impl WithSchema for User { /// fn generate_schema() -> serde_json::Value { -/// // Generate and return a JSON Schema -/// json!({ +/// serde_json::json!({ /// "type": "object", /// "properties": { -/// "field1": { "type": "string" }, -/// "field2": { "type": "number" } +/// "name": {"type": "string"}, +/// "age": {"type": "integer", "minimum": 0} /// }, -/// "required": ["field1"] +/// "required": ["name", "age"] /// }) /// } /// } /// ``` +/// +/// Trait encapsulating JSON Schema information for the given serializer/deserializer. +/// +/// This trait allows types to provide JSON Schema information that can be used for +/// documentation, validation, and client generation. +/// +/// ## Behavior with `schemars` Feature Flag +/// +/// When the `schemars` feature is enabled, implementations for complex types use +/// the `schemars` crate to automatically generate rich, JSON Schema 2020-12 conforming schemas. +/// When the feature is disabled, primitive types still provide basic schemas, +/// but complex types return empty schemas, unless manually implemented. pub trait WithSchema { /// Generate a JSON Schema for this type. /// diff --git a/tests/schema.rs b/tests/schema.rs index 9baeb97..5721bf4 100644 --- a/tests/schema.rs +++ b/tests/schema.rs @@ -35,6 +35,7 @@ trait SchemaTestService { async fn no_input_handler() -> HandlerResult; async fn json_handler(input: Json) -> HandlerResult>; async fn complex_handler(input: Json) -> HandlerResult>>; + async fn empty_output_handler(input: String) -> HandlerResult<()>; } struct SchemaTestServiceImpl; @@ -60,13 +61,16 @@ impl SchemaTestService for SchemaTestServiceImpl { ) -> HandlerResult>> { Ok(Json(HashMap::from([("original".to_string(), input.0)]))) } + async fn empty_output_handler(&self, _ctx: Context<'_>, _input: String) -> HandlerResult<()> { + Ok(()) + } } #[test] fn schema_discovery_and_validation() { let discovery = ServeSchemaTestService::::discover(); assert_eq!(discovery.name.to_string(), "SchemaTestService"); - assert_eq!(discovery.handlers.len(), 4); + assert_eq!(discovery.handlers.len(), 5); for handler in &discovery.handlers { let input = handler @@ -77,65 +81,89 @@ fn schema_discovery_and_validation() { .output .as_ref() .expect("Handler should have output schema"); - let input_schema = input - .json_schema - .as_ref() - .expect("Input schema should exist"); - let output_schema = output - .json_schema - .as_ref() - .expect("Output schema should exist"); match handler.name.to_string().as_str() { - "string_handler" => { - assert_eq!( - input_schema.get("type").and_then(|v| v.as_str()), - Some("string") - ); - assert_eq!( - output_schema.get("type").and_then(|v| v.as_str()), - Some("integer") - ); + "string_handler" | "json_handler" | "complex_handler" | "empty_output_handler" => { + let input_schema = input + .json_schema + .as_ref() + .expect("Input schema should exist for handlers with input"); + let output_schema = output.json_schema.as_ref(); + + match handler.name.to_string().as_str() { + "string_handler" => { + assert_eq!( + input_schema.get("type").and_then(|v| v.as_str()), + Some("string") + ); + assert!(output_schema.is_some()); + assert_eq!( + output_schema.unwrap().get("type").and_then(|v| v.as_str()), + Some("integer") + ); + } + "json_handler" => { + #[cfg(feature = "schemars")] + { + let obj = input_schema + .as_object() + .expect("Schema should be an object"); + assert!( + obj.contains_key("properties"), + "Json schema should have properties" + ); + assert!(obj["properties"]["name"]["type"] == "string"); + assert!(obj["properties"]["age"]["type"] == "integer"); + } + #[cfg(not(feature = "schemars"))] + assert_eq!(input_schema, &serde_json::json!({})); + } + "complex_handler" => { + #[cfg(feature = "schemars")] + { + let obj = input_schema + .as_object() + .expect("Schema should be an object"); + assert!(obj.contains_key("properties") || obj.contains_key("$ref")); + let props = obj.get("properties").or_else(|| obj.get("$ref")).unwrap(); + assert!(props.is_object(), "Complex schema should define structure"); + } + #[cfg(not(feature = "schemars"))] + assert_eq!(input_schema, &serde_json::json!({})); + } + "empty_output_handler" => { + assert_eq!( + input_schema.get("type").and_then(|v| v.as_str()), + Some("string") + ); + // For empty output handler, we don't expect json_schema to be set in output + assert!( + output_schema.is_none(), + "Empty output handler should have json_schema set to None" + ); + // Verify that set_content_type_if_empty is set + assert_eq!(output.set_content_type_if_empty, Some(false)); + } + _ => unreachable!("Unexpected handler"), + } } "no_input_handler" => { - assert_eq!( - input_schema.get("type").and_then(|v| v.as_str()), - Some("null") + // For no_input_handler, we don't expect json_schema to be set + assert!( + input.json_schema.is_none(), + "No input handler should have json_schema set to None" ); + + let output_schema = output + .json_schema + .as_ref() + .expect("Output schema should exist"); + assert_eq!( output_schema.get("type").and_then(|v| v.as_str()), Some("string") ); } - "json_handler" => { - #[cfg(feature = "schemars")] - { - let obj = input_schema - .as_object() - .expect("Schema should be an object"); - assert!( - obj.contains_key("properties"), - "Json schema should have properties" - ); - assert!(obj["properties"]["name"]["type"] == "string"); - assert!(obj["properties"]["age"]["type"] == "integer"); - } - #[cfg(not(feature = "schemars"))] - assert_eq!(input_schema, &serde_json::json!({})); - } - "complex_handler" => { - #[cfg(feature = "schemars")] - { - let obj = input_schema - .as_object() - .expect("Schema should be an object"); - assert!(obj.contains_key("properties") || obj.contains_key("$ref")); - let props = obj.get("properties").or_else(|| obj.get("$ref")).unwrap(); - assert!(props.is_object(), "Complex schema should define structure"); - } - #[cfg(not(feature = "schemars"))] - assert_eq!(input_schema, &serde_json::json!({})); - } _ => unreachable!("Unexpected handler"), } } From ccc340e91057595c39a367075e2e49fef238862a Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Fri, 11 Apr 2025 11:36:35 +0200 Subject: [PATCH 3/6] Reorganized the `WithSchema` trait together with `WithContentType` in a single trait called `PayloadMetadata`. Those logically belong together, and are used really for describing payloads. --- macros/src/gen.rs | 28 +-- src/discovery.rs | 40 ++++ src/lib.rs | 2 +- src/serde.rs | 547 ++++++++++++++++++++++++---------------------- tests/schema.rs | 6 +- 5 files changed, 341 insertions(+), 282 deletions(-) diff --git a/macros/src/gen.rs b/macros/src/gen.rs index 751f224..e489ab0 100644 --- a/macros/src/gen.rs +++ b/macros/src/gen.rs @@ -197,37 +197,21 @@ impl<'a> ServiceGenerator<'a> { let input_schema = match &handler.arg { Some(PatType { ty, .. }) => { quote! { - Some(::restate_sdk::discovery::InputPayload { - content_type: Some(<#ty as ::restate_sdk::serde::WithContentType>::content_type().to_string()), - json_schema: Some(<#ty as ::restate_sdk::serde::WithSchema>::generate_schema()), - required: Some(true), - }) + Some(::restate_sdk::discovery::InputPayload::from_metadata::<#ty>()) } } None => quote! { - Some(::restate_sdk::discovery::InputPayload { - content_type: None, - json_schema: None, - required: None, - }) + Some(::restate_sdk::discovery::InputPayload::empty()) } }; - let output_ok = &handler.output_ok; - let output_schema = match output_ok { + let output_ty = &handler.output_ok; + let output_schema = match output_ty { syn::Type::Tuple(tuple) if tuple.elems.is_empty() => quote! { - Some(::restate_sdk::discovery::OutputPayload { - content_type: None, - json_schema: None, - set_content_type_if_empty: Some(false), - }) + Some(::restate_sdk::discovery::OutputPayload::empty()) }, _ => quote! { - Some(::restate_sdk::discovery::OutputPayload { - content_type: Some(<#output_ok as ::restate_sdk::serde::WithContentType>::content_type().to_string()), - json_schema: Some(<#output_ok as ::restate_sdk::serde::WithSchema>::generate_schema()), - set_content_type_if_empty: Some(false), - }) + Some(::restate_sdk::discovery::OutputPayload::from_metadata::<#output_ty>()) } }; diff --git a/src/discovery.rs b/src/discovery.rs index 949ad7e..5c544da 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -8,3 +8,43 @@ mod generated { } pub use generated::*; + +use crate::serde::PayloadMetadata; + +impl InputPayload { + pub fn empty() -> Self { + Self { + content_type: None, + json_schema: None, + required: None, + } + } + + pub fn from_metadata() -> Self { + let input_metadata = T::input_metadata(); + Self { + content_type: Some(input_metadata.accept_content_type.to_owned()), + json_schema: T::json_schema(), + required: Some(input_metadata.is_required), + } + } +} + +impl OutputPayload { + pub fn empty() -> Self { + Self { + content_type: None, + json_schema: None, + set_content_type_if_empty: Some(false), + } + } + + pub fn from_metadata() -> Self { + let output_metadata = T::output_metadata(); + Self { + content_type: Some(output_metadata.content_type.to_owned()), + json_schema: T::json_schema(), + set_content_type_if_empty: Some(output_metadata.set_content_type_if_empty), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index e62fe22..d183e97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,7 +27,7 @@ //! - [Scheduling & Timers][crate::context::ContextTimers]: Let a handler pause for a certain amount of time. Restate durably tracks the timer across failures. //! - [Awakeables][crate::context::ContextAwakeables]: Durable Futures to wait for events and the completion of external tasks. //! - [Error Handling][crate::errors]: Restate retries failures infinitely. Use `TerminalError` to stop retries. -//! - [Serialization][crate::serde]: The SDK serializes results to send them to the Server. Includes [Schema Generation][crate::serde::WithSchema] for API documentation & discovery. +//! - [Serialization][crate::serde]: The SDK serializes results to send them to the Server. Includes [Schema Generation and payload metadata](crate::serde::PayloadMetadata) for documentation & discovery. //! - [Serving][crate::http_server]: Start an HTTP server to expose services. //! //! # SDK Overview diff --git a/src/serde.rs b/src/serde.rs index ed02d52..c78cf9b 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -4,8 +4,8 @@ //! //! Therefore, the types of the values that are stored, need to either: //! - be a primitive type -//! - use a wrapper type [`Json`] for using [`serde-json`](https://serde.rs/) -//! - have the [`Serialize`] and [`Deserialize`] trait implemented +//! - use a wrapper type [`Json`] for using [`serde-json`](https://serde.rs/). To enable JSON schema generation, you'll need to enable the `schemars` feature. See [PayloadMetadata] for more details. +//! - have the [`Serialize`] and [`Deserialize`] trait implemented. If you need to use a type for the handler input/output, you'll also need to implement [PayloadMetadata] to reply with correct content type and enable **JSON schema generation**. //! use bytes::Bytes; @@ -40,182 +40,15 @@ where fn deserialize(bytes: &mut Bytes) -> Result; } -/// Trait encapsulating `content-type` information for the given serializer/deserializer. +/// ## Payload metadata and Json Schemas /// -/// This is used by service discovery to correctly specify the content type. -pub trait WithContentType { - fn content_type() -> &'static str; -} - -// --- Default implementation for Unit type - -impl Serialize for () { - type Error = Infallible; - - fn serialize(&self) -> Result { - Ok(Bytes::new()) - } -} - -impl Deserialize for () { - type Error = Infallible; - - fn deserialize(_: &mut Bytes) -> Result { - Ok(()) - } -} - -impl WithContentType for () { - fn content_type() -> &'static str { - "" - } -} - -// --- Passthrough implementation - -impl Serialize for Vec { - type Error = Infallible; - - fn serialize(&self) -> Result { - Ok(Bytes::copy_from_slice(self)) - } -} - -impl Deserialize for Vec { - type Error = Infallible; - - fn deserialize(b: &mut Bytes) -> Result { - Ok(b.to_vec()) - } -} - -impl WithContentType for Vec { - fn content_type() -> &'static str { - APPLICATION_OCTET_STREAM - } -} - -impl Serialize for Bytes { - type Error = Infallible; - - fn serialize(&self) -> Result { - Ok(self.clone()) - } -} - -impl Deserialize for Bytes { - type Error = Infallible; - - fn deserialize(b: &mut Bytes) -> Result { - Ok(b.clone()) - } -} - -impl WithContentType for Bytes { - fn content_type() -> &'static str { - APPLICATION_OCTET_STREAM - } -} - -// --- Primitives - -macro_rules! impl_serde_primitives { - ($ty:ty) => { - impl Serialize for $ty { - type Error = serde_json::Error; - - fn serialize(&self) -> Result { - serde_json::to_vec(&self).map(Bytes::from) - } - } - - impl Deserialize for $ty { - type Error = serde_json::Error; - - fn deserialize(bytes: &mut Bytes) -> Result { - serde_json::from_slice(&bytes) - } - } - - impl WithContentType for $ty { - fn content_type() -> &'static str { - APPLICATION_JSON - } - } - }; -} - -impl_serde_primitives!(String); -impl_serde_primitives!(u8); -impl_serde_primitives!(u16); -impl_serde_primitives!(u32); -impl_serde_primitives!(u64); -impl_serde_primitives!(u128); -impl_serde_primitives!(i8); -impl_serde_primitives!(i16); -impl_serde_primitives!(i32); -impl_serde_primitives!(i64); -impl_serde_primitives!(i128); -impl_serde_primitives!(bool); -impl_serde_primitives!(f32); -impl_serde_primitives!(f64); - -// --- Json responses - -/// Wrapper type to use [`serde_json`] with Restate's [`Serialize`]/[`Deserialize`] traits. -pub struct Json(pub T); - -impl Json { - pub fn into_inner(self) -> T { - self.0 - } -} - -impl From for Json { - fn from(value: T) -> Self { - Self(value) - } -} - -impl Serialize for Json -where - T: serde::Serialize, -{ - type Error = serde_json::Error; - - fn serialize(&self) -> Result { - serde_json::to_vec(&self.0).map(Bytes::from) - } -} - -impl Deserialize for Json -where - for<'a> T: serde::Deserialize<'a>, -{ - type Error = serde_json::Error; - - fn deserialize(bytes: &mut Bytes) -> Result { - serde_json::from_slice(bytes).map(Json) - } -} - -impl Default for Json { - fn default() -> Self { - Self(T::default()) - } -} - -impl WithContentType for Json { - fn content_type() -> &'static str { - APPLICATION_JSON - } -} - -// -- Schema Generation - -/// ## JSON Schema Generation +/// The SDK propagates during discovery some metadata to restate-server service catalog. This includes: +/// +/// * The JSON schema of the payload. See below for more details. +/// * The [InputMetadata] used to instruct restate how to accept requests. +/// * The [OutputMetadata] used to instruct restate how to send responses out. /// -/// The SDK provides three approaches for generating JSON Schemas for handler inputs and outputs: +/// There are three approaches for generating JSON Schemas for handler inputs and outputs: /// /// ### 1. Primitive Types /// @@ -231,15 +64,14 @@ impl WithContentType for Json { /// } /// ``` /// -/// ### 2. Using Json with schemars +/// ### 2. Using `Json` with schemars /// /// For complex types wrapped in `Json`, you need to add the `schemars` feature and derive `JsonSchema`: /// /// ```rust /// use restate_sdk::prelude::*; /// -/// #[derive(serde::Serialize, serde::Deserialize)] -/// #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)] /// struct User { /// name: String, /// age: u32, @@ -261,11 +93,11 @@ impl WithContentType for Json { /// /// ### 3. Custom Implementation /// -/// You can also implement the `WithSchema` trait directly for your types to provide +/// You can also implement the [PayloadMetadata] trait directly for your types to provide /// custom schemas without relying on the `schemars` feature: /// /// ```rust -/// use restate_sdk::serde::{WithSchema, WithContentType, Serialize, Deserialize}; +/// use restate_sdk::serde::{PayloadMetadata, Serialize, Deserialize}; /// /// #[derive(serde::Serialize, serde::Deserialize)] /// struct User { @@ -273,17 +105,17 @@ impl WithContentType for Json { /// age: u32, /// } /// -/// // Implement WithSchema directly -/// impl WithSchema for User { -/// fn generate_schema() -> serde_json::Value { -/// serde_json::json!({ +/// // Implement PayloadMetadata directly and override the json_schema implementation +/// impl PayloadMetadata for User { +/// fn json_schema() -> Option { +/// Some(serde_json::json!({ /// "type": "object", /// "properties": { /// "name": {"type": "string"}, /// "age": {"type": "integer", "minimum": 0} /// }, /// "required": ["name", "age"] -/// }) +/// })) /// } /// } /// ``` @@ -299,146 +131,349 @@ impl WithContentType for Json { /// the `schemars` crate to automatically generate rich, JSON Schema 2020-12 conforming schemas. /// When the feature is disabled, primitive types still provide basic schemas, /// but complex types return empty schemas, unless manually implemented. -pub trait WithSchema { +pub trait PayloadMetadata { /// Generate a JSON Schema for this type. /// /// Returns a JSON value representing the schema for this type. When the `schemars` /// feature is enabled, this returns an auto-generated JSON Schema 2020-12 conforming schema. When the feature is disabled, /// this returns an empty schema for complex types, but basic schemas for primitives. - fn generate_schema() -> serde_json::Value; + /// + /// If returns none, no schema is provided. This should be used when the payload is not expected to be json + fn json_schema() -> Option { + Some(serde_json::Value::Object(serde_json::Map::default())) + } + + /// Returns the [InputMetadata]. The default implementation returns metadata suitable for JSON payloads. + fn input_metadata() -> InputMetadata { + InputMetadata::default() + } + + /// Returns the [OutputMetadata]. The default implementation returns metadata suitable for JSON payloads. + fn output_metadata() -> OutputMetadata { + OutputMetadata::default() + } } -// Helper function to create an empty schema -fn empty_schema() -> serde_json::Value { - serde_json::json!({}) +/// This struct encapsulates input payload metadata used by discovery. +/// +/// The default implementation works well with Json payloads. +pub struct InputMetadata { + /// Content type of the input. It can accept wildcards, in the same format as the 'Accept' header. + /// + /// By default, is `application/json`. + pub accept_content_type: &'static str, + /// If true, Restate itself will reject requests **without content-types**. + pub is_required: bool, } -// Basic implementations for primitive types (always available) +impl Default for InputMetadata { + fn default() -> Self { + Self { + accept_content_type: APPLICATION_JSON, + is_required: true, + } + } +} + +/// This struct encapsulates output payload metadata used by discovery. +/// +/// The default implementation works for Json payloads. +pub struct OutputMetadata { + /// Content type of the output. + /// + /// By default, is `application/json`. + pub content_type: &'static str, + /// If true, the specified content-type is set even if the output is empty. This should be set to `true` only for encodings that can return a serialized empty byte array (e.g. Protobuf). + pub set_content_type_if_empty: bool, +} + +impl Default for OutputMetadata { + fn default() -> Self { + Self { + content_type: APPLICATION_JSON, + set_content_type_if_empty: false, + } + } +} + +// --- Default implementation for Unit type + +impl Serialize for () { + type Error = Infallible; + + fn serialize(&self) -> Result { + Ok(Bytes::new()) + } +} + +impl Deserialize for () { + type Error = Infallible; -impl WithSchema for () { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "null" }) + fn deserialize(_: &mut Bytes) -> Result { + Ok(()) } } -impl WithSchema for String { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "string" }) +// --- Passthrough implementation + +impl Serialize for Vec { + type Error = Infallible; + + fn serialize(&self) -> Result { + Ok(Bytes::copy_from_slice(self)) + } +} + +impl Deserialize for Vec { + type Error = Infallible; + + fn deserialize(b: &mut Bytes) -> Result { + Ok(b.to_vec()) } } -impl WithSchema for bool { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "boolean" }) +impl PayloadMetadata for Vec { + fn json_schema() -> Option { + None + } + + fn input_metadata() -> InputMetadata { + InputMetadata { + accept_content_type: "*/*", + is_required: true, + } + } + + fn output_metadata() -> OutputMetadata { + OutputMetadata { + content_type: APPLICATION_OCTET_STREAM, + set_content_type_if_empty: false, + } } } -impl WithSchema for u8 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "integer", "minimum": 0, "maximum": 255 }) +impl Serialize for Bytes { + type Error = Infallible; + + fn serialize(&self) -> Result { + Ok(self.clone()) } } -impl WithSchema for u16 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "integer", "minimum": 0, "maximum": 65535 }) +impl Deserialize for Bytes { + type Error = Infallible; + + fn deserialize(b: &mut Bytes) -> Result { + Ok(b.clone()) } } -impl WithSchema for u32 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "integer", "minimum": 0, "maximum": 4_294_967_295u64 }) +impl PayloadMetadata for Bytes { + fn json_schema() -> Option { + None + } + + fn input_metadata() -> InputMetadata { + InputMetadata { + accept_content_type: "*/*", + is_required: true, + } + } + + fn output_metadata() -> OutputMetadata { + OutputMetadata { + content_type: APPLICATION_OCTET_STREAM, + set_content_type_if_empty: false, + } } } +// --- Option implementation + +impl Serialize for Option { + type Error = T::Error; -impl WithSchema for u64 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "integer", "minimum": 0 }) + fn serialize(&self) -> Result { + if self.is_none() { + return Ok(Bytes::new()); + } + T::serialize(self.as_ref().unwrap()) } } -impl WithSchema for u128 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "integer", "minimum": 0 }) +impl Deserialize for Option { + type Error = T::Error; + + fn deserialize(b: &mut Bytes) -> Result { + if b.is_empty() { + return Ok(None); + } + T::deserialize(b).map(Some) } } -impl WithSchema for i8 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "integer", "minimum": -128, "maximum": 127 }) +impl PayloadMetadata for Option { + fn input_metadata() -> InputMetadata { + InputMetadata { + accept_content_type: T::input_metadata().accept_content_type, + is_required: false, + } } + + fn output_metadata() -> OutputMetadata { + OutputMetadata { + content_type: T::output_metadata().content_type, + set_content_type_if_empty: false, + } + } +} + +// --- Primitives + +macro_rules! impl_integer_primitives { + ($ty:ty) => { + impl Serialize for $ty { + type Error = serde_json::Error; + + fn serialize(&self) -> Result { + serde_json::to_vec(&self).map(Bytes::from) + } + } + + impl Deserialize for $ty { + type Error = serde_json::Error; + + fn deserialize(bytes: &mut Bytes) -> Result { + serde_json::from_slice(&bytes) + } + } + + impl PayloadMetadata for $ty { + fn json_schema() -> Option { + let min = <$ty>::MIN; + let max = <$ty>::MAX; + Some(serde_json::json!({ "type": "integer", "minimum": min, "maximum": max })) + } + } + }; +} + +impl_integer_primitives!(u8); +impl_integer_primitives!(u16); +impl_integer_primitives!(u32); +impl_integer_primitives!(u64); +impl_integer_primitives!(u128); +impl_integer_primitives!(i8); +impl_integer_primitives!(i16); +impl_integer_primitives!(i32); +impl_integer_primitives!(i64); +impl_integer_primitives!(i128); + +macro_rules! impl_serde_primitives { + ($ty:ty) => { + impl Serialize for $ty { + type Error = serde_json::Error; + + fn serialize(&self) -> Result { + serde_json::to_vec(&self).map(Bytes::from) + } + } + + impl Deserialize for $ty { + type Error = serde_json::Error; + + fn deserialize(bytes: &mut Bytes) -> Result { + serde_json::from_slice(&bytes) + } + } + }; } -impl WithSchema for i16 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "integer", "minimum": -32768, "maximum": 32767 }) +impl_serde_primitives!(String); +impl_serde_primitives!(bool); +impl_serde_primitives!(f32); +impl_serde_primitives!(f64); + +impl PayloadMetadata for String { + fn json_schema() -> Option { + Some(serde_json::json!({ "type": "string" })) } } -impl WithSchema for i32 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }) +impl PayloadMetadata for bool { + fn json_schema() -> Option { + Some(serde_json::json!({ "type": "boolean" })) } } -impl WithSchema for i64 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "integer" }) +impl PayloadMetadata for f32 { + fn json_schema() -> Option { + Some(serde_json::json!({ "type": "number" })) } } -impl WithSchema for i128 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "integer" }) +impl PayloadMetadata for f64 { + fn json_schema() -> Option { + Some(serde_json::json!({ "type": "number" })) } } -impl WithSchema for f32 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "number" }) +// --- Json wrapper + +/// Wrapper type to use [`serde_json`] with Restate's [`Serialize`]/[`Deserialize`] traits. +pub struct Json(pub T); + +impl Json { + pub fn into_inner(self) -> T { + self.0 } } -impl WithSchema for f64 { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "number" }) +impl From for Json { + fn from(value: T) -> Self { + Self(value) } } -impl WithSchema for Vec { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "string", "format": "byte" }) +impl Serialize for Json +where + T: serde::Serialize, +{ + type Error = serde_json::Error; + + fn serialize(&self) -> Result { + serde_json::to_vec(&self.0).map(Bytes::from) } } -impl WithSchema for Bytes { - fn generate_schema() -> serde_json::Value { - serde_json::json!({ "type": "string", "format": "byte" }) +impl Deserialize for Json +where + for<'a> T: serde::Deserialize<'a>, +{ + type Error = serde_json::Error; + + fn deserialize(bytes: &mut Bytes) -> Result { + serde_json::from_slice(bytes).map(Json) } } -impl WithSchema for Option { - fn generate_schema() -> serde_json::Value { - T::generate_schema() +impl Default for Json { + fn default() -> Self { + Self(T::default()) } } // When schemars is disabled - works with any T #[cfg(not(feature = "schemars"))] -impl WithSchema for Json { - fn generate_schema() -> serde_json::Value { - empty_schema() // Empty schema returns "accept all */*" +impl PayloadMetadata for Json { + fn json_schema() -> Option { + Some(serde_json::json!({})) } } // When schemars is enabled - requires T: JsonSchema #[cfg(feature = "schemars")] -impl WithSchema for Json { - fn generate_schema() -> serde_json::Value { - let schema = schemars::schema_for!(T); - serde_json::to_value(schema).unwrap_or_else(|e| { - tracing::debug!("Failed to convert schema to JSON: {}", e); - empty_schema() - }) +impl PayloadMetadata for Json { + fn json_schema() -> Option { + Some(schemars::schema_for!(T).to_value()) } } diff --git a/tests/schema.rs b/tests/schema.rs index 5721bf4..0619ef7 100644 --- a/tests/schema.rs +++ b/tests/schema.rs @@ -1,5 +1,5 @@ use restate_sdk::prelude::*; -use restate_sdk::serde::{Json, WithSchema}; +use restate_sdk::serde::{Json, PayloadMetadata}; use restate_sdk::service::Discoverable; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -171,10 +171,10 @@ fn schema_discovery_and_validation() { #[test] fn schema_generation() { - let string_schema = ::generate_schema(); + let string_schema = ::json_schema().unwrap(); assert_eq!(string_schema["type"], "string"); - let json_schema = as WithSchema>::generate_schema(); + let json_schema = as PayloadMetadata>::json_schema().unwrap(); #[cfg(feature = "schemars")] assert!(json_schema["properties"]["name"]["type"] == "string"); #[cfg(not(feature = "schemars"))] From d630c5a1164ebbcad5b7e596f80611507236ef07 Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Fri, 11 Apr 2025 11:36:59 +0200 Subject: [PATCH 4/6] schemars feature is implicitly declared by cargo, no need to manually specify it --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8d33902..04987b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ default = ["http_server", "rand", "uuid", "tracing-span-filter"] hyper = ["dep:hyper", "http-body-util", "restate-sdk-shared-core/http"] http_server = ["hyper", "hyper/server", "hyper/http2", "hyper-util", "tokio/net", "tokio/signal", "tokio/macros"] tracing-span-filter = ["dep:tracing-subscriber"] -schemars = ["dep:schemars"] [dependencies] bytes = "1.10" From 6a83a4881d798e6e2370c5e918e6e8e435805650 Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Fri, 11 Apr 2025 11:37:24 +0200 Subject: [PATCH 5/6] Enable in test-services the schemars feature, this should give it a good coverage --- test-services/Cargo.toml | 4 +++- test-services/src/cancel_test.rs | 3 ++- test-services/src/counter.rs | 3 ++- test-services/src/map_object.rs | 3 ++- test-services/src/proxy.rs | 5 +++-- test-services/src/test_utils_service.rs | 6 +++--- .../src/virtual_object_command_interpreter.rs | 11 ++++++----- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/test-services/Cargo.toml b/test-services/Cargo.toml index 79c4e48..477e793 100644 --- a/test-services/Cargo.toml +++ b/test-services/Cargo.toml @@ -6,9 +6,11 @@ publish = false [dependencies] anyhow = "1.0" +bytes = "1.10.1" tokio = { version = "1", features = ["full"] } tracing-subscriber = "0.3" futures = "0.3" -restate-sdk = { path = ".." } +restate-sdk = { path = "..", features = ["schemars"] } +schemars = "1.0.0-alpha.17" serde = { version = "1", features = ["derive"] } tracing = "0.1.40" diff --git a/test-services/src/cancel_test.rs b/test-services/src/cancel_test.rs index 768b951..ecbd866 100644 --- a/test-services/src/cancel_test.rs +++ b/test-services/src/cancel_test.rs @@ -1,10 +1,11 @@ use crate::awakeable_holder; use anyhow::anyhow; use restate_sdk::prelude::*; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::time::Duration; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub(crate) enum BlockingOperation { Call, diff --git a/test-services/src/counter.rs b/test-services/src/counter.rs index 0b51d86..7456c7e 100644 --- a/test-services/src/counter.rs +++ b/test-services/src/counter.rs @@ -1,8 +1,9 @@ use restate_sdk::prelude::*; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tracing::info; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub(crate) struct CounterUpdateResponse { old_value: u64, diff --git a/test-services/src/map_object.rs b/test-services/src/map_object.rs index cf5ab76..5dae831 100644 --- a/test-services/src/map_object.rs +++ b/test-services/src/map_object.rs @@ -1,8 +1,9 @@ use anyhow::anyhow; use restate_sdk::prelude::*; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub(crate) struct Entry { key: String, diff --git a/test-services/src/proxy.rs b/test-services/src/proxy.rs index 6b1f221..67f91af 100644 --- a/test-services/src/proxy.rs +++ b/test-services/src/proxy.rs @@ -2,10 +2,11 @@ use futures::future::BoxFuture; use futures::FutureExt; use restate_sdk::context::RequestTarget; use restate_sdk::prelude::*; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::time::Duration; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub(crate) struct ProxyRequest { service_name: String, @@ -33,7 +34,7 @@ impl ProxyRequest { } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub(crate) struct ManyCallRequest { proxy_request: ProxyRequest, diff --git a/test-services/src/test_utils_service.rs b/test-services/src/test_utils_service.rs index 0152062..325d208 100644 --- a/test-services/src/test_utils_service.rs +++ b/test-services/src/test_utils_service.rs @@ -15,7 +15,7 @@ pub(crate) trait TestUtilsService { #[name = "uppercaseEcho"] async fn uppercase_echo(input: String) -> HandlerResult; #[name = "rawEcho"] - async fn raw_echo(input: Vec) -> Result, Infallible>; + async fn raw_echo(input: bytes::Bytes) -> Result, Infallible>; #[name = "echoHeaders"] async fn echo_headers() -> HandlerResult>>; #[name = "sleepConcurrently"] @@ -37,8 +37,8 @@ impl TestUtilsService for TestUtilsServiceImpl { Ok(input.to_ascii_uppercase()) } - async fn raw_echo(&self, _: Context<'_>, input: Vec) -> Result, Infallible> { - Ok(input) + async fn raw_echo(&self, _: Context<'_>, input: bytes::Bytes) -> Result, Infallible> { + Ok(input.to_vec()) } async fn echo_headers( diff --git a/test-services/src/virtual_object_command_interpreter.rs b/test-services/src/virtual_object_command_interpreter.rs index d401c91..b620ef4 100644 --- a/test-services/src/virtual_object_command_interpreter.rs +++ b/test-services/src/virtual_object_command_interpreter.rs @@ -1,16 +1,17 @@ use anyhow::anyhow; use futures::TryFutureExt; use restate_sdk::prelude::*; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::time::Duration; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub(crate) struct InterpretRequest { commands: Vec, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(tag = "type")] #[serde(rename_all_fields = "camelCase")] pub(crate) enum Command { @@ -39,7 +40,7 @@ pub(crate) enum Command { GetEnvVariable { env_name: String }, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(tag = "type")] #[serde(rename_all_fields = "camelCase")] pub(crate) enum AwaitableCommand { @@ -51,14 +52,14 @@ pub(crate) enum AwaitableCommand { RunThrowTerminalException { reason: String }, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub(crate) struct ResolveAwakeable { awakeable_key: String, value: String, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub(crate) struct RejectAwakeable { awakeable_key: String, From 4b4c52494d5917e4ac18657ab4c7c0be97d77db1 Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Fri, 11 Apr 2025 11:37:41 +0200 Subject: [PATCH 6/6] Enable in schema example the schemars feature, also improve the example a bit --- Cargo.toml | 5 +++++ examples/schema.rs | 15 ++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 04987b8..af1f95d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,11 @@ name = "tracing" path = "examples/tracing.rs" required-features = ["tracing-span-filter"] +[[example]] +name = "schema" +path = "examples/schema.rs" +required-features = ["schemars"] + [features] default = ["http_server", "rand", "uuid", "tracing-span-filter"] hyper = ["dep:hyper", "http-body-util", "restate-sdk-shared-core/http"] diff --git a/examples/schema.rs b/examples/schema.rs index f6765be..a7072b6 100644 --- a/examples/schema.rs +++ b/examples/schema.rs @@ -1,14 +1,15 @@ +//! Run with auto-generated schemas for `Json` using `schemars`: +//! cargo run --example schema --features schemars +//! +//! Run with primitive schemas only: +//! cargo run --example schema + use restate_sdk::prelude::*; -/// Run with auto-generated schemas for `Json` using `schemars`: -/// cargo run --example schema --features schemars -/// -/// Run with primitive schemas only: -/// cargo run --example schema +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::time::Duration; -#[derive(Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Serialize, Deserialize, JsonSchema)] struct Product { id: String, name: String,