use crate::model::{Field, MetaMap, Type};
use convert_case::{Case, Casing};
use eyre::{ContextCompat, Result};
use serde::de::{Error, Unexpected};
use serde::ser::SerializeStruct;
use serde::*;
use std::fmt::Write;
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[non_exhaustive]
pub struct EndpointSchema {
pub name: String,
pub code: u32,
pub parameters: Vec<Field>,
pub returns: Vec<Field>,
#[serde(default)]
pub stream_response: Option<Type>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub json_schema: serde_json::Value,
pub roles: Vec<String>,
#[serde(default)]
pub errors: Vec<EndpointErrorSchema>,
#[serde(default, skip_serializing_if = "MetaMap::is_empty")]
pub meta: MetaMap,
}
impl EndpointSchema {
pub fn new(
name: impl Into<String>,
code: u32,
parameters: Vec<Field>,
returns: Vec<Field>,
) -> Self {
Self {
name: name.into(),
code,
parameters,
returns,
stream_response: None,
description: "".to_string(),
json_schema: Default::default(),
roles: Vec::new(),
errors: Vec::new(),
meta: MetaMap::default(),
}
}
#[must_use]
pub fn with_meta(mut self, meta: MetaMap) -> Self {
self.meta = meta;
self
}
pub fn with_stream_response_type(mut self, stream_response: Type) -> Self {
self.stream_response = Some(stream_response);
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = desc.into();
self
}
pub fn with_roles(mut self, roles: Vec<String>) -> Self {
self.roles = roles;
self
}
pub fn with_errors(mut self, errors: Vec<EndpointErrorSchema>) -> Self {
self.errors = errors;
self
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, PartialOrd, Eq, Ord)]
#[non_exhaustive]
pub struct EndpointErrorSchema {
pub name: String,
pub code: EndpointErrorCodeRef,
#[serde(default)]
pub message: String,
#[serde(default)]
pub fields: Vec<Field>,
}
impl EndpointErrorSchema {
pub fn new(name: impl Into<String>, code: EndpointErrorCodeRef) -> Self {
Self {
name: name.into(),
code,
message: String::new(),
fields: Vec::new(),
}
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = message.into();
self
}
#[must_use]
pub fn with_fields(mut self, fields: Vec<Field>) -> Self {
self.fields = fields;
self
}
}
#[derive(Clone, Debug, Hash, PartialEq, PartialOrd, Eq, Ord)]
pub struct EndpointErrorCodeRef {
pub ty: Type,
pub variant: String,
}
impl EndpointErrorCodeRef {
pub const ENUM_NAME: &'static str = "ErrorCode";
pub fn new(variant: impl Into<String>) -> Self {
Self {
ty: Type::enum_ref(Self::ENUM_NAME, true),
variant: variant.into(),
}
}
pub fn variant(&self) -> &str {
&self.variant
}
pub fn path(&self) -> String {
format!("{}::{}", Self::ENUM_NAME, self.variant)
}
fn validate_ty(ty: Type) -> std::result::Result<Type, String> {
match &ty {
Type::EnumRef { name, .. } if name == Self::ENUM_NAME => Ok(ty),
Type::EnumRef { name, .. } => {
Err(format!("expected {} enum ref, got {name}", Self::ENUM_NAME))
}
_ => Err(format!("expected {} enum ref", Self::ENUM_NAME)),
}
}
fn parse_path(path: &str) -> std::result::Result<Self, String> {
let (enum_name, variant) = path
.split_once("::")
.ok_or_else(|| format!("expected {}::Variant", Self::ENUM_NAME))?;
if enum_name != Self::ENUM_NAME {
return Err(format!(
"expected {} enum path, got {enum_name}",
Self::ENUM_NAME
));
}
if variant.is_empty() || variant.contains("::") {
return Err(format!("expected {}::Variant", Self::ENUM_NAME));
}
Ok(Self::new(variant))
}
}
impl std::fmt::Display for EndpointErrorCodeRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.path())
}
}
impl Serialize for EndpointErrorCodeRef {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut state = serializer.serialize_struct("EndpointErrorCodeRef", 2)?;
state.serialize_field("ty", &self.ty)?;
state.serialize_field("variant", &self.variant)?;
state.end()
}
}
impl<'de> Deserialize<'de> for EndpointErrorCodeRef {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Helper {
Path(String),
Structured { ty: Type, variant: String },
}
match Helper::deserialize(deserializer)? {
Helper::Path(path) => Self::parse_path(&path)
.map_err(|err| D::Error::invalid_value(Unexpected::Str(&path), &err.as_str())),
Helper::Structured { ty, variant } => {
let ty = Self::validate_ty(ty).map_err(D::Error::custom)?;
Ok(Self { ty, variant })
}
}
}
}
pub fn encode_header<T: Serialize>(v: T, schema: EndpointSchema) -> Result<String> {
let mut s = String::new();
write!(s, "0{}", schema.name.to_ascii_lowercase())?;
let v = serde_json::to_value(&v)?;
for (i, f) in schema.parameters.iter().enumerate() {
let key = f.name.to_case(Case::Camel);
let value = v.get(&key).with_context(|| format!("key: {key}"))?;
if value.is_null() {
continue;
}
write!(
s,
", {}{}",
i + 1,
urlencoding::encode(&value.to_string().replace('\"', ""))
)?;
}
Ok(s)
}
#[cfg(test)]
mod forward_compat_tests {
use super::*;
use crate::model::MetaMap;
#[test]
fn unknown_future_fields_are_ignored() {
let json = r#"{
"name": "UserListSymbols",
"code": 10020,
"parameters": [],
"returns": [],
"roles": ["Admin"],
"x_future_openapi_binding": {"path": "/rpc/UserListSymbols"},
"some_field_from_2_5": 42
}"#;
let schema: EndpointSchema =
serde_json::from_str(json).expect("unknown fields must not break deserialization");
assert_eq!(schema.name, "UserListSymbols");
assert_eq!(schema.code, 10020);
assert!(schema.meta.is_empty());
}
#[test]
fn absent_meta_defaults_empty() {
let json = r#"{"name":"A","code":1,"parameters":[],"returns":[],"roles":[]}"#;
let schema: EndpointSchema = serde_json::from_str(json).unwrap();
assert!(schema.meta.is_empty());
}
#[test]
fn meta_round_trips_including_unknown_keys() {
let mut meta = MetaMap::default();
meta.insert("example", serde_json::json!({"symbol": "BTC"}));
meta.insert("x-openapi-tags", serde_json::json!(["trading"]));
meta.insert("deprecated", serde_json::json!(true));
let schema = EndpointSchema::new("A", 1, vec![], vec![]).with_meta(meta.clone());
let text = serde_json::to_string(&schema).unwrap();
let back: EndpointSchema = serde_json::from_str(&text).unwrap();
assert_eq!(back.meta, meta);
assert_eq!(
back.meta.get("x-openapi-tags"),
Some(&serde_json::json!(["trading"]))
);
}
#[test]
fn empty_meta_is_not_serialized() {
let schema = EndpointSchema::new("A", 1, vec![], vec![]);
let text = serde_json::to_string(&schema).unwrap();
assert!(
!text.contains("meta"),
"empty meta leaked into output: {text}"
);
let field = Field::new("x", Type::String);
let text = serde_json::to_string(&field).unwrap();
assert!(!text.contains("meta"), "empty field meta leaked: {text}");
}
#[test]
fn field_meta_round_trips_and_preserves_derives() {
let mut meta = MetaMap::default();
meta.insert("minimum", serde_json::json!(0));
let field = Field::new("amount", Type::Int64).with_meta(meta);
let back: Field = serde_json::from_str(&serde_json::to_string(&field).unwrap()).unwrap();
assert_eq!(back.meta.get("minimum"), Some(&serde_json::json!(0)));
let mut set = std::collections::BTreeSet::new();
set.insert(field.clone());
assert!(set.contains(&field));
assert_ne!(field, Field::new("amount", Type::Int64));
}
}