feat: added readme and documentation

This commit is contained in:
pyrite 2025-08-07 16:28:00 +02:00
parent daeff053f4
commit 8794c076b3
Signed by: pyrite
GPG key ID: 7F1BA9170CD35D15
8 changed files with 279 additions and 26 deletions

24
flake.lock generated
View file

@ -6,11 +6,11 @@
"rust-analyzer-src": "rust-analyzer-src" "rust-analyzer-src": "rust-analyzer-src"
}, },
"locked": { "locked": {
"lastModified": 1744618085, "lastModified": 1752821162,
"narHash": "sha256-+VdhZsIiIDtyOL88c4U/Os1PsCMLOCyScIeWL4hxJRM=", "narHash": "sha256-mwQHDPD8DTQW7LIV7werk1TUVh7Wg/IuYeuTArlEqlo=",
"owner": "nix-community", "owner": "nix-community",
"repo": "fenix", "repo": "fenix",
"rev": "a85d390a5607188dca2dbc39b5b37571651d69ce", "rev": "934ce7f8a4dbf849f334b8c311be5d5d97a1af69",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -39,11 +39,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1744463964, "lastModified": 1752687322,
"narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=", "narHash": "sha256-RKwfXA4OZROjBTQAl9WOZQFm7L8Bo93FQwSJpAiSRvo=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650", "rev": "6e987485eb2c77e5dcc5af4e3c70843711ef9251",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -55,11 +55,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1744463964, "lastModified": 1752687322,
"narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=", "narHash": "sha256-RKwfXA4OZROjBTQAl9WOZQFm7L8Bo93FQwSJpAiSRvo=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650", "rev": "6e987485eb2c77e5dcc5af4e3c70843711ef9251",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -79,11 +79,11 @@
"rust-analyzer-src": { "rust-analyzer-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1744539868, "lastModified": 1752743757,
"narHash": "sha256-NPUnfDAwLD69aKetxjC7lV5ysrvs1IKC0Sy4Zai10Mw=", "narHash": "sha256-bsiX5DRwYMsaiBykptwJac36AlWTh5ZaHUPHYf6XiPY=",
"owner": "rust-lang", "owner": "rust-lang",
"repo": "rust-analyzer", "repo": "rust-analyzer",
"rev": "8365cf853e791c93fa8bc924f031f11949bb1a3c", "rev": "f73ce3c8a897be1ae5cf77332288e331824435e5",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -18,12 +18,14 @@
system: system:
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
toolchain = fenix.packages.${system}.stable.toolchain; # toolchain = fenix.packages.${system}.stable.toolchain;
nightlyToolchain = fenix.packages.${system}.latest.toolchain;
in in
{ {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
packages = [ packages = [
toolchain # toolchain
nightlyToolchain
]; ];
}; };
} }

View file

@ -7,13 +7,17 @@ license-file = { workspace = true }
repository = { workspace = true } repository = { workspace = true }
homepage = { workspace = true } homepage = { workspace = true }
[hints]
mostly-unused = true
[features] [features]
default = ["scopes", "requests", "builder", "models"] default = ["scopes", "requests", "builder", "models", "reqwest"]
scopes = ["builder"] scopes = ["builder"]
builder = ["requests", "dep:bon"] builder = ["requests", "dep:bon"]
requests = ["models"] requests = ["models"]
models = ["dep:serde_repr"] models = ["dep:serde_repr"]
strum = ["dep:strum"] strum = ["dep:strum"]
reqwest = ["dep:reqwest"]
[dependencies] [dependencies]
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
@ -22,7 +26,7 @@ serde_json = { workspace = true }
bon = { version = "3.6", optional = true } bon = { version = "3.6", optional = true }
bytes = "1" bytes = "1"
http = "1" http = "1"
reqwest = { version = "0.12", default-features = false, features = [ reqwest = { version = "0.12", default-features = false, optional = true, features = [
"rustls-tls", "rustls-tls",
"json", "json",
"brotli", "brotli",

101
torn-api/README.md Normal file
View file

@ -0,0 +1,101 @@
<h1 align="center">torn-api.rs</h1>
<div align="center">
<strong>
Rust Torn API bindings
</strong>
</div>
<br />
<div align="center">
<!-- Version -->
<a href="https://crates.io/crates/torn-api">
<img src="https://img.shields.io/crates/v/torn-api.svg?style=flat-square"
alt="Crates.io version" /></a>
<!-- Docs -->
<a href="https://docs.rs/torn-api">
<img src="https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square" alt="docs.rs docs" />
</a>
<!-- License -->
<img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square" alt="license" />
</div>
<br />
Async and typesafe bindings for the [Torn API](https://www.torn.com/swagger.php) that are auto-generated based on the v2 OpenAPI spec.
## Installation
torn-api requires an async runtime such as [tokio](https://github.com/tokio-rs/tokio) or [smol](https://github.com/smol-rs/smol) in order to function. It *should* be fully runtime agnostic when the `reqwest` feature is disabled.
```toml
[dependencies]
torn-api = "1.7"
```
### Features
- `reqwest`: Include an implementation of the client which uses the [reqwest](https://github.com/seanmonstar/reqwest) crate as its HTTP client. Requires tokio runtime.
- `models`: Generate response and parameter model definitions.
- `requests`: Generate requests model definitions.
- `scopes`: Generate scope objects which group endpoints by category.
- `builder`: Generate builders using [bon](https://github.com/elastio/bon) for all request structs.
- `strum`: Derive [EnumIs](https://docs.rs/strum/latest/strum/derive.EnumIs.html) and [EnumTryAs](https://docs.rs/strum/latest/strum/derive.EnumTryAs.html) for all auto-generated enums.
## Quickstart
```rust
use torn_api::{executor::{ReqwestClient, ExecutorExt}, models::RacingRaceTypeEnum};
let client = ReqwestClient::new("XXXXXXXXXXXXX");
let response = client.user().races(|r| r.cat(RacingRaceTypeEnum::Official)).await.unwrap();
let race = &response.races[0];
println!("Race '{}': winner was {}", race.title, race.results[0].driver_id);
```
### Use with undocumented endpoints
The v2 API exposes v1 endpoints as undocumented endpoints in cases where they haven't been ported over yet. It is still possible (though not recommended) to use this crate with such endpoints by manually implementing the [`IntoRequest`](https://docs.rs/torn-api/latest/torn_api/request/trait.IntoRequest.html) trait.
```rust
use torn_api::{
executor::{ReqwestClient, Executor},
models::UserId,
request::{IntoRequest, ApiRequest}
};
#[derive(serde::Deserialize)]
struct UserBasic {
id: UserId,
name: String,
level: i32
}
struct UserBasicRequest(UserId);
impl IntoRequest for UserBasicRequest {
type Discriminant = UserId;
type Response = UserBasic;
fn into_request(self) -> (Self::Discriminant, ApiRequest) {
let request = ApiRequest {
path: format!("/user/{}/basic", self.0),
parameters: Vec::default(),
};
(self.0, request)
}
}
let client = ReqwestClient::new("XXXXXXXXXXXXX");
let basic = client.fetch(UserBasicRequest(UserId(1))).await.unwrap();
```
### Implementing your own API executor
If you don't wish to use reqwest, or want to use custom logic for which API to use, you have to implement the [`Executor`](https://docs.rs/torn-api/latest/torn_api/executor/trait.Executor.html) trait for your custom executor.
## Safety
The crate is compiled with `#![forbid(unsafe_code)]`.
## Warnings
- ⚠️ The Torn v2 API, on which this wrapper is based, is under active development and changes frequently. No guarantees are made that this wrapper always matches the latest version of the API.
- ⚠️ This crate contains a lot of macro-heavy, auto-generated code. If you experience slow compile times, you may want try testing the [nightly only `-Zhint-mostly-unused` option](https://blog.rust-lang.org/inside-rust/2025/07/15/call-for-testing-hint-mostly-unused) to see if improvements in compile time apply to your use case.

View file

@ -1,22 +1,25 @@
use std::future::Future; use std::future::Future;
use futures::{Stream, StreamExt}; use futures::{Stream, StreamExt};
#[cfg(feature = "reqwest")]
use http::{header::AUTHORIZATION, HeaderMap, HeaderValue}; use http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
use serde::Deserialize; use serde::Deserialize;
#[cfg(feature = "reqwest")]
use crate::request::ApiRequest;
use crate::request::{ApiResponse, IntoRequest};
#[cfg(feature = "scopes")] #[cfg(feature = "scopes")]
use crate::scopes::{ use crate::scopes::{
BulkFactionScope, BulkForumScope, BulkMarketScope, BulkRacingScope, BulkTornScope, BulkFactionScope, BulkForumScope, BulkKeyScope, BulkMarketScope, BulkRacingScope,
BulkUserScope, FactionScope, ForumScope, MarketScope, RacingScope, TornScope, UserScope, BulkTornScope, BulkUserScope, FactionScope, ForumScope, KeyScope, MarketScope, RacingScope,
}; TornScope, UserScope,
use crate::{
request::{ApiRequest, ApiResponse, IntoRequest},
scopes::{BulkKeyScope, KeyScope},
}; };
/// Central trait of the crate that is used to execute api requests.
pub trait Executor: Sized { pub trait Executor: Sized {
type Error: From<serde_json::Error> + From<crate::ApiError> + Send; type Error: From<serde_json::Error> + From<crate::ApiError> + Send;
/// Execute an api request.
fn execute<R>( fn execute<R>(
self, self,
request: R, request: R,
@ -24,12 +27,13 @@ pub trait Executor: Sized {
where where
R: IntoRequest; R: IntoRequest;
/// Execute a request and deserialise the associated response type.
fn fetch<R>(self, request: R) -> impl Future<Output = Result<R::Response, Self::Error>> + Send fn fetch<R>(self, request: R) -> impl Future<Output = Result<R::Response, Self::Error>> + Send
where where
R: IntoRequest, R: IntoRequest,
{ {
// HACK: workaround for not using `async` in trait declaration. // HACK: workaround for not using `async` in trait declaration.
// The future is `Send` but `&self` might not be. // The future is `Send` but `self` might not be.
let fut = self.execute(request); let fut = self.execute(request);
async { async {
let resp = fut.await.1?; let resp = fut.await.1?;
@ -59,9 +63,11 @@ pub trait Executor: Sized {
} }
} }
/// Trait that is used for the execution of bulk requests.
pub trait BulkExecutor: Sized { pub trait BulkExecutor: Sized {
type Error: From<serde_json::Error> + From<crate::ApiError> + Send; type Error: From<serde_json::Error> + From<crate::ApiError> + Send;
/// Generate response stream from a set of api requests.
fn execute<R>( fn execute<R>(
self, self,
requests: impl IntoIterator<Item = R>, requests: impl IntoIterator<Item = R>,
@ -69,6 +75,7 @@ pub trait BulkExecutor: Sized {
where where
R: IntoRequest; R: IntoRequest;
/// Generate a stream of deserialised responsed based on a set of api requests.
fn fetch_many<R>( fn fetch_many<R>(
self, self,
requests: impl IntoIterator<Item = R>, requests: impl IntoIterator<Item = R>,
@ -115,6 +122,7 @@ pub trait BulkExecutor: Sized {
} }
} }
/// Convenience trait extension to provide easy access to all scopes
#[cfg(feature = "scopes")] #[cfg(feature = "scopes")]
pub trait ExecutorExt: Executor + Sized { pub trait ExecutorExt: Executor + Sized {
fn user(self) -> UserScope<Self>; fn user(self) -> UserScope<Self>;
@ -166,6 +174,7 @@ where
} }
} }
/// Convenience trait extension to provide easy access to all bulk scopes
#[cfg(feature = "scopes")] #[cfg(feature = "scopes")]
pub trait BulkExecutorExt: BulkExecutor + Sized { pub trait BulkExecutorExt: BulkExecutor + Sized {
fn user_bulk(self) -> BulkUserScope<Self>; fn user_bulk(self) -> BulkUserScope<Self>;
@ -217,9 +226,13 @@ where
} }
} }
#[cfg(feature = "reqwest")]
/// Default executor based on the reqwest HTTP client.
pub struct ReqwestClient(reqwest::Client); pub struct ReqwestClient(reqwest::Client);
#[cfg(feature = "reqwest")]
impl ReqwestClient { impl ReqwestClient {
/// Instantiate a new client which will use the provided API key.
pub fn new(api_key: &str) -> Self { pub fn new(api_key: &str) -> Self {
let mut headers = HeaderMap::with_capacity(1); let mut headers = HeaderMap::with_capacity(1);
headers.insert( headers.insert(
@ -237,6 +250,7 @@ impl ReqwestClient {
} }
} }
#[cfg(feature = "reqwest")]
impl ReqwestClient { impl ReqwestClient {
async fn execute_api_request(&self, request: ApiRequest) -> Result<ApiResponse, crate::Error> { async fn execute_api_request(&self, request: ApiRequest) -> Result<ApiResponse, crate::Error> {
let url = request.url(); let url = request.url();
@ -249,6 +263,7 @@ impl ReqwestClient {
} }
} }
#[cfg(feature = "reqwest")]
impl Executor for &ReqwestClient { impl Executor for &ReqwestClient {
type Error = crate::Error; type Error = crate::Error;
@ -261,6 +276,7 @@ impl Executor for &ReqwestClient {
} }
} }
#[cfg(feature = "reqwest")]
impl BulkExecutor for &ReqwestClient { impl BulkExecutor for &ReqwestClient {
type Error = crate::Error; type Error = crate::Error;
@ -277,7 +293,7 @@ impl BulkExecutor for &ReqwestClient {
} }
} }
#[cfg(test)] #[cfg(all(test, feature = "reqwest"))]
mod test { mod test {
use crate::{scopes::test::test_client, ApiError, Error}; use crate::{scopes::test::test_client, ApiError, Error};

View file

@ -1,14 +1,128 @@
#![forbid(unsafe_code)]
//! <h1 align="center">torn-api.rs</h1>
//! <div align="center">
//! <strong>
//! Rust Torn API bindings
//! </strong>
//! </div>
//!
//! <br />
//!
//! <div align="center">
//! <!-- Version -->
//! <a href="https://crates.io/crates/torn-api">
//! <img src="https://img.shields.io/crates/v/torn-api.svg?style=flat-square"
//! alt="Crates.io version" /></a>
//! <!-- Docs -->
//! <a href="https://docs.rs/torn-api">
//! <img src="https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square" alt="docs.rs docs" />
//! </a>
//! <!-- License -->
//! <img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square" alt="license" />
//! </div>
//!
//! <br />
//!
//! Async and typesafe bindings for the [Torn API](https://www.torn.com/swagger.php) that are auto-generated based on the v2 OpenAPI spec.
//!
//! ## Installation
//! torn-api requires an async runtime such as [tokio](https://github.com/tokio-rs/tokio) or [smol](https://github.com/smol-rs/smol) in order to function. It *should* be fully runtime agnostic when the `reqwest` feature is disabled.
//! ```toml
//! [dependencies]
//! torn-api = "1.7"
//! ```
//!
//! ### Features
//! - `reqwest`: Include an implementation of the client which uses the [reqwest](https://github.com/seanmonstar/reqwest) crate as its HTTP client. Requires tokio runtime.
//! - `models`: Generate response and parameter model definitions.
//! - `requests`: Generate requests model definitions.
//! - `scopes`: Generate scope objects which group endpoints by category.
//! - `builder`: Generate builders using [bon](https://github.com/elastio/bon) for all request structs.
//! - `strum`: Derive [EnumIs](https://docs.rs/strum/latest/strum/derive.EnumIs.html) and [EnumTryAs](https://docs.rs/strum/latest/strum/derive.EnumTryAs.html) for all auto-generated enums.
//!
//! ## Quickstart
//!
//! ```rust,no_run
//! use torn_api::{executor::{ReqwestClient, ExecutorExt}, models::RacingRaceTypeEnum};
//! # #[tokio::main]
//! # async fn main() {
//! let client = ReqwestClient::new("XXXXXXXXXXXXX");
//!
//! let response = client.user().races(|r| r.cat(RacingRaceTypeEnum::Official)).await.unwrap();
//!
//! let race = &response.races[0];
//!
//! println!("Race '{}': winner was {}", race.title, race.results[0].driver_id);
//! # }
//! ```
//!
//! ### Use with undocumented endpoints
//! The v2 API exposes v1 endpoints as undocumented endpoints in cases where they haven't been ported over yet. It is still possible (though not recommended) to use this crate with such endpoints by manually implementing the [`IntoRequest`](https://docs.rs/torn-api/latest/torn_api/request/trait.IntoRequest.html) trait.
//!
//!
//! ```rust,no_run
//! use torn_api::{
//! executor::{ReqwestClient, Executor},
//! models::UserId,
//! request::{IntoRequest, ApiRequest}
//! };
//!
//! #[derive(serde::Deserialize)]
//! struct UserBasic {
//! id: UserId,
//! name: String,
//! level: i32
//! }
//!
//! struct UserBasicRequest(UserId);
//!
//! impl IntoRequest for UserBasicRequest {
//! type Discriminant = UserId;
//! type Response = UserBasic;
//! fn into_request(self) -> (Self::Discriminant, ApiRequest) {
//! let request = ApiRequest {
//! path: format!("/user/{}/basic", self.0),
//! parameters: Vec::default(),
//! };
//!
//! (self.0, request)
//! }
//! }
//!
//! # #[tokio::main]
//! # async fn main() {
//! let client = ReqwestClient::new("XXXXXXXXXXXXX");
//! let basic = client.fetch(UserBasicRequest(UserId(1))).await.unwrap();
//! # }
//! ```
//!
//! ### Implementing your own API executor
//! If you don't wish to use reqwest, or want to use custom logic for which API to use, you have to implement the [`Executor`](https://docs.rs/torn-api/latest/torn_api/executor/trait.Executor.html) trait for your custom executor.
//!
//! ## Safety
//! The crate is compiled with `#![forbid(unsafe_code)]`.
//!
//! ## Warnings
//! - ⚠️ The Torn v2 API, on which this wrapper is based, is under active development and changes frequently. No guarantees are made that this wrapper always matches the latest version of the API.
//! - ⚠️ This crate contains a lot of macro-heavy, auto-generated code. If you experience slow compile times, you may want try testing the [nightly only `-Zhint-mostly-unused` option](https://blog.rust-lang.org/inside-rust/2025/07/15/call-for-testing-hint-mostly-unused) to see if improvements in compile time apply to your use case.
use thiserror::Error; use thiserror::Error;
/// Traits to execute api requests
pub mod executor; pub mod executor;
#[cfg(feature = "models")] #[cfg(feature = "models")]
/// Auto-generated model definitions.
pub mod models; pub mod models;
#[cfg(feature = "requests")] #[cfg(feature = "requests")]
/// Auto-generated parameter definitions.
pub mod parameters; pub mod parameters;
/// Api request traits and auto-generated definitions.
pub mod request; pub mod request;
#[cfg(feature = "scopes")] #[cfg(feature = "scopes")]
/// Auto-generated api categories for convenient access.
pub mod scopes; pub mod scopes;
/// Error returned by the API
#[derive(Debug, Error, Clone, PartialEq, Eq)] #[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ApiError { pub enum ApiError {
#[error("Unhandled error, should not occur")] #[error("Unhandled error, should not occur")]
@ -134,16 +248,19 @@ impl ApiError {
} }
} }
/// Error for invalid parameter values
#[derive(Debug, Error, PartialEq, Eq)] #[derive(Debug, Error, PartialEq, Eq)]
pub enum ParameterError { pub enum ParameterError {
#[error("value `{value}` is out of range for parameter {name}")] #[error("value `{value}` is out of range for parameter {name}")]
OutOfRange { name: &'static str, value: i32 }, OutOfRange { name: &'static str, value: i32 },
} }
/// Error returned by the default Executor
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
#[error("Parameter error: {0}")] #[error("Parameter error: {0}")]
Parameter(#[from] ParameterError), Parameter(#[from] ParameterError),
#[cfg(feature = "reqwest")]
#[error("Network error: {0}")] #[error("Network error: {0}")]
Network(#[from] reqwest::Error), Network(#[from] reqwest::Error),
#[error("Parsing error: {0}")] #[error("Parsing error: {0}")]

View file

@ -2,11 +2,15 @@ use bytes::Bytes;
use http::StatusCode; use http::StatusCode;
#[cfg(feature = "requests")] #[cfg(feature = "requests")]
/// Auto-generated api requests definitions.
pub mod models; pub mod models;
/// A generic api request description.
#[derive(Default)] #[derive(Default)]
pub struct ApiRequest { pub struct ApiRequest {
/// The relative path relative to "<https://api.torn.com/v2>".
pub path: String, pub path: String,
/// All url parameters.
pub parameters: Vec<(&'static str, String)>, pub parameters: Vec<(&'static str, String)>,
} }
@ -28,17 +32,25 @@ impl ApiRequest {
} }
} }
/// A generic api response.
pub struct ApiResponse { pub struct ApiResponse {
/// The response body as binary blob.
pub body: Option<Bytes>, pub body: Option<Bytes>,
/// The HTTP response code.
pub status: StatusCode, pub status: StatusCode,
} }
/// Trait for typed api requests
pub trait IntoRequest: Send { pub trait IntoRequest: Send {
/// If used in bulk request, the discriminant is used to distinguish the responses. For
/// endpoints which have no path parameters this will be `()`.
type Discriminant: Send + 'static; type Discriminant: Send + 'static;
/// The response type which shall be deserialised.
type Response: for<'de> serde::Deserialize<'de> + Send; type Response: for<'de> serde::Deserialize<'de> + Send;
fn into_request(self) -> (Self::Discriminant, ApiRequest); fn into_request(self) -> (Self::Discriminant, ApiRequest);
} }
/* #[cfg(feature = "requests")]
pub(crate) struct WrappedApiRequest<R> pub(crate) struct WrappedApiRequest<R>
where where
R: IntoRequest, R: IntoRequest,
@ -47,6 +59,7 @@ where
request: ApiRequest, request: ApiRequest,
} }
#[cfg(feature = "requests")]
impl<R> IntoRequest for WrappedApiRequest<R> impl<R> IntoRequest for WrappedApiRequest<R>
where where
R: IntoRequest, R: IntoRequest,
@ -56,7 +69,7 @@ where
fn into_request(self) -> (Self::Discriminant, ApiRequest) { fn into_request(self) -> (Self::Discriminant, ApiRequest) {
(self.discriminant, self.request) (self.discriminant, self.request)
} }
} } */
#[cfg(test)] #[cfg(test)]
mod test {} mod test {}

View file

@ -1,6 +1,6 @@
include!(concat!(env!("OUT_DIR"), "/scopes.rs")); include!(concat!(env!("OUT_DIR"), "/scopes.rs"));
#[cfg(test)] #[cfg(all(test, feature = "reqwest"))]
pub(super) mod test { pub(super) mod test {
use std::{collections::VecDeque, sync::OnceLock, time::Duration}; use std::{collections::VecDeque, sync::OnceLock, time::Duration};