Skip to content
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
372 changes: 229 additions & 143 deletions crates/cli/src/commands.rs

Large diffs are not rendered by default.

62 changes: 30 additions & 32 deletions crates/cli/src/episodes.rs
Original file line number Diff line number Diff line change
@@ -1,35 +1,31 @@
use crate::EpisodeCommand;
use anyhow::{Context, Result, ensure};
use listenbox_sync_engine::{
api::{Api, string},
publicapi as p,
};
use anyhow::{Result, ensure};
use listenbox_sync_engine::{api::Api, publicapi as p};
use std::collections::HashSet;
pub async fn run(api: &Api, command: EpisodeCommand) -> Result<()> {
match command {
EpisodeCommand::List { show, limit } => {
let mut cursor = None;
let mut seen = HashSet::new();
loop {
let page = api
.json(
api.client().list_episodes(p::ListEpisodesParams {
show_slug: show.clone(),
limit: Some(i64::from(limit)),
cursor,
}),
&[200],
)
.await?;
for episode in page["episodes"]
.as_array()
.context("episode page missing episodes")?
let page = match api
.client()
.list_episodes(p::ListEpisodesParams {
show_slug: show.clone(),
limit: Some(i64::from(limit)),
cursor,
})
.await?
{
let id = string(episode, "id")?;
ensure!(crate::valid_id(id, "ep_"), "invalid episode ID in list");
p::ListEpisodesResponse::Status200(value) => value,
response => return Err(api.response_error(response).await),
};
for episode in page.episodes {
let id = episode.id;
ensure!(crate::valid_id(&id, "ep_"), "invalid episode ID in list");
println!("{id}");
}
cursor = page["next_cursor"].as_str().map(str::to_owned);
cursor = page.next_cursor;
let Some(next) = &cursor else {
return Ok(());
};
Expand Down Expand Up @@ -61,21 +57,23 @@ pub async fn run(api: &Api, command: EpisodeCommand) -> Result<()> {
}
EpisodeCommand::Delete { episode, yes } => {
ensure!(yes, "episodes delete: --yes is required");
let result = api
.json(
api.client()
.create_episode_deletion(p::CreateEpisodeDeletionParams {
episode_id: episode.clone(),
}),
&[202],
)
.await?;
let result = match api
.client()
.create_episode_deletion(p::CreateEpisodeDeletionParams {
episode_id: episode.clone(),
})
.await?
{
p::CreateEpisodeDeletionResponse::Status202(value) => value,
response => return Err(api.response_error(response).await),
};
crate::commands::delete_events(
api,
api.client()
.episode_deletion_events(p::EpisodeDeletionEventsParams {
episode_deletion_run_id: string(&result, "episode_deletion_run_id")?.into(),
}),
episode_deletion_run_id: result.episode_deletion_run_id,
})
.await?,
"episode",
"episode_id",
&episode,
Expand Down
50 changes: 25 additions & 25 deletions crates/sync-engine/src/api.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use crate::{auth::StoredAuth, config::Config};
use crate::{auth::StoredAuth, config::Config, publicapi as p};
use anyhow::{Context, Result, bail, ensure};
use reqwest::{Client, Response};
use reqwest_middleware::{ClientWithMiddleware, RequestBuilder};
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::{
future::Future,
Expand Down Expand Up @@ -67,12 +66,13 @@ impl Api {
Ok(reqwest_middleware::ClientBuilder::new(builder.build()?).build())
}

pub fn client(&self) -> crate::publicapi::Client {
pub fn client(&self) -> p::Client<&Self> {
crate::publicapi::Client::new(
self.http.clone(),
self.config.api_origin.clone(),
self.credential.clone(),
)
.with_transport(self)
}

pub async fn wait<T>(&self, work: impl Future<Output = Result<T>>) -> Result<T> {
Expand Down Expand Up @@ -136,31 +136,18 @@ impl Api {
self.wait(read_bounded(response, limit)).await
}

pub async fn decode<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
serde_json::from_slice(&self.bytes(response, 4 << 20).await?).context("decode API response")
}

pub async fn json(&self, request: RequestBuilder, statuses: &[u16]) -> Result<Value> {
let response = self.send(request).await?;
self.accept(response, statuses).await
}

pub async fn accept(&self, response: Response, statuses: &[u16]) -> Result<Value> {
if !statuses.contains(&response.status().as_u16()) {
return Err(self.response_error(response).await);
}
if response.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(Value::Null);
}
self.decode(response).await
}

pub async fn response_error(&self, response: Response) -> anyhow::Error {
let status = response.status().as_u16();
pub async fn response_error<R: p::Response>(&self, response: R) -> anyhow::Error {
let status = response.status();
if status == 401 {
return AuthenticationRequired.into();
}
let raw = self.bytes(response, 64 << 10).await.unwrap_or_default();
let error = match self.wait(async { Ok(response.into_error().await) }).await {
Ok(error) => error,
Err(error) => return error,
};
let p::Error::Http { body: raw, .. } = error else {
return error.into();
};
let message = serde_json::from_slice::<Value>(&raw)
.ok()
.and_then(|body| {
Expand All @@ -180,6 +167,19 @@ impl Api {
}
}

impl p::Transport for &Api {
type Error = anyhow::Error;

async fn execute<R: p::Response>(&self, request: RequestBuilder, limit: usize) -> Result<R> {
self.wait(async {
R::decode(self.send(request).await?, limit)
.await
.map_err(Into::into)
})
.await
}
}

pub async fn read_bounded(mut response: Response, limit: usize) -> Result<Vec<u8>> {
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await? {
Expand Down
67 changes: 36 additions & 31 deletions crates/sync-engine/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,28 +39,29 @@ pub async fn login_with(
mut open_browser: impl FnMut(&str),
) -> Result<()> {
let mut response = api
.send(
api.client()
.create_cli_authorization(p::CreateCLIAuthorizationParams {
body: serde_json::from_value(json!({"client": client, "scopes": SCOPES}))?,
}),
)
.client()
.create_cli_authorization(p::CreateCLIAuthorizationParams {
body: serde_json::from_value(json!({"client": client, "scopes": SCOPES}))?,
})
.await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED && api.credential.is_some() {
if matches!(response, p::CreateCLIAuthorizationResponse::Status401(()))
&& api.credential.is_some()
{
api.credential = None;
response = api
.send(
api.client()
.create_cli_authorization(p::CreateCLIAuthorizationParams {
body: serde_json::from_value(json!({"client": client, "scopes": SCOPES}))?,
}),
)
.client()
.create_cli_authorization(p::CreateCLIAuthorizationParams {
body: serde_json::from_value(json!({"client": client, "scopes": SCOPES}))?,
})
.await?;
}
let created = api.accept(response, &[201]).await?;
let credential = string(&created, "credential")?;
let created = match response {
p::CreateCLIAuthorizationResponse::Status201(created) => created,
response => return Err(api.response_error(response).await),
};
let credential = &created.credential;
ensure!(credential_valid(credential), "invalid pending credential");
let mut verification = Url::parse(string(&created, "verification_url")?)?;
let mut verification = Url::parse(&created.verification_url)?;
ensure!(
matches!(verification.scheme(), "http" | "https")
&& verification.host_str().is_some()
Expand All @@ -81,27 +82,27 @@ pub async fn login_with(
let mut events = Events::open(
api,
api.client()
.cli_authorization_events(p::CliAuthorizationEventsParams {
code: string(&created, "code")?.into(),
}),
.cli_authorization_events(p::CliAuthorizationEventsParams { code: created.code })
.await?,
)
.await?;
let event = events.next(api).await?;
match string(&event, "type")? {
"cli.authorization.approved" => {
api.credential = Some(credential.to_owned());
let identity = api.json(api.client().whoami(), &[200]).await?;
let identity = match api.client().whoami().await? {
p::WhoamiResponse::Status200(value) => value,
response => return Err(api.response_error(response).await),
};
ensure!(
string(&identity, "team_id")? == string(&event, "team_id")?,
identity.team_id == string(&event, "team_id")?,
"approved credential team does not match approved team"
);
let scopes = identity["scopes"]
.as_array()
.context("identity missing scopes")?;
for scope in SCOPES {
let scopes: Vec<p::ApiKeyScope> = serde_json::from_value(json!(SCOPES))?;
for scope in scopes {
ensure!(
scopes.iter().any(|value| value.as_str() == Some(scope)),
"approved credential missing scope {scope}"
identity.scopes.contains(&scope),
"approved credential missing scope {scope:?}"
);
}
write_private_json(
Expand Down Expand Up @@ -132,11 +133,15 @@ pub fn logout(config: &crate::config::Config) -> Result<()> {
}

pub async fn status(api: &Api) -> Result<()> {
let identity = api.json(api.client().whoami(), &[200]).await?;
let email = string(&identity, "email")?.trim();
let identity = match api.client().whoami().await? {
p::WhoamiResponse::Status200(value) => value,
response => return Err(api.response_error(response).await),
};
let email = identity.email.trim();
ensure!(!email.is_empty(), "identity email is empty");
let name = identity["name"]
.as_str()
let name = identity
.name
.as_deref()
.unwrap_or("")
.split_whitespace()
.collect::<Vec<_>>()
Expand Down
44 changes: 30 additions & 14 deletions crates/sync-engine/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,20 @@ impl Client {
if api.credential.is_none() {
return Err(crate::api::AuthenticationRequired.into());
}
Ok(serde_json::from_value(
api.json(
api.client().list_episodes(p::ListEpisodesParams {
Ok(
match api
.client()
.list_episodes(p::ListEpisodesParams {
show_slug: slug,
cursor,
limit: Some(50),
}),
&[200],
)
.await?,
)?)
})
.await?
{
p::ListEpisodesResponse::Status200(value) => value,
response => return Err(api.response_error(response).await),
},
)
}
fn api(&self, cancel: CancellationToken) -> Result<Api> {
Api::new(self.config.clone(), cancel)
Expand All @@ -103,15 +106,28 @@ impl Client {
if api.credential.is_none() {
return Err(crate::api::AuthenticationRequired.into());
}
let client = api.client();
let (teams, shows, account) = tokio::try_join!(
api.json(api.client().list_client_teams(), &[200]),
api.json(api.client().list_shows(), &[200]),
api.json(api.client().whoami(), &[200])
client.list_client_teams(),
client.list_shows(),
client.whoami()
)?;
let teams = match teams {
p::ListClientTeamsResponse::Status200(teams) => teams,
response => return Err(api.response_error(response).await),
};
let shows = match shows {
p::ListShowsResponse::Status200(shows) => shows,
response => return Err(api.response_error(response).await),
};
let account = match account {
p::WhoamiResponse::Status200(account) => account,
response => return Err(api.response_error(response).await),
};
Ok(Catalog {
teams: serde_json::from_value(teams)?,
import_team: Some(serde_json::from_value::<p::APIKeyWhoami>(account)?.team_id),
shows: serde_json::from_value(shows)?,
teams,
import_team: Some(account.team_id),
shows,
})
}
pub async fn import_playlist(
Expand Down
Loading
Loading