Skip to content

Commit 81ec815

Browse files
authored
add ClientSpecifiesVersionInHeader::on_missing (#1475)
1 parent f35f3a1 commit 81ec815

File tree

3 files changed

+87
-19
lines changed

3 files changed

+87
-19
lines changed

CHANGELOG.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
https://github.com/oxidecomputer/dropshot/compare/v0.16.4\...HEAD[Full list of commits]
1717

18+
* https://github.com/oxidecomputer/dropshot/pull/1475[#1475] Added `ClientSpecifiesVersionInHeader::on_missing` to provide a default version when the header is missing, intended for use when you're not in control of all clients
19+
1820
== 0.16.4 (released 2025-09-04)
1921

2022
https://github.com/oxidecomputer/dropshot/compare/v0.16.3\...v0.16.4[Full list of commits]

dropshot/src/versioning.rs

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,9 @@ pub trait DynamicVersionPolicy: std::fmt::Debug + Send + Sync {
114114
) -> Result<Version, HttpError>;
115115
}
116116

117-
/// Implementation of `DynamicVersionPolicy` where the client must specify a
117+
/// Implementation of `DynamicVersionPolicy` where the client specifies a
118118
/// specific semver in a specific header and we always use whatever they
119-
/// requested
119+
/// requested.
120120
///
121121
/// An incoming request will be rejected with a 400-level error if:
122122
///
@@ -125,17 +125,22 @@ pub trait DynamicVersionPolicy: std::fmt::Debug + Send + Sync {
125125
/// [`ClientSpecifiesVersionInHeader::new()`], which implies that the client
126126
/// is trying to use a newer version of the API than this server supports.
127127
///
128+
/// By default, incoming requests will also be rejected with a 400-level error
129+
/// if the header is missing. To override this behavior, supply a default
130+
/// version via [`Self::on_missing`].
131+
///
128132
/// If you need anything more flexible (e.g., validating the provided version
129133
/// against a fixed set of supported versions), you'll want to impl
130134
/// `DynamicVersionPolicy` yourself.
131135
#[derive(Debug)]
132136
pub struct ClientSpecifiesVersionInHeader {
133137
name: HeaderName,
134138
max_version: Version,
139+
on_missing: Option<Version>,
135140
}
136141

137142
impl ClientSpecifiesVersionInHeader {
138-
/// Make a new `ClientSpecifiesVersionInHeader` policy
143+
/// Make a new `ClientSpecifiesVersionInHeader` policy.
139144
///
140145
/// Arguments:
141146
///
@@ -148,7 +153,25 @@ impl ClientSpecifiesVersionInHeader {
148153
name: HeaderName,
149154
max_version: Version,
150155
) -> ClientSpecifiesVersionInHeader {
151-
ClientSpecifiesVersionInHeader { name, max_version }
156+
ClientSpecifiesVersionInHeader { name, max_version, on_missing: None }
157+
}
158+
159+
/// If the header is missing, use the provided version instead.
160+
///
161+
/// By default, the policy will reject requests with a missing header. Call
162+
/// this function to use the provided version instead.
163+
///
164+
/// Typically, the provided version should either be a fixed supported
165+
/// version (for backwards compatibility with older clients), or the newest
166+
/// supported version (in case clients are generally kept up-to-date but not
167+
/// all clients send the header).
168+
///
169+
/// Using this function is not recommended if you control all clients—in
170+
/// that case, arrange for clients to send the header instead. In
171+
/// particular, **at Oxide, do not use this function for internal APIs.**
172+
pub fn on_missing(mut self, version: Version) -> Self {
173+
self.on_missing = Some(version);
174+
self
152175
}
153176
}
154177

@@ -159,13 +182,25 @@ impl DynamicVersionPolicy for ClientSpecifiesVersionInHeader {
159182
_log: &Logger,
160183
) -> Result<Version, HttpError> {
161184
let v = parse_header(request.headers(), &self.name)?;
162-
if v <= self.max_version {
163-
Ok(v)
164-
} else {
165-
Err(HttpError::for_bad_request(
185+
match (v, &self.on_missing) {
186+
(Some(v), _) => {
187+
if v <= self.max_version {
188+
Ok(v)
189+
} else {
190+
Err(HttpError::for_bad_request(
191+
None,
192+
format!(
193+
"server does not support this API version: {}",
194+
v
195+
),
196+
))
197+
}
198+
}
199+
(None, Some(on_missing)) => Ok(on_missing.clone()),
200+
(None, None) => Err(HttpError::for_bad_request(
166201
None,
167-
format!("server does not support this API version: {}", v),
168-
))
202+
format!("missing expected header {:?}", self.name),
203+
)),
169204
}
170205
}
171206
}
@@ -175,17 +210,12 @@ impl DynamicVersionPolicy for ClientSpecifiesVersionInHeader {
175210
fn parse_header<T>(
176211
headers: &http::HeaderMap,
177212
header_name: &HeaderName,
178-
) -> Result<T, HttpError>
213+
) -> Result<Option<T>, HttpError>
179214
where
180215
T: FromStr,
181216
<T as FromStr>::Err: std::fmt::Display,
182217
{
183-
let v_value = headers.get(header_name).ok_or_else(|| {
184-
HttpError::for_bad_request(
185-
None,
186-
format!("missing expected header {:?}", header_name),
187-
)
188-
})?;
218+
let Some(v_value) = headers.get(header_name) else { return Ok(None) };
189219

190220
let v_str = v_value.to_str().map_err(|_| {
191221
HttpError::for_bad_request(
@@ -197,10 +227,12 @@ where
197227
)
198228
})?;
199229

200-
v_str.parse::<T>().map_err(|e| {
230+
let v = v_str.parse::<T>().map_err(|e| {
201231
HttpError::for_bad_request(
202232
None,
203233
format!("bad value for header {:?}: {}: {}", header_name, e, v_str),
204234
)
205-
})
235+
})?;
236+
237+
Ok(Some(v))
206238
}

dropshot/tests/integration-tests/versions.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,40 @@ async fn test_versions() {
325325
}
326326
}
327327

328+
#[tokio::test]
329+
async fn test_versions_on_missing() {
330+
let logctx = common::create_log_context("test_versions_on_missing");
331+
let server = ServerBuilder::new(api(), (), logctx.log.clone())
332+
.version_policy(VersionPolicy::Dynamic(Box::new(
333+
ClientSpecifiesVersionInHeader::new(
334+
VERSION_HEADER_NAME.parse().unwrap(),
335+
Version::new(1, 4, 0),
336+
)
337+
.on_missing(Version::new(1, 1, 0)),
338+
)))
339+
.start()
340+
.unwrap();
341+
342+
let server_addr = server.local_addr();
343+
let url = format!("http://{}/demo", server_addr);
344+
let client = reqwest::Client::new();
345+
346+
// Missing header => should resolve to 1.1.0.
347+
let request = client.request(Method::GET, &url);
348+
let response = request.send().await.unwrap();
349+
assert_eq!(response.status(), StatusCode::OK);
350+
let body: String = response.json().await.unwrap();
351+
assert_eq!(body, HANDLER2_MSG);
352+
353+
// Now add a version header.
354+
let request =
355+
client.request(Method::GET, &url).header(VERSION_HEADER_NAME, "1.4.0");
356+
let response = request.send().await.unwrap();
357+
assert_eq!(response.status(), StatusCode::OK);
358+
let body: String = response.json().await.unwrap();
359+
assert_eq!(body, HANDLER4_MSG);
360+
}
361+
328362
/// Test that the generated OpenAPI spec only refers to handlers in that version
329363
/// and types that are used by those handlers.
330364
#[test]

0 commit comments

Comments
 (0)