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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

489 changes: 489 additions & 0 deletions gears/system/oagw/docs/DECOMPOSITION.md

Large diffs are not rendered by default.

363 changes: 363 additions & 0 deletions gears/system/oagw/docs/features/gear-foundation.md

Large diffs are not rendered by default.

370 changes: 370 additions & 0 deletions gears/system/oagw/docs/features/plugin-management.md

Large diffs are not rendered by default.

471 changes: 471 additions & 0 deletions gears/system/oagw/docs/features/proxy-http.md

Large diffs are not rendered by default.

361 changes: 361 additions & 0 deletions gears/system/oagw/docs/features/proxy-streaming.md

Large diffs are not rendered by default.

416 changes: 416 additions & 0 deletions gears/system/oagw/docs/features/route-management.md

Large diffs are not rendered by default.

563 changes: 563 additions & 0 deletions gears/system/oagw/docs/features/traffic-policy.md

Large diffs are not rendered by default.

538 changes: 538 additions & 0 deletions gears/system/oagw/docs/features/upstream-management.md

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions gears/system/oagw/oagw/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ test-utils = [
"toolkit/bootstrap",
"dep:async-stream",
"dep:tower",
"dep:rustls",
"dep:rustls-pki-types",
"dep:rcgen",
"tokio/net",
"tokio/sync",
Expand All @@ -44,6 +42,7 @@ inventory = { workspace = true }
async-trait = { workspace = true }
axum = { workspace = true }
http = { workspace = true }
http-body-util = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
uuid = { workspace = true, features = ["v4", "serde"] }
Expand Down Expand Up @@ -75,7 +74,9 @@ mime = { workspace = true }
form_urlencoded = "1"
pingora-memory-cache = "0.8"
futures-util = { workspace = true, features = ["sink"] }
tokio = { workspace = true, features = ["time"] }
tokio = { workspace = true, features = ["time", "net", "sync", "rt", "io-util", "macros"] }
tokio-rustls = { workspace = true }
rustls-native-certs = { workspace = true }
tokio-retry = { workspace = true }
hyper = { workspace = true }
hyper-util = { workspace = true }
Expand All @@ -86,10 +87,11 @@ pingora-load-balancing = { version = "0.8", features = ["rustls"] }
pingora-http = { version = "0.8.1" }
httparse = "1"
# test-utils optional deps
rustls = { workspace = true }
rustls-pki-types = { workspace = true }
# test-utils optional deps
async-stream = { workspace = true, optional = true }
tower = { workspace = true, features = ["util"], optional = true }
rustls = { workspace = true, optional = true }
rustls-pki-types = { workspace = true, optional = true }
rcgen = { workspace = true, optional = true }

[dev-dependencies]
Expand Down
3 changes: 3 additions & 0 deletions gears/system/oagw/oagw/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//! Transport layer for the OAGW gear.

pub mod rest;
253 changes: 253 additions & 0 deletions gears/system/oagw/oagw/src/api/rest/dto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
//! Wire shapes for the OAGW management API.
//!
//! Write DTOs mirror the frozen JSON Schemas, with the deliberate scheme
//! widening recorded in the FEATURE documents. Read shapes are the domain
//! entities themselves, whose `tenant_id` is not serialized.

use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::domain::model::{
AuthConfig, CorsConfig, Headers, PluginBindings, PluginKind, RateLimit, RouteMatch, Server,
};

/// Create or replace an upstream.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpstreamWrite {
/// Ignored on write; the server owns identifiers.
#[serde(default)]
pub id: Option<Uuid>,
/// Whether the upstream serves traffic.
#[serde(default = "default_true")]
pub enabled: bool,
/// Routing alias. Derived from the endpoint host when omitted.
#[serde(default)]
pub alias: Option<String>,
/// Free-form tags.
#[serde(default)]
pub tags: Vec<String>,
/// Endpoint pool.
pub server: Server,
/// Application protocol identifier.
pub protocol: String,
/// Authentication configuration.
#[serde(default)]
pub auth: AuthConfig,
/// Header transformation rules.
#[serde(default)]
pub headers: Headers,
/// Guard and transform plugin bindings.
#[serde(default)]
pub plugins: PluginBindings,
/// Rate-limit configuration.
#[serde(default)]
pub rate_limit: Option<RateLimit>,
/// CORS configuration.
#[serde(default)]
pub cors: Option<CorsConfig>,
}

/// Create a route.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RouteCreate {
/// Ignored on write.
#[serde(default)]
pub id: Option<Uuid>,
/// Whether the route participates in matching.
#[serde(default = "default_true")]
pub enabled: bool,
/// Free-form tags.
#[serde(default)]
pub tags: Vec<String>,
/// Parent upstream.
pub upstream_id: Uuid,
/// Match criteria.
#[serde(rename = "match")]
pub match_: RouteMatch,
/// Guard and transform plugin bindings.
#[serde(default)]
pub plugins: PluginBindings,
/// Rate-limit configuration.
#[serde(default)]
pub rate_limit: Option<RateLimit>,
}

/// Replace a route. `upstream_id` is immutable and therefore absent.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RouteReplace {
/// Ignored on write.
#[serde(default)]
pub id: Option<Uuid>,
/// Whether the route participates in matching.
#[serde(default = "default_true")]
pub enabled: bool,
/// Free-form tags.
#[serde(default)]
pub tags: Vec<String>,
/// Match criteria.
#[serde(rename = "match")]
pub match_: RouteMatch,
/// Guard and transform plugin bindings.
#[serde(default)]
pub plugins: PluginBindings,
/// Rate-limit configuration.
#[serde(default)]
pub rate_limit: Option<RateLimit>,
}

/// Create a custom plugin definition.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginCreate {
/// Ignored on write.
#[serde(default)]
pub id: Option<Uuid>,
/// Name, unique within the tenant.
pub name: String,
/// Optional description.
#[serde(default)]
pub description: String,
/// Which phase the plugin participates in.
pub plugin_type: PluginKind,
/// Optional configuration schema.
#[serde(default)]
pub config_schema: serde_json::Value,
/// Plugin source text.
#[serde(default)]
pub source_code: String,
}

/// A page of results.
#[derive(Debug, Clone, Serialize)]
pub struct Page<T> {
/// The items on this page.
pub items: Vec<T>,
/// How many items the tenant owns in total.
pub total: usize,
}

/// The `referenced_by` body of a 409 `PluginInUse`.
#[derive(Debug, Clone, Serialize)]
pub struct ReferencedBy {
/// Identifiers of referencing upstreams.
pub upstreams: Vec<String>,
/// Identifiers of referencing routes.
pub routes: Vec<String>,
}

/// OData-style paging parameters.
#[derive(Debug, Clone, Deserialize)]
pub struct ListParams {
/// Page size. Defaults to 50, capped at 100.
#[serde(rename = "$top", default)]
pub top: Option<usize>,
/// Offset into the collection.
#[serde(rename = "$skip", default)]
pub skip: Option<usize>,
}

/// Default page size.
pub const DEFAULT_TOP: usize = 50;
/// Maximum page size.
pub const MAX_TOP: usize = 100;

impl ListParams {
/// The effective page size, defaulted and capped.
#[must_use]
pub fn effective_top(&self) -> usize {
self.top.unwrap_or(DEFAULT_TOP).clamp(1, MAX_TOP)
}

/// The effective offset.
#[must_use]
pub fn effective_skip(&self) -> usize {
self.skip.unwrap_or(0)
}

/// Apply paging to a collection.
#[must_use]
pub fn paginate<T: Clone>(&self, all: &[T]) -> Page<T> {
let items = all
.iter()
.skip(self.effective_skip())
.take(self.effective_top())
.cloned()
.collect();
Page {
items,
total: all.len(),
}
}
}

const fn default_true() -> bool {
true
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn top_defaults_to_fifty_and_caps_at_one_hundred() {
let p = ListParams {
top: None,
skip: None,
};
assert_eq!(p.effective_top(), 50);
assert_eq!(p.effective_skip(), 0);

let p = ListParams {
top: Some(1000),
skip: Some(3),
};
assert_eq!(p.effective_top(), 100);
assert_eq!(p.effective_skip(), 3);
}

#[test]
fn paging_slices_and_reports_the_full_total() {
let all: Vec<u32> = (0..10).collect();
let p = ListParams {
top: Some(3),
skip: Some(8),
};
let page = p.paginate(&all);
assert_eq!(page.items, vec![8, 9]);
assert_eq!(page.total, 10);
}

#[test]
fn an_upstream_write_accepts_a_plaintext_endpoint() {
let w: UpstreamWrite = serde_json::from_value(serde_json::json!({
"server": {"endpoints": [{"scheme": "http", "host": "example.com", "port": 80}]},
"protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"
}))
.unwrap();
assert!(w.enabled);
assert!(w.alias.is_none());
assert_eq!(w.server.endpoints.len(), 1);
}

#[test]
fn an_upstream_write_rejects_an_unknown_field() {
let r: Result<UpstreamWrite, _> = serde_json::from_value(serde_json::json!({
"server": {"endpoints": [{"scheme": "http", "host": "e"}]},
"protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1",
"surprise": 1
}));
assert!(r.is_err());
}

#[test]
fn a_route_replace_has_no_upstream_id_field() {
let r: Result<RouteReplace, _> = serde_json::from_value(serde_json::json!({
"upstream_id": "00000000-0000-0000-0000-000000000000",
"match": {"http": {"methods": ["GET"], "path": "/"}}
}));
assert!(r.is_err(), "upstream_id is immutable and must be rejected");
}
}
Loading