Skip to Content
Rust RPC 🧪

Rust RPC Client (Experimental)

The Rust client can be generated with vovk generate using the rs or rsSrc template.

Generate a Rust package with the CLI:

npx vovk generate --from rs --out ./rust_package

This produces the following structure:

      • 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

If you prefer generating source files to embed in another Rust project, use the rsSrc template:

npx vovk generate --from rsSrc --out ./rust_src

This generates:

    • http_request.rs
    • lib.rs
    • read_full_schema.rs
    • schema.json

Configuring the Rust Client

You can configure generation so the client is produced automatically by the default generate command (no flags) and during vovk dev, which performs “hot generation” whenever the schema changes. Add the rs template to the composed client config:

vovk.config.mjs
/** @type {import('vovk').VovkConfig} */ const config = { composedClient: { fromTemplates: ['cjs', 'mjs', 'rs'], // keep the default "cjs" and "mjs" templates }, }; export default config;

The rs template (and others) has a default outDir (./dist_rust) for composed clients. Override it via template definitions:

vovk.config.mjs
/** @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 snippets below are adapted from a real example described on the Hello World page.

A controller like this:

UserController.ts
UserService.ts
1import { z } from "zod";
2import { prefix, post, operation } from "vovk";
3import { withZod } from "vovk-zod";
4import UserService from "./UserService";
5
6@prefix("users")
7export default class UserController {
8 @operation({
9 summary: "Update user",
10 description: "Update user by ID",
11 })
12 @post("{id}")
13 static updateUser = withZod({
14 body: z
15 .object({
16 email: z.email().meta({
17 description: "User email",
18 examples: ["john@example.com", "jane@example.com"],
19 }),
20 profile: z
21 .object({
22 name: z.string().meta({
23 description: "User full name",
24 examples: ["John Doe", "Jane Smith"],
25 }),
26 age: z
27 .int()
28 .min(16)
29 .max(120)
30 .meta({ description: "User age", examples: [25, 30] }),
31 })
32 .meta({ description: "User profile object" }),
33 })
34 .meta({ description: "User data object" }),
35 params: z
36 .object({
37 id: z.uuid().meta({
38 description: "User ID",
39 examples: ["123e4567-e89b-12d3-a456-426614174000"],
40 }),
41 })
42 .meta({
43 description: "Path parameters",
44 }),
45 query: z
46 .object({
47 notify: z
48 .enum(["email", "push", "none"])
49 .meta({ description: "Notification type" }),
50 })
51 .meta({
52 description: "Query parameters",
53 }),
54 output: z
55 .object({
56 success: z.boolean().meta({ description: "Success status" }),
57 })
58 .meta({ description: "Response object" }),
59 async handle(req, { id }) {
60 const body = await req.json();
61 const notify = req.nextUrl.searchParams.get("notify");
62
63 return UserService.updateUser(id, body, notify);
64 },
65 });
66}
1import { z } from "zod";
2import { prefix, post, operation } from "vovk";
3import { withZod } from "vovk-zod";
4import UserService from "./UserService";
5
6@prefix("users")
7export default class UserController {
8 @operation({
9 summary: "Update user",
10 description: "Update user by ID",
11 })
12 @post("{id}")
13 static updateUser = withZod({
14 body: z
15 .object({
16 email: z.email().meta({
17 description: "User email",
18 examples: ["john@example.com", "jane@example.com"],
19 }),
20 profile: z
21 .object({
22 name: z.string().meta({
23 description: "User full name",
24 examples: ["John Doe", "Jane Smith"],
25 }),
26 age: z
27 .int()
28 .min(16)
29 .max(120)
30 .meta({ description: "User age", examples: [25, 30] }),
31 })
32 .meta({ description: "User profile object" }),
33 })
34 .meta({ description: "User data object" }),
35 params: z
36 .object({
37 id: z.uuid().meta({
38 description: "User ID",
39 examples: ["123e4567-e89b-12d3-a456-426614174000"],
40 }),
41 })
42 .meta({
43 description: "Path parameters",
44 }),
45 query: z
46 .object({
47 notify: z
48 .enum(["email", "push", "none"])
49 .meta({ description: "Notification type" }),
50 })
51 .meta({
52 description: "Query parameters",
53 }),
54 output: z
55 .object({
56 success: z.boolean().meta({ description: "Success status" }),
57 })
58 .meta({ description: "Response object" }),
59 async handle(req, { id }) {
60 const body = await req.json();
61 const notify = req.nextUrl.searchParams.get("notify");
62
63 return UserService.updateUser(id, body, notify);
64 },
65 });
66}

…emits a Vovk.ts schema, which is then used to generate the Rust client following Rust conventions, adding comments from the schema description, and choosing suitable types. For example, age is generated as u8 due to min/max constraints.

./dist_rust/src/lib.rs
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(&params), headers, api_root, disable_client_validation, ); result } }

All RPC modules are generated in lib.rs, which contains RPC functions and the associated types. Nested structures are emitted as nested modules with corresponding struct definitions or types.

Access nested structures via _::. For example, body.profile is update_user_::body_::profile.

Use use to import and rename structs to follow PascalCase:

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), } }

Under the hood, it uses reqwest  for HTTP, jsonschema  for client-side validation, and other common crates.

JSON Lines Endpoints

For continuous streaming with JSON Lines endpoints, the client implements the Iterator trait to return an async-capable iterator for streamed data.

A controller like this:

StreamController.ts
StreamService.ts
1import { prefix, get, operation } from "vovk";
2import { withZod } from "vovk-zod";
3import { z } from "zod";
4import StreamService from "./StreamService";
5
6@prefix("streams")
7export default class StreamController {
8 @operation({
9 summary: "Stream tokens",
10 description: "Stream tokens to the client",
11 })
12 @get("tokens")
13 static streamTokens = withZod({
14 iteration: z
15 .object({
16 message: z.string().meta({ description: "Message from the token" }),
17 })
18 .meta({
19 description: "Streamed token object",
20 }),
21 async *handle() {
22 yield* StreamService.streamTokens();
23 },
24 });
25}
1import { prefix, get, operation } from "vovk";
2import { withZod } from "vovk-zod";
3import { z } from "zod";
4import StreamService from "./StreamService";
5
6@prefix("streams")
7export default class StreamController {
8 @operation({
9 summary: "Stream tokens",
10 description: "Stream tokens to the client",
11 })
12 @get("tokens")
13 static streamTokens = withZod({
14 iteration: z
15 .object({
16 message: z.string().meta({ description: "Message from the token" }),
17 })
18 .meta({
19 description: "Streamed token object",
20 }),
21 async *handle() {
22 yield* StreamService.streamTokens();
23 },
24 });
25}

Compiles to:

./dist_rust/src/lib.rs
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(&params), headers, api_root, disable_client_validation, ); result } }

Usage:

use std::io::Write; use vovk_hello_world_local::open_api_rpc::stream_rpc; pub fn main() { let stream_response = stream_rpc::stream_tokens( (), // no body, query, or params (), (), 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), } }

Roadmap

  • 🧩 Use async (.await) instead of blocking calls.
  • 🐞 Allow circular $refs with OpenAPI Mixins.
  • ✨ Generate importable types for named schemas defined in components/schemas.
Last updated on