diff --git a/crates/nvisy-nats/src/stream/event.rs b/crates/nvisy-nats/src/stream/event.rs index 164e3d72..6aeb884e 100644 --- a/crates/nvisy-nats/src/stream/event.rs +++ b/crates/nvisy-nats/src/stream/event.rs @@ -1,7 +1,7 @@ //! Event types for stream processing. //! //! This module contains common event types and the file job type -//! used in processing pipelines. +//! used in redaction pipelines. use jiff::Timestamp; #[cfg(feature = "schema")] @@ -12,7 +12,7 @@ use uuid::Uuid; /// File processing job. /// -/// Represents a unit of work in a file processing pipeline. +/// Represents a unit of work in a file redaction pipeline. /// Each job targets a specific file and carries a generic payload /// that defines the processing parameters. /// diff --git a/crates/nvisy-server/src/extract/version.rs b/crates/nvisy-server/src/extract/version.rs index 08da3cdf..8d14ad8b 100644 --- a/crates/nvisy-server/src/extract/version.rs +++ b/crates/nvisy-server/src/extract/version.rs @@ -6,7 +6,7 @@ use std::convert::Infallible; use std::fmt; -use std::num::NonZeroU32; +use std::num::NonZeroU16; use axum::RequestPartsExt; use axum::extract::FromRequestParts; @@ -20,7 +20,7 @@ use crate::extract::Path; const VERSION_PREFIX: char = 'v'; /// The unstable version number. -const UNSTABLE_VERSION: u32 = 0; +const UNSTABLE_VERSION: u16 = 0; /// Enhanced version parameter extractor for API versioning support. /// @@ -39,7 +39,7 @@ const UNSTABLE_VERSION: u32 = 0; /// # Version Validation /// /// The extractor automatically parses and validates version parameters: -/// - `v1` → `Version::Stable(NonZeroU32::new(1).unwrap())` +/// - `v1` → `Version::Stable(NonZeroU16::new(1).unwrap())` /// - `v0` → `Version::Unstable` /// - `invalid` → `Version::Unrecognized` #[must_use] @@ -62,8 +62,8 @@ pub enum Version { /// /// These versions follow semantic versioning principles and /// are expected to provide backward compatibility guarantees. - /// The contained `NonZeroU32` represents the version number. - Stable(NonZeroU32), + /// The contained `NonZeroU16` represents the version number. + Stable(NonZeroU16), } impl Version { @@ -76,9 +76,9 @@ impl Version { /// # Examples /// /// ```rust - /// # use std::num::NonZeroU32; + /// # use std::num::NonZeroU16; /// # use nvisy_server::extract::Version; - /// assert_eq!(Version::new("v1"), Version::Stable(NonZeroU32::new(1).unwrap())); + /// assert_eq!(Version::new("v1"), Version::Stable(NonZeroU16::new(1).unwrap())); /// assert_eq!(Version::new("v0"), Version::Unstable); /// assert_eq!(Version::new("invalid"), Version::Unrecognized); /// ``` @@ -91,9 +91,9 @@ impl Version { pub fn new(version: &str) -> Self { let number = version .strip_prefix(VERSION_PREFIX) - .and_then(|x| x.parse::().ok()); + .and_then(|x| x.parse::().ok()); - match number.map(NonZeroU32::new) { + match number.map(NonZeroU16::new) { None => Self::Unrecognized, Some(Some(x)) => Self::Stable(x), Some(None) => Self::Unstable, @@ -165,7 +165,7 @@ impl Version { /// assert!(!v0.is_v(1)); /// ``` #[must_use] - pub fn is_v(&self, version: u32) -> bool { + pub fn is_v(&self, version: u16) -> bool { match self { Self::Unstable => version == UNSTABLE_VERSION, Self::Stable(x) => x.get() == version, @@ -189,7 +189,7 @@ impl Version { /// assert_eq!(Version::new("v0").into_inner(), Some(0)); /// assert_eq!(Version::new("invalid").into_inner(), None); /// ``` - pub fn into_inner(self) -> Option { + pub fn into_inner(self) -> Option { match self { Self::Unrecognized => None, Self::Stable(x) => Some(x.get()), diff --git a/crates/nvisy-server/src/middleware/mod.rs b/crates/nvisy-server/src/middleware/mod.rs index 59bbf1f9..675d43ea 100644 --- a/crates/nvisy-server/src/middleware/mod.rs +++ b/crates/nvisy-server/src/middleware/mod.rs @@ -53,6 +53,7 @@ mod recovery; mod route_category; mod security; mod specification; +mod sunset; pub use authentication::{RouterAuthExt, require_authentication, validate_token_middleware}; pub use authorization::require_admin; @@ -64,3 +65,4 @@ pub use security::{ CorsConfig, FrameOptions, ReferrerPolicy, RouterSecurityExt, SecurityHeadersConfig, }; pub use specification::{OpenApiConfig, RouterOpenApiExt}; +pub use sunset::{SunsetConfig, sunset_headers}; diff --git a/crates/nvisy-server/src/middleware/sunset.rs b/crates/nvisy-server/src/middleware/sunset.rs new file mode 100644 index 00000000..77cd95a3 --- /dev/null +++ b/crates/nvisy-server/src/middleware/sunset.rs @@ -0,0 +1,142 @@ +//! Sunset deprecation header middleware. +//! +//! Adds `Sunset`, `Deprecation`, and `Link` HTTP headers to responses +//! from deprecated API versions, signalling to clients that the version +//! will be removed after a specified date. +//! +//! Headers follow [RFC 8594](https://httpwg.org/specs/rfc8594.html) and +//! the [Deprecation header draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-deprecation-header/). + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::Extension; +use axum::http::{HeaderValue, Request}; +use axum::middleware::Next; +use axum::response::Response; +use jiff::civil::Date; + +use crate::extract::Version; + +/// Per-version sunset entry: precomputed header values. +#[derive(Clone)] +struct SunsetEntry { + sunset_date: HeaderValue, + successor_link: HeaderValue, +} + +/// Configuration for the sunset deprecation middleware. +/// +/// Maps API version numbers to their sunset date. The successor +/// version is automatically set to `version + 1`. Only versions +/// present in the map receive deprecation headers; active versions +/// pass through unmodified. +/// +/// Cloning is cheap: the inner map is behind an [`Arc`]. +/// +/// # Example +/// +/// ```rust +/// use axum::middleware; +/// use jiff::civil::date; +/// use nvisy_server::middleware::SunsetConfig; +/// +/// let config = SunsetConfig::new() +/// .deprecate(1, date(2025, 11, 1)); +/// ``` +#[derive(Clone, Default)] +pub struct SunsetConfig { + versions: Arc>, +} + +impl SunsetConfig { + /// Creates an empty config with no deprecated versions. + pub fn new() -> Self { + Self::default() + } + + /// Registers a deprecated API version. + /// + /// - `version`: the version number (e.g. `1` for `/api/v1`) + /// - `sunset_date`: the date after which the version may be removed + /// + /// The successor is automatically set to `version + 1`. + /// + /// # Panics + /// + /// Panics if `version` is 0. + pub fn deprecate(mut self, version: u16, sunset_date: Date) -> Self { + assert!(version > 0, "API version must be non-zero"); + + let http_date = sunset_date + .strftime("%a, %d %b %Y 00:00:00 GMT") + .to_string(); + let successor = version + 1; + + Arc::make_mut(&mut self.versions).insert( + version, + SunsetEntry { + sunset_date: HeaderValue::from_str(&http_date) + .expect("formatted date must be a valid header value"), + successor_link: HeaderValue::from_str(&format!( + "; rel=\"successor-version\"" + )) + .expect("successor link must be a valid header value"), + }, + ); + self + } +} + +/// Axum middleware function that adds sunset deprecation headers to +/// responses for deprecated API versions. +/// +/// Extracts the version number from the [`Version`] extractor and +/// checks it against the configured deprecated versions. Requests +/// that don't match any deprecated version pass through unmodified. +pub async fn sunset_headers( + Extension(config): Extension, + version: Version, + req: Request, + next: Next, +) -> Response { + let mut response = next.run(req).await; + + if let Some(entry) = version.into_inner().and_then(|v| config.versions.get(&v)) { + let headers = response.headers_mut(); + headers.insert("sunset", entry.sunset_date.clone()); + headers.insert("deprecation", HeaderValue::from_static("true")); + headers.append("link", entry.successor_link.clone()); + } + + response +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deprecate_builds_headers() { + let config = SunsetConfig::new().deprecate(1, Date::new(2025, 11, 1).unwrap()); + assert!(config.versions.contains_key(&1)); + assert!(!config.versions.contains_key(&2)); + } + + #[test] + fn successor_is_version_plus_one() { + let config = SunsetConfig::new().deprecate(3, Date::new(2026, 6, 15).unwrap()); + let entry = &config.versions[&3]; + assert_eq!( + entry.successor_link, + HeaderValue::from_static("; rel=\"successor-version\""), + ); + } + + #[test] + #[should_panic(expected = "non-zero")] + fn deprecate_zero_panics() { + SunsetConfig::new().deprecate(0, Date::new(2025, 1, 1).unwrap()); + } +} diff --git a/migrations/2026-01-19-045012_pipelines/up.sql b/migrations/2026-01-19-045012_pipelines/up.sql index f2439859..84729bf6 100644 --- a/migrations/2026-01-19-045012_pipelines/up.sql +++ b/migrations/2026-01-19-045012_pipelines/up.sql @@ -1,5 +1,5 @@ -- Pipeline: Workflow definitions, connections, and execution tracking --- This migration creates tables for user-defined processing pipelines +-- This migration creates tables for redaction pipeline definitions -- Sync status enum for connections CREATE TYPE SYNC_STATUS AS ENUM ( @@ -269,7 +269,7 @@ CREATE INDEX workspace_pipelines_name_trgm_idx -- Comments COMMENT ON TABLE workspace_pipelines IS - 'User-defined processing pipeline definitions with step configurations.'; + 'Redaction pipeline definitions with step configurations.'; COMMENT ON COLUMN workspace_pipelines.id IS 'Unique pipeline identifier'; COMMENT ON COLUMN workspace_pipelines.workspace_id IS 'Parent workspace reference';