diff --git a/Cargo.lock b/Cargo.lock index 720245c..4b08536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8884,6 +8884,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "clap", + "futures", "rain-math-float", "rain_orderbook_bindings", "rain_orderbook_common", diff --git a/Cargo.toml b/Cargo.toml index a45f199..dafad0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ utoipa-swagger-ui = { version = "9", features = ["rocket"] } tokio = { version = "1", features = ["full"] } alloy = { version = "=1.0.12", default-features = false, features = ["std", "serde"] } async-trait = "0.1" +futures = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" diff --git a/lib/rain.orderbook b/lib/rain.orderbook index d9fff37..2625096 160000 --- a/lib/rain.orderbook +++ b/lib/rain.orderbook @@ -1 +1 @@ -Subproject commit d9fff371d75d9d3d25087d20c77209b430a5c248 +Subproject commit 26250966bbc8bac8895ad236defbc4c7851a0fc1 diff --git a/src/main.rs b/src/main.rs index a1e4885..e4121e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,6 +61,7 @@ enum StartupError { routes::order::post_order_cancel, routes::orders::get_orders_by_tx, routes::orders::get_orders_by_address, + routes::orders::get_orders_by_token, routes::trades::get_trades_by_tx, routes::trades::get_trades_by_address, routes::registry::get_registry, diff --git a/src/routes/order/mod.rs b/src/routes/order/mod.rs index 620ed23..f3898ba 100644 --- a/src/routes/order/mod.rs +++ b/src/routes/order/mod.rs @@ -34,13 +34,15 @@ impl<'a> OrderDataSource for RaindexOrderDataSource<'a> { order_hash: Some(hash), ..Default::default() }; - self.client - .get_orders(None, Some(filters), None) + let result = self + .client + .get_orders(None, Some(filters), None, None) .await .map_err(|e| { tracing::error!(error = %e, "failed to query orders"); ApiError::Internal("failed to query orders".into()) - }) + })?; + Ok(result.orders().to_vec()) } async fn get_order_quotes( diff --git a/src/routes/orders.rs b/src/routes/orders.rs deleted file mode 100644 index a7159b8..0000000 --- a/src/routes/orders.rs +++ /dev/null @@ -1,87 +0,0 @@ -use crate::auth::AuthenticatedKey; -use crate::error::{ApiError, ApiErrorResponse}; -use crate::fairings::{GlobalRateLimit, TracingSpan}; -use crate::types::common::{ValidatedAddress, ValidatedFixedBytes}; -use crate::types::orders::{OrdersByTxResponse, OrdersListResponse, OrdersPaginationParams}; -use rocket::serde::json::Json; -use rocket::{Route, State}; -use tracing::Instrument; - -#[utoipa::path( - get, - path = "/v1/orders/tx/{tx_hash}", - tag = "Orders", - security(("basicAuth" = [])), - params( - ("tx_hash" = String, Path, description = "Transaction hash"), - ), - responses( - (status = 200, description = "Orders from transaction", body = OrdersByTxResponse), - (status = 202, description = "Transaction not yet indexed", body = ApiErrorResponse), - (status = 401, description = "Unauthorized", body = ApiErrorResponse), - (status = 429, description = "Rate limited", body = ApiErrorResponse), - (status = 404, description = "Transaction not found", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), - ) -)] -#[get("/tx/")] -pub async fn get_orders_by_tx( - _global: GlobalRateLimit, - _key: AuthenticatedKey, - shared_raindex: &State, - span: TracingSpan, - tx_hash: ValidatedFixedBytes, -) -> Result, ApiError> { - async move { - tracing::info!(tx_hash = ?tx_hash, "request received"); - let raindex = shared_raindex.read().await; - raindex - .run_with_client(move |_client| async move { todo!() }) - .await - .map_err(ApiError::from)? - } - .instrument(span.0) - .await -} - -#[utoipa::path( - get, - path = "/v1/orders/{address}", - tag = "Orders", - security(("basicAuth" = [])), - params( - ("address" = String, Path, description = "Owner address"), - OrdersPaginationParams, - ), - responses( - (status = 200, description = "Paginated list of orders", body = OrdersListResponse), - (status = 400, description = "Bad request", body = ApiErrorResponse), - (status = 401, description = "Unauthorized", body = ApiErrorResponse), - (status = 429, description = "Rate limited", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), - ) -)] -#[get("/
?", rank = 2)] -pub async fn get_orders_by_address( - _global: GlobalRateLimit, - _key: AuthenticatedKey, - shared_raindex: &State, - span: TracingSpan, - address: ValidatedAddress, - params: OrdersPaginationParams, -) -> Result, ApiError> { - async move { - tracing::info!(address = ?address, params = ?params, "request received"); - let raindex = shared_raindex.read().await; - raindex - .run_with_client(move |_client| async move { todo!() }) - .await - .map_err(ApiError::from)? - } - .instrument(span.0) - .await -} - -pub fn routes() -> Vec { - rocket::routes![get_orders_by_tx, get_orders_by_address] -} diff --git a/src/routes/orders/get_by_owner.rs b/src/routes/orders/get_by_owner.rs new file mode 100644 index 0000000..3c8e454 --- /dev/null +++ b/src/routes/orders/get_by_owner.rs @@ -0,0 +1,46 @@ +use crate::auth::AuthenticatedKey; +use crate::error::{ApiError, ApiErrorResponse}; +use crate::fairings::{GlobalRateLimit, TracingSpan}; +use crate::types::common::ValidatedAddress; +use crate::types::orders::{OrdersListResponse, OrdersPaginationParams}; +use rocket::serde::json::Json; +use rocket::State; +use tracing::Instrument; + +#[utoipa::path( + get, + path = "/v1/orders/owner/{address}", + tag = "Orders", + security(("basicAuth" = [])), + params( + ("address" = String, Path, description = "Owner address"), + OrdersPaginationParams, + ), + responses( + (status = 200, description = "Paginated list of orders", body = OrdersListResponse), + (status = 400, description = "Bad request", body = ApiErrorResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 429, description = "Rate limited", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ) +)] +#[get("/owner/
?")] +pub async fn get_orders_by_address( + _global: GlobalRateLimit, + _key: AuthenticatedKey, + shared_raindex: &State, + span: TracingSpan, + address: ValidatedAddress, + params: OrdersPaginationParams, +) -> Result, ApiError> { + async move { + tracing::info!(address = ?address, params = ?params, "request received"); + let raindex = shared_raindex.read().await; + raindex + .run_with_client(move |_client| async move { todo!() }) + .await + .map_err(ApiError::from)? + } + .instrument(span.0) + .await +} diff --git a/src/routes/orders/get_by_token.rs b/src/routes/orders/get_by_token.rs new file mode 100644 index 0000000..c3fae10 --- /dev/null +++ b/src/routes/orders/get_by_token.rs @@ -0,0 +1,277 @@ +use super::{ + build_order_summary, build_pagination, OrdersListDataSource, RaindexOrdersListDataSource, + DEFAULT_PAGE_SIZE, +}; +use crate::auth::AuthenticatedKey; +use crate::error::{ApiError, ApiErrorResponse}; +use crate::fairings::{GlobalRateLimit, TracingSpan}; +use crate::types::common::ValidatedAddress; +use crate::types::orders::{OrderSide, OrdersByTokenParams, OrdersListResponse}; +use alloy::primitives::Address; +use futures::future::join_all; +use rain_orderbook_common::raindex_client::orders::GetOrdersFilters; +use rain_orderbook_common::raindex_client::orders::GetOrdersTokenFilter; +use rocket::serde::json::Json; +use rocket::State; +use tracing::Instrument; + +pub(crate) async fn process_get_orders_by_token( + ds: &dyn OrdersListDataSource, + address: Address, + side: Option, + page: Option, + page_size: Option, +) -> Result { + let token_filter = match side { + Some(OrderSide::Input) => GetOrdersTokenFilter { + inputs: Some(vec![address]), + outputs: None, + }, + Some(OrderSide::Output) => GetOrdersTokenFilter { + inputs: None, + outputs: Some(vec![address]), + }, + None => GetOrdersTokenFilter { + inputs: Some(vec![address]), + outputs: Some(vec![address]), + }, + }; + + let filters = GetOrdersFilters { + active: Some(true), + tokens: Some(token_filter), + ..Default::default() + }; + + let page_num = page.unwrap_or(1); + let effective_page_size = page_size.unwrap_or(DEFAULT_PAGE_SIZE as u16); + let (orders, total_count) = ds + .get_orders_list(filters, Some(page_num), Some(effective_page_size)) + .await?; + + let quote_futures: Vec<_> = orders.iter().map(|o| ds.get_order_quotes(o)).collect(); + let quote_results = join_all(quote_futures).await; + + let mut summaries = Vec::with_capacity(orders.len()); + for (order, quotes_result) in orders.iter().zip(quote_results) { + let io_ratio = match quotes_result { + Ok(quotes) => quotes + .first() + .and_then(|q| q.data.as_ref()) + .map(|d| d.formatted_ratio.clone()) + .unwrap_or_else(|| "-".into()), + Err(err) => { + tracing::warn!( + order_hash = ?order.order_hash(), + error = ?err, + "quote fetch failed; using fallback io_ratio" + ); + "-".into() + } + }; + summaries.push(build_order_summary(order, &io_ratio)?); + } + + let pagination = build_pagination(total_count, page_num.into(), effective_page_size.into()); + Ok(OrdersListResponse { + orders: summaries, + pagination, + }) +} + +#[utoipa::path( + get, + path = "/v1/orders/token/{address}", + tag = "Orders", + security(("basicAuth" = [])), + params( + ("address" = String, Path, description = "Token address"), + OrdersByTokenParams, + ), + responses( + (status = 200, description = "Paginated list of orders for token", body = OrdersListResponse), + (status = 400, description = "Bad request", body = ApiErrorResponse), + (status = 422, description = "Unprocessable entity", body = ApiErrorResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 429, description = "Rate limited", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ) +)] +#[get("/token/
?")] +pub async fn get_orders_by_token( + _global: GlobalRateLimit, + _key: AuthenticatedKey, + shared_raindex: &State, + span: TracingSpan, + address: ValidatedAddress, + params: OrdersByTokenParams, +) -> Result, ApiError> { + async move { + tracing::info!(address = ?address, params = ?params, "request received"); + let addr = address.0; + let side = params.side; + let page = params.page; + let page_size = params.page_size; + let raindex = shared_raindex.read().await; + let response = raindex + .run_with_client(move |client| async move { + let ds = RaindexOrdersListDataSource { client: &client }; + process_get_orders_by_token(&ds, addr, side, page, page_size).await + }) + .await + .map_err(ApiError::from)??; + Ok(Json(response)) + } + .instrument(span.0) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::routes::order::test_fixtures::{mock_order, mock_quote}; + use crate::routes::orders::test_fixtures::MockOrdersListDataSource; + use crate::test_helpers::{ + basic_auth_header, mock_invalid_raindex_config, seed_api_key, TestClientBuilder, + }; + use rocket::http::{Header, Status}; + + #[rocket::async_test] + async fn test_process_get_orders_by_token_success() { + let ds = MockOrdersListDataSource { + orders: Ok(vec![mock_order()]), + total_count: 1, + quotes: Ok(vec![mock_quote("1.5")]), + }; + let addr: Address = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + .parse() + .unwrap(); + let result = process_get_orders_by_token(&ds, addr, None, None, None) + .await + .unwrap(); + + assert_eq!(result.orders.len(), 1); + assert_eq!(result.orders[0].input_token.symbol, "USDC"); + assert_eq!(result.orders[0].output_token.symbol, "WETH"); + assert_eq!(result.orders[0].io_ratio, "1.5"); + assert_eq!(result.pagination.total_orders, 1); + assert_eq!(result.pagination.page, 1); + assert!(!result.pagination.has_more); + } + + #[rocket::async_test] + async fn test_process_get_orders_by_token_empty() { + let ds = MockOrdersListDataSource { + orders: Ok(vec![]), + total_count: 0, + quotes: Ok(vec![]), + }; + let addr: Address = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + .parse() + .unwrap(); + let result = process_get_orders_by_token(&ds, addr, Some(OrderSide::Input), None, None) + .await + .unwrap(); + + assert!(result.orders.is_empty()); + assert_eq!(result.pagination.total_orders, 0); + assert_eq!(result.pagination.total_pages, 0); + } + + #[rocket::async_test] + async fn test_process_get_orders_by_token_quote_failure_shows_dash() { + let ds = MockOrdersListDataSource { + orders: Ok(vec![mock_order()]), + total_count: 1, + quotes: Err(ApiError::Internal("quote error".into())), + }; + let addr: Address = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + .parse() + .unwrap(); + let result = process_get_orders_by_token(&ds, addr, None, None, None) + .await + .unwrap(); + + assert_eq!(result.orders[0].io_ratio, "-"); + } + + #[rocket::async_test] + async fn test_process_get_orders_by_token_query_failure() { + let ds = MockOrdersListDataSource { + orders: Err(ApiError::Internal("failed".into())), + total_count: 0, + quotes: Ok(vec![]), + }; + let addr: Address = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + .parse() + .unwrap(); + let result = process_get_orders_by_token(&ds, addr, None, None, None).await; + assert!(matches!(result, Err(ApiError::Internal(_)))); + } + + #[rocket::async_test] + async fn test_pagination_math() { + use super::super::build_pagination; + + let p = build_pagination(250, 1, 100); + assert_eq!(p.total_orders, 250); + assert_eq!(p.total_pages, 3); + assert!(p.has_more); + + let p = build_pagination(250, 3, 100); + assert_eq!(p.total_pages, 3); + assert!(!p.has_more); + + let p = build_pagination(0, 1, 100); + assert_eq!(p.total_pages, 0); + assert!(!p.has_more); + + let p = build_pagination(100, 1, 100); + assert_eq!(p.total_pages, 1); + assert!(!p.has_more); + + let p = build_pagination(101, 1, 100); + assert_eq!(p.total_pages, 2); + assert!(p.has_more); + } + + #[rocket::async_test] + async fn test_get_orders_by_token_401_without_auth() { + let client = TestClientBuilder::new().build().await; + let response = client + .get("/v1/orders/token/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913") + .dispatch() + .await; + assert_eq!(response.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn test_get_orders_by_token_500_when_client_init_fails() { + let config = mock_invalid_raindex_config().await; + let client = TestClientBuilder::new() + .raindex_config(config) + .build() + .await; + let (key_id, secret) = seed_api_key(&client).await; + let header = basic_auth_header(&key_id, &secret); + let response = client + .get("/v1/orders/token/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913") + .header(Header::new("Authorization", header)) + .dispatch() + .await; + assert_eq!(response.status(), Status::InternalServerError); + } + + #[rocket::async_test] + async fn test_get_orders_by_token_invalid_address_returns_404() { + let client = TestClientBuilder::new().build().await; + let (key_id, secret) = seed_api_key(&client).await; + let header = basic_auth_header(&key_id, &secret); + let response = client + .get("/v1/orders/token/not-an-address") + .header(Header::new("Authorization", header)) + .dispatch() + .await; + assert_eq!(response.status(), Status::UnprocessableEntity); + } +} diff --git a/src/routes/orders/get_by_tx.rs b/src/routes/orders/get_by_tx.rs new file mode 100644 index 0000000..34a4052 --- /dev/null +++ b/src/routes/orders/get_by_tx.rs @@ -0,0 +1,45 @@ +use crate::auth::AuthenticatedKey; +use crate::error::{ApiError, ApiErrorResponse}; +use crate::fairings::{GlobalRateLimit, TracingSpan}; +use crate::types::common::ValidatedFixedBytes; +use crate::types::orders::OrdersByTxResponse; +use rocket::serde::json::Json; +use rocket::State; +use tracing::Instrument; + +#[utoipa::path( + get, + path = "/v1/orders/tx/{tx_hash}", + tag = "Orders", + security(("basicAuth" = [])), + params( + ("tx_hash" = String, Path, description = "Transaction hash"), + ), + responses( + (status = 200, description = "Orders from transaction", body = OrdersByTxResponse), + (status = 202, description = "Transaction not yet indexed", body = ApiErrorResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 429, description = "Rate limited", body = ApiErrorResponse), + (status = 404, description = "Transaction not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ) +)] +#[get("/tx/")] +pub async fn get_orders_by_tx( + _global: GlobalRateLimit, + _key: AuthenticatedKey, + shared_raindex: &State, + span: TracingSpan, + tx_hash: ValidatedFixedBytes, +) -> Result, ApiError> { + async move { + tracing::info!(tx_hash = ?tx_hash, "request received"); + let raindex = shared_raindex.read().await; + raindex + .run_with_client(move |_client| async move { todo!() }) + .await + .map_err(ApiError::from)? + } + .instrument(span.0) + .await +} diff --git a/src/routes/orders/mod.rs b/src/routes/orders/mod.rs new file mode 100644 index 0000000..0f774dd --- /dev/null +++ b/src/routes/orders/mod.rs @@ -0,0 +1,171 @@ +mod get_by_owner; +mod get_by_token; +mod get_by_tx; + +use crate::error::ApiError; +use crate::types::common::TokenRef; +use crate::types::orders::{OrderSummary, OrdersPagination}; +use async_trait::async_trait; +use rain_orderbook_common::raindex_client::order_quotes::RaindexOrderQuote; +use rain_orderbook_common::raindex_client::orders::{GetOrdersFilters, RaindexOrder}; +use rain_orderbook_common::raindex_client::RaindexClient; +use rocket::Route; + +pub(crate) const DEFAULT_PAGE_SIZE: u32 = 20; + +#[async_trait(?Send)] +pub(crate) trait OrdersListDataSource { + async fn get_orders_list( + &self, + filters: GetOrdersFilters, + page: Option, + page_size: Option, + ) -> Result<(Vec, u32), ApiError>; + + async fn get_order_quotes( + &self, + order: &RaindexOrder, + ) -> Result, ApiError>; +} + +pub(crate) struct RaindexOrdersListDataSource<'a> { + pub client: &'a RaindexClient, +} + +#[async_trait(?Send)] +impl<'a> OrdersListDataSource for RaindexOrdersListDataSource<'a> { + async fn get_orders_list( + &self, + filters: GetOrdersFilters, + page: Option, + page_size: Option, + ) -> Result<(Vec, u32), ApiError> { + let result = self + .client + .get_orders(None, Some(filters), page, page_size) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to query orders"); + ApiError::Internal("failed to query orders".into()) + })?; + Ok((result.orders().to_vec(), result.total_count())) + } + + async fn get_order_quotes( + &self, + order: &RaindexOrder, + ) -> Result, ApiError> { + order.get_quotes(None, None).await.map_err(|e| { + tracing::error!(error = %e, "failed to query order quotes"); + ApiError::Internal("failed to query order quotes".into()) + }) + } +} + +pub(crate) fn build_order_summary( + order: &RaindexOrder, + io_ratio: &str, +) -> Result { + let inputs = order.inputs_list().items(); + let outputs = order.outputs_list().items(); + + let input = inputs.first().ok_or_else(|| { + tracing::error!("order has no input vaults"); + ApiError::Internal("order has no input vaults".into()) + })?; + let output = outputs.first().ok_or_else(|| { + tracing::error!("order has no output vaults"); + ApiError::Internal("order has no output vaults".into()) + })?; + + let input_token_info = input.token(); + let output_token_info = output.token(); + let created_at: u64 = order.timestamp_added().try_into().unwrap_or(0); + + Ok(OrderSummary { + order_hash: order.order_hash(), + owner: order.owner(), + input_token: TokenRef { + address: input_token_info.address(), + symbol: input_token_info.symbol().unwrap_or_default(), + decimals: input_token_info.decimals(), + }, + output_token: TokenRef { + address: output_token_info.address(), + symbol: output_token_info.symbol().unwrap_or_default(), + decimals: output_token_info.decimals(), + }, + output_vault_balance: output.formatted_balance(), + io_ratio: io_ratio.to_string(), + created_at, + orderbook_id: order.orderbook(), + }) +} + +pub(crate) fn build_pagination(total_count: u32, page: u32, page_size: u32) -> OrdersPagination { + let total_orders = total_count as u64; + let total_pages = if page_size == 0 { + 0 + } else { + total_orders.div_ceil(page_size as u64) + }; + OrdersPagination { + page, + page_size, + total_orders, + total_pages, + has_more: (page as u64) < total_pages, + } +} + +pub use get_by_owner::*; +pub use get_by_token::*; +pub use get_by_tx::*; + +pub fn routes() -> Vec { + rocket::routes![ + get_by_tx::get_orders_by_tx, + get_by_owner::get_orders_by_address, + get_by_token::get_orders_by_token + ] +} + +#[cfg(test)] +pub(crate) mod test_fixtures { + use super::OrdersListDataSource; + use crate::error::ApiError; + use async_trait::async_trait; + use rain_orderbook_common::raindex_client::order_quotes::RaindexOrderQuote; + use rain_orderbook_common::raindex_client::orders::{GetOrdersFilters, RaindexOrder}; + + pub struct MockOrdersListDataSource { + pub orders: Result, ApiError>, + pub total_count: u32, + pub quotes: Result, ApiError>, + } + + #[async_trait(?Send)] + impl OrdersListDataSource for MockOrdersListDataSource { + async fn get_orders_list( + &self, + _filters: GetOrdersFilters, + _page: Option, + _page_size: Option, + ) -> Result<(Vec, u32), ApiError> { + match &self.orders { + Ok(orders) => Ok((orders.clone(), self.total_count)), + Err(_) => Err(ApiError::Internal("failed to query orders".into())), + } + } + + async fn get_order_quotes( + &self, + _order: &RaindexOrder, + ) -> Result, ApiError> { + match &self.quotes { + Ok(quotes) => Ok(quotes.clone()), + Err(_) => Err(ApiError::Internal("failed to query order quotes".into())), + } + } + } +} diff --git a/src/routes/swap/mod.rs b/src/routes/swap/mod.rs index 2cffbce..e8ef464 100644 --- a/src/routes/swap/mod.rs +++ b/src/routes/swap/mod.rs @@ -56,13 +56,15 @@ impl<'a> SwapDataSource for RaindexSwapDataSource<'a> { }), ..Default::default() }; - self.client - .get_orders(None, Some(filters), None) + let result = self + .client + .get_orders(None, Some(filters), None, None) .await .map_err(|e| { tracing::error!(error = %e, "failed to query orders for pair"); ApiError::Internal("failed to query orders".into()) - }) + })?; + Ok(result.orders().to_vec()) } async fn build_candidates_for_pair( diff --git a/src/types/orders.rs b/src/types/orders.rs index 5293be3..63151c7 100644 --- a/src/types/orders.rs +++ b/src/types/orders.rs @@ -1,6 +1,6 @@ use crate::types::common::TokenRef; use alloy::primitives::{Address, FixedBytes}; -use rocket::form::FromForm; +use rocket::form::{FromForm, FromFormField}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; @@ -10,10 +10,31 @@ use utoipa::{IntoParams, ToSchema}; pub struct OrdersPaginationParams { #[field(name = "page")] #[param(example = 1)] - pub page: Option, + pub page: Option, #[field(name = "pageSize")] #[param(example = 20)] - pub page_size: Option, + pub page_size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromFormField, ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum OrderSide { + Input, + Output, +} + +#[derive(Debug, Clone, FromForm, Serialize, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +#[serde(rename_all = "camelCase")] +pub struct OrdersByTokenParams { + #[field(name = "side")] + pub side: Option, + #[field(name = "page")] + #[param(example = 1)] + pub page: Option, + #[field(name = "pageSize")] + #[param(example = 20)] + pub page_size: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]