Skip to main content

rust_okx/model/validation/
mod.rs

1//! Reusable validation utilities for typed HTTP request models.
2//!
3//! This module separates generic validation mechanics from endpoint-specific
4//! business rules. Request implementations should describe which constraints
5//! apply, while the functions in this module perform the actual checks.
6
7use crate::model::OrderSide;
8
9const CLIENT_REQUEST_ID_MAX_LEN: usize = 32;
10/// A validation failure detected before an HTTP request is sent.
11#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
12#[non_exhaustive]
13pub enum RequestValidationError {
14    /// A required string field was empty.
15    #[error("request field `{field}` must not be empty")]
16    EmptyField {
17        /// OKX wire-field name.
18        field: &'static str,
19    },
20
21    /// A string field exceeded the endpoint's documented maximum length.
22    #[error("request field `{field}` must be at most {max} characters")]
23    TooLong {
24        /// OKX wire-field name.
25        field: &'static str,
26
27        /// Maximum allowed character count.
28        max: usize,
29    },
30
31    /// A string field's length was outside the documented range.
32    #[error("request field `{field}` must contain between {min} and {max} characters")]
33    LengthOutOfRange {
34        /// OKX wire-field name.
35        field: &'static str,
36
37        /// Inclusive minimum character count.
38        min: usize,
39
40        /// Inclusive maximum character count.
41        max: usize,
42    },
43
44    /// A field did not match the endpoint's required textual format.
45    #[error("request field `{field}` has invalid format: {expected}")]
46    InvalidFormat {
47        /// OKX wire-field name.
48        field: &'static str,
49
50        /// Human-readable description of the expected format.
51        expected: &'static str,
52    },
53
54    /// A numeric request value was outside the endpoint's documented range.
55    #[error("request field `{field}` must be between {min} and {max}")]
56    OutOfRange {
57        /// OKX wire-field name.
58        field: &'static str,
59
60        /// Inclusive lower bound.
61        min: u64,
62
63        /// Inclusive upper bound.
64        max: u64,
65    },
66
67    /// A decimal-string request value was outside the documented range.
68    #[error("request field `{field}` must be between {min} and {max}")]
69    DecimalOutOfRange {
70        /// OKX wire-field name.
71        field: &'static str,
72
73        /// Inclusive lower bound as documented by OKX.
74        min: &'static str,
75
76        /// Inclusive upper bound as documented by OKX.
77        max: &'static str,
78    },
79
80    /// A field is required under a particular condition.
81    #[error("request field `{field}` is required when {condition}")]
82    RequiredWhen {
83        /// OKX wire-field name.
84        field: &'static str,
85
86        /// Human-readable description of the condition.
87        condition: &'static str,
88    },
89
90    /// A field is not applicable under a particular condition.
91    #[error("request field `{field}` is not applicable when {condition}")]
92    NotApplicable {
93        /// OKX wire-field name.
94        field: &'static str,
95
96        /// Human-readable description of the condition.
97        condition: &'static str,
98    },
99
100    /// None of a set of conditionally required fields was provided.
101    #[error("at least one of these request fields is required: {fields}")]
102    AtLeastOneRequired {
103        /// Comma-separated OKX wire-field names.
104        fields: &'static str,
105    },
106
107    /// More than one mutually exclusive field was provided.
108    #[error("request fields are mutually exclusive: {fields}")]
109    MutuallyExclusive {
110        /// Comma-separated OKX wire-field names.
111        fields: &'static str,
112    },
113}
114
115/// Validation implemented by typed request models.
116///
117/// Endpoint accessors call this before serializing and sending a request so
118/// obvious client-side mistakes fail without consuming an OKX rate-limit slot.
119pub trait ValidateRequest {
120    /// Validate all constraints represented by this SDK version.
121    fn validate(&self) -> Result<(), RequestValidationError>;
122}
123
124/// Validate a required string.
125///
126/// Leading and trailing whitespace is ignored when checking whether the value
127/// is empty. The original value is not modified.
128pub(crate) fn non_empty(field: &'static str, value: &str) -> Result<(), RequestValidationError> {
129    if value.trim().is_empty() {
130        return Err(RequestValidationError::EmptyField { field });
131    }
132
133    Ok(())
134}
135
136/// Validate an optional string.
137///
138/// `None` is valid, but `Some("")` and whitespace-only values are rejected.
139pub(crate) fn optional_non_empty(
140    field: &'static str,
141    value: Option<&str>,
142) -> Result<(), RequestValidationError> {
143    if value.is_some_and(|value| value.trim().is_empty()) {
144        return Err(RequestValidationError::EmptyField { field });
145    }
146
147    Ok(())
148}
149
150/// Require an optional string under an endpoint-specific condition.
151///
152/// A present but empty value produces [`RequestValidationError::EmptyField`].
153/// A missing value produces [`RequestValidationError::RequiredWhen`].
154pub(crate) fn require_when(
155    field: &'static str,
156    value: Option<&str>,
157    condition: &'static str,
158) -> Result<(), RequestValidationError> {
159    match value {
160        Some(value) => non_empty(field, value),
161
162        None => Err(RequestValidationError::RequiredWhen { field, condition }),
163    }
164}
165
166/// Reject a field when it is not applicable to the current request.
167///
168/// This function only checks whether the option is present. Validation of the
169/// contained value should be performed separately.
170pub(crate) fn reject_when_present<T>(
171    field: &'static str,
172    value: Option<&T>,
173    condition: &'static str,
174) -> Result<(), RequestValidationError> {
175    if value.is_some() {
176        return Err(RequestValidationError::NotApplicable { field, condition });
177    }
178
179    Ok(())
180}
181
182/// Validate that a string does not exceed a maximum character count.
183///
184/// This counts Unicode scalar values via [`str::chars`], not bytes. Fields that
185/// require ASCII-only values should also perform a separate format check.
186pub(crate) fn max_length(
187    field: &'static str,
188    value: &str,
189    max: usize,
190) -> Result<(), RequestValidationError> {
191    if value.chars().count() > max {
192        return Err(RequestValidationError::TooLong { field, max });
193    }
194
195    Ok(())
196}
197
198/// Validate that a string's character count is within an inclusive range.
199///
200/// Lengths use [`usize`] because iterator counts and collection lengths in Rust
201/// are represented by `usize`.
202pub(crate) fn length_range(
203    field: &'static str,
204    value: &str,
205    min: usize,
206    max: usize,
207) -> Result<(), RequestValidationError> {
208    let length = value.chars().count();
209
210    if !(min..=max).contains(&length) {
211        return Err(RequestValidationError::LengthOutOfRange { field, min, max });
212    }
213
214    Ok(())
215}
216
217/// Validate an unsigned API value against an inclusive range.
218///
219/// API-level numeric values use [`u64`] rather than [`usize`] because their
220/// wire representation must not depend on the target platform's pointer size.
221pub(crate) fn range_u64(
222    field: &'static str,
223    value: u64,
224    min: u64,
225    max: u64,
226) -> Result<(), RequestValidationError> {
227    if !(min..=max).contains(&value) {
228        return Err(RequestValidationError::OutOfRange { field, min, max });
229    }
230
231    Ok(())
232}
233
234/// Validate a string against a documented set of wire values.
235pub(crate) fn one_of(
236    field: &'static str,
237    value: &str,
238    allowed: &[&str],
239    expected: &'static str,
240) -> Result<(), RequestValidationError> {
241    non_empty(field, value)?;
242    if !allowed.contains(&value) {
243        return Err(RequestValidationError::InvalidFormat { field, expected });
244    }
245    Ok(())
246}
247
248/// Validate an optional string against a documented set of wire values.
249pub(crate) fn optional_one_of(
250    field: &'static str,
251    value: Option<&str>,
252    allowed: &[&str],
253    expected: &'static str,
254) -> Result<(), RequestValidationError> {
255    if let Some(value) = value {
256        one_of(field, value, allowed, expected)?;
257    }
258    Ok(())
259}
260
261/// Validate an unsigned integer encoded as an ASCII decimal string.
262pub(crate) fn unsigned_integer_string(
263    field: &'static str,
264    value: &str,
265) -> Result<(), RequestValidationError> {
266    non_empty(field, value)?;
267    if !value.bytes().all(|byte| byte.is_ascii_digit()) {
268        return Err(RequestValidationError::InvalidFormat {
269            field,
270            expected: "an unsigned integer encoded with ASCII digits",
271        });
272    }
273    Ok(())
274}
275
276/// Validate an optional unsigned integer encoded as an ASCII decimal string.
277pub(crate) fn optional_unsigned_integer_string(
278    field: &'static str,
279    value: Option<&str>,
280) -> Result<(), RequestValidationError> {
281    if let Some(value) = value {
282        unsigned_integer_string(field, value)?;
283    }
284    Ok(())
285}
286
287/// Validate a positive finite decimal encoded as a string.
288///
289/// Scientific notation, signs, `NaN`, and infinity are rejected because OKX
290/// documents request amounts and prices as ordinary decimal strings.
291pub(crate) fn positive_decimal_string(
292    field: &'static str,
293    value: &str,
294) -> Result<(), RequestValidationError> {
295    non_empty(field, value)?;
296    let mut dot_seen = false;
297    let mut digit_seen = false;
298    let mut non_zero_seen = false;
299
300    for byte in value.bytes() {
301        match byte {
302            b'0'..=b'9' => {
303                digit_seen = true;
304                non_zero_seen |= byte != b'0';
305            }
306            b'.' if !dot_seen => dot_seen = true,
307            _ => {
308                return Err(RequestValidationError::InvalidFormat {
309                    field,
310                    expected: "a positive decimal string",
311                });
312            }
313        }
314    }
315
316    if !digit_seen || !non_zero_seen || value.starts_with('.') || value.ends_with('.') {
317        return Err(RequestValidationError::InvalidFormat {
318            field,
319            expected: "a positive decimal string",
320        });
321    }
322    Ok(())
323}
324
325/// Validate a finite, non-negative decimal encoded as a string.
326///
327/// Scientific notation, signs, `NaN`, and infinity are rejected. Unlike
328/// [`positive_decimal_string`], zero is accepted.
329pub(crate) fn non_negative_decimal_string(
330    field: &'static str,
331    value: &str,
332) -> Result<(), RequestValidationError> {
333    non_empty(field, value)?;
334    let mut dot_seen = false;
335    let mut digit_seen = false;
336
337    for byte in value.bytes() {
338        match byte {
339            b'0'..=b'9' => digit_seen = true,
340            b'.' if !dot_seen => dot_seen = true,
341            _ => {
342                return Err(RequestValidationError::InvalidFormat {
343                    field,
344                    expected: "a non-negative decimal string",
345                });
346            }
347        }
348    }
349
350    if !digit_seen || value.starts_with('.') || value.ends_with('.') {
351        return Err(RequestValidationError::InvalidFormat {
352            field,
353            expected: "a non-negative decimal string",
354        });
355    }
356    Ok(())
357}
358
359/// Validate a positive integer encoded as an ASCII decimal string.
360pub(crate) fn positive_unsigned_integer_string(
361    field: &'static str,
362    value: &str,
363) -> Result<(), RequestValidationError> {
364    unsigned_integer_string(field, value)?;
365    if value.bytes().all(|byte| byte == b'0') {
366        return Err(RequestValidationError::InvalidFormat {
367            field,
368            expected: "a positive integer encoded with ASCII digits",
369        });
370    }
371    Ok(())
372}
373
374/// Validate an optional positive decimal encoded as a string.
375pub(crate) fn optional_positive_decimal_string(
376    field: &'static str,
377    value: Option<&str>,
378) -> Result<(), RequestValidationError> {
379    if let Some(value) = value {
380        positive_decimal_string(field, value)?;
381    }
382    Ok(())
383}
384
385/// Validate a positive decimal string against inclusive numeric bounds.
386pub(crate) fn decimal_string_range(
387    field: &'static str,
388    value: &str,
389    min: f64,
390    max: f64,
391    min_display: &'static str,
392    max_display: &'static str,
393) -> Result<(), RequestValidationError> {
394    positive_decimal_string(field, value)?;
395    let parsed = value
396        .parse::<f64>()
397        .map_err(|_| RequestValidationError::InvalidFormat {
398            field,
399            expected: "a finite positive decimal string",
400        })?;
401    if !parsed.is_finite() || !(min..=max).contains(&parsed) {
402        return Err(RequestValidationError::DecimalOutOfRange {
403            field,
404            min: min_display,
405            max: max_display,
406        });
407    }
408    Ok(())
409}
410
411/// Validate a collection length against inclusive documented bounds.
412pub(crate) fn collection_length(
413    field: &'static str,
414    length: usize,
415    min: usize,
416    max: usize,
417) -> Result<(), RequestValidationError> {
418    if !(min..=max).contains(&length) {
419        return Err(RequestValidationError::LengthOutOfRange { field, min, max });
420    }
421    Ok(())
422}
423
424/// Validate that every string in a collection is non-empty.
425pub(crate) fn non_empty_items<'a>(
426    field: &'static str,
427    values: impl IntoIterator<Item = &'a str>,
428) -> Result<(), RequestValidationError> {
429    for value in values {
430        non_empty(field, value)?;
431    }
432    Ok(())
433}
434
435/// Require at least one field in a group to be present.
436///
437/// Each boolean should indicate whether the corresponding request field was
438/// provided.
439pub(crate) fn at_least_one(
440    fields: &'static str,
441    present: &[bool],
442) -> Result<(), RequestValidationError> {
443    if !present.iter().copied().any(|present| present) {
444        return Err(RequestValidationError::AtLeastOneRequired { fields });
445    }
446
447    Ok(())
448}
449
450/// Require no more than one field in a group to be present.
451///
452/// This is useful for request parameters such as `ordId` and `clOrdId` when an
453/// endpoint treats them as mutually exclusive identifiers.
454pub(crate) fn at_most_one(
455    fields: &'static str,
456    present: &[bool],
457) -> Result<(), RequestValidationError> {
458    let count = present.iter().copied().filter(|present| *present).count();
459
460    if count > 1 {
461        return Err(RequestValidationError::MutuallyExclusive { fields });
462    }
463
464    Ok(())
465}
466
467/// Require exactly one field in a group to be present.
468///
469/// This combines [`at_least_one`] and [`at_most_one`].
470pub(crate) fn exactly_one(
471    fields: &'static str,
472    present: &[bool],
473) -> Result<(), RequestValidationError> {
474    at_least_one(fields, present)?;
475    at_most_one(fields, present)
476}
477
478/// Validata side field in the Order.
479///
480/// Only buy or sell, other string will be rejected.
481pub(crate) fn validate_side(side: &OrderSide) -> Result<(), RequestValidationError> {
482    match side {
483        OrderSide::Buy | OrderSide::Sell => Ok(()),
484        _ => Err(RequestValidationError::InvalidFormat {
485            field: "side",
486            expected: "buy or sell",
487        }),
488    }
489}
490
491/// Validata cl_q_req_id field in the ConvertQuoteRequest struct.
492///
493/// [Specialification](https://www.okx.com/docs-v5/en/#funding-account-rest-api-convert-trade):
494/// This field from the quote_id field of ConvertQuote struct.
495pub(crate) fn validate_client_request_id(
496    field: &'static str,
497    value: Option<&str>,
498) -> Result<(), RequestValidationError> {
499    let Some(value) = value else {
500        return Ok(());
501    };
502
503    non_empty(field, value)?;
504    if value.chars().count() > CLIENT_REQUEST_ID_MAX_LEN {
505        return Err(RequestValidationError::TooLong {
506            field,
507            max: CLIENT_REQUEST_ID_MAX_LEN,
508        });
509    }
510    if !value.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
511        return Err(RequestValidationError::InvalidFormat {
512            field,
513            expected: "1-32 ASCII alphanumeric characters",
514        });
515    }
516
517    Ok(())
518}
519
520#[cfg(test)]
521mod tests;