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
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.

pub mod rest;
89 changes: 89 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,89 @@
//! REST representations that are not simply the domain aggregate.

use serde::Serialize;
use uuid::Uuid;

use crate::domain::model::{PluginDef, PluginKind, PluginPhase};

/// Envelope returned by every list endpoint.
///
/// `total` is the number of matches *before* `$top`/`$skip`, so a client can
/// page without a second request.
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct ListResponse {
#[schema(value_type = Vec<Object>)]
pub items: Vec<serde_json::Value>,
pub total: usize,
}

impl ListResponse {
#[must_use]
pub fn new(items: Vec<serde_json::Value>, total: usize) -> Self {
Self { items, total }
}
}

/// A custom plugin definition, without its source.
///
/// The source is served separately by `GET /oagw/v1/plugins/{id}/source` so a
/// listing never carries potentially large script bodies.
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct PluginResponse {
pub id: Uuid,
pub tenant_id: Uuid,
/// Anonymous GTS identifier, e.g. `gts.cf.core.oagw.guard_plugin.v1~{uuid}`.
pub gts_id: String,
pub plugin_type: PluginKind,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub phases: Vec<PluginPhase>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Object)]
pub config_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gc_eligible_at: Option<u64>,
}

impl From<&PluginDef> for PluginResponse {
fn from(plugin: &PluginDef) -> Self {
Self {
id: plugin.id,
tenant_id: plugin.tenant_id,
gts_id: plugin.gts_id(),
plugin_type: plugin.plugin_type,
name: plugin.name.clone(),
description: plugin.description.clone(),
phases: plugin.phases.clone(),
config_schema: plugin.config_schema.clone(),
last_used_at: plugin.last_used_at,
gc_eligible_at: plugin.gc_eligible_at,
}
}
}

/// Body of `GET /oagw/v1/plugins/{id}/source`.
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct PluginSourceResponse {
pub id: Uuid,
pub gts_id: String,
pub name: String,
pub source_code: String,
}

impl From<&PluginDef> for PluginSourceResponse {
fn from(plugin: &PluginDef) -> Self {
Self {
id: plugin.id,
gts_id: plugin.gts_id(),
name: plugin.name.clone(),
source_code: plugin.source_code.clone(),
}
}
}

impl toolkit::api::api_dto::ResponseApiDto for ListResponse {}
impl toolkit::api::api_dto::ResponseApiDto for PluginResponse {}
impl toolkit::api::api_dto::ResponseApiDto for PluginSourceResponse {}
108 changes: 108 additions & 0 deletions gears/system/oagw/oagw/src/api/rest/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! Rendering of gateway errors onto the wire.

use axum::response::{IntoResponse, Response};
use http::{HeaderName, HeaderValue, StatusCode, header};

use crate::domain::error::{
ERROR_SOURCE_GATEWAY, ERROR_SOURCE_UPSTREAM, OagwError, PROBLEM_JSON,
};

/// `X-OAGW-Error-Source` as a header name.
pub const ERROR_SOURCE: HeaderName = HeaderName::from_static("x-oagw-error-source");

/// Render `error` as RFC 9457 problem details anchored at `instance`.
#[must_use]
pub fn problem_response(error: &OagwError, instance: Option<&str>) -> Response {
let status =
StatusCode::from_u16(error.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let problem = error.to_problem(instance);
let body = serde_json::to_vec(&problem).unwrap_or_else(|_| {
// Serializing a problem document cannot realistically fail; if it
// does, still answer with a valid problem body rather than an empty
// one.
br#"{"type":"about:blank","title":"Internal Error","status":500,"detail":"error serialization failed"}"#
.to_vec()
});

let mut response = (
status,
[(header::CONTENT_TYPE, HeaderValue::from_static(PROBLEM_JSON))],
body,
)
.into_response();

mark_gateway(&mut response);
for (name, value) in &error.headers {
if let (Ok(name), Ok(value)) = (
HeaderName::try_from(name.to_ascii_lowercase()),
HeaderValue::from_str(value),
) {
response.headers_mut().insert(name, value);
}
}
if let Some(seconds) = error.retry_after_seconds {
response
.headers_mut()
.insert(header::RETRY_AFTER, HeaderValue::from(seconds));
}
if error.kind.retriable() {
response.headers_mut().insert(
HeaderName::from_static("x-oagw-retriable"),
HeaderValue::from_static("true"),
);
}
response
}

/// Stamp a response as gateway-originated.
pub fn mark_gateway(response: &mut Response) {
response
.headers_mut()
.insert(ERROR_SOURCE, HeaderValue::from_static(ERROR_SOURCE_GATEWAY));
}

/// Stamp a response as relayed from the upstream.
pub fn mark_upstream(response: &mut Response) {
response
.headers_mut()
.insert(ERROR_SOURCE, HeaderValue::from_static(ERROR_SOURCE_UPSTREAM));
}

/// Wrapper that carries the request URI so `instance` can be filled in.
#[derive(Debug)]
pub struct ApiError {
pub inner: OagwError,
pub instance: Option<String>,
}

impl ApiError {
#[must_use]
pub fn new(inner: OagwError, instance: impl Into<String>) -> Self {
Self {
inner,
instance: Some(instance.into()),
}
}
}

impl From<OagwError> for ApiError {
fn from(inner: OagwError) -> Self {
Self {
inner,
instance: None,
}
}
}

impl IntoResponse for ApiError {
fn into_response(self) -> Response {
problem_response(&self.inner, self.instance.as_deref())
}
}

/// Result alias for the management handlers.
pub type ApiResult<T> = Result<T, ApiError>;

#[cfg(test)]
#[path = "error_tests.rs"]
mod tests;
72 changes: 72 additions & 0 deletions gears/system/oagw/oagw/src/api/rest/error_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//! How a gateway error reaches the wire.

use super::*;
use crate::domain::error::ErrorKind;
use axum::body::to_bytes;

async fn body_json(response: Response) -> serde_json::Value {
let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}

#[tokio::test]
async fn a_problem_response_is_rfc_9457_and_marked_gateway() {
let error = OagwError::new(ErrorKind::RouteNotFound, "no route here");
let response = problem_response(&error, Some("/oagw/v1/proxy/x/y"));

assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"application/problem+json"
);
assert_eq!(response.headers().get(ERROR_SOURCE).unwrap(), "gateway");

let json = body_json(response).await;
assert_eq!(json["status"], 404);
assert_eq!(json["instance"], "/oagw/v1/proxy/x/y");
}

#[tokio::test]
async fn a_retriable_error_advertises_retry_after_and_the_retriable_marker() {
let error = OagwError::new(ErrorKind::RateLimitExceeded, "slow down").with_retry_after(30);
let response = problem_response(&error, None);

assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(response.headers().get(header::RETRY_AFTER).unwrap(), "30");
assert_eq!(response.headers().get("x-oagw-retriable").unwrap(), "true");
}

#[tokio::test]
async fn a_non_retriable_error_carries_no_retriable_marker() {
let response = problem_response(&OagwError::validation("bad"), None);
assert!(response.headers().get("x-oagw-retriable").is_none());
assert!(response.headers().get(header::RETRY_AFTER).is_none());
}

#[tokio::test]
async fn attached_headers_reach_the_response() {
let error = OagwError::new(ErrorKind::RateLimitExceeded, "slow down")
.with_header("X-RateLimit-Limit", "100")
.with_header("X-RateLimit-Remaining", "0");
let response = problem_response(&error, None);
assert_eq!(response.headers().get("x-ratelimit-limit").unwrap(), "100");
assert_eq!(response.headers().get("x-ratelimit-remaining").unwrap(), "0");
}

#[tokio::test]
async fn an_api_error_renders_through_into_response() {
let response = ApiError::new(OagwError::conflict("taken"), "/oagw/v1/upstreams")
.into_response();
assert_eq!(response.status(), StatusCode::CONFLICT);
let json = body_json(response).await;
assert_eq!(json["instance"], "/oagw/v1/upstreams");
}

#[test]
fn responses_can_be_marked_as_relayed_from_the_upstream() {
let mut response = StatusCode::OK.into_response();
mark_upstream(&mut response);
assert_eq!(response.headers().get(ERROR_SOURCE).unwrap(), "upstream");
mark_gateway(&mut response);
assert_eq!(response.headers().get(ERROR_SOURCE).unwrap(), "gateway");
}
Loading