Skip to main content

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}