apca/api/v2/
watchlist.rs

1// Copyright (C) 2021-2024 The apca Developers
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use std::ops::Deref;
5
6use chrono::DateTime;
7use chrono::Utc;
8
9use http::Method;
10use http_endpoint::Bytes;
11
12use serde::Deserialize;
13use serde::Serialize;
14
15use serde_json::from_slice as from_json;
16use serde_json::to_vec as to_json;
17
18use uuid::Uuid;
19
20use crate::api::v2::account;
21use crate::api::v2::asset;
22use crate::Str;
23
24
25/// An ID uniquely identifying a watchlist.
26#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq)]
27pub struct Id(pub Uuid);
28
29impl Deref for Id {
30  type Target = Uuid;
31
32  #[inline]
33  fn deref(&self) -> &Self::Target {
34    &self.0
35  }
36}
37
38
39/// A watchlist.
40#[derive(Debug, Deserialize, Eq, PartialEq)]
41pub struct Watchlist {
42  /// The watchlist's ID.
43  #[serde(rename = "id")]
44  pub id: Id,
45  /// The watchlist's user-defined name.
46  #[serde(rename = "name")]
47  pub name: String,
48  /// The account's ID.
49  #[serde(rename = "account_id")]
50  pub account_id: account::Id,
51  /// Timestamp this watchlist was created at.
52  #[serde(rename = "created_at")]
53  pub created_at: DateTime<Utc>,
54  /// Timestamp this watchlist was last updated at.
55  #[serde(rename = "updated_at")]
56  pub updated_at: DateTime<Utc>,
57  /// The list of watched assets.
58  #[serde(rename = "assets")]
59  pub assets: Vec<asset::Asset>,
60  /// The type is non-exhaustive and open to extension.
61  #[doc(hidden)]
62  #[serde(skip)]
63  pub _non_exhaustive: (),
64}
65
66
67/// A request to create a watch list.
68#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
69pub struct CreateReq {
70  /// The watchlist's name.
71  #[serde(rename = "name")]
72  pub name: String,
73  /// The symbols to watch.
74  #[serde(rename = "symbols")]
75  pub symbols: Vec<String>,
76  /// The type is non-exhaustive and open to extension.
77  #[doc(hidden)]
78  pub _non_exhaustive: (),
79}
80
81
82/// A helper for initializing [`CreateReq`] objects.
83#[derive(Clone, Debug, Default, Eq, PartialEq)]
84pub struct CreateReqInit {
85  /// The symbols to watch.
86  pub symbols: Vec<String>,
87  /// The type is non-exhaustive and open to extension.
88  #[doc(hidden)]
89  pub _non_exhaustive: (),
90}
91
92impl CreateReqInit {
93  /// Create a [`CreateReq`] from a `CreateReqInit`.
94  #[inline]
95  pub fn init<S>(self, name: S) -> CreateReq
96  where
97    S: Into<String>,
98  {
99    let Self {
100      symbols,
101      _non_exhaustive: (),
102    } = self;
103
104    CreateReq {
105      name: name.into(),
106      symbols,
107      _non_exhaustive: (),
108    }
109  }
110}
111
112
113/// A request to update a watch list.
114pub type UpdateReq = CreateReq;
115/// A helper for initializing [`UpdateReq`] objects.
116pub type UpdateReqInit = CreateReqInit;
117
118
119Endpoint! {
120  /// The representation of a POST request to the /v2/watchlists endpoint.
121  pub Create(CreateReq),
122  Ok => Watchlist, [
123      /// The watchlist was created successfully.
124      /* 200 */ OK,
125  ],
126  Err => CreateError, [
127    /// The watchlist name was not unique or other parts of the input
128    /// are not valid.
129    /* 422 */ UNPROCESSABLE_ENTITY => InvalidInput,
130  ]
131
132  #[inline]
133  fn path(_input: &Self::Input) -> Str {
134      "/v2/watchlists".into()
135  }
136
137  #[inline]
138  fn method() -> Method {
139      Method::POST
140  }
141
142  fn body(input: &Self::Input) -> Result<Option<Bytes>, Self::ConversionError> {
143      let json = to_json(input)?;
144      let bytes = Bytes::from(json);
145      Ok(Some(bytes))
146  }
147}
148
149
150Endpoint! {
151  /// The representation of a GET request to the
152  /// /v2/watchlists/{watchlist-id} endpoint.
153  pub Get(Id),
154  Ok => Watchlist, [
155    /// The watchlist object with the given ID was retrieved successfully.
156    /* 200 */ OK,
157  ],
158  Err => GetError, [
159    /// No watchlist was found with the given ID.
160    /* 404 */ NOT_FOUND => NotFound,
161  ]
162
163  fn path(input: &Self::Input) -> Str {
164    format!("/v2/watchlists/{}", input.as_simple()).into()
165  }
166}
167
168
169Endpoint! {
170  /// The representation of a PUT request to the
171  /// /v2/watchlists/{watchlist-id} endpoint.
172  pub Update((Id, UpdateReq)),
173  Ok => Watchlist, [
174    /// The watchlist object with the given ID was retrieved successfully.
175    /* 200 */ OK,
176  ],
177  Err => UpdateError, [
178    /// No watchlist was found with the given ID.
179    /* 404 */ NOT_FOUND => NotFound,
180    /// The watchlist name was not unique or other parts of the input
181    /// are not valid.
182    /* 422 */ UNPROCESSABLE_ENTITY => InvalidInput,
183  ]
184
185  fn path(input: &Self::Input) -> Str {
186    let (id, _) = input;
187    format!("/v2/watchlists/{}", id.as_simple()).into()
188  }
189
190  #[inline]
191  fn method() -> Method {
192      Method::PUT
193  }
194
195  fn body(input: &Self::Input) -> Result<Option<Bytes>, Self::ConversionError> {
196    let (_, request) = input;
197    let json = to_json(request)?;
198    let bytes = Bytes::from(json);
199    Ok(Some(bytes))
200  }
201}
202
203
204EndpointNoParse! {
205  /// The representation of a DELETE request to the
206  /// /v2/watchlists/{watchlist-id} endpoint.
207  pub Delete(Id),
208  Ok => (), [
209    /// The watchlist was deleted successfully.
210    /* 204 */ NO_CONTENT,
211  ],
212  Err => DeleteError, [
213    /// No watchlist was found with the given ID.
214    /* 404 */ NOT_FOUND => NotFound,
215  ]
216
217  #[inline]
218  fn method() -> Method {
219    Method::DELETE
220  }
221
222  fn path(input: &Self::Input) -> Str {
223    format!("/v2/watchlists/{}", input.as_simple()).into()
224  }
225
226  #[inline]
227  fn parse(body: &[u8]) -> Result<Self::Output, Self::ConversionError> {
228    debug_assert_eq!(body, b"");
229    Ok(())
230  }
231
232  fn parse_err(body: &[u8]) -> Result<Self::ApiError, Vec<u8>> {
233    from_json::<Self::ApiError>(body).map_err(|_| body.to_vec())
234  }
235}
236
237
238#[cfg(test)]
239mod tests {
240  use super::*;
241
242  use crate::api::v2::account;
243  use crate::api_info::ApiInfo;
244  use crate::Client;
245  use crate::RequestError;
246
247  use test_log::test;
248
249
250  /// Check that we can create, retrieve, and delete a watchlist.
251  #[test(tokio::test)]
252  async fn create_get_delete() {
253    let api_info = ApiInfo::from_env().unwrap();
254    let client = Client::new(api_info);
255    let expected_symbols = vec!["AAPL".to_string(), "AMZN".to_string()];
256    let id = Uuid::new_v4().to_string();
257    let request = CreateReqInit {
258      symbols: expected_symbols.clone(),
259      ..Default::default()
260    }
261    .init(id.clone());
262
263    let created = client.issue::<Create>(&request).await.unwrap();
264    let result = client.issue::<Get>(&created.id).await;
265    client.issue::<Delete>(&created.id).await.unwrap();
266
267    let watchlist = result.unwrap();
268    let tracked_symbols = watchlist
269      .assets
270      .into_iter()
271      .map(|a| a.symbol)
272      .collect::<Vec<_>>();
273    assert_eq!(tracked_symbols, expected_symbols);
274
275    // Also check that the reported account ID matches our account.
276    let account = client.issue::<account::Get>(&()).await.unwrap();
277    assert_eq!(watchlist.name, id);
278    assert_eq!(watchlist.account_id, account.id);
279  }
280
281  /// Check that we get back the expected error when attempting to
282  /// create a watchlist with a name that is already taken.
283  #[test(tokio::test)]
284  async fn create_duplicate_name() {
285    let api_info = ApiInfo::from_env().unwrap();
286    let client = Client::new(api_info);
287
288    let name = "the-name";
289    let request = CreateReqInit {
290      symbols: vec!["SPY".to_string()],
291      ..Default::default()
292    }
293    .init(name);
294
295    let created = client.issue::<Create>(&request).await.unwrap();
296    let result = client.issue::<Create>(&request).await;
297
298    client.issue::<Delete>(&created.id).await.unwrap();
299
300    let err = result.unwrap_err();
301    match err {
302      RequestError::Endpoint(CreateError::InvalidInput(_)) => (),
303      _ => panic!("Received unexpected error: {err:?}"),
304    };
305  }
306
307  /// Verify that we report the appropriate error when attempting to
308  /// retrieve a watchlist that does not exist.
309  #[test(tokio::test)]
310  async fn get_non_existent() {
311    let api_info = ApiInfo::from_env().unwrap();
312    let client = Client::new(api_info);
313
314    let request = CreateReqInit {
315      symbols: vec!["AAPL".to_string()],
316      ..Default::default()
317    }
318    .init(Uuid::new_v4().to_string());
319
320    let created = client.issue::<Create>(&request).await.unwrap();
321    let () = client.issue::<Delete>(&created.id).await.unwrap();
322
323    let err = client.issue::<Get>(&created.id).await.unwrap_err();
324    match err {
325      RequestError::Endpoint(GetError::NotFound(_)) => (),
326      _ => panic!("Received unexpected error: {err:?}"),
327    };
328  }
329
330  /// Check that we can update a watchlist.
331  #[test(tokio::test)]
332  async fn update() {
333    let api_info = ApiInfo::from_env().unwrap();
334    let client = Client::new(api_info);
335    let symbols = vec!["AAPL".to_string()];
336    let id = Uuid::new_v4().to_string();
337    let request = CreateReqInit {
338      symbols: symbols.clone(),
339      ..Default::default()
340    }
341    .init(&id);
342
343    let created = client.issue::<Create>(&request).await.unwrap();
344
345    let id2 = Uuid::new_v4().to_string();
346    let symbols = vec!["AMZN".to_string(), "SPY".to_string()];
347
348    let req = UpdateReqInit {
349      symbols: symbols.clone(),
350      ..Default::default()
351    }
352    .init(&id2);
353
354    let result = client.issue::<Update>(&(created.id, req)).await;
355    let () = client.issue::<Delete>(&created.id).await.unwrap();
356
357    let watchlist = result.unwrap();
358    assert_eq!(watchlist.name, id2.to_string());
359    let symbols = watchlist
360      .assets
361      .iter()
362      .map(|asset| &asset.symbol)
363      .collect::<Vec<_>>();
364    assert_eq!(symbols, vec!["AMZN", "SPY"]);
365  }
366
367  /// Verify that we report the appropriate error when attempting to
368  /// delete a watchlist that does not exist.
369  #[test(tokio::test)]
370  async fn delete_non_existent() {
371    let api_info = ApiInfo::from_env().unwrap();
372    let client = Client::new(api_info);
373
374    let id = Id(Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap());
375    let err = client.issue::<Delete>(&id).await.unwrap_err();
376    match err {
377      RequestError::Endpoint(DeleteError::NotFound(_)) => (),
378      _ => panic!("Received unexpected error: {err:?}"),
379    };
380  }
381}