rust
53 lines · 8 steps
Distinguishing absent from null in an Axum PATCH
A double-Option JSON field lets a partial-update handler tell "field omitted" apart from "field set to null."
Explained by
highlit
1use axum::{
2 extract::{Path, State},
3 http::StatusCode,
4 Json,
5};
6use serde::Deserialize;
7use sqlx::PgPool;
8use uuid::Uuid;
9
10#[derive(Debug, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct DraftPatch {
13 #[serde(default, deserialize_with = "deserialize_optional_field")]
14 pub title: Option<Option<String>>,
15 #[serde(default, deserialize_with = "deserialize_optional_field")]
16 pub body: Option<Option<String>>,
17}
18
19fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
20where
21 T: Deserialize<'de>,
22 D: serde::Deserializer<'de>,
23{
24 Ok(Some(Option::deserialize(deserializer)?))
25}
26
27pub async fn patch_draft(
28 State(pool): State<PgPool>,
29 Path(id): Path<Uuid>,
30 Json(patch): Json<DraftPatch>,
31) -> Result<StatusCode, StatusCode> {
32 let row = sqlx::query!(
33 r#"
34 UPDATE drafts
35 SET title = COALESCE($2, title),
36 body = COALESCE($3, body),
37 updated_at = now()
38 WHERE id = $1
39 RETURNING id
40 "#,
41 id,
42 patch.title.flatten(),
43 patch.body.flatten(),
44 )
45 .fetch_optional(&pool)
46 .await
47 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
48
49 match row {
50 Some(_) => Ok(StatusCode::NO_CONTENT),
51 None => Err(StatusCode::NOT_FOUND),
52 }
53}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Option<Option<T>> models three states: absent, null, and present — exactly what PATCH semantics need.
- 2A custom serde deserializer with #[serde(default)] converts a missing key into the outer None cleanly.
- 3COALESCE combined with flatten() lets the same SQL leave omitted columns untouched while still updating provided ones.
Related explainers
rust
use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(untagged)]
Parsing flexible JSON shapes with serde
deserialization
enums
json
Intermediate
6 steps
rust
use std::cmp::{Ordering, Reverse}; use std::collections::BinaryHeap; use std::fs::File; use std::io::{self, BufRead, BufReader, BufWriter, Lines, Write};
K-way merge of sorted logs in Rust
binary-heap
k-way-merge
streaming-io
Intermediate
8 steps
rust
use axum::{ extract::{Path, State}, response::sse::{Event, KeepAlive, Sse}, };
Streaming import progress with SSE in Axum
server-sent-events
streams
watch-channel
Advanced
7 steps
rust
use std::f64::consts::PI; #[derive(Debug, Clone, Copy)] pub struct LatLng {
Building geographic bounding boxes in Rust
geospatial
value-types
option
Intermediate
7 steps
rust
use chrono::{Duration, NaiveDate}; #[derive(Debug)] pub struct DateRange {
Parsing and iterating date ranges in Rust
error-handling
iterators
parsing
Intermediate
7 steps
rust
use std::collections::VecDeque; use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant};
Building a counting semaphore in Rust
concurrency
synchronization
condition-variable
Advanced
9 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/distinguishing-absent-from-null-in-an-axum-patch-explained-rust-5e58/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.