
ServiceStack's Add ServiceStack Reference feature allows clients to generate Native Types for Rust - providing a simple way to give Rust clients typed access to your ServiceStack Services.
Rust - Correctness from the wire to application code​
The servicestack crate (source) brings Rust's priorities to API integration. Generated DTOs implement the traits that associate each request with its response, route and HTTP method:
let response = client.send(&Hello {
name: "World".to_string(),
}).await?;
println!("{}", response.result);
The async client is the default, with an optional blocking client. Supports structured errors, authentication with automatic token refresh, typed AutoQuery, multipart uploads, batch and one-way calls, and access to the underlying reqwest configuration when finer control is needed.
Most importantly, changes at the API boundary become ordinary Rust compiler feedback - no scattering serde_json::Value through business logic.
First class development experience​
Rust continues to see rapid adoption for systems programming, WebAssembly, CLI tooling and high-performance network services, where its ownership model delivers memory safety without a garbage collector. To maximize the experience for calling ServiceStack APIs from these environments, Rust is supported as a 1st class Add ServiceStack Reference language which gives Rust developers an end-to-end typed API for consuming ServiceStack APIs, with DTOs generated from a single command-line.
Ideal idiomatic Typed Message-based API​
Rust DTOs are generated as plain structs deriving serde's Serialize/Deserialize along with Debug, Clone, PartialEq and Default, following Rust's snake_case naming conventions with #[serde(rename)] attributes mapping them to their wire names. Optional properties are generated as Option<T>, whilst the built-in ServiceStack types are referenced from the servicestack crate.
Here's a sample of generated Rust DTOs containing a string Enum, an AutoQuery Request and a standard Request DTO:
use serde::{Serialize, Deserialize};
use serde_json::Value;
use std::collections::HashMap;
use servicestack::*;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub enum RoomType {
#[default]
#[serde(rename = "Single")]
Single,
#[serde(rename = "Queen")]
Queen,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(default)]
pub struct HelloResponse {
pub result: String,
}
// Route("/hello/{Name}")
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(default)]
pub struct Hello {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl IRequest for Hello {
const NAME: &'static str = "Hello";
const VERB: &'static str = "GET";
}
impl IReturn for Hello { type Response = HelloResponse; }
/// Find Bookings
// Route("/bookings", "GET")
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(default)]
pub struct QueryBookings {
#[serde(flatten)]
pub query_db: QueryDb,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<i32>,
}
impl IRequest for QueryBookings {
const NAME: &'static str = "QueryBookings";
const VERB: &'static str = "GET";
}
impl IReturn for QueryBookings { type Response = QueryResponse<Booking>; }
The generated IRequest and IReturn impls are what enable the end-to-end typed API, where the associated Response type lets the client resolve each API's Response Type from its Request DTO, whilst the NAME and VERB associated consts resolve its route and HTTP Method - all at compile time with no runtime reflection.
Types that inherit a base class have its properties flattened into them with #[serde(flatten)], as Rust has no inheritance.
Installation​
The only requirements for Rust Apps to perform typed API Requests are the generated Rust DTOs and the generic JsonServiceClient in the servicestack crate:
cargo add servicestack
Or in your Cargo.toml:
[dependencies]
servicestack = "0.1"
serde = { version = "1", features = ["derive"] }
Requires Rust 1.88+.
It's a small dependency with a minimal footprint, built on reqwest and serde using rustls so it doesn't require OpenSSL or any other system dependencies.
Features​
| Feature | Description |
|---|---|
blocking |
Sync client in servicestack::blocking for CLIs and scripts |
multipart |
multipart/form-data file uploads |
servicestack = { version = "0.1", features = ["blocking"] }
Simple command-line utility for Rust​
Rust DTOs can be generated from the command-line with the cross-platform get-dtos script which can be run with Node.js without needing to install anything:
npx get-dtos
Running it without any arguments displays the available options for adding and updating ServiceStack References.
Adding a ServiceStack Reference​
To Add a Rust ServiceStack Reference just call npx get-dtos rust with the URL of a remote ServiceStack instance:
npx get-dtos rust https://blazor-vue.web-templates.io
Result:
Saved to: dtos.rs
Calling npx get-dtos rust with just a URL will save the DTOs using the Host name, you can override this by specifying a FileName as the 2nd argument:
npx get-dtos rust https://blazor-vue.web-templates.io Bookings
Result:
Saved to: Bookings.dtos.rs
Updating a ServiceStack Reference​
To Update an existing ServiceStack Reference, call npx get-dtos rust with the Filename:
npx get-dtos rust dtos.rs
Result:
Updated: dtos.rs
Which will update the File with the latest Rust Server DTOs. You can also customize how DTOs are generated by uncommenting the Rust DTO Customization Options and updating them again.
Updating all Rust DTOs​
Calling npx get-dtos rust without any arguments will update all Rust DTOs in the current directory:
npx get-dtos rust
Smart Generic JsonServiceClient​
The generic JsonServiceClient is a 1st class client with the same rich featureset of the smart ServiceClients in other 1st class supported languages sporting a terse, typed flexible API with support for additional untyped params, custom URLs and HTTP Methods and raw Response bodies.
It includes built-in support for a number of ServiceStack Auth options including HTTP Basic Auth and stateless Bearer Token Auth Providers like API Key and JWT Auth as well as stateful Sessions used by the popular credentials Auth Provider, whose Session Cookies are maintained in the client's cookie store. Refresh Tokens are also supported, where expired JWT Bearer Tokens are transparently refreshed behind-the-scenes before automatically retrying the failed Request.
The client is built on reqwest and is cheap to clone and safe to share between tasks, where clones share the same connection pool, cookie store and auth tokens:
impl JsonServiceClient {
// Client Configuration
pub fn new(base_url: &str) -> Self // sends Requests to /api
pub fn new_json_service_client(base_url: &str) -> Self // sends Requests to /json/reply
pub fn with_client(base_url: &str, http: reqwest::Client) -> Self
pub fn base_url(&self) -> &str
pub fn reply_base_url(&self) -> &str
pub fn set_base_path(&mut self, base_path: &str) -> &mut Self
pub fn set_bearer_token(&mut self, token: &str) -> &mut Self
pub fn bearer_token(&self) -> Option<String>
pub fn set_refresh_token(&mut self, token: &str) -> &mut Self
pub fn set_refresh_token_uri(&mut self, uri: &str) -> &mut Self
pub fn set_credentials(&mut self, user_name: &str, password: &str) -> &mut Self
pub fn set_header(&mut self, name: &str, value: &str) -> &mut Self
pub fn set_user_agent(&mut self, user_agent: &str) -> &mut Self
pub fn set_on_authentication_required<F>(&mut self, callback: F) -> &mut Self
// Typed API
pub async fn send<T: IReturn>(&self, request: &T) -> Result<T::Response>
pub async fn get<T: IReturn>(&self, request: &T) -> Result<T::Response>
pub async fn post<T: IReturn>(&self, request: &T) -> Result<T::Response>
pub async fn put<T: IReturn>(&self, request: &T) -> Result<T::Response>
pub async fn patch<T: IReturn>(&self, request: &T) -> Result<T::Response>
pub async fn delete<T: IReturn>(&self, request: &T) -> Result<T::Response>
pub async fn send_void<T: IReturnVoid>(&self, request: &T) -> Result<()>
pub async fn api<T: IReturn>(&self, request: &T) -> ApiResult<T::Response>
pub async fn authenticate(&self, user_name: &str, password: &str) -> Result<AuthenticateResponse>
// Batched and one-way Requests
pub async fn send_all<T: IReturn>(&self, requests: &[T]) -> Result<Vec<T::Response>>
pub async fn publish<T: IRequest>(&self, request: &T) -> Result<()>
// URL API
pub async fn get_url<R: DeserializeOwned>(&self, path: &str) -> Result<R>
pub async fn get_url_with<R: DeserializeOwned>(&self, path: &str, args: &[(&str,&str)]) -> Result<R>
pub async fn post_url<R: DeserializeOwned, B: Serialize>(&self, path: &str, body: &B) -> Result<R>
pub async fn put_url<R: DeserializeOwned, B: Serialize>(&self, path: &str, body: &B) -> Result<R>
pub async fn patch_url<R: DeserializeOwned, B: Serialize>(&self, path: &str, body: &B) -> Result<R>
pub async fn delete_url<R: DeserializeOwned>(&self, path: &str) -> Result<R>
pub async fn send_url<R: DeserializeOwned, B: Serialize>(&self, method: &str, path: &str, body: Option<&B>) -> Result<R>
pub async fn get_url_string(&self, path: &str) -> Result<String>
// File Uploads, requires the `multipart` feature
pub async fn post_files_with_request<T: IReturn>(&self, request: &T, files: Vec<UploadFile>) -> Result<T::Response>
}
The sync servicestack::blocking::JsonServiceClient mirrors the same API without async/await, other than multipart file uploads which are only available on the async client.
Making Typed API Requests​
Making API Requests in Rust is the same as all other ServiceStack's Service Clients by sending a populated Request DTO using a JsonServiceClient which returns a typed Response DTO:
use servicestack::JsonServiceClient;
mod dtos; use dtos::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = JsonServiceClient::new("https://blazor-vue.web-templates.io");
let res = client.send(&Hello { name: Some("World".into()) }).await?; // res is a HelloResponse
println!("{}", res.result);
Ok(())
}
send uses the HTTP Method the API is annotated with, use get, post, put, patch or delete to send a Request DTO with a specific HTTP Method:
let res = client.post(&CreateBooking { name: Some("Booking".into()), ..Default::default() }).await?;
APIs that don't return a Response Body are sent with send_void, which accepts the Request DTOs generated with an IReturnVoid impl:
client.send_void(&DeleteBooking { id: 1 }).await?;
Resolving the HTTP Method​
The VERB of a Request DTO's generated IRequest impl also determines whether it's sent in the QueryString or the JSON Request Body, where only GET, DELETE, HEAD and OPTIONS Requests send their populated properties in the QueryString:
// GET /api/Hello?name=World
client.send(&Hello { name: Some("World".into()) }).await?;
// POST /api/CreateBooking {"name":"Booking"}
client.send(&CreateBooking { name: Some("Booking".into()), ..Default::default() }).await?;
Request DTOs that aren't annotated with a Verb default to POST, which can be overridden per Request by sending it with an explicit HTTP Method:
let res = client.get(&Hello { name: Some("World".into()) }).await?; // GET /api/Hello?name=World
let res = client.post(&Hello { name: Some("World".into()) }).await?; // POST /api/Hello {"name":"World"}
Batched and one-way Requests are sent to their own routes, i.e. send_all sends its Requests to the API's batched /api/Hello[] route whilst publish sends them to the one-way endpoint, e.g. /json/oneway/Hello when using new_json_service_client.
Sync client​
Enable the blocking feature for the same API without async/await, ideal for CLIs and scripts:
use servicestack::blocking::JsonServiceClient;
let client = JsonServiceClient::new("https://blazor-vue.web-templates.io");
let res = client.send(&Hello { name: Some("World".into()) })?;
WARNING
As it creates its own runtime, the blocking client can't be used from within an async context, e.g. inside a #[tokio::main] fn - use the async client there instead.
AutoQuery Requests​
AutoQuery APIs return a typed QueryResponse<T>, with the query params of their base type flattened into the Request DTO:
use servicestack::QueryDb;
let res = client.send(&QueryBookings {
query_db: QueryDb { take: Some(5), order_by_desc: Some("id".into()), ..Default::default() },
..Default::default()
}).await?;
for booking in res.results { // booking is a Booking
println!("{} {}", booking.id, booking.name);
}
As the query params are Option<T> they're only sent when populated, letting the Server apply its own defaults otherwise. In addition to results, QueryResponse<T> returns the offset of the current page and the total number of matching results when requested with include: "Total":
let res = client.send(&QueryBookings {
query_db: QueryDb {
skip: Some(10),
take: Some(10),
order_by: Some("id".into()),
include: Some("Total".into()), // include the Total of matching results
fields: Some("id,name,roomType".into()), // limit the fields returned
..Default::default()
},
..Default::default()
}).await?;
println!("{} {} {}", res.offset, res.total, res.results.len());
Properties of a Type's base class are accessed through the field they're flattened into, e.g. a Booking inheriting AuditBase accesses its audit fields with:
println!("{:?}", booking.audit_base.created_by);
Making API Requests with URLs​
In addition to making Typed API Requests you can also call Services using relative or absolute urls:
let res: HelloResponse = client.get_url("/hello/World").await?;
let res: HelloResponse = client.post_url("/custom-path", &request).await?;
Where relative paths are resolved against the client's Base URL whilst absolute URLs are sent as-is, letting the same client call APIs on different hosts. put_url, patch_url and delete_url are also available, or use send_url to send a Request with any HTTP Method:
let res: IdResponse = client.put_url("/bookings/1", &request).await?;
let res: EmptyResponse = client.delete_url("/bookings/1").await?;
let res: HelloResponse = client
.send_url(servicestack::HTTP_POST, "/hello", Some(&request))
.await?;
Use get_url_with to append additional args to the QueryString, which are URL encoded and serialized using the same QueryString Serialization as Request DTOs:
let res: HelloResponse = client
.get_url_with("/hello/World", &[("field", "name"), ("comment", "a b&c")])
.await?;
Raw Data Responses​
Use get_url_string to access a raw Response Body, useful for APIs returning content like CSV:
let csv = client.get_url_string("/api/QueryBookings.csv").await?;
Batched Requests​
Multiple Request DTOs of the same Type can be sent together in a single Request with send_all, which returns all their Responses:
let responses = client.send_all(&[
Hello { name: Some("A".into()) },
Hello { name: Some("B".into()) },
]).await?;
for res in responses { // res is a HelloResponse
println!("{}", res.result);
}
Which sends them to the API's batched /api/Hello[] route in a single Request, returning a Vec<T::Response> of all their Responses in the same order.
Or send a Request to a one-way endpoint that ignores its Response:
client.publish(&Hello { name: Some("World".into()) }).await?;
Error Handling​
Failed API Requests return an Error containing the HTTP Status Code and the API's structured ResponseStatus error:
match client.send(&CreateBooking::default()).await {
Ok(res) => println!("{}", res.id),
Err(err) => {
println!("{:?}", err.status_code()); // Some(400)
println!("{:?}", err.error_code()); // Some("NotEmpty")
println!("{:?}", err.error_message()); // Some("'Name' must not be empty.")
println!("{:?}", err.field_error("Name")); // Some("'Name' must not be empty.")
println!("{}", err.is_unauthorized()); // false
}
}
All failures are returned in a single Error enum, letting a match handle API errors, transport failures and invalid Responses together:
use servicestack::Error;
match client.send(&Hello::default()).await {
Ok(res) => println!("{}", res.result),
Err(Error::Api(ex)) => { // the API returned an error Response
println!("{} {}", ex.status_code, ex.error_message());
for field_error in ex.field_errors() {
println!(" {}: {}", field_error.field_name, field_error.message);
}
}
Err(Error::Http(err)) => println!("connection failed: {err}"), // reqwest error
Err(Error::Serialization(err)) => println!("invalid Response: {err}"), // serde_json error
}
Error::Api holds a boxed WebServiceException with the raw HTTP error Response and the typed accessors you'd expect:
ex.status_code // HTTP Status Code, e.g. 400
ex.status_description // HTTP Status Description, e.g. "Bad Request"
ex.response_status // Option<ResponseStatus>, the API's structured error
ex.response_body // raw error body, e.g. when a HTML or plain text error was returned
ex.error_code() // "NotEmpty"
ex.error_message() // "'Name' must not be empty."
ex.field_errors() // &[ResponseError] of all field validation errors
ex.field_error("Name") // Some("'Name' must not be empty.")
ex.is_validation_error() // has field validation errors
ex.is_unauthorized() // 401
ex.is_not_found() // 404
As it implements std::error::Error and Display, it can be propagated with ? and formatted directly, e.g. 400 NotEmpty: 'Name' must not be empty. Non JSON error Responses are also converted into a WebServiceException populated from the HTTP Status, so a single error type can be used to handle all API failures.
Field validation errors are returned in typed ResponseError structs which can be mapped back to the fields of your UI:
if let Some(status) = err.response_status() {
for field_error in &status.errors {
println!("{} {} {}", field_error.field_name, field_error.error_code, field_error.message);
}
}
Alternatively api returns errors in its result instead of a separate Err, which can be preferable when handling validation errors is part of normal control flow:
let api = client.api(&CreateBooking::default()).await;
if api.failed() {
println!("{:?} {:?}", api.error_code(), api.field_error("Name"));
} else {
println!("{}", api.response().unwrap().id);
}
ApiResult<T> holds the typed response and any ResponseStatus error alongside its succeeded(), failed(), error_code(), error_message(), field_error() and response() helpers. As all errors are converted into a ResponseStatus, transport and serialization failures are also returned in api.error with an HttpError or SerializationException Error Code, whilst into_result() converts it back into a standard Result<T, ResponseStatus>:
match client.api(&CreateBooking::default()).await.into_result() {
Ok(res) => println!("{}", res.id),
Err(status) => println!("{} {}", status.error_code, status.message),
}
Authenticating using Basic Auth​
Basic Auth support is implemented in JsonServiceClient and follows the same API made available in the C# Service Clients:
let mut client = JsonServiceClient::new(base_url);
client.set_credentials("username", "password");
let res = client.send(&SecureRequest::default()).await?;
Authenticating using Credentials​
Alternatively you can authenticate using userName/password credentials with the authenticate API:
let auth = client.authenticate("username", "password").await?;
This will populate the JsonServiceClient with Session Cookies which will transparently be sent on subsequent requests to make authenticated requests, as well as using any Bearer and Refresh Tokens the Server returns.
Session Cookies are maintained in the client's cookie jar whilst its tokens are available from bearer_token(). The AuthenticateResponse also returns the authenticated User's info, roles and permissions:
let auth = client.authenticate("test", "test").await?;
println!("{} {} {}", auth.user_id, auth.user_name, auth.display_name);
println!("{:?} {:?}", auth.roles, auth.permissions);
println!("{:?}", client.bearer_token());
// All subsequent Requests are made with the Authenticated Session
let res = client.send(&HelloSecure { name: Some("World".into()) }).await?;
As the auth state is shared behind a lock, Requests sent through a &self client (and any of its clones) all see the updated tokens.
Authenticating using JWT​
Use set_bearer_token to Authenticate with a ServiceStack JWT Provider using a JWT Token:
client.set_bearer_token(&jwt);
Alternatively you can use just a Refresh Token instead:
client.set_refresh_token(&refresh_token);
Where the client will automatically fetch a new JWT Bearer Token using the Refresh Token for authenticated requests, i.e. when a Request fails with a 401 Unauthorized the client sends its Refresh Token to the GetAccessToken API, updates its Bearer Token then transparently retries the original Request:
POST /api/Hello -> 401 Unauthorized
POST /api/GetAccessToken -> 200 { "accessToken": "..." }
POST /api/Hello -> 200 { "result": "Hello, World!" }
Use set_refresh_token_uri to fetch Access Tokens from a different central Auth Server:
client.set_refresh_token_uri("https://auth.example.org/api/GetAccessToken");
Authenticating using an API Key​
Use set_bearer_token to Authenticate with an API Key:
client.set_bearer_token("ak-87949de37e894627a9f6173154e7cafa");
Built-in ServiceStack DTOs​
The servicestack crate includes typed Request DTOs for ServiceStack's built-in APIs, letting you call them without needing to generate them:
use servicestack::*;
// Authentication
let auth = client.send(&Authenticate {
provider: Some("credentials".into()),
user_name: Some("test".into()),
password: Some("test".into()),
remember_me: Some(true),
..Default::default()
}).await?;
let reg = client.send(&Register {
user_name: Some("new-user".into()),
email: Some("user@example.org".into()),
password: Some("p@55wOrd".into()),
auto_login: Some(true),
..Default::default()
}).await?;
// JWT
let token = client.send(&ConvertSessionToToken::default()).await?; // Session -> JWT
let access = client.send(&GetAccessToken { refresh_token, ..Default::default() }).await?;
// API Keys
let keys = client.send(&GetApiKeys { environment: "live".into(), ..Default::default() }).await?;
let new_keys = client.send(&RegenerateApiKeys { environment: "live".into(), ..Default::default() }).await?;
// Roles and Permissions
let roles = client.send(&AssignRoles {
user_name: "user".into(),
roles: vec!["Employee".into()],
..Default::default()
}).await?;
client.send(&UnAssignRoles { user_name: "user".into(), roles: vec!["Employee".into()], ..Default::default() }).await?;
// Navigation
let nav = client.send(&GetNavItems::default()).await?;
Together with the built-in Response Types and base types that generated DTOs reference:
| Type | Description |
|---|---|
ResponseStatus |
ServiceStack's structured error response |
ResponseError |
An individual field validation error |
EmptyResponse |
Response of APIs that don't return a Response Body |
IdResponse |
Response returning the Id of the created or updated entity |
StringResponse |
Response returning a single result string |
StringsResponse |
Response returning a list of string results |
AuditBase |
Audit fields flattened into AutoQuery CRUD data models |
QueryBase |
Query params supported by all AutoQuery Requests |
QueryDb |
Base type of AutoQuery RDBMS Requests |
QueryData |
Base type of AutoQuery Data Requests |
QueryResponse<T> |
Typed Response of AutoQuery Requests |
NavItem |
A Navigation Item returned by GetNavItems |
Transparently handle 401 Unauthorized Responses​
If the server returns a 401 Unauthorized Response either because the client was Unauthenticated or the configured Bearer Token or API Key used had expired or was invalidated, you can use set_on_authentication_required to re-configure the client before automatically retrying the original request, e.g:
client.set_on_authentication_required(|client| {
Box::pin(async move {
client.authenticate("username", "password").await?;
Ok(())
})
});
// Automatically retries requests returning 401 Responses
let res = client.send(&Secured::default()).await?;
The client is cheap to clone, so the callback receives its own handle sharing the same cookie store and auth tokens. The blocking client uses the same API without the boxed future:
client.set_on_authentication_required(|client| {
client.authenticate("username", "password")?;
Ok(())
});
A configured Refresh Token takes precedence over the callback, which is only used when no Refresh Token is set or refreshing it failed.
Requires servicestack v0.1.1+.
Uploading Files​
Enable the multipart feature to upload files with an API Request:
use servicestack::UploadFile;
let res = client.post_files_with_request(&UploadPhoto { album: "Holiday".into() }, vec![
UploadFile {
field_name: "file".into(),
file_name: "photo.png".into(),
content_type: Some("image/png".into()),
data: std::fs::read("photo.png")?,
},
]).await?;
Any populated properties on the Request DTO are sent as form fields alongside the uploaded files, whilst field_name defaults to file and content_type is detected by the Server when it's None. To upload multiple files just send multiple UploadFiles:
let res = client.post_files_with_request(&UploadPhotos { album: "Holiday".into() }, vec![
UploadFile { field_name: "files".into(), file_name: "1.png".into(),
content_type: Some("image/png".into()), data: std::fs::read("1.png")? },
UploadFile { field_name: "files".into(), file_name: "2.txt".into(),
content_type: None, data: b"file contents".to_vec() },
]).await?;
INFO
File uploads are only available on the async client
Client Configuration​
let mut client = JsonServiceClient::new("https://example.org");
client.set_header("X-Custom", "Value");
client.set_user_agent("my-app/1.0");
// Or supply a pre-configured reqwest Client for timeouts, proxies and TLS
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
let client = JsonServiceClient::with_client("https://example.org", http);
JsonServiceClient::new sends Requests to ServiceStack's pre-defined /api route. Use JsonServiceClient::new_json_service_client for older ServiceStack instances that only have the /json/reply routes enabled, or set_base_path for a custom base path:
let api_client = JsonServiceClient::new("https://example.org"); // /api/Hello
let json_client = JsonServiceClient::new_json_service_client("https://example.org"); // /json/reply/Hello
let mut custom_client = JsonServiceClient::new("https://example.org");
custom_client.set_base_path("custom/api"); // /custom/api/Hello
The default reqwest::Client is created with a 60s timeout and a cookie store enabled for maintaining Session Cookies, so supply .cookie_store(true) when configuring your own client if you're using Session-based Auth.
Sharing the Client between tasks​
JsonServiceClient is cheap to clone and safe to share, where clones share the same connection pool, cookie jar and auth tokens - so a single configured client should be cloned into your App's tasks to benefit from reqwest's connection pooling and to have any refreshed Bearer Tokens visible to all of them:
let client = JsonServiceClient::new("https://blazor-vue.web-templates.io"); // configure before sharing
let mut tasks = Vec::new();
for name in names {
let client = client.clone();
tasks.push(tokio::spawn(async move {
client.send(&Hello { name: Some(name) }).await
}));
}
for task in tasks {
println!("{}", task.await??.result);
}
Requests are sent through a &self client so a shared client only needs to be mut when re-configuring it with its set_* methods.
QueryString Serialization​
Request DTO properties and args sent in the QueryString use ServiceStack's JS Object notation for complex types, where each Request DTO is first serialized by serde then converted into its QueryString representation:
| Rust Value | QueryString |
|---|---|
Some("World") |
name=World |
Some(true) |
enabled=true |
Some(1.5) |
rate=1.5 |
vec![1,2,3] |
ids=[1,2,3] |
HashMap of {"a":1,"b":2} |
meta={a:1,b:2} |
None |
(omitted) |
As names and values are percent-encoded, "a b&c" is sent as a+b%26c, whilst None properties generated with #[serde(skip_serializing_if = "Option::is_none")] are omitted entirely so the Server can apply its own defaults.
URL Utils​
The utils the client uses to construct its API Requests are also exported in servicestack::url for building your own URLs and integrations:
use servicestack::url::*;
combine_with("https://example.org/", "/api/") // "https://example.org/api"
to_absolute_url("https://example.org", "/api/Hello") // "https://example.org/api/Hello"
append_query_string("/api/Hello", &[("name","World")]) // "/api/Hello?name=World"
qs_value(&serde_json::json!([1,2,3])) // "[1,2,3]"
encode_uri_component("a b") // "a+b"
has_request_body("GET") // false
Along with the HTTP constants used in ServiceStack APIs, e.g. HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_PATCH, HTTP_DELETE, HTTP_OPTIONS, HTTP_HEAD, MIME_TYPE_JSON and DEFAULT_BASE_PATH.
Calling AI Chat and OpenAI compatible APIs​
As Request DTOs are just structs, more advanced APIs like AI Chat's OpenAI-compatible ChatCompletion API can also be called with typed DTOs, where polymorphic content parts are sent as serde_json::Value:
// The ChatCompletion API requires an authenticated User
client.authenticate("username", "password").await?;
let request = ChatCompletion {
model: "openai/gpt-oss-120b".to_string(),
messages: vec![AiMessage {
role: "user".to_string(),
// Content parts are polymorphic, e.g. text, image_url or input_audio
content: Some(vec![serde_json::to_value(AiTextContent {
ai_content: AiContent { r#type: "text".to_string() },
text: "Capital of France? Answer in 3 words".to_string(),
})?]),
..Default::default()
}],
..Default::default()
};
let res = match client.send(&request).await {
Ok(res) => res,
Err(err) => {
// Handle rate limited or temporarily unavailable LLMs
if matches!(err.status_code(), Some(429 | 502 | 503 | 504)) { /* retry */ }
return Err(err.into());
}
};
println!("{}", res.choices[0].message.content);
Testing​
As JsonServiceClient accepts any Base URL, APIs can be tested against a mock HTTP Server like mockito without needing to mock the client itself:
#[tokio::test]
async fn sends_get_request_in_query_string() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/Hello?name=World")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"result":"Hello, World!"}"#)
.create_async()
.await;
let client = JsonServiceClient::new(&server.url());
let res = client.send(&Hello { name: Some("World".into()) })
.await
.expect("send failed");
assert_eq!(res.result, "Hello, World!");
mock.assert_async().await;
}
Which is how the client's own async and blocking test suites verify the Requests they send, in addition to its integration tests which are run against the live test.servicestack.net Services:
cargo test --all-features # unit tests
cargo test --all-features -- --ignored # integration tests
Use the SERVICESTACK_TEST_URL Environment Variable to run the integration tests against a different ServiceStack instance.
Examples​
The hello example is a small runnable App calling the live test.servicestack.net Services with typed APIs, batched Requests, structured validation errors and authenticated Requests, whilst hello_blocking calls the same API with the sync client:
cargo run --example hello
cargo run --example hello_blocking --features blocking
DTO Customization Options​
In most cases you'll just use the generated Rust DTOs as-is, however you can further customize how the DTOs are generated by overriding the default options.
The header in the generated DTOs show the different options Rust native types support with their defaults. To override a value, remove the // and specify the value to the right of the :. Any uncommented value will be sent to the server to override any server defaults.
/* Options:
Date: 2026-08-06 15:38:36
Version: 10.09
Tip: To override a DTO option, remove "//" prefix before updating
BaseUrl: https://blazor-vue.web-templates.io
//GlobalNamespace:
//MakePropertiesOptional: False
//AddServiceStackTypes: True
//AddResponseStatus: False
//AddImplicitVersion:
//AddDescriptionAsComments: True
//IncludeTypes:
//ExcludeTypes:
//DefaultImports: serde::{Serialize, Deserialize},serde_json::Value,std::collections::HashMap
*/
AddResponseStatus​
Automatically add a response_status property on all Response DTOs, regardless if it wasn't already defined:
pub struct GetTechnologyResponse {
#[serde(rename = "responseStatus")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_status: Option<ResponseStatus>,
}
IncludeTypes​
Is used as a Whitelist to specify only the types you would like to have code-generated:
/* Options:
IncludeTypes: GetTechnology,GetTechnologyResponse
*/
To include a Request DTO and all its dependent types, use the .* suffix:
/* Options:
IncludeTypes: GetTechnology.*
*/
Or include all types within a Tag Group with:
/* Options:
IncludeTypes: {tag}
*/
ExcludeTypes​
Is used as a Blacklist to specify which types you would like excluded from being generated:
/* Options:
ExcludeTypes: GetTechnology,GetTechnologyResponse
*/
Change Default Server Configuration​
The above defaults are also overridable on the ServiceStack Server by modifying the default config on the NativeTypesFeature Plugin, e.g:
//Server example in C#
var nativeTypes = this.GetPlugin<NativeTypesFeature>();
nativeTypes.MetadataTypesConfig.AddResponseStatus = true;
Rust specific functionality can be added by the RustGenerator, e.g. to change the crate generated DTOs reference:
RustGenerator.LibraryCrate = "my_servicestack";
Properties of abstract Types with sub types are emitted as serde_json::Value since Rust doesn't support sub classing, which can be disabled with:
RustGenerator.PolymorphicPropertiesAsAny = false;
Customize DTO Type generation​
Additional Rust specific customization can be statically configured like PreTypeFilter, InnerTypeFilter & PostTypeFilter (available in all languages) which can be used to inject custom code in the generated DTOs output, e.g:
RustGenerator.PreTypeFilter = (sb, type) => {
if (type.IsInterface != true)
{
sb.AppendLine("#[allow(dead_code)]");
}
};
There's also PrePropertyFilter & PostPropertyFilter for generating source before and after properties.
Whilst the generic [EmitCode] attribute lets you emit the same code in multiple languages with the same syntax.