Rust RPC Client (experimental)
Rust client can be generated with vovk generate
command using rs template or rsSrc template.
Generate Rust package with the CLI:
npx vovk generate --from rs --out ./rust_package
This will generate a Rust package with the following structure:
- http_request.rs
- lib.rs
- read_full_schema.rs
- schema.json
- Cargo.toml
- README.md
The package is ready to be published to crates.io with cargo publish
command.
cargo publish --manifest-path rust_package/Cargo.toml
In case if you want to generate Rust source only, to be used as part of another Rust project, you can use rsSrc
template:
npx vovk generate --from rsSrc --out ./rust_src
This will generate a Rust source with the following structure:
- http_request.rs
- lib.rs
- read_full_schema.rs
- schema.json
Configuring the Rust Client
The generation can be configured, so the client will be generated automatically with generate
command with no flags but also when vovk dev is run, that performs “hot generation” whenever the schema is changed. In order to do that, you need to add rs
template to the composed client config:
/** @type {import('vovk').VovkConfig} */
const config = {
composedClient: {
fromTemplates: ['cjs', 'mjs', 'rs'], // keep the default "cjs" and "mjs" templates
},
};
export default config;
The rs template as well as some other templates has default outDir
(by default equals to ./dist_rust
) for the composed client configuration, that can be changed by defining a template definition in the config:
/** @type {import('vovk').VovkConfig} */
const config = {
// ...
clientTemplateDefs: {
rs: {
extends: 'rs', // extends the built-in "rs" template
composedClient: {
outDir: './my_dist_rust', // updates the out directory for the composed client
},
},
},
};
export default config;
Generated Rust Client Example
JSON endpoints
All the code below was copied with slight modifications from the real example, explained at Hello World page.
A controller like this:
… will emit Vovk.ts Schema, that in its turn will be used to generate the Rust client, following the language conventions where possible, adding comments defined at schema description
, and generating the most suitable types. For example, the age
is generated as u8
(unsigned 8-bit integer) because of presence of min
and max
constraints.
mod http_request;
mod read_full_schema;
pub use crate::http_request::HttpException;
pub mod user_rpc {
#[allow(unused_imports)]
use crate::http_request::{HttpException, http_request, http_request_stream};
use std::collections::HashMap;
// UserRPC.update_user POST `http://localhost:3000/api/users/{id}`
pub mod update_user_ {
use serde::{Serialize, Deserialize};
/// User data object
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(non_snake_case, non_camel_case_types)]
pub struct body {
/// User email
pub email: String,
/// User profile object
pub profile: body_::profile,
}
#[allow(non_snake_case)]
pub mod body_ {
use serde::{Serialize, Deserialize};
/// User profile object
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(non_snake_case, non_camel_case_types)]
pub struct profile {
/// User full name
pub name: String,
/// User age
pub age: u8,
}
}
/// Query parameters
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(non_snake_case, non_camel_case_types)]
pub struct query {
/// Notification type
pub notify: query_::notify,
}
#[allow(non_snake_case)]
pub mod query_ {
use serde::{Serialize, Deserialize};
/// Notification type
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(non_camel_case_types)]
pub enum notify {
#[serde(rename = "email")]
email,
#[serde(rename = "push")]
push,
#[serde(rename = "none")]
none,
}
}
/// Path parameters
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(non_snake_case, non_camel_case_types)]
pub struct params {
/// User ID
pub id: String,
}
/// Response object
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(non_snake_case, non_camel_case_types)]
pub struct output {
/// Success status
pub success: bool,
}
}
/// Summary: Update user
/// Description: Update user by ID
/// Params: Path parameters
/// Body: User data object
/// Query: Query parameters
/// Returns: Response object
pub fn update_user(
body: update_user_::body,
query: update_user_::query,
params: update_user_::params,
headers: Option<&HashMap<String, String>>,
api_root: Option<&str>,
disable_client_validation: bool,
) -> Result<update_user_::output, HttpException>{
let result = http_request::<
update_user_::output,
update_user_::body,
update_user_::query,
update_user_::params
>(
"http://localhost:3000/api",
"",
"UserRPC",
"updateUser",
Some(&body),
None,
Some(&query),
Some(¶ms),
headers,
api_root,
disable_client_validation,
);
result
}
}
All RPC modules are generated in the lib.rs
file, that contains all the RPC methods as functions but also types that are used in the RPC methods. The nested data structures are generated as nested modules, containing corresponding struct
definitions or other types.
The nested structures can be accessed with the _::
syntax. For example, in order to get the profile
type from the update_user_
module, you can use update_user_::body_::profile
syntax.
The structs can be imported and renamed with use
statement to follow PascalCase naming convention:
use std::io::Write;
use vovk_hello_world::user_rpc;
pub fn main() {
use user_rpc::update_user_::{
body as Body,
body_::profile as Profile,
query as Query,
query_::notify as Notify,
params as Params,
};
let update_user_response = user_rpc::update_user(
Body {
email: String::from("john@example.com"),
profile: Profile {
name: String::from("John Doe"),
age: 25
}
},
Query {
notify: Notify::email
},
Params {
id: String::from("123e4567-e89b-12d3-a456-426614174000")
},
None, // Headers (hashmap)
None, // API root
false, // Disable client validation
);
match update_user_response {
Ok(response) => println!("user_rpc.update_user: {:?}", response),
Err(e) => println!("update_user error: {:?}", e),
}
}
Behind the scenes it uses reqwest for HTTP requests, jsonschema for client-side validation and some other libraries.
JSONLines endpoints
To implement continious streaming with JSONLines endpoints, the client uses Iterator
trait to return an async iterator that can be used to iterate over the streamed data.
A controller like this:
Will be compiled to the following Rust code:
pub mod stream_rpc {
#[allow(unused_imports)]
use crate::http_request::{HttpException, http_request, http_request_stream};
use std::collections::HashMap;
// StreamRPC.stream_tokens GET `http://localhost:3000/api/streams/tokens`
pub mod stream_tokens_ {
use serde::{Serialize, Deserialize};
/// Streamed token object
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(non_snake_case, non_camel_case_types)]
pub struct iteration {
/// Message from the token
pub message: String,
}
}
/// Summary: Stream tokens
/// Description: Stream tokens to the client
pub fn stream_tokens(
body: (),
query: (),
params: (),
headers: Option<&HashMap<String, String>>,
api_root: Option<&str>,
disable_client_validation: bool,
) -> Result<Box<dyn Iterator<Item = stream_tokens_::iteration>>, HttpException>{
let result = http_request_stream::<
stream_tokens_::iteration,
(),
(),
()
>(
"http://localhost:3000/api",
"",
"StreamRPC",
"streamTokens",
Some(&body),
None,
Some(&query),
Some(¶ms),
headers,
api_root,
disable_client_validation,
);
result
}
}
That can be used like this:
use std::io::Write;
use vovk_hello_world_local::open_api_rpc::stream_rpc;
pub fn main() {
let stream_response = stream_rpc::stream_tokens(
(), // () as no body nor query or params are expected
(),
(),
None,
None,
false,
);
match stream_response {
Ok(stream) => {
print!("streamTokens:\n");
for (_i, item) in stream.enumerate() {
print!("{}", item.message.as_str());
std::io::stdout().flush().unwrap();
}
println!();
},
Err(e) => println!("Error initiating stream: {:?}", e),
}
}