Skip to content

Commit 8e4605b

Browse files
committed
add TypeScript client gen
1 parent c5350fa commit 8e4605b

16 files changed

Lines changed: 564 additions & 44 deletions

File tree

Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ members = [
99
"crates/rpc-transport-stdio",
1010
"crates/rpc-transport-inprocess",
1111
"crates/rpc-server",
12+
"crates/rpc-openapi",
1213
"examples-crate",
1314
]
1415

1516
[workspace.package]
1617
version = "0.1.0"
17-
edition = "2021"
18+
edition = "2024"
1819
rust-version = "1.75"
1920
license = "MIT"
2021
authors = ["Andrew Gazelka"]
@@ -30,6 +31,10 @@ serde = { version = "1.0", features = ["derive"] }
3031
serde_json = "1.0"
3132
rmp-serde = "1.3"
3233

34+
# Schema
35+
schema = { path = "/Users/andrewgazelka/Projects/superglide/schema/schema" }
36+
schema-openapi = { path = "/Users/andrewgazelka/Projects/superglide/schema/schema-openapi" }
37+
3338
# WebSocket
3439
tokio = { version = "1.42", features = ["full"] }
3540
tokio-tungstenite = "0.24"
@@ -48,6 +53,7 @@ rpc-transport-ws = { path = "crates/rpc-transport-ws" }
4853
rpc-transport-stdio = { path = "crates/rpc-transport-stdio" }
4954
rpc-transport-inprocess = { path = "crates/rpc-transport-inprocess" }
5055
rpc-server = { path = "crates/rpc-server" }
56+
rpc-openapi = { path = "crates/rpc-openapi" }
5157

5258
[profile.dev]
5359
opt-level = 0

README.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ Schema generation powered by [schema](https://github.com/andrewgazelka/schema) -
3030
| **Streaming** | 🚧 | Support streaming requests and responses |
3131
| **Any transport** || WebSocket, HTTP, Stdio, in-process channels, custom transports |
3232
| **Decoupled evolution** | 🚧 | Server changes don't require client recompilation - client and server evolve independently |
33-
| **Schema generation** | 📋 | Generate schemas from RPC definitions for OpenAPI specs, MCP servers (requires `#[derive(Schema)]` crate) |
33+
| **Schema generation** | | Generate OpenAPI specs and TypeScript clients from RPC definitions via `schema` crate |
3434
| **Observability** | 📋 | LLM-first observability via RPC - query transaction history, inspect logs, view request/response pairs |
35-
| **Language agnostic** | 📋 | Work with any language, not just Rust - clients in Python, JavaScript, Go, etc. |
35+
| **Language agnostic** | 🚧 | TypeScript clients via codegen, Python/Go/etc. planned |
3636

3737
### Low Priority (Needs More Thought)
3838

@@ -43,6 +43,8 @@ Schema generation powered by [schema](https://github.com/andrewgazelka/schema) -
4343
## Usage
4444

4545
```rust
46+
use schema::Schema;
47+
4648
// Define your API
4749
rpc! {
4850
extern "Rust" {
@@ -72,12 +74,40 @@ let client = client::Client::new(transport, MessagePackCodec);
7274
let result = client.add(2, 3).await?; // => 5
7375
```
7476

77+
### Generate OpenAPI & TypeScript
78+
79+
```rust
80+
// Get schema from RPC definition
81+
let schemas = client::Client::<AnyTransport, AnyCodec>::schema();
82+
83+
// Generate OpenAPI 3.0 spec
84+
let openapi = generate_openapi_spec("My API", "1.0.0", schemas);
85+
86+
// Generate TypeScript client
87+
let ts_client = generate_typescript_client("MyApiClient", "http://localhost:8080", schemas);
88+
```
89+
90+
Output TypeScript:
91+
92+
```typescript
93+
export class MyApiClient {
94+
async add(arg0: number, arg1: number): Promise<number> {
95+
return this.request<number>('add', [arg0, arg1]);
96+
}
97+
98+
async greet(arg0: string): Promise<string> {
99+
return this.request<string>('greet', [arg0]);
100+
}
101+
}
102+
```
103+
75104
## Features
76105

77106
- **Modular**: Separate crates for transport, codec, core
78107
- **Pluggable**: Mix & match any transport with any codec
79108
- **Type-safe**: Macro generates fully-typed client/server
80109
- **Modern**: Native async traits (Rust 1.75+)
110+
- **Schema-aware**: Automatic OpenAPI & TypeScript generation
81111
- **Flexible transports**: WebSocket, Stdio, in-process, HTTP (planned)
82112

83113
## Packages

crates/rpc-codec-json/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ repository.workspace = true
1010
rpc-core.workspace = true
1111
serde.workspace = true
1212
serde_json.workspace = true
13+
schema.workspace = true

crates/rpc-codec-json/src/lib.rs

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! Best for web APIs, debugging, and when message inspection is important.
55
66
use rpc_core::{Codec, Error, Result};
7+
use schema::Schema;
78
use serde::{Deserialize, Serialize};
89

910
/// JSON codec using serde_json.
@@ -26,11 +27,11 @@ use serde::{Deserialize, Serialize};
2627
pub struct JsonCodec;
2728

2829
impl Codec for JsonCodec {
29-
fn encode<T: Serialize>(&self, value: &T) -> Result<Vec<u8>> {
30+
fn encode<T: Serialize + Schema>(&self, value: &T) -> Result<Vec<u8>> {
3031
serde_json::to_vec(value).map_err(|e| Error::codec(format!("JSON encode error: {}", e)))
3132
}
3233

33-
fn decode<T: for<'de> Deserialize<'de>>(&self, bytes: &[u8]) -> Result<T> {
34+
fn decode<T: for<'de> Deserialize<'de> + Schema>(&self, bytes: &[u8]) -> Result<T> {
3435
serde_json::from_slice(bytes).map_err(|e| Error::codec(format!("JSON decode error: {}", e)))
3536
}
3637
}
@@ -53,7 +54,7 @@ mod tests {
5354
#[test]
5455
fn test_json_human_readable() {
5556
let codec = JsonCodec;
56-
let value = ("hello", 42);
57+
let value = ("hello".to_string(), 42);
5758

5859
let encoded = codec.encode(&value).unwrap();
5960
let json_str = String::from_utf8(encoded).unwrap();
@@ -65,17 +66,13 @@ mod tests {
6566

6667
#[test]
6768
fn test_json_complex_types() {
68-
use std::collections::HashMap;
69-
7069
let codec = JsonCodec;
71-
let mut map = HashMap::new();
72-
map.insert("key1".to_string(), 42);
73-
map.insert("key2".to_string(), 100);
70+
let value = (42, "test".to_string(), true);
7471

75-
let encoded = codec.encode(&map).unwrap();
76-
let decoded: HashMap<String, i32> = codec.decode(&encoded).unwrap();
72+
let encoded = codec.encode(&value).unwrap();
73+
let decoded: (i32, String, bool) = codec.decode(&encoded).unwrap();
7774

78-
assert_eq!(map, decoded);
75+
assert_eq!(value, decoded);
7976
}
8077

8178
#[test]
@@ -90,12 +87,12 @@ mod tests {
9087

9188
#[test]
9289
fn test_json_nested_structures() {
93-
#[derive(Debug, Serialize, Deserialize, PartialEq)]
90+
#[derive(Debug, Serialize, Deserialize, Schema, PartialEq)]
9491
struct Inner {
9592
value: i32,
9693
}
9794

98-
#[derive(Debug, Serialize, Deserialize, PartialEq)]
95+
#[derive(Debug, Serialize, Deserialize, Schema, PartialEq)]
9996
struct Outer {
10097
name: String,
10198
inner: Inner,

crates/rpc-codec-msgpack/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ repository.workspace = true
1010
rpc-core.workspace = true
1111
serde.workspace = true
1212
rmp-serde.workspace = true
13+
schema.workspace = true

crates/rpc-codec-msgpack/src/lib.rs

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! Best for high-throughput applications and bandwidth-constrained scenarios.
55
66
use rpc_core::{Codec, Error, Result};
7+
use schema::Schema;
78
use serde::{Deserialize, Serialize};
89

910
/// MessagePack codec using rmp-serde.
@@ -29,12 +30,12 @@ use serde::{Deserialize, Serialize};
2930
pub struct MessagePackCodec;
3031

3132
impl Codec for MessagePackCodec {
32-
fn encode<T: Serialize>(&self, value: &T) -> Result<Vec<u8>> {
33+
fn encode<T: Serialize + Schema>(&self, value: &T) -> Result<Vec<u8>> {
3334
rmp_serde::to_vec(value)
3435
.map_err(|e| Error::codec(format!("MessagePack encode error: {}", e)))
3536
}
3637

37-
fn decode<T: for<'de> Deserialize<'de>>(&self, bytes: &[u8]) -> Result<T> {
38+
fn decode<T: for<'de> Deserialize<'de> + Schema>(&self, bytes: &[u8]) -> Result<T> {
3839
rmp_serde::from_slice(bytes)
3940
.map_err(|e| Error::codec(format!("MessagePack decode error: {}", e)))
4041
}
@@ -69,17 +70,13 @@ mod tests {
6970

7071
#[test]
7172
fn test_msgpack_complex_types() {
72-
use std::collections::HashMap;
73-
7473
let codec = MessagePackCodec;
75-
let mut map = HashMap::new();
76-
map.insert("key1".to_string(), 42);
77-
map.insert("key2".to_string(), 100);
74+
let value = (42, "test".to_string(), true);
7875

79-
let encoded = codec.encode(&map).unwrap();
80-
let decoded: HashMap<String, i32> = codec.decode(&encoded).unwrap();
76+
let encoded = codec.encode(&value).unwrap();
77+
let decoded: (i32, String, bool) = codec.decode(&encoded).unwrap();
8178

82-
assert_eq!(map, decoded);
79+
assert_eq!(value, decoded);
8380
}
8481

8582
#[test]
@@ -94,12 +91,14 @@ mod tests {
9491

9592
#[test]
9693
fn test_msgpack_nested_structures() {
97-
#[derive(Debug, Serialize, Deserialize, PartialEq)]
94+
use schema::Schema;
95+
96+
#[derive(Debug, Serialize, Deserialize, Schema, PartialEq)]
9897
struct Inner {
9998
value: i32,
10099
}
101100

102-
#[derive(Debug, Serialize, Deserialize, PartialEq)]
101+
#[derive(Debug, Serialize, Deserialize, Schema, PartialEq)]
103102
struct Outer {
104103
name: String,
105104
inner: Inner,

crates/rpc-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ repository.workspace = true
1010
thiserror.workspace = true
1111
serde = { workspace = true }
1212
futures.workspace = true
13+
schema.workspace = true
1314

1415
[dev-dependencies]
1516
serde_json.workspace = true

crates/rpc-core/src/lib.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! This crate provides the fundamental abstractions needed for building
44
//! transport-agnostic and codec-agnostic RPC systems.
55
6+
use schema::Schema;
67
use serde::{Deserialize, Serialize};
78
use std::fmt;
89

@@ -56,14 +57,14 @@ pub trait Transport: Send {
5657
/// serialization format (JSON, MessagePack, Protobuf, etc.).
5758
pub trait Codec: Send + Sync {
5859
/// Encode a value to bytes
59-
fn encode<T: Serialize>(&self, value: &T) -> Result<Vec<u8>>;
60+
fn encode<T: Serialize + Schema>(&self, value: &T) -> Result<Vec<u8>>;
6061

6162
/// Decode bytes to a value
62-
fn decode<T: for<'de> Deserialize<'de>>(&self, bytes: &[u8]) -> Result<T>;
63+
fn decode<T: for<'de> Deserialize<'de> + Schema>(&self, bytes: &[u8]) -> Result<T>;
6364
}
6465

6566
/// RPC request structure
66-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67+
#[derive(Debug, Clone, Serialize, Deserialize, Schema, PartialEq, Eq)]
6768
pub struct RpcRequest {
6869
/// Request ID for matching responses
6970
pub id: u64,
@@ -74,7 +75,7 @@ pub struct RpcRequest {
7475
}
7576

7677
/// RPC response structure
77-
#[derive(Debug, Clone, Serialize, Deserialize)]
78+
#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
7879
pub struct RpcResponse {
7980
/// Request ID this response corresponds to
8081
pub id: u64,
@@ -83,7 +84,7 @@ pub struct RpcResponse {
8384
}
8485

8586
/// Result of an RPC call
86-
#[derive(Debug, Clone, Serialize, Deserialize)]
87+
#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
8788
pub enum ResponseResult {
8889
/// Successful result with data
8990
Ok(Vec<u8>),

crates/rpc-macro/src/lib.rs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use proc_macro::TokenStream;
44
use quote::quote;
5-
use syn::{parse_macro_input, ItemForeignMod, ReturnType};
5+
use syn::{ItemForeignMod, ReturnType, parse_macro_input};
66

77
/// Generate RPC client and server traits from a function signature list.
88
///
@@ -25,6 +25,7 @@ pub fn rpc(input: TokenStream) -> TokenStream {
2525
let mut client_methods = Vec::new();
2626
let mut server_methods = Vec::new();
2727
let mut dispatch_arms = Vec::new();
28+
let mut schema_entries = Vec::new();
2829

2930
for item in &foreign_mod.items {
3031
if let syn::ForeignItem::Fn(func) = item {
@@ -97,6 +98,27 @@ pub fn rpc(input: TokenStream) -> TokenStream {
9798
rpc_core::ResponseResult::Ok(result_data)
9899
}
99100
});
101+
102+
// Generate schema entry
103+
schema_entries.push(quote! {
104+
{
105+
use ::schema::Schema;
106+
schema_map.insert(
107+
#method_str.to_string(),
108+
MethodSchema {
109+
name: #method_str.to_string(),
110+
params: {
111+
let schema_type = <(#(#param_types,)*)>::schema();
112+
::schema_openapi::to_openapi_schema(&schema_type)
113+
},
114+
returns: {
115+
let schema_type = <#return_type>::schema();
116+
::schema_openapi::to_openapi_schema(&schema_type)
117+
},
118+
}
119+
);
120+
}
121+
});
100122
}
101123
}
102124

@@ -137,11 +159,21 @@ pub fn rpc(input: TokenStream) -> TokenStream {
137159
}
138160

139161
let expanded = quote! {
162+
/// Schema information for a single RPC method
163+
#[derive(Debug, Clone)]
164+
pub struct MethodSchema {
165+
pub name: String,
166+
pub params: ::serde_json::Value,
167+
pub returns: ::serde_json::Value,
168+
}
169+
140170
pub mod client {
141171
use rpc_core::{Transport, Codec, Message, RpcRequest, RpcResponse, ResponseResult};
142172
use std::sync::Arc;
143173
use tokio::sync::Mutex;
144174
use std::sync::atomic::{AtomicU64, Ordering};
175+
use std::collections::HashMap;
176+
use super::MethodSchema;
145177

146178
pub struct Client<T, C>
147179
where
@@ -166,6 +198,13 @@ pub fn rpc(input: TokenStream) -> TokenStream {
166198
}
167199
}
168200

201+
/// Get schema information for all RPC methods
202+
pub fn schema() -> HashMap<String, MethodSchema> {
203+
let mut schema_map = HashMap::new();
204+
#(#schema_entries)*
205+
schema_map
206+
}
207+
169208
#(#client_methods)*
170209
}
171210
}

crates/rpc-openapi/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "rpc-openapi"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
authors.workspace = true
7+
repository.workspace = true
8+
9+
[dependencies]
10+
serde = { workspace = true }
11+
serde_json.workspace = true
12+
schema.workspace = true
13+
schema-openapi.workspace = true

0 commit comments

Comments
 (0)