
LabelZoom Rust SDK
Official Rust client for the LabelZoom API. Converts barcode labels between ZPL, EPL, TSPL, DPL, PDF, LabelZoom XML/JSON, and raster images.
Rust 1.85+. Blocking, with a rustls-backed HTTP stack — no async runtime, and no OpenSSL headers needed to build it anywhere.
Install
= "1.0"
= { = "1.0", = false }
Drops ureq, rustls and ring entirely and leaves you to supply a
Transport. CI builds this configuration on every run, so it stays a
real, working mode rather than an aspiration.
Quick start
An API key is optional. Without one you get the free tier — watermarked output, first label only, a 1 MB request cap, and no multi-page, JSON-target, or image-to-image conversion.
use ;
let client = new; // anonymous; this works
let request = new
.options;
client.convert?.save?;
With a credential:
let client = builder.api_key.build?;
LabelZoomClient::new() reads LABELZOOM_API_KEY from the environment. .api_key("") — or the
more readable .anonymous() — forces the free tier and suppresses that fallback.
Why a value struct and not a fluent chain
The .NET, Node, Java and PHP SDKs expose client.convert().fromZpl(body).toPng().execute(). A
chain that defers its errors to a terminal send() is as un-Rust as it is un-Go, and Option<T>
already expresses "not set" without one. The wire behaviour is identical — that is what the shared
conformance suite proves. See API_CONTRACT.md §9.
Formats
Sources (13): Zpl Epl Tspl Dpl Xml Json Pdf Png Bmp Gif Jpeg Jpg Url
Targets (11): Zpl Epl Tspl Dpl Xml Json Pdf Png Bmp Gif Jpeg
SourceFormat and TargetFormat are separate enums. SourceFormat::Jpg is an input spelling
that normalizes to jpeg on the wire, and SourceFormat::Url tells the server to go fetch a
document rather than naming a format — so neither has a TargetFormat counterpart, and
TargetFormat::Url does not compile.
Both enums are #[non_exhaustive], and that is not boilerplate: Epl, Tspl and Dpl became
targets in contract 1.1.0, when the printer-language writers shipped. Without it, every such
addition would be a breaking change for any downstream match.
The printer languages round-trip: pdf→epl and zpl→tspl are real conversions. Their output
is text/plain, but EPL's GW and TSPL's BITMAP commands inline raw binary, so read
result.bytes rather than result.text() whenever a label might carry graphics.
Options
Every field of ConversionOptions is an Option, and only the ones you set are sent —
#[serde(skip_serializing_if = "Option::is_none")] is that rule expressed in the type system. The
SDK never substitutes a default of its own, so a change to a server default reaches you without a
crate upgrade.
new
.dpi // server default 203
.rotation // must be a multiple of 90
.scaling // percent; server default 100
.color_mode
.darkness // 0-100 luminance threshold
.watermark // an explicit false IS sent
.label_size // INCHES
.pdf_page // 0-BASED
.zpl_commands_to_ignore
Two units are routinely misread and are pinned by the shared fixtures:
label_sizeis in inches, not dots. Leave it unset to have the server detect the size.pdf_pageis 0-based. Leave it unset to convert every page.
data is one record per output label; ConversionOptions::extra carries anything the crate does
not model yet, since unknown keys are ignored server-side.
Errors
Rust has no inheritance, so rule E4's "every API error shares one base type" is a single enum variant:
match client.convert
ApiError carries the status, the message, the untruncated raw body, and the X-LZ-Request-Id
support handle; ApiErrorKind says which class it is, with the per-status data on the variant
that has it (Forbidden { is_paid_feature }, RateLimited { retry_after_seconds }).
Error::Validation is a sibling of Error::Api, not a member: it reports a request rejected
locally, before any network call, which is a bug in the calling code rather than a server response.
Retries
429s, 5xx responses and transport failures are retried automatically — twice by default, for three
attempts — with a 1s/2s/4s backoff under full jitter. A Retry-After header is honoured on any
retryable status when it asks for longer than the backoff would wait. No other 4xx is ever retried.
let client = builder.max_retries.build?; // disable retrying
Testing your own code
The HTTP backend, the sleeper and the environment lookup are all injectable, so a test never opens a socket, never sleeps, and never picks up a developer's real key:
use ;
;
let client = builder
.transport
.sleeper
.jitter
.env_lookup
.build?;
Transport is part of the crate's design rather than a test-only afterthought — it is also how
default-features = false works.
Development
The test suite is the shared conformance fixtures in ../conformance/ — the
same 83 cases the .NET, Node, Java, Python, PHP, Go and Ruby suites run — plus an assertion that it
executed every one of them. conformance/skips/rust.json is empty: Rust compiles, so the two
typecheck/* cases are run for real, by building a snippet from tests/typecheck/snippets/ in a
throwaway crate and asserting the compiler rejects it with the expected error code.
cargo run --example smoke does one anonymous conversion against the live API. It is not part of
the test suite, which is offline by design.
License
MIT — see LICENSE.