rust-okx 0.4.1

Async Rust client for the OKX v5 REST API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
//! Reusable validation utilities for typed HTTP request models.
//!
//! This module separates generic validation mechanics from endpoint-specific
//! business rules. Request implementations should describe which constraints
//! apply, while the functions in this module perform the actual checks.

use crate::model::OrderSide;

const CLIENT_REQUEST_ID_MAX_LEN: usize = 32;
/// A validation failure detected before an HTTP request is sent.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RequestValidationError {
    /// A required string field was empty.
    #[error("request field `{field}` must not be empty")]
    EmptyField {
        /// OKX wire-field name.
        field: &'static str,
    },

    /// A string field exceeded the endpoint's documented maximum length.
    #[error("request field `{field}` must be at most {max} characters")]
    TooLong {
        /// OKX wire-field name.
        field: &'static str,

        /// Maximum allowed character count.
        max: usize,
    },

    /// A string field's length was outside the documented range.
    #[error("request field `{field}` must contain between {min} and {max} characters")]
    LengthOutOfRange {
        /// OKX wire-field name.
        field: &'static str,

        /// Inclusive minimum character count.
        min: usize,

        /// Inclusive maximum character count.
        max: usize,
    },

    /// A field did not match the endpoint's required textual format.
    #[error("request field `{field}` has invalid format: {expected}")]
    InvalidFormat {
        /// OKX wire-field name.
        field: &'static str,

        /// Human-readable description of the expected format.
        expected: &'static str,
    },

    /// A numeric request value was outside the endpoint's documented range.
    #[error("request field `{field}` must be between {min} and {max}")]
    OutOfRange {
        /// OKX wire-field name.
        field: &'static str,

        /// Inclusive lower bound.
        min: u64,

        /// Inclusive upper bound.
        max: u64,
    },

    /// A decimal-string request value was outside the documented range.
    #[error("request field `{field}` must be between {min} and {max}")]
    DecimalOutOfRange {
        /// OKX wire-field name.
        field: &'static str,

        /// Inclusive lower bound as documented by OKX.
        min: &'static str,

        /// Inclusive upper bound as documented by OKX.
        max: &'static str,
    },

    /// A field is required under a particular condition.
    #[error("request field `{field}` is required when {condition}")]
    RequiredWhen {
        /// OKX wire-field name.
        field: &'static str,

        /// Human-readable description of the condition.
        condition: &'static str,
    },

    /// A field is not applicable under a particular condition.
    #[error("request field `{field}` is not applicable when {condition}")]
    NotApplicable {
        /// OKX wire-field name.
        field: &'static str,

        /// Human-readable description of the condition.
        condition: &'static str,
    },

    /// None of a set of conditionally required fields was provided.
    #[error("at least one of these request fields is required: {fields}")]
    AtLeastOneRequired {
        /// Comma-separated OKX wire-field names.
        fields: &'static str,
    },

    /// More than one mutually exclusive field was provided.
    #[error("request fields are mutually exclusive: {fields}")]
    MutuallyExclusive {
        /// Comma-separated OKX wire-field names.
        fields: &'static str,
    },
}

/// Validation implemented by typed request models.
///
/// Endpoint accessors call this before serializing and sending a request so
/// obvious client-side mistakes fail without consuming an OKX rate-limit slot.
pub trait ValidateRequest {
    /// Validate all constraints represented by this SDK version.
    fn validate(&self) -> Result<(), RequestValidationError>;
}

/// Validate a required string.
///
/// Leading and trailing whitespace is ignored when checking whether the value
/// is empty. The original value is not modified.
pub(crate) fn non_empty(field: &'static str, value: &str) -> Result<(), RequestValidationError> {
    if value.trim().is_empty() {
        return Err(RequestValidationError::EmptyField { field });
    }

    Ok(())
}

/// Validate an optional string.
///
/// `None` is valid, but `Some("")` and whitespace-only values are rejected.
pub(crate) fn optional_non_empty(
    field: &'static str,
    value: Option<&str>,
) -> Result<(), RequestValidationError> {
    if value.is_some_and(|value| value.trim().is_empty()) {
        return Err(RequestValidationError::EmptyField { field });
    }

    Ok(())
}

/// Require an optional string under an endpoint-specific condition.
///
/// A present but empty value produces [`RequestValidationError::EmptyField`].
/// A missing value produces [`RequestValidationError::RequiredWhen`].
pub(crate) fn require_when(
    field: &'static str,
    value: Option<&str>,
    condition: &'static str,
) -> Result<(), RequestValidationError> {
    match value {
        Some(value) => non_empty(field, value),

        None => Err(RequestValidationError::RequiredWhen { field, condition }),
    }
}

/// Reject a field when it is not applicable to the current request.
///
/// This function only checks whether the option is present. Validation of the
/// contained value should be performed separately.
pub(crate) fn reject_when_present<T>(
    field: &'static str,
    value: Option<&T>,
    condition: &'static str,
) -> Result<(), RequestValidationError> {
    if value.is_some() {
        return Err(RequestValidationError::NotApplicable { field, condition });
    }

    Ok(())
}

/// Validate that a string does not exceed a maximum character count.
///
/// This counts Unicode scalar values via [`str::chars`], not bytes. Fields that
/// require ASCII-only values should also perform a separate format check.
pub(crate) fn max_length(
    field: &'static str,
    value: &str,
    max: usize,
) -> Result<(), RequestValidationError> {
    if value.chars().count() > max {
        return Err(RequestValidationError::TooLong { field, max });
    }

    Ok(())
}

/// Validate that a string's character count is within an inclusive range.
///
/// Lengths use [`usize`] because iterator counts and collection lengths in Rust
/// are represented by `usize`.
pub(crate) fn length_range(
    field: &'static str,
    value: &str,
    min: usize,
    max: usize,
) -> Result<(), RequestValidationError> {
    let length = value.chars().count();

    if !(min..=max).contains(&length) {
        return Err(RequestValidationError::LengthOutOfRange { field, min, max });
    }

    Ok(())
}

/// Validate an unsigned API value against an inclusive range.
///
/// API-level numeric values use [`u64`] rather than [`usize`] because their
/// wire representation must not depend on the target platform's pointer size.
pub(crate) fn range_u64(
    field: &'static str,
    value: u64,
    min: u64,
    max: u64,
) -> Result<(), RequestValidationError> {
    if !(min..=max).contains(&value) {
        return Err(RequestValidationError::OutOfRange { field, min, max });
    }

    Ok(())
}

/// Validate a string against a documented set of wire values.
pub(crate) fn one_of(
    field: &'static str,
    value: &str,
    allowed: &[&str],
    expected: &'static str,
) -> Result<(), RequestValidationError> {
    non_empty(field, value)?;
    if !allowed.contains(&value) {
        return Err(RequestValidationError::InvalidFormat { field, expected });
    }
    Ok(())
}

/// Validate an optional string against a documented set of wire values.
pub(crate) fn optional_one_of(
    field: &'static str,
    value: Option<&str>,
    allowed: &[&str],
    expected: &'static str,
) -> Result<(), RequestValidationError> {
    if let Some(value) = value {
        one_of(field, value, allowed, expected)?;
    }
    Ok(())
}

/// Validate an unsigned integer encoded as an ASCII decimal string.
pub(crate) fn unsigned_integer_string(
    field: &'static str,
    value: &str,
) -> Result<(), RequestValidationError> {
    non_empty(field, value)?;
    if !value.bytes().all(|byte| byte.is_ascii_digit()) {
        return Err(RequestValidationError::InvalidFormat {
            field,
            expected: "an unsigned integer encoded with ASCII digits",
        });
    }
    Ok(())
}

/// Validate an optional unsigned integer encoded as an ASCII decimal string.
pub(crate) fn optional_unsigned_integer_string(
    field: &'static str,
    value: Option<&str>,
) -> Result<(), RequestValidationError> {
    if let Some(value) = value {
        unsigned_integer_string(field, value)?;
    }
    Ok(())
}

/// Validate a positive finite decimal encoded as a string.
///
/// Scientific notation, signs, `NaN`, and infinity are rejected because OKX
/// documents request amounts and prices as ordinary decimal strings.
pub(crate) fn positive_decimal_string(
    field: &'static str,
    value: &str,
) -> Result<(), RequestValidationError> {
    non_empty(field, value)?;
    let mut dot_seen = false;
    let mut digit_seen = false;
    let mut non_zero_seen = false;

    for byte in value.bytes() {
        match byte {
            b'0'..=b'9' => {
                digit_seen = true;
                non_zero_seen |= byte != b'0';
            }
            b'.' if !dot_seen => dot_seen = true,
            _ => {
                return Err(RequestValidationError::InvalidFormat {
                    field,
                    expected: "a positive decimal string",
                });
            }
        }
    }

    if !digit_seen || !non_zero_seen || value.starts_with('.') || value.ends_with('.') {
        return Err(RequestValidationError::InvalidFormat {
            field,
            expected: "a positive decimal string",
        });
    }
    Ok(())
}

/// Validate a finite, non-negative decimal encoded as a string.
///
/// Scientific notation, signs, `NaN`, and infinity are rejected. Unlike
/// [`positive_decimal_string`], zero is accepted.
pub(crate) fn non_negative_decimal_string(
    field: &'static str,
    value: &str,
) -> Result<(), RequestValidationError> {
    non_empty(field, value)?;
    let mut dot_seen = false;
    let mut digit_seen = false;

    for byte in value.bytes() {
        match byte {
            b'0'..=b'9' => digit_seen = true,
            b'.' if !dot_seen => dot_seen = true,
            _ => {
                return Err(RequestValidationError::InvalidFormat {
                    field,
                    expected: "a non-negative decimal string",
                });
            }
        }
    }

    if !digit_seen || value.starts_with('.') || value.ends_with('.') {
        return Err(RequestValidationError::InvalidFormat {
            field,
            expected: "a non-negative decimal string",
        });
    }
    Ok(())
}

/// Validate a positive integer encoded as an ASCII decimal string.
pub(crate) fn positive_unsigned_integer_string(
    field: &'static str,
    value: &str,
) -> Result<(), RequestValidationError> {
    unsigned_integer_string(field, value)?;
    if value.bytes().all(|byte| byte == b'0') {
        return Err(RequestValidationError::InvalidFormat {
            field,
            expected: "a positive integer encoded with ASCII digits",
        });
    }
    Ok(())
}

/// Validate an optional positive decimal encoded as a string.
pub(crate) fn optional_positive_decimal_string(
    field: &'static str,
    value: Option<&str>,
) -> Result<(), RequestValidationError> {
    if let Some(value) = value {
        positive_decimal_string(field, value)?;
    }
    Ok(())
}

/// Validate a positive decimal string against inclusive numeric bounds.
pub(crate) fn decimal_string_range(
    field: &'static str,
    value: &str,
    min: f64,
    max: f64,
    min_display: &'static str,
    max_display: &'static str,
) -> Result<(), RequestValidationError> {
    positive_decimal_string(field, value)?;
    let parsed = value
        .parse::<f64>()
        .map_err(|_| RequestValidationError::InvalidFormat {
            field,
            expected: "a finite positive decimal string",
        })?;
    if !parsed.is_finite() || !(min..=max).contains(&parsed) {
        return Err(RequestValidationError::DecimalOutOfRange {
            field,
            min: min_display,
            max: max_display,
        });
    }
    Ok(())
}

/// Validate a collection length against inclusive documented bounds.
pub(crate) fn collection_length(
    field: &'static str,
    length: usize,
    min: usize,
    max: usize,
) -> Result<(), RequestValidationError> {
    if !(min..=max).contains(&length) {
        return Err(RequestValidationError::LengthOutOfRange { field, min, max });
    }
    Ok(())
}

/// Validate that every string in a collection is non-empty.
pub(crate) fn non_empty_items<'a>(
    field: &'static str,
    values: impl IntoIterator<Item = &'a str>,
) -> Result<(), RequestValidationError> {
    for value in values {
        non_empty(field, value)?;
    }
    Ok(())
}

/// Require at least one field in a group to be present.
///
/// Each boolean should indicate whether the corresponding request field was
/// provided.
pub(crate) fn at_least_one(
    fields: &'static str,
    present: &[bool],
) -> Result<(), RequestValidationError> {
    if !present.iter().copied().any(|present| present) {
        return Err(RequestValidationError::AtLeastOneRequired { fields });
    }

    Ok(())
}

/// Require no more than one field in a group to be present.
///
/// This is useful for request parameters such as `ordId` and `clOrdId` when an
/// endpoint treats them as mutually exclusive identifiers.
pub(crate) fn at_most_one(
    fields: &'static str,
    present: &[bool],
) -> Result<(), RequestValidationError> {
    let count = present.iter().copied().filter(|present| *present).count();

    if count > 1 {
        return Err(RequestValidationError::MutuallyExclusive { fields });
    }

    Ok(())
}

/// Require exactly one field in a group to be present.
///
/// This combines [`at_least_one`] and [`at_most_one`].
pub(crate) fn exactly_one(
    fields: &'static str,
    present: &[bool],
) -> Result<(), RequestValidationError> {
    at_least_one(fields, present)?;
    at_most_one(fields, present)
}

/// Validata side field in the Order.
///
/// Only buy or sell, other string will be rejected.
pub(crate) fn validate_side(side: &OrderSide) -> Result<(), RequestValidationError> {
    match side {
        OrderSide::Buy | OrderSide::Sell => Ok(()),
        _ => Err(RequestValidationError::InvalidFormat {
            field: "side",
            expected: "buy or sell",
        }),
    }
}

/// Validata cl_q_req_id field in the ConvertQuoteRequest struct.
///
/// [Specialification](https://www.okx.com/docs-v5/en/#funding-account-rest-api-convert-trade):
/// This field from the quote_id field of ConvertQuote struct.
pub(crate) fn validate_client_request_id(
    field: &'static str,
    value: Option<&str>,
) -> Result<(), RequestValidationError> {
    let Some(value) = value else {
        return Ok(());
    };

    non_empty(field, value)?;
    if value.chars().count() > CLIENT_REQUEST_ID_MAX_LEN {
        return Err(RequestValidationError::TooLong {
            field,
            max: CLIENT_REQUEST_ID_MAX_LEN,
        });
    }
    if !value.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
        return Err(RequestValidationError::InvalidFormat {
            field,
            expected: "1-32 ASCII alphanumeric characters",
        });
    }

    Ok(())
}

#[cfg(test)]
mod tests;