Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix double referenced types in paginated endpoints #337

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions rspotify-model/src/album.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pub struct SimplifiedAlbum {
}

/// Full Album Object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct FullAlbum {
pub artists: Vec<SimplifiedArtist>,
pub album_type: AlbumType,
Expand Down Expand Up @@ -67,7 +67,7 @@ pub struct PageSimplifiedAlbums {
}

/// Saved Album object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct SavedAlbum {
pub added_at: DateTime<Utc>,
pub album: FullAlbum,
Expand Down
2 changes: 1 addition & 1 deletion rspotify-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub mod audio;
pub mod auth;
pub mod category;
pub mod context;
pub(in crate) mod custom_serde;
pub(crate) mod custom_serde;
pub mod device;
pub mod enums;
pub mod error;
Expand Down
4 changes: 2 additions & 2 deletions rspotify-model/src/playlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub struct SimplifiedPlaylist {
}

/// Full playlist object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct FullPlaylist {
pub collaborative: bool,
pub description: Option<String>,
Expand All @@ -62,7 +62,7 @@ pub struct PlaylistItem {
}

/// Featured playlists object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct FeaturedPlaylists {
pub message: String,
pub playlists: Page<SimplifiedPlaylist>,
Expand Down
2 changes: 1 addition & 1 deletion rspotify-model/src/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pub struct Show {
}

/// Full show object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct FullShow {
pub available_markets: Vec<String>,
pub copyrights: Vec<Copyright>,
Expand Down
2 changes: 1 addition & 1 deletion src/auth_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub struct AuthCodeSpotify {
pub oauth: OAuth,
pub config: Config,
pub token: Arc<Mutex<Option<Token>>>,
pub(in crate) http: HttpClient,
pub(crate) http: HttpClient,
}

/// This client has access to the base methods.
Expand Down
2 changes: 1 addition & 1 deletion src/auth_code_pkce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub struct AuthCodePkceSpotify {
pub token: Arc<Mutex<Option<Token>>>,
/// The code verifier for the authentication process
pub verifier: Option<String>,
pub(in crate) http: HttpClient,
pub(crate) http: HttpClient,
}

/// This client has access to the base methods.
Expand Down
2 changes: 1 addition & 1 deletion src/client_creds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub struct ClientCredsSpotify {
pub config: Config,
pub creds: Credentials,
pub token: Arc<Mutex<Option<Token>>>,
pub(in crate) http: HttpClient,
pub(crate) http: HttpClient,
}

/// This client has access to the base methods.
Expand Down
63 changes: 39 additions & 24 deletions src/clients/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::{
auth_urls,
clients::{
convert_result,
pagination::{paginate, Paginator},
pagination::{paginate, paginate_with_ctx, Paginator},
},
http::{BaseHttpClient, Form, Headers, HttpClient, Query},
join_ids,
Expand Down Expand Up @@ -313,13 +313,20 @@ where
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-albums)
fn artist_albums<'a>(
&'a self,
artist_id: &'a ArtistId<'_>,
artist_id: ArtistId<'a>,
album_type: Option<AlbumType>,
market: Option<Market>,
) -> Paginator<'_, ClientResult<SimplifiedAlbum>> {
paginate(
move |limit, offset| {
self.artist_albums_manual(artist_id, album_type, market, Some(limit), Some(offset))
paginate_with_ctx(
(self, artist_id),
move |(slf, artist_id), limit, offset| {
slf.artist_albums_manual(
artist_id.as_ref(),
album_type,
market,
Some(limit),
Some(offset),
)
},
self.get_config().pagination_chunks,
)
Expand All @@ -328,7 +335,7 @@ where
/// The manually paginated version of [`Self::artist_albums`].
async fn artist_albums_manual(
&self,
artist_id: &ArtistId<'_>,
artist_id: ArtistId<'_>,
album_type: Option<AlbumType>,
market: Option<Market>,
limit: Option<u32>,
Expand Down Expand Up @@ -466,18 +473,21 @@ where
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-albums-tracks)
fn album_track<'a>(
&'a self,
album_id: &'a AlbumId<'_>,
album_id: AlbumId<'a>,
) -> Paginator<'_, ClientResult<SimplifiedTrack>> {
paginate(
move |limit, offset| self.album_track_manual(album_id, Some(limit), Some(offset)),
paginate_with_ctx(
(self, album_id),
move |(slf, album_id), limit, offset| {
slf.album_track_manual(album_id.as_ref(), Some(limit), Some(offset))
},
self.get_config().pagination_chunks,
)
}

/// The manually paginated version of [`Self::album_track`].
async fn album_track_manual(
&self,
album_id: &AlbumId<'_>,
album_id: AlbumId<'_>,
limit: Option<u32>,
offset: Option<u32>,
) -> ClientResult<Page<SimplifiedTrack>> {
Expand Down Expand Up @@ -626,12 +636,13 @@ where
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-shows-episodes)
fn get_shows_episodes<'a>(
&'a self,
id: &'a ShowId<'_>,
id: ShowId<'a>,
market: Option<Market>,
) -> Paginator<'_, ClientResult<SimplifiedEpisode>> {
paginate(
move |limit, offset| {
self.get_shows_episodes_manual(id, market, Some(limit), Some(offset))
paginate_with_ctx(
(self, id),
move |(slf, id), limit, offset| {
slf.get_shows_episodes_manual(id.as_ref(), market, Some(limit), Some(offset))
},
self.get_config().pagination_chunks,
)
Expand All @@ -640,7 +651,7 @@ where
/// The manually paginated version of [`Self::get_shows_episodes`].
async fn get_shows_episodes_manual(
&self,
id: &ShowId<'_>,
id: ShowId<'_>,
market: Option<Market>,
limit: Option<u32>,
offset: Option<u32>,
Expand Down Expand Up @@ -991,15 +1002,16 @@ where
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-playlists-tracks)
fn playlist_items<'a>(
&'a self,
playlist_id: &'a PlaylistId<'_>,
playlist_id: PlaylistId<'a>,
fields: Option<&'a str>,
market: Option<Market>,
) -> Paginator<'_, ClientResult<PlaylistItem>> {
paginate(
move |limit, offset| {
self.playlist_items_manual(
paginate_with_ctx(
(self, playlist_id, fields),
move |(slf, playlist_id, fields), limit, offset| {
slf.playlist_items_manual(
playlist_id.as_ref(),
fields,
*fields,
market,
Some(limit),
Some(offset),
Expand Down Expand Up @@ -1045,18 +1057,21 @@ where
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-list-users-playlists)
fn user_playlists<'a>(
&'a self,
user_id: &'a UserId<'_>,
user_id: UserId<'a>,
) -> Paginator<'_, ClientResult<SimplifiedPlaylist>> {
paginate(
move |limit, offset| self.user_playlists_manual(user_id, Some(limit), Some(offset)),
paginate_with_ctx(
(self, user_id),
move |(slf, user_id), limit, offset| {
slf.user_playlists_manual(user_id.as_ref(), Some(limit), Some(offset))
},
self.get_config().pagination_chunks,
)
}

/// The manually paginated version of [`Self::user_playlists`].
async fn user_playlists_manual(
&self,
user_id: &UserId<'_>,
user_id: UserId<'_>,
limit: Option<u32>,
offset: Option<u32>,
) -> ClientResult<Page<SimplifiedPlaylist>> {
Expand Down
4 changes: 2 additions & 2 deletions src/clients/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ use std::fmt::Write as _;
use serde::Deserialize;

/// Converts a JSON response from Spotify into its model.
pub(in crate) fn convert_result<'a, T: Deserialize<'a>>(input: &'a str) -> ClientResult<T> {
pub(crate) fn convert_result<'a, T: Deserialize<'a>>(input: &'a str) -> ClientResult<T> {
serde_json::from_str::<T>(input).map_err(Into::into)
}

/// Append device ID to an API path.
pub(in crate) fn append_device_id(path: &str, device_id: Option<&str>) -> String {
pub(crate) fn append_device_id(path: &str, device_id: Option<&str>) -> String {
let mut new_path = path.to_string();
if let Some(device_id) = device_id {
if path.contains('?') {
Expand Down
11 changes: 11 additions & 0 deletions src/clients/pagination/iter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ use crate::{model::Page, ClientError, ClientResult};
/// Alias for `Iterator<Item = T>`, since sync mode is enabled.
pub type Paginator<'a, T> = Box<dyn Iterator<Item = T> + 'a>;

pub fn paginate_with_ctx<'a, Ctx: 'a, T: 'a, Request: 'a>(
ctx: Ctx,
req: Request,
page_size: u32,
) -> Paginator<'a, ClientResult<T>>
Copy link
Owner

@ramsayleung ramsayleung Sep 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a little confused that what's ctx for? What's purpose of ctx variable?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the sync case: not much. This is only to keep compatibility with the async version, which needs those. I'll try to explain why on the other review comments.

where
Request: Fn(&Ctx, u32, u32) -> ClientResult<Page<T>>,
{
paginate(move |limit, offset| req(&ctx, limit, offset), page_size)
}

/// This is used to handle paginated requests automatically.
pub fn paginate<'a, T: 'a, Request: 'a>(
req: Request,
Expand Down
8 changes: 6 additions & 2 deletions src/clients/pagination/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
//! * A `Paginator` struct which wraps the iterable of items
//! * A `paginate` function, which returns a `Paginator` based on a request that
//! may be repeated in order to return a continuous sequence of `Page`s
//! * A `paginate_with_ctx` function that does the same as the `paginate`
//! function, but accepts a generic context that works around lifetime issues
//! in the async version due to restrictions in HRTBs
//! (<https://kevincox.ca/2022/04/16/rust-generic-closure-lifetimes/>)
//!
//! Note that `Paginator` should actually be a trait so that a dynamic
//! allocation can be avoided when returning it with `-> impl Iterator<T>`, as
Expand All @@ -25,6 +29,6 @@ mod iter;
mod stream;

#[cfg(feature = "__sync")]
pub use iter::{paginate, Paginator};
pub use iter::{paginate, paginate_with_ctx, Paginator};
#[cfg(feature = "__async")]
pub use stream::{paginate, Paginator};
pub use stream::{paginate, paginate_with_ctx, Paginator};
27 changes: 27 additions & 0 deletions src/clients/pagination/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,34 @@ use futures::{future::Future, stream::Stream};
/// Alias for `futures::stream::Stream<Item = T>`, since async mode is enabled.
pub type Paginator<'a, T> = Pin<Box<dyn Stream<Item = T> + 'a>>;

pub type RequestFuture<'a, T> = Pin<Box<dyn 'a + Future<Output = ClientResult<Page<T>>>>>;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to say, It's a little headache to understand this function signature 😂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean, coming up with it wasn't easy either. 😀 In essence it's mostly the signature of a Future that returns a ClientResult<Page<T>> with the speciality that it is Boxed (and pinned) here.


/// This is used to handle paginated requests automatically.
pub fn paginate_with_ctx<'a, Ctx: 'a, T, Request>(
ctx: Ctx,
req: Request,
page_size: u32,
) -> Paginator<'a, ClientResult<T>>
where
T: 'a + Unpin,
Request: 'a + for<'ctx> Fn(&'ctx Ctx, u32, u32) -> RequestFuture<'ctx, T>,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if I understand the design correctly, so I try to retell the story.

What the signature means is to allow req function to hold a reference to Ctx in the returned future with HRTB

So that the returned value of the req function is allowed to reference the argument(Ctx), we pass it.

All the thing we want to do is to specify that the lifetime of Ctx parameter must outlive the lifetime of RequestFuture?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds correct. I'll add my version, so you can double check. :)

The problem we need to solve is: When we move anything into the closure (req), the returned future must not reference it in some way, since we can't ensure that the closure outlives the returned future.

The way we solve it: We pass the reference from outside and await the future nearly immediately afterwards. As such, the lifetime bounds are upheld and the result we get no longer references ctx so we can safely stream it to the consumer.

Due to the limitations in HRTBs, we can't formulate a bound at this time that works with a raw impl Future<...> so we need to Box it (which creates the monstrosity from your above review comment 😅).

Copy link
Owner

@ramsayleung ramsayleung Oct 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a little confused about this:

We pass the reference from outside and await the future nearly immediately afterwards. As such, the lifetime bounds are upheld

    fn user_playlists<'a>(
        &'a self,
        user_id: UserId<'a>,
    ) -> Paginator<'_, ClientResult<SimplifiedPlaylist>> {
        paginate_with_ctx(
            (self, user_id),
            move |(slf, user_id), limit, offset| {
                slf.user_playlists_manual(user_id.as_ref(), Some(limit), Some(offset))
            },
            self.get_config().pagination_chunks,
        )
    }

We pass the (self, user_id) as the ctx, but how does the lifetime bound?

and the result we get no longer references ctx so we can safely stream it to the consumer.

According to the function signature: for<'ctx> Fn(&'ctx Ctx, u32, u32), the closure references to ctx, do you mean the result RequestFuture doesn't reference to ctx?

By the way, I realize Rust is hard, I still have a lot to learn. 😂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a little confused about this:

We pass the reference from outside and await the future nearly immediately afterwards. As such, the lifetime bounds are upheld

...
We pass the (self, user_id) as the ctx, but how does the lifetime bound?

What I meant by that is:

pub fn paginate_with_ctx<'a, Ctx: 'a, T, Request>(...) -> Paginator<'a, ClientResult<T>>
where
    T: 'a + Unpin,
    Request: 'a + for<'ctx> Fn(&'ctx Ctx, u32, u32) -> RequestFuture<'ctx, T>,
{
    let mut offset = 0;
    Box::pin(stream! {
        loop {
            // we create a reference to ctx here
            let page = req(&ctx, page_size, offset).await?;
            // due to `await`, the reference is not used again after this line
            offset += page.items.len() as u32;
            for item in page.items {
                // doesn't reference ctx, so yielding the items is fine
                yield Ok(item);
            }
            ...
        }
    })
}

and the result we get no longer references ctx so we can safely stream it to the consumer.

According to the function signature: for<'ctx> Fn(&'ctx Ctx, u32, u32), the closure references to ctx, do you mean the result RequestFuture doesn't reference to ctx?

The function itself takes a reference and so does the RequestFuture, but when RequestFuture is awaited, we get a ClientResult<T> which does not reference ctx.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get your point, it makes sense:

pub fn paginate_with_ctx<'a, Ctx: 'a, T, Request>(...) -> Paginator<'a, ClientResult<T>>
where
    T: 'a + Unpin,
    Request: 'a + for<'ctx> Fn(&'ctx Ctx, u32, u32) -> RequestFuture<'ctx, T>,
{
    let mut offset = 0;
    Box::pin(stream! {
        loop {
            // we create a reference to ctx here. 
            // the `&ctx` is a shortlived reference, it's just like we put `ctx` into a bounded scope.
           //  ---- scope begins here ----
            let page = req(&ctx, page_size, offset).await?;
            // due to `await`, the reference is not used again after this line
           // ------ scope ends here -----
            offset += page.items.len() as u32;
            for item in page.items {
                // doesn't reference ctx, so yielding the items is fine
                yield Ok(item);
            }
            ...
        }
    })
}

It's clear now :)

{
use async_stream::stream;
let mut offset = 0;
Box::pin(stream! {
loop {
let page = req(&ctx, page_size, offset).await?;
offset += page.items.len() as u32;
for item in page.items {
yield Ok(item);
}
if page.next.is_none() {
break;
}
}
})
}

pub fn paginate<'a, T, Fut, Request>(req: Request, page_size: u32) -> Paginator<'a, ClientResult<T>>
where
T: 'a + Unpin,
Expand Down
12 changes: 6 additions & 6 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ pub mod prelude {
}

/// Common headers as constants.
pub(in crate) mod params {
pub(crate) mod params {
pub const CLIENT_ID: &str = "client_id";
pub const CODE: &str = "code";
pub const GRANT_TYPE: &str = "grant_type";
Expand All @@ -177,14 +177,14 @@ pub(in crate) mod params {
}

/// Common alphabets for random number generation and similars
pub(in crate) mod alphabets {
pub(crate) mod alphabets {
pub const ALPHANUM: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
/// From <https://datatracker.ietf.org/doc/html/rfc7636#section-4.1>
pub const PKCE_CODE_VERIFIER: &[u8] =
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
}

pub(in crate) mod auth_urls {
pub(crate) mod auth_urls {
pub const AUTHORIZE: &str = "https://accounts.spotify.com/authorize";
pub const TOKEN: &str = "https://accounts.spotify.com/api/token";
}
Expand Down Expand Up @@ -276,7 +276,7 @@ impl Default for Config {
///
/// It is assumed that system always provides high-quality cryptographically
/// secure random data, ideally backed by hardware entropy sources.
pub(in crate) fn generate_random_string(length: usize, alphabet: &[u8]) -> String {
pub(crate) fn generate_random_string(length: usize, alphabet: &[u8]) -> String {
let mut buf = vec![0u8; length];
getrandom(&mut buf).unwrap();
let range = alphabet.len();
Expand All @@ -287,13 +287,13 @@ pub(in crate) fn generate_random_string(length: usize, alphabet: &[u8]) -> Strin
}

#[inline]
pub(in crate) fn join_ids<'a, T: Id + 'a>(ids: impl IntoIterator<Item = T>) -> String {
pub(crate) fn join_ids<'a, T: Id + 'a>(ids: impl IntoIterator<Item = T>) -> String {
let ids = ids.into_iter().collect::<Vec<_>>();
ids.iter().map(Id::id).collect::<Vec<_>>().join(",")
}

#[inline]
pub(in crate) fn join_scopes(scopes: &HashSet<String>) -> String {
pub(crate) fn join_scopes(scopes: &HashSet<String>) -> String {
scopes
.iter()
.map(String::as_str)
Expand Down
8 changes: 4 additions & 4 deletions tests/test_with_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ async fn test_album_tracks() {
let birdy_uri = AlbumId::from_uri("spotify:album:6akEvsycLGftJxYudPjmqK").unwrap();
creds_client()
.await
.album_track_manual(&birdy_uri, Some(2), None)
.album_track_manual(birdy_uri, Some(2), None)
.await
.unwrap();
}
Expand All @@ -71,7 +71,7 @@ async fn test_artists_albums() {
creds_client()
.await
.artist_albums_manual(
&birdy_uri,
birdy_uri,
Some(AlbumType::Album),
Some(Market::Country(Country::UnitedStates)),
Some(10),
Expand Down Expand Up @@ -190,7 +190,7 @@ mod test_pagination {
let album = AlbumId::from_uri(ALBUM).unwrap();

let names = client
.album_track(&album)
.album_track(album)
.map(|track| track.unwrap().name)
.collect::<Vec<_>>();

Expand All @@ -208,7 +208,7 @@ mod test_pagination {
let album = AlbumId::from_uri(ALBUM).unwrap();

let names = client
.album_track(&album)
.album_track(album)
.map(|track| track.unwrap().name)
.collect::<Vec<_>>()
.await;
Expand Down
Loading