Rust Client
vovk generate creates the Rust client from the rs or rsSrc template.
Install the generator package:
npm install vovk-rust --save-devGenerate a Rust package with the CLI:
npx vovk generate --from rs --out ./rust_packageThis produces:
- http_request.rs
- lib.rs
- read_full_schema.rs
- schema.json
- Cargo.toml
- README.md
Publish to crates.io with:
cargo publish --manifest-path rust_package/Cargo.toml --allow-dirty--allow-dirty lets Cargo publish files that git doesn’t track yet. crates.io needs a description and a license: the crate takes them from package.json or from outputConfig.package.
To generate source files for another Rust project instead, use the rsSrc template:
npx vovk generate --from rsSrc --out ./rust_srcThis generates:
- http_request.rs
- lib.rs
- read_full_schema.rs
- schema.json
lib.rs is the root of the module. Generate the files into the other project’s src, for example with --out ../my_app/src/api, and point a module at it in src/main.rs or src/lib.rs:
// the items the app doesn't use would be dead code warnings
#[allow(dead_code, unused_imports)]
#[path = "api/lib.rs"]
mod api;The procedures are then api::user_rpc::update_user and so on. The project needs the crates the files use:
[dependencies]
serde = { version = "1.0.164", features = ["derive"] }
serde_json = { version = "1.0.143", features = ["preserve_order"] }
reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
tokio = { version = "1.49", features = ["macros", "rt-multi-thread", "io-util"] }
futures-util = "0.3"
tokio-util = { version = "0.7", features = ["codec"] }
jsonschema = { version = "0.57", default-features = false }
urlencoding = "2.1"
once_cell = "1.17"Configuring the Rust Client
You can configure the default generate command (without flags) to create the client. vovk dev creates it too, and creates it again on every schema change. Add the rs template to the composed client config:
/** @type {import('vovk').VovkConfig} */
const config = {
composedClient: {
fromTemplates: ['ts', 'rs'], // keep the default "ts" template
},
clientTemplateDefs: {
rs: {
extends: 'rs', // extends the built-in "rs" template
outputConfig: {
origin: 'https://example.com', // the server the Rust client calls
},
},
},
};
export default config;The Rust client calls an absolute URL, so it needs outputConfig.origin. Set it on the rs template, and the TypeScript client keeps its relative URLs. Without an origin, vovk generate warns, and every call needs its own api_root.
Like other templates, the rs template has a default outDir for composed clients: ./dist_rust. Change it in the template definitions:
/** @type {import('vovk').VovkConfig} */
const config = {
// ...
clientTemplateDefs: {
rs: {
extends: 'rs', // extends the built-in "rs" template
composedClient: {
outDir: './my_dist_rust', // custom output directory for the composed client
},
},
},
};
export default config;Generated Rust Client Example
JSON Endpoints
The samples below are based on the example from the Hello World page.
A controller like this:
user-controller.ts
import { operation, post, prefix, procedure } from 'vovk';
import { z } from 'zod';
import UserService from './user-service';
@prefix('users')
export default class UserController {
@operation({
summary: 'Update user',
description: 'Update user by ID',
})
@post('{id}')
static updateUser = procedure({
body: z
.object({
email: z.email().meta({
description: 'User email',
examples: ['john@example.com', 'jane@example.com'],
}),
profile: z
.object({
name: z
.string()
.min(2)
.meta({
description: 'User full name',
examples: ['John Doe', 'Jane Smith'],
}),
age: z
.int()
.min(16)
.max(120)
.meta({ description: 'User age', examples: [25, 30] }),
})
.meta({ description: 'User profile object' }),
})
.meta({ description: 'User data object' }),
params: z
.object({
id: z.uuid().meta({
description: 'User ID',
examples: ['123e4567-e89b-12d3-a456-426614174000'],
}),
})
.meta({
description: 'Path parameters',
}),
query: z
.object({
notify: z
.enum(['email', 'push', 'none'])
.meta({ description: 'Notification type' }),
})
.meta({
description: 'Query parameters',
}),
output: z
.object({
success: z.boolean().meta({ description: 'Success status' }),
id: z.uuid().meta({ description: 'User ID' }),
notify: z.enum(['email', 'push', 'none']).meta({
description: 'Notification type',
}),
})
.meta({ description: 'Response object' }),
}).handle(async (req, { id }) => {
const body = await req.vovk.body();
const { notify } = req.vovk.query();
return UserService.updateUser(id, body, notify);
});
}…emits a Vovk.ts schema. The Rust client is generated from it. It adds comments from the schema description and picks fitting types: age becomes a u8 because of its min and max.
// what a schema brings and the code can't choose: a large variant, a deep type, and text that clippy reads as a
// misplaced list or footnote; an older clippy doesn't know some of these lints
#![allow(unknown_lints)]
#![allow(clippy::large_enum_variant, clippy::type_complexity)]
#![allow(clippy::doc_lazy_continuation, clippy::doc_overindented_list_items, clippy::doc_suspicious_footnotes)]
mod http_request;
mod read_full_schema;
// relative paths, so the files also build as a module of another crate
pub use self::http_request::{set_client_factory, HttpException};
// the reqwest the functions take: an app builds a Client or a Form with it, whatever reqwest version the app has
pub use reqwest;
pub mod user_rpc {
#[allow(unused_imports)]
use super::http_request::{Endpoint, HttpException, RequestBody, http_request, http_request_stream};
#[allow(unused_imports)]
use futures_util::Stream;
use std::collections::HashMap;
#[allow(unused_imports)]
use std::pin::Pin;
// UserRPC.update_user POST `https://hello-world.vovk.dev/api/users/{id}`
#[allow(non_camel_case_types)]
pub mod update_user_ {
#[allow(unused_imports)]
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_ {
#[allow(unused_imports)]
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_ {
#[allow(unused_imports)]
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,
/// User ID
pub id: String,
/// Notification type
pub notify: output_::notify,
}
#[allow(non_snake_case)]
pub mod output_ {
#[allow(unused_imports)]
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,
}
}
}
/// Summary: Update user
/// Description: Update user by ID
/// Params: Path parameters
/// Body: User data object
/// Query: Query parameters
/// Returns: Response object
pub async 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>{
http_request::<
update_user_::output,
update_user_::body,
update_user_::query,
update_user_::params
>(
&Endpoint {
api_root: "https://hello-world.vovk.dev/api",
segment_path: "",
segment_name: "",
controller_name: "UserRPC",
handler_name: "updateUser",
},
RequestBody::Json(&body),
Some(&query),
Some(¶ms),
headers,
api_root,
disable_client_validation,
).await
}
}All RPC modules are in lib.rs, with the RPC functions and their types. Nested structures become nested modules with their struct definitions or types.
An RPC module is a mod, and each of its procedures is an async fn. Both are named in snake_case: UserRPC.updateUser is user_rpc::update_user, and its types are in the module update_user_. Each module names its functions in schema order. A name that is already taken gets the first free suffix, _2, _3, so getUserByID and getUserById give get_user_by_id and get_user_by_id_2. The module imports http_request and http_request_stream, so these names count as taken.
Reach nested structures with _::: body.profile is update_user_::body_::profile. This avoids name collisions and follows the schema’s structure one to one.
With use, you can import the structs under PascalCase names. The functions are async; this sample runs them on tokio (cargo add tokio --features macros,rt-multi-thread):
use vovk_hello_world::user_rpc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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
).await?;
println!("user_rpc.update_user response: {:?}", update_user_response);
Ok(())
}The client uses reqwest for HTTP, jsonschema for client-side validation, and other common crates. Client-side validation reads a schema as JSON Schema 2020-12, or as draft 7 when the schema declares it. It checks formats such as email and uuid. It checks uri, duration and hostname as zod does, so it takes what a zod server takes.
The crate needs Rust 1.85 or later. Its Cargo.toml sets rust-version = "1.85" and resolver 3, so Cargo locks dependency versions that support Rust 1.85. A project that depends on the crate resolves dependencies with its own resolver. With edition 2024, or with resolver = "3" (Cargo 1.84+), it builds on Rust 1.85. An edition 2021 project without that resolver gets the newest versions, which need Rust 1.88.
For a procedure that takes multipart/form-data, the body is a reqwest::multipart::Form. Field names go out as written, in quotes, as browsers send them. Give files names without ", \ or line breaks. reqwest escapes these with a backslash, which the server keeps in the name, and a " makes the form unreadable.
A function without an output schema returns a serde_json::Value: the parsed JSON, or null for an empty body. A text/* response, or a response of any type with a charset, is a string, decoded as UTF-8 unless the charset names another encoding. Any other response, such as a file, is a string that holds its bytes in base64.
A failed call returns an HttpException. Its status_code() is the response status, or 0 when the call failed before a response came, for example in client-side validation. message() and cause() hold the error the server sent. When no response came, or the response broke off, source() is the reqwest::Error, without the URL.
The calls send their requests with a default reqwest::Client, which has no timeout. To set a timeout, a proxy or default headers, give set_client_factory a function that builds the client. The crate calls it again for each thread and tokio runtime it sends calls from, since a pooled connection works only on the runtime that opened it.
use std::time::Duration;
vovk_hello_world::set_client_factory(|| {
vovk_hello_world::reqwest::Client::builder().timeout(Duration::from_secs(30)).build().expect("a client")
});The client, and a multipart::Form for a procedure that takes one, come from the reqwest the crate re-exports as vovk_hello_world::reqwest. A reqwest the project adds itself is the same type only when it is the same version, 0.12.
JSON Lines Endpoints
For JSON Lines endpoints, the async function resolves to a futures::Stream of Result<T, HttpException> items. Read it with StreamExt::next from the futures crate.
A JSON Lines endpoint without an iteration schema gets a regular function instead: it reads the whole response and returns the items as a JSON array.
A controller like this:
stream-controller.ts
import { get, operation, prefix, procedure } from 'vovk';
import { z } from 'zod';
import StreamService from './stream-service';
@prefix('streams')
export default class StreamController {
@operation({
summary: 'Stream tokens',
description: 'Stream tokens to the client',
})
@get('tokens')
static streamTokens = procedure({
validateEachIteration: true,
iteration: z
.object({
message: z.string().meta({ description: 'Message from the token' }),
})
.meta({
description: 'Streamed token object',
}),
}).handle(async function* () {
yield* StreamService.streamTokens();
});
}Compiles to:
pub mod stream_rpc {
#[allow(unused_imports)]
use super::http_request::{Endpoint, HttpException, RequestBody, http_request, http_request_stream};
#[allow(unused_imports)]
use futures_util::Stream;
use std::collections::HashMap;
#[allow(unused_imports)]
use std::pin::Pin;
// StreamRPC.stream_tokens GET `https://hello-world.vovk.dev/api/streams/tokens`
#[allow(non_camel_case_types)]
pub mod stream_tokens_ {
#[allow(unused_imports)]
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
#[allow(unused_variables)]
pub async fn stream_tokens(
body: (),
query: (),
params: (),
headers: Option<&HashMap<String, String>>,
api_root: Option<&str>,
disable_client_validation: bool,
) -> Result<Pin<Box<dyn Stream<Item = Result<stream_tokens_::iteration, HttpException>> + Send>>, HttpException>{
http_request_stream::<
stream_tokens_::iteration,
(),
(),
()
>(
&Endpoint {
api_root: "https://hello-world.vovk.dev/api",
segment_path: "",
segment_name: "",
controller_name: "StreamRPC",
handler_name: "streamTokens",
},
RequestBody::None,
Some(&query),
Some(¶ms),
headers,
api_root,
disable_client_validation,
).await
}
}Usage, with the futures crate (cargo add futures):
use futures::StreamExt;
use std::io::{stdout, Write};
use vovk_hello_world::stream_rpc;
pub async fn consume_stream() -> Result<(), Box<dyn std::error::Error>> {
let mut stream = stream_rpc::stream_tokens((), (), (), None, None, false).await?;
while let Some(item) = stream.next().await {
let val = item.expect("stream item should be Ok");
let message = val.message;
print!("{}", message.as_str());
stdout().flush().expect("flush stdout");
}
Ok(())
}Roadmap/bugs
- ✨ Generate importable types for named schemas defined in
components/schemas.