Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/nvisy-nats/src/stream/event.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand All @@ -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.
///
Expand Down
22 changes: 11 additions & 11 deletions crates/nvisy-server/src/extract/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
///
Expand All @@ -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]
Expand All @@ -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 {
Expand All @@ -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);
/// ```
Expand All @@ -91,9 +91,9 @@ impl Version {
pub fn new(version: &str) -> Self {
let number = version
.strip_prefix(VERSION_PREFIX)
.and_then(|x| x.parse::<u32>().ok());
.and_then(|x| x.parse::<u16>().ok());

match number.map(NonZeroU32::new) {
match number.map(NonZeroU16::new) {
None => Self::Unrecognized,
Some(Some(x)) => Self::Stable(x),
Some(None) => Self::Unstable,
Expand Down Expand Up @@ -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,
Expand All @@ -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<u32> {
pub fn into_inner(self) -> Option<u16> {
match self {
Self::Unrecognized => None,
Self::Stable(x) => Some(x.get()),
Expand Down
2 changes: 2 additions & 0 deletions crates/nvisy-server/src/middleware/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -64,3 +65,4 @@ pub use security::{
CorsConfig, FrameOptions, ReferrerPolicy, RouterSecurityExt, SecurityHeadersConfig,
};
pub use specification::{OpenApiConfig, RouterOpenApiExt};
pub use sunset::{SunsetConfig, sunset_headers};
142 changes: 142 additions & 0 deletions crates/nvisy-server/src/middleware/sunset.rs
Original file line number Diff line number Diff line change
@@ -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<HashMap<u16, SunsetEntry>>,
}

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!(
"</api/v{successor}>; 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<SunsetConfig>,
version: Version,
req: Request<Body>,
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("</api/v4>; rel=\"successor-version\""),
);
}

#[test]
#[should_panic(expected = "non-zero")]
fn deprecate_zero_panics() {
SunsetConfig::new().deprecate(0, Date::new(2025, 1, 1).unwrap());
}
}
4 changes: 2 additions & 2 deletions migrations/2026-01-19-045012_pipelines/up.sql
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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';
Expand Down
Loading