Crate metadata: JSON to Rust structs
With Crate as the Root name, the sample registry record becomes:
{
"name": "serde_json",
"max_version": "1.0.133",
"downloads": 486211953,
"categories": ["encoding", "parser-implementations"],
"repository": "https://github.com/serde-rs/json",
"links": {
"owners": "/api/v1/crates/serde_json/owners",
"versions": null
},
"yanked": false
}
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Crate {
pub name: String,
pub max_version: String,
pub downloads: i64,
pub categories: Vec<String>,
pub repository: String,
pub links: Links,
pub yanked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Links {
pub owners: String,
pub versions: Option<serde_json::Value>,
}
Fields are pub, whole numbers i64, decimals f64 and arrays Vec<T>. With only a null to go on, versions is Option<serde_json::Value>; until you set the real type, inspect it with methods such as .as_str() or .as_array(). downloads can’t be negative, so u64 is a fair edit.
Parse it
Add serde with its derive feature, and serde_json, to Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
fn main() -> Result<(), Box<dyn std::error::Error>> {
let body = std::fs::read_to_string("crate.json")?;
let krate: Crate = serde_json::from_str(&body)?;
println!("{} has {} downloads", krate.name, krate.downloads);
Ok(())
}
crate is a keyword, hence krate. serde_json::to_string_pretty(&krate)? writes it back out. With reqwest, enable its json feature and call .json::<Crate>() on the response. Serde ignores undeclared keys unless the struct has #[serde(deny_unknown_fields)]. If from_str fails with expected value at line 1 column 1, the input isn’t JSON, often an HTML error page; check it in the JSON validator.
Renames, raw identifiers and Option
Fields are snake_case; where that changes a key, #[serde(rename = "...")] keeps the original, and a key that is a Rust keyword becomes a raw identifier. With Events as the root name:
[
{ "id": 1, "type": "push", "createdAt": "2026-11-01T10:00:00Z" },
{ "id": 2, "type": "release", "createdAt": "2026-11-03T08:30:00Z", "tag": "v1.4.0" }
]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub id: i64,
pub r#type: String,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
}
tag is missing from the first event, so it is Option<String> with #[serde(default, skip_serializing_if = "Option::is_none")]: decoding accepts the missing key and encoding omits it again. A key that was present but null, like versions above, is an Option without the skip, so it serializes back to null. Both come from merging array items first, as JSON to TypeScript explains; JSON to Go handles the same cases with pointers.
If every key in a struct follows one convention, you can replace the per-field renames with a single #[serde(rename_all = "camelCase")] on the struct.