embacle_server/uhp/version.rs
1// ABOUTME: UHP protocol version constants, negotiation, and the UHP-Version response header
2// ABOUTME: An unsupported requested version is refused with 400, never silently substituted
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Version negotiation.
8//!
9//! Two rules, both checked by the suite. Every response carries `UHP-Version`
10//! naming the contract it was served under, so a client never has to guess. And
11//! a request naming a version this server does not speak is **refused with
12//! 400**, not served under a different one — silently substituting hands the
13//! client a body it may not be able to parse, which is worse than a clean
14//! refusal.
15
16use axum::extract::Request;
17use axum::http::{HeaderName, HeaderValue};
18use axum::middleware::Next;
19use axum::response::{IntoResponse, Response};
20use serde_json::json;
21
22use super::error::UhpFailure;
23
24/// The only protocol version this server implements.
25pub const VERSION: &str = "2026-08-11";
26
27/// Every version this server can serve.
28pub const SUPPORTED: &[&str] = &[VERSION];
29
30/// The negotiation header, in both directions.
31pub const HEADER: &str = "uhp-version";
32
33/// Refuse a request for a version we do not speak; stamp every answer with the
34/// version it was served under.
35///
36/// # Errors
37///
38/// Answers `400 unsupported_protocol_version` when the request names a version
39/// outside [`SUPPORTED`], with the supported list in `error.detail.supported`
40/// so the client can retry without a second round trip.
41pub async fn negotiate(request: Request, next: Next) -> Response {
42 if let Some(asked) = request.headers().get(HEADER) {
43 let asked = asked.to_str().unwrap_or_default();
44 if !asked.is_empty() && !SUPPORTED.contains(&asked) {
45 return UhpFailure::new(
46 super::error::ErrorType::InvalidRequestError,
47 "unsupported_protocol_version",
48 format!("this server does not speak protocol version '{asked}'"),
49 )
50 .with_detail(json!({ "supported": SUPPORTED }))
51 .into_response();
52 }
53 }
54
55 let mut response = next.run(request).await;
56 response.headers_mut().insert(
57 HeaderName::from_static(HEADER),
58 HeaderValue::from_static(VERSION),
59 );
60 response
61}