From 5c666741142698482f2e50ab823c5a1dae293577 Mon Sep 17 00:00:00 2001 From: Alexey Timin Date: Mon, 20 Jul 2026 19:37:04 +0200 Subject: [PATCH 1/4] implement login command for auth2 --- Cargo.toml | 2 +- src/cmd.rs | 1 + src/cmd/login.rs | 275 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 3 + 4 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 src/cmd/login.rs diff --git a/Cargo.toml b/Cargo.toml index c5d16a1..79dd3b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ toml = "1.0.6" serde = { version = "1.0.219", features = ["derive"] } anyhow = "1.0.98" url = { version = "2.5.4", features = ["serde"] } -tokio = { version = "1.52.3", features = ["rt-multi-thread"] } +tokio = { version = "1.52.3", features = ["io-util", "net", "rt-multi-thread", "time"] } time-humanize = "0.1.3" bytesize = "2.3.1" colored = "3.0.0" diff --git a/src/cmd.rs b/src/cmd.rs index 2f40532..fd1584a 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -7,6 +7,7 @@ pub(crate) mod attachment; pub(crate) mod bucket; pub(crate) mod cp; pub(crate) mod lifecycle; +pub(crate) mod login; pub(crate) mod replica; pub(crate) mod rm; pub(crate) mod server; diff --git a/src/cmd/login.rs b/src/cmd/login.rs new file mode 100644 index 0000000..0412674 --- /dev/null +++ b/src/cmd/login.rs @@ -0,0 +1,275 @@ +// Copyright 2026 ReductStore +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::{net::SocketAddr, time::Duration}; + +use anyhow::{anyhow, bail, Context}; +use clap::{Arg, ArgMatches, Command}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + time::timeout, +}; +use url::Url; + +use crate::{ + cmd::ALIAS_OR_URL_HELP, + config::{find_alias, ConfigFile}, + context::CliContext, + io::std::output, +}; + +const LOGIN_TIMEOUT: Duration = Duration::from_secs(300); + +pub(crate) fn login_cmd() -> Command { + Command::new("login") + .about("Authenticate an alias or URL through OAuth2") + .arg( + Arg::new("ALIAS_OR_URL") + .help(ALIAS_OR_URL_HELP) + .required(true), + ) +} + +pub(crate) async fn login_handler(ctx: &CliContext, args: &ArgMatches) -> anyhow::Result<()> { + let alias_or_url = args + .get_one::("ALIAS_OR_URL") + .expect("required argument"); + let target = login_target(ctx, alias_or_url)?; + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("Failed to start local OAuth2 callback server")?; + let login_url = login_url(&target.url, listener.local_addr()?)?; + + output!(ctx, "Open this URL to authenticate:\n {login_url}"); + let callback = timeout(LOGIN_TIMEOUT, receive_callback(&listener)) + .await + .map_err(|_| anyhow!("Timed out waiting for OAuth2 authentication callback"))??; + if let Some(alias_name) = target.alias_name { + save_token(ctx, &alias_name, &callback.access_token)?; + output!( + ctx, + "Logged in to '{}'. Token expires in {}s.", + alias_name, + callback.expires_in + ); + } else { + let mut authenticated_url = target.url; + authenticated_url + .set_username(&callback.access_token) + .expect("gateway URL supports a username"); + output!( + ctx, + "Logged in to '{}'. Token expires in {}s.\nUse this URL for future commands:\n {}", + target.name, + callback.expires_in, + authenticated_url + ); + } + Ok(()) +} + +struct LoginTarget { + name: String, + url: Url, + alias_name: Option, +} + +struct Callback { + access_token: String, + expires_in: u64, +} + +fn login_target(ctx: &CliContext, alias_or_url: &str) -> anyhow::Result { + if let Ok(alias) = find_alias(ctx, alias_or_url) { + return Ok(LoginTarget { + name: alias_or_url.to_owned(), + url: alias.url, + alias_name: Some(alias_or_url.to_owned()), + }); + } + + let mut url = Url::parse(alias_or_url) + .map_err(|_| anyhow!("'{}' isn't an alias or a valid HTTP URL", alias_or_url))?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + bail!("'{}' isn't an alias or a valid HTTP URL", alias_or_url); + } + url.set_username("") + .map_err(|_| anyhow!("'{}' isn't an alias or a valid HTTP URL", alias_or_url))?; + url.set_password(None) + .map_err(|_| anyhow!("'{}' isn't an alias or a valid HTTP URL", alias_or_url))?; + url.set_query(None); + url.set_fragment(None); + Ok(LoginTarget { + name: url.to_string(), + url, + alias_name: None, + }) +} + +fn login_url(gateway_url: &Url, callback_addr: SocketAddr) -> anyhow::Result { + let mut url = gateway_url.join("/api/v1/auth/login")?; + let callback_url = format!("http://{callback_addr}/callback"); + url.query_pairs_mut() + .append_pair("cli_redirect", &callback_url); + Ok(url) +} + +async fn receive_callback(listener: &TcpListener) -> anyhow::Result { + let (mut stream, _) = listener.accept().await?; + let request_target = read_request_target(&mut stream).await?; + let callback = parse_callback(&request_target); + let response = if callback.is_ok() { + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\nAuthentication successful. You can close this tab." + } else { + "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + }; + stream.write_all(response.as_bytes()).await?; + callback +} + +async fn read_request_target(stream: &mut TcpStream) -> anyhow::Result { + let mut request = Vec::with_capacity(1024); + loop { + if request.len() >= 16 * 1024 { + bail!("OAuth2 callback request is too large"); + } + let mut buffer = [0_u8; 1024]; + let read = stream.read(&mut buffer).await?; + if read == 0 { + bail!("OAuth2 callback request is incomplete"); + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = std::str::from_utf8(&request).context("OAuth2 callback is not valid HTTP")?; + let request_line = request + .lines() + .next() + .context("OAuth2 callback is missing request line")?; + let mut parts = request_line.split_whitespace(); + if parts.next() != Some("GET") { + bail!("OAuth2 callback must use GET"); + } + parts + .next() + .map(str::to_owned) + .context("OAuth2 callback is missing request target") +} + +fn parse_callback(request_target: &str) -> anyhow::Result { + let url = Url::parse(&format!("http://localhost{request_target}"))?; + if url.path() != "/callback" { + bail!("OAuth2 callback has an invalid path"); + } + let query = url + .query_pairs() + .collect::>(); + let access_token = query + .get("access_token") + .filter(|token| !token.is_empty()) + .context("OAuth2 callback is missing access token")? + .to_string(); + let expires_in = query + .get("expires_in") + .context("OAuth2 callback is missing token expiration")? + .parse() + .context("OAuth2 callback has invalid token expiration")?; + Ok(Callback { + access_token, + expires_in, + }) +} + +fn save_token(ctx: &CliContext, alias_name: &str, access_token: &str) -> anyhow::Result<()> { + let mut config_file = ConfigFile::load(ctx.config_path())?; + let alias = config_file + .mut_config() + .aliases + .get_mut(alias_name) + .ok_or_else(|| anyhow!("Alias '{}' does not exist", alias_name))?; + alias.token = access_token.to_owned(); + config_file.save() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{config::ConfigFile, context::tests::context}; + use rstest::rstest; + + #[test] + fn parses_callback_token_and_expiration() { + let callback = + parse_callback("/callback?access_token=token%20value&expires_in=3600").unwrap(); + assert_eq!(callback.access_token, "token value"); + assert_eq!(callback.expires_in, 3600); + } + + #[test] + fn rejects_invalid_callback() { + assert!(parse_callback("/other?access_token=token&expires_in=3600").is_err()); + assert!(parse_callback("/callback?expires_in=3600").is_err()); + assert!(parse_callback("/callback?access_token=token&expires_in=invalid").is_err()); + } + + #[rstest] + fn saves_received_token(context: CliContext) { + save_token(&context, "default", "oauth-token").unwrap(); + let config = ConfigFile::load(context.config_path()).unwrap(); + assert_eq!(config.config().aliases["default"].token, "oauth-token"); + } + + #[test] + fn constructs_loopback_login_url() { + let url = login_url( + &Url::parse("https://gateway.example.com").unwrap(), + "127.0.0.1:1234".parse().unwrap(), + ) + .unwrap(); + assert_eq!(url.as_str(), "https://gateway.example.com/api/v1/auth/login?cli_redirect=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback"); + } + + #[rstest] + fn resolves_direct_gateway_url(context: CliContext) { + let target = + login_target(&context, "http://old-token@localhost:8384?ignored=value").unwrap(); + assert_eq!(target.name, "http://localhost:8384/"); + assert_eq!(target.url.as_str(), "http://localhost:8384/"); + assert_eq!(target.alias_name, None); + } + + #[rstest] + fn rejects_invalid_login_target(context: CliContext) { + assert!(login_target(&context, "invalid-target").is_err()); + assert!(login_target(&context, "ftp://localhost:8384").is_err()); + } + + #[tokio::test] + async fn receives_http_callback() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let callback = tokio::spawn(async move { receive_callback(&listener).await }); + let mut stream = TcpStream::connect(address).await.unwrap(); + stream + .write_all( + b"GET /callback?access_token=oauth-token&expires_in=3600 HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await + .unwrap(); + stream.shutdown().await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + + let callback = callback.await.unwrap().unwrap(); + assert_eq!(callback.access_token, "oauth-token"); + assert_eq!(callback.expires_in, 3600); + assert!(std::str::from_utf8(&response) + .unwrap() + .contains("Authentication successful")); + } +} diff --git a/src/main.rs b/src/main.rs index 234cf1a..853bd19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ use std::time::Duration; use crate::cmd::bucket::{bucket_cmd, bucket_handler}; use crate::cmd::cp::{cp_cmd, cp_handler}; use crate::cmd::lifecycle::{lifecycle_cmd, lifecycle_handler}; +use crate::cmd::login::{login_cmd, login_handler}; use crate::cmd::replica::{replication_cmd, replication_handler}; use crate::cmd::rm::{rm_cmd, rm_handler}; use crate::cmd::server::{server_cmd, server_handler}; @@ -75,6 +76,7 @@ fn cli() -> Command { .subcommand(token_cmd()) .subcommand(replication_cmd()) .subcommand(lifecycle_cmd()) + .subcommand(login_cmd()) .subcommand(cp_cmd()) .subcommand(rm_cmd()) } @@ -102,6 +104,7 @@ async fn main() -> anyhow::Result<()> { Some(("token", args)) => token_handler(&ctx, args.subcommand()).await, Some(("replica", args)) => replication_handler(&ctx, args.subcommand()).await, Some(("lifecycle", args)) => lifecycle_handler(&ctx, args.subcommand()).await, + Some(("login", args)) => login_handler(&ctx, args).await, Some(("cp", args)) => cp_handler(&ctx, args).await, Some(("rm", args)) => rm_handler(&ctx, args).await, From 65528273e7aef6103176454b463a5b57cb394149 Mon Sep 17 00:00:00 2001 From: Alexey Timin Date: Mon, 20 Jul 2026 20:03:18 +0200 Subject: [PATCH 2/4] add kilo skills --- .gitignore | 7 +- .kilo/skills/create-pr/SKILL.md | 68 +++++++++++++++++++ .../create-pr/scripts/fetch_pr_template.sh | 20 ++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 .kilo/skills/create-pr/SKILL.md create mode 100755 .kilo/skills/create-pr/scripts/fetch_pr_template.sh diff --git a/.gitignore b/.gitignore index 5bd05d6..cc77a9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ target -.idea \ No newline at end of file +.idea +.kilo/ +!.kilo/ +.kilo/* +!.kilo/skills/ +!.kilo/skills/** diff --git a/.kilo/skills/create-pr/SKILL.md b/.kilo/skills/create-pr/SKILL.md new file mode 100644 index 0000000..5cc8d3b --- /dev/null +++ b/.kilo/skills/create-pr/SKILL.md @@ -0,0 +1,68 @@ +--- +name: create-pr +description: Create GitHub pull requests with the gh CLI using the reductstore/.github PR template, then update CHANGELOG.md with the created PR ID and commit the changelog (do not push). Use when a user asks to open a PR and record its ID in the changelog. +--- + +# Create Pr + +## Overview + +Create a PR using the standardized template from reductstore/.github, then record the PR number in CHANGELOG.md and commit the changelog without pushing. + +## Workflow + +### 1) Preconditions +- Ensure `gh auth status` is logged in and the current branch is the intended PR branch. +- Ensure the working tree is clean (or only the intended changes are present). +- Use context from the current conversation to infer intent, scope, and any constraints. + +### 2) Understand changes and rationale +- Compare the current branch against `main` to understand what changed and why (use this to derive the PR title, description, and rationale): + - `git fetch origin main` + - `git log --oneline origin/main..HEAD` + - `git diff --stat origin/main...HEAD` + - `git diff origin/main...HEAD` +- If the branch name starts with an issue number (e.g., `123-...`), use that issue in the rationale and fill the `Closes #` line in the PR template. +- If the branch name does not include an issue number, infer the likely issue from changes and conversation context, or leave `Closes #` empty if unsure. + +### 3) Fetch the PR template +Use the helper script to download the latest PR template from reductstore/.github: + +```bash +./.kilo/skills/create-pr/scripts/fetch_pr_template.sh /tmp/pr_template.md +``` + +Fill in the template file with the relevant summary, testing, and rationale derived from the diff and conversation context. + +### 4) Create the PR with gh +Use the filled template as the PR body: + +```bash +gh pr create --title "" --body-file /tmp/pr_template.md +``` + +Capture the PR number after creation: + +```bash +gh pr view --json number -q .number +``` + +### 5) Update CHANGELOG.md +- Find the appropriate section (usually the most recent/unreleased section). +- Add a new entry following the existing style in the file. +- Include the PR ID as `#<number>` exactly as prior entries do. + +### 6) Commit (no push) +Stage and commit the changelog update only: + +```bash +git add CHANGELOG.md +git commit -m "Update changelog for PR #<number>" +``` + +Do not push. + +## Resources + +### scripts/ +- `fetch_pr_template.sh`: Download the latest PR template from reductstore/.github. diff --git a/.kilo/skills/create-pr/scripts/fetch_pr_template.sh b/.kilo/skills/create-pr/scripts/fetch_pr_template.sh new file mode 100755 index 0000000..cd9730b --- /dev/null +++ b/.kilo/skills/create-pr/scripts/fetch_pr_template.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 <output-file>" >&2 + exit 1 +fi + +output_file="$1" + +curl -sS -L \ + "https://raw.githubusercontent.com/reductstore/.github/main/.github/pull_request_template.md" \ + -o "$output_file" + +if [[ ! -s "$output_file" ]]; then + echo "Template download failed or empty: $output_file" >&2 + exit 1 +fi + +echo "Template saved to $output_file" From 2a5c8032148e48b195618f3305aa90ededab5a01 Mon Sep 17 00:00:00 2001 From: Alexey Timin <atimin@gmail.com> Date: Mon, 20 Jul 2026 20:06:40 +0200 Subject: [PATCH 3/4] Update changelog for PR #271 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 620c6a6..821fce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add OAuth2 login command for aliases and direct gateway URLs, [PR-271](https://github.com/reductstore/reduct-cli/pull/271) by @atimin - Add destination prefixes to replica create, update, and show commands, [PR-247](https://github.com/reductstore/reduct-cli/pull/247) by @ychampion - Add compression option to replica create, update, and show commands, [PR-261](https://github.com/reductstore/reduct-cli/pull/261) by @vbmade2000 - Improve time parsing for --start and --stop options, [PR-226](https://github.com/reductstore/reduct-cli/pull/226) by @yaslam-dev From 8b720336d1188e83838dcdb01b194a8e1b4a5d59 Mon Sep 17 00:00:00 2001 From: Alexey Timin <atimin@gmail.com> Date: Mon, 20 Jul 2026 20:24:39 +0200 Subject: [PATCH 4/4] use hyper --- Cargo.lock | 10 +++++ Cargo.toml | 3 ++ src/cmd/login.rs | 105 +++++++++++++++++++++++++++++------------------ 3 files changed, 78 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b4524c..9176b0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -717,6 +717,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -730,6 +736,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1458,6 +1465,9 @@ dependencies = [ "dialoguer", "dirs", "futures-util", + "http-body-util", + "hyper", + "hyper-util", "indicatif", "log", "mime_guess", diff --git a/Cargo.toml b/Cargo.toml index 79dd3b3..981ccb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,9 @@ colored = "3.0.0" chrono = "0.4.41" dialoguer = "0.12.0" futures-util = { version = "0.3.30", features = [] } +http-body-util = "0.1.3" +hyper = { version = "1.10.1", features = ["http1", "server"] } +hyper-util = { version = "0.1.20", features = ["tokio"] } indicatif = "0.18.3" async-trait = "0.1.89" bytes = "1.11.0" diff --git a/src/cmd/login.rs b/src/cmd/login.rs index 0412674..be2adc8 100644 --- a/src/cmd/login.rs +++ b/src/cmd/login.rs @@ -3,13 +3,22 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use std::{net::SocketAddr, time::Duration}; +use std::{net::SocketAddr, sync::Arc, time::Duration}; use anyhow::{anyhow, bail, Context}; use clap::{Arg, ArgMatches, Command}; +use http_body_util::Full; +use hyper::{ + body::{Bytes, Incoming}, + header::{CONNECTION, CONTENT_TYPE}, + server::conn::http1, + service::service_fn, + Method, Request, Response, StatusCode, +}; +use hyper_util::rt::TokioIo; use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::{TcpListener, TcpStream}, + net::TcpListener, + sync::{oneshot, Mutex}, time::timeout, }; use url::Url; @@ -118,47 +127,60 @@ fn login_url(gateway_url: &Url, callback_addr: SocketAddr) -> anyhow::Result<Url } async fn receive_callback(listener: &TcpListener) -> anyhow::Result<Callback> { - let (mut stream, _) = listener.accept().await?; - let request_target = read_request_target(&mut stream).await?; - let callback = parse_callback(&request_target); - let response = if callback.is_ok() { - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n<html><body>Authentication successful. You can close this tab.</body></html>" + let (stream, _) = listener.accept().await?; + let (sender, receiver) = oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + + http1::Builder::new() + .serve_connection( + TokioIo::new(stream), + service_fn(move |request| handle_callback(request, Arc::clone(&sender))), + ) + .await + .context("Failed to receive OAuth2 callback")?; + + receiver + .await + .context("OAuth2 callback closed without a response")? +} + +async fn handle_callback( + request: Request<Incoming>, + sender: Arc<Mutex<Option<oneshot::Sender<anyhow::Result<Callback>>>>>, +) -> Result<Response<Full<Bytes>>, hyper::Error> { + let callback = if request.method() == Method::GET { + let request_target = request + .uri() + .path_and_query() + .map_or("/", |path_and_query| path_and_query.as_str()); + parse_callback(request_target) } else { - "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + Err(anyhow!("OAuth2 callback must use GET")) }; - stream.write_all(response.as_bytes()).await?; - callback -} + let response = callback_response(&callback); -async fn read_request_target(stream: &mut TcpStream) -> anyhow::Result<String> { - let mut request = Vec::with_capacity(1024); - loop { - if request.len() >= 16 * 1024 { - bail!("OAuth2 callback request is too large"); - } - let mut buffer = [0_u8; 1024]; - let read = stream.read(&mut buffer).await?; - if read == 0 { - bail!("OAuth2 callback request is incomplete"); - } - request.extend_from_slice(&buffer[..read]); - if request.windows(4).any(|window| window == b"\r\n\r\n") { - break; - } + if let Some(sender) = sender.lock().await.take() { + let _ = sender.send(callback); } - let request = std::str::from_utf8(&request).context("OAuth2 callback is not valid HTTP")?; - let request_line = request - .lines() - .next() - .context("OAuth2 callback is missing request line")?; - let mut parts = request_line.split_whitespace(); - if parts.next() != Some("GET") { - bail!("OAuth2 callback must use GET"); + + Ok(response) +} + +fn callback_response(callback: &anyhow::Result<Callback>) -> Response<Full<Bytes>> { + let mut response = Response::builder().header(CONNECTION, "close"); + if callback.is_ok() { + response = response + .status(StatusCode::OK) + .header(CONTENT_TYPE, "text/html; charset=utf-8"); + response.body(Full::new(Bytes::from_static( + b"<html><body>Authentication successful. You can close this tab.</body></html>", + ))) + } else { + response + .status(StatusCode::BAD_REQUEST) + .body(Full::new(Bytes::new())) } - parts - .next() - .map(str::to_owned) - .context("OAuth2 callback is missing request target") + .expect("valid OAuth2 callback response") } fn parse_callback(request_target: &str) -> anyhow::Result<Callback> { @@ -201,6 +223,10 @@ mod tests { use super::*; use crate::{config::ConfigFile, context::tests::context}; use rstest::rstest; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, + }; #[test] fn parses_callback_token_and_expiration() { @@ -261,7 +287,6 @@ mod tests { ) .await .unwrap(); - stream.shutdown().await.unwrap(); let mut response = Vec::new(); stream.read_to_end(&mut response).await.unwrap();