use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const METHOD: &str = "POST";
pub const PATH: &str = "/saddle/v1/ingress/profusegw/invoke";
pub const MEDIA_TYPE: &str = "application/json";
pub const MAX_BODY_BYTES: usize = 1024 * 1024;
pub const MAX_ID_BYTES: usize = 256;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProfuseGwTarget {
pub app: String,
#[serde(rename = "interfaceId")]
pub interface_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProfuseGwUserInfo {
#[serde(rename = "userId")]
pub user_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProfuseGwTraceInfo {
#[serde(default)]
pub trace_id: String,
pub rpc_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProfuseGwLdcInfo {
pub zone: String,
pub idc: String,
pub env: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProfuseGwContext {
#[serde(rename = "userInfo")]
pub user_info: ProfuseGwUserInfo,
#[serde(rename = "traceInfo")]
pub trace_info: ProfuseGwTraceInfo,
#[serde(rename = "ldcInfo")]
pub ldc_info: ProfuseGwLdcInfo,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct IngressEnvelope {
pub target: ProfuseGwTarget,
#[serde(rename = "profuseGwContext")]
pub profuse_gw_context: ProfuseGwContext,
#[serde(rename = "requestData")]
pub request_data: Value,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IngressIdentity {
pub request_id: String,
pub call_id: String,
pub deadline_unix_ms: i64,
}
impl IngressIdentity {
pub fn new(
request_id: impl Into<String>,
call_id: impl Into<String>,
deadline_unix_ms: i64,
) -> Result<Self, CodecError> {
let identity = Self {
request_id: request_id.into(),
call_id: call_id.into(),
deadline_unix_ms,
};
validate_identity(&identity)?;
Ok(identity)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AcceptedIngress {
pub identity: IngressIdentity,
pub interface_id: String,
pub user_id: String,
pub trace_id: String,
pub rpc_id: String,
pub zone: String,
pub idc: String,
pub env: String,
pub request_data: Value,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CodecError {
pub http_status: u16,
pub code: &'static str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProfuseGwListenerAdapter {
application: String,
}
impl ProfuseGwListenerAdapter {
pub fn new(application: impl Into<String>) -> Result<Self, CodecError> {
let application = application.into();
validate_id(&application)?;
Ok(Self { application })
}
pub fn application(&self) -> &str {
&self.application
}
pub fn accept(
&self,
method: &str,
path: &str,
content_type: &str,
identity: IngressIdentity,
body: &[u8],
) -> Result<AcceptedIngress, CodecError> {
validate_identity(&identity)?;
let envelope = decode(method, path, content_type, body)?;
if envelope.target.app != self.application {
return Err(error(404, "APPLICATION_NOT_FOUND"));
}
let trace_id = if envelope.profuse_gw_context.trace_info.trace_id.is_empty() {
format!("saddle-{}", identity.request_id)
} else {
envelope.profuse_gw_context.trace_info.trace_id
};
Ok(AcceptedIngress {
identity,
interface_id: envelope.target.interface_id,
user_id: envelope.profuse_gw_context.user_info.user_id,
trace_id,
rpc_id: envelope.profuse_gw_context.trace_info.rpc_id,
zone: envelope.profuse_gw_context.ldc_info.zone,
idc: envelope.profuse_gw_context.ldc_info.idc,
env: envelope.profuse_gw_context.ldc_info.env,
request_data: envelope.request_data,
})
}
}
pub fn decode(
method: &str,
path: &str,
content_type: &str,
body: &[u8],
) -> Result<IngressEnvelope, CodecError> {
if method != METHOD {
return Err(error(405, "METHOD_NOT_ALLOWED"));
}
if path != PATH {
return Err(error(404, "PATH_NOT_FOUND"));
}
if content_type
.split(';')
.next()
.map(str::trim)
.filter(|value| value.eq_ignore_ascii_case(MEDIA_TYPE))
.is_none()
{
return Err(error(415, "MEDIA_TYPE_NOT_SUPPORTED"));
}
if body.len() > MAX_BODY_BYTES {
return Err(error(413, "BODY_TOO_LARGE"));
}
let envelope: IngressEnvelope =
serde_json::from_slice(body).map_err(|_| error(400, "INVALID_JSON_ENVELOPE"))?;
validate(&envelope)?;
Ok(envelope)
}
fn validate(envelope: &IngressEnvelope) -> Result<(), CodecError> {
validate_id(&envelope.target.app)?;
validate_id(&envelope.target.interface_id)?;
validate_id(&envelope.profuse_gw_context.user_info.user_id)?;
if !envelope.profuse_gw_context.trace_info.trace_id.is_empty() {
validate_trace_id(&envelope.profuse_gw_context.trace_info.trace_id)?;
}
validate_id(&envelope.profuse_gw_context.trace_info.rpc_id)?;
validate_id(&envelope.profuse_gw_context.ldc_info.zone)?;
validate_id(&envelope.profuse_gw_context.ldc_info.idc)?;
validate_id(&envelope.profuse_gw_context.ldc_info.env)?;
if !envelope.request_data.is_object() {
return Err(error(400, "INVALID_REQUEST_DATA"));
}
Ok(())
}
fn validate_identity(identity: &IngressIdentity) -> Result<(), CodecError> {
validate_id(&identity.request_id)?;
validate_id(&identity.call_id)?;
if identity.deadline_unix_ms <= 0 {
return Err(error(400, "INVALID_DEADLINE"));
}
Ok(())
}
fn validate_id(value: &str) -> Result<(), CodecError> {
if value.trim().is_empty() || value.len() > MAX_ID_BYTES {
return Err(error(400, "INVALID_IDENTITY"));
}
Ok(())
}
fn validate_trace_id(value: &str) -> Result<(), CodecError> {
if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_control) {
return Err(error(400, "INVALID_TRACE_ID"));
}
Ok(())
}
const fn error(http_status: u16, code: &'static str) -> CodecError {
CodecError { http_status, code }
}
#[cfg(test)]
mod tests {
use super::*;
const VALID: &[u8] = br#"{
"target":{"app":"bill-service","interfaceId":"bill.query"},
"profuseGwContext":{"userInfo":{"userId":"2088\u4e2d\u6587\u7528\u6237"},"traceInfo":{"traceId":"trace-1","rpcId":"0"},"ldcInfo":{"zone":"z1","idc":"i1","env":"test"}},
"requestData":{"account":"A-1"}
}"#;
fn identity() -> IngressIdentity {
IngressIdentity::new("request-1", "call-1", 1_800_000_000_000).unwrap()
}
#[test]
fn listener_accepts_fixed_app_and_preserves_identity() {
let accepted = ProfuseGwListenerAdapter::new("bill-service")
.unwrap()
.accept(METHOD, PATH, MEDIA_TYPE, identity(), VALID)
.unwrap();
assert_eq!(accepted.identity.request_id, "request-1");
assert_eq!(accepted.identity.call_id, "call-1");
assert_eq!(accepted.identity.deadline_unix_ms, 1_800_000_000_000);
assert_eq!(accepted.interface_id, "bill.query");
assert_eq!(accepted.user_id, "2088中文用户");
assert_eq!(accepted.trace_id, "trace-1");
assert_eq!(accepted.rpc_id, "0");
assert_eq!(accepted.zone, "z1");
assert_eq!(accepted.request_data["account"], "A-1");
}
#[test]
fn listener_generates_trace_only_when_ingress_omits_it() {
let body = String::from_utf8(VALID.to_vec())
.unwrap()
.replace("trace-1", "");
let accepted = ProfuseGwListenerAdapter::new("bill-service")
.unwrap()
.accept(METHOD, PATH, MEDIA_TYPE, identity(), body.as_bytes())
.unwrap();
assert_eq!(accepted.trace_id, "saddle-request-1");
assert_eq!(accepted.rpc_id, "0");
}
#[test]
fn trace_id_is_opaque_bounded_and_control_free() {
let opaque = String::from_utf8(VALID.to_vec())
.unwrap()
.replace("trace-1", "opaque/非HEX:值");
let accepted = ProfuseGwListenerAdapter::new("bill-service")
.unwrap()
.accept(METHOD, PATH, MEDIA_TYPE, identity(), opaque.as_bytes())
.unwrap();
assert_eq!(accepted.trace_id, "opaque/非HEX:值");
let control = String::from_utf8(VALID.to_vec())
.unwrap()
.replace("trace-1", "bad\\ttrace");
assert_eq!(
ProfuseGwListenerAdapter::new("bill-service")
.unwrap()
.accept(METHOD, PATH, MEDIA_TYPE, identity(), control.as_bytes())
.unwrap_err()
.code,
"INVALID_TRACE_ID"
);
let oversized = String::from_utf8(VALID.to_vec())
.unwrap()
.replace("trace-1", &"x".repeat(MAX_ID_BYTES + 1));
assert_eq!(
ProfuseGwListenerAdapter::new("bill-service")
.unwrap()
.accept(METHOD, PATH, MEDIA_TYPE, identity(), oversized.as_bytes())
.unwrap_err()
.code,
"INVALID_TRACE_ID"
);
}
#[test]
fn codec_rejects_old_shape_and_foreign_app() {
let old = br#"{"protocol":"saddle-profusegw/1","request_id":"r"}"#;
let foreign_app = String::from_utf8(VALID.to_vec())
.unwrap()
.replace("bill-service", "other-app");
assert_eq!(
decode(METHOD, PATH, MEDIA_TYPE, old)
.unwrap_err()
.http_status,
400
);
assert_eq!(
ProfuseGwListenerAdapter::new("bill-service")
.unwrap()
.accept(METHOD, PATH, MEDIA_TYPE, identity(), foreign_app.as_bytes())
.unwrap_err()
.code,
"APPLICATION_NOT_FOUND"
);
}
#[test]
fn framing_and_identity_fail_before_acceptance() {
for (method, path, media_type, expected) in [
("GET", PATH, MEDIA_TYPE, 405),
(METHOD, "/business/route", MEDIA_TYPE, 404),
(METHOD, PATH, "text/plain", 415),
] {
assert_eq!(
decode(method, path, media_type, VALID)
.unwrap_err()
.http_status,
expected
);
}
assert_eq!(
IngressIdentity::new("", "call-1", 1).unwrap_err().code,
"INVALID_IDENTITY"
);
}
#[test]
fn context_overlay_and_oversized_body_are_rejected() {
let overlay = br#"{
"target":{"app":"bill-service","interfaceId":"bill.query"},
"profuseGwContext":{"userInfo":{"userId":"2088"},"headers":{}},
"requestData":{}
}"#;
assert_eq!(
decode(METHOD, PATH, MEDIA_TYPE, overlay)
.unwrap_err()
.http_status,
400
);
assert_eq!(
decode(METHOD, PATH, MEDIA_TYPE, &vec![b' '; MAX_BODY_BYTES + 1])
.unwrap_err()
.http_status,
413
);
}
}