@smbcloud / cli / commits / d67bf83

Implement auth apps

Seto Elkahfi committed Apr 15, 2023 at 22:44 UTC d67bf830c72bcdd0ac98460c32863726b121c0f3
14 files changed +325 -185
Cargo.lock
+13
@@ -1082,6 +1082,7 @@ dependencies = [
1082 "reqwest",
1083 "serde",
1084 "serde_json",
1085 + "smbpndk-model",
1086 "smbpndk-networking",
1087 "spinners",
1088 "tokio",
@@ -1091,16 +1092,28 @@ dependencies = [
1092 "url-builder",
1093 ]
1094
1095 +[[package]]
1096 +name = "smbpndk-model"
1097 +version = "0.1.0"
1098 +dependencies = [
1099 + "serde",
1100 + "serde_json",
1101 +]
1102 +
1103 [[package]]
1104 name = "smbpndk-networking"
1105 version = "0.1.0"
1106 dependencies = [
1107 + "anyhow",
1108 "async-trait",
1109 + "dirs",
1110 + "home",
1111 "log",
1112 "maybe-async",
1113 "reqwest",
1114 "serde",
1115 "serde_json",
1116 + "smbpndk-model",
1117 "thiserror",
1118 ]
1119
Cargo.toml
+2 -1
@@ -1,5 +1,6 @@
1 [workspace]
2 members = [
3 "smbpndk-networking",
4 - "smbpndk-cli"
4 + "smbpndk-cli",
5 + "smbpndk-model"
6 ]
\ No newline at end of file
smbpndk-cli/Cargo.toml
+2
@@ -8,6 +8,7 @@ repository = "https://github.com/smbpndk/smbpndk-cli"
8 documentation = "https://docs.smbpndk.com/cli"
9 keywords = ["cli", "cloud", "developer", "platform"]
10 categories = ["command-line-utilities"]
11 +readme = "../README.md"
12
13 [[bin]]
14 name = "smb"
@@ -28,6 +29,7 @@ reqwest = { version = "0.11", features = ["json"] }
29 serde = { version = "1", features = ["derive"] }
30 serde_json = "1.0.82"
31 smbpndk-networking = { path = "../smbpndk-networking" }
32 +smbpndk-model = { path = "../smbpndk-model" }
33 spinners = "4.1.0"
34 tokio = { version = "1.14.1", features = ["full"] }
35 tracing = { version = "0.1.35", features = ["log"] }
smbpndk-cli/src/auth_app/cli.rs
+5 -5
@@ -2,27 +2,27 @@ use clap::Subcommand;
2
3 #[derive(Subcommand)]
4 pub enum Commands {
5 - #[clap(about = "Add new project.")]
5 + #[clap(about = "Add new auth app.")]
6 New {},
7
8 - #[clap(about = "List all your projects.")]
8 + #[clap(about = "List all your auth apps.")]
9 List {},
10
11 - #[clap(about = "Show detail of a project.")]
11 + #[clap(about = "Show detail of a auth app.")]
12 Show {
13 /// Project Id
14 #[clap(short, long, required = true)]
15 id: String,
16 },
17
18 - #[clap(about = "Delete a project.")]
18 + #[clap(about = "Delete a auth app.")]
19 Delete {
20 /// Project name
21 #[clap(short, long, required = true)]
22 id: String,
23 },
24
25 - #[clap(about = "Use project for current CLI session.")]
25 + #[clap(about = "Use auth app for current CLI session.")]
26 Use {
27 #[clap(short, long, required = true)]
28 id: String,
smbpndk-cli/src/auth_app/mod.rs
+149 -7
@@ -1,24 +1,166 @@
1 -use crate::{auth_app::cli::Commands, util::CommandResult};
2 -use anyhow::Result;
1 +use std::{fs::OpenOptions, io::Write};
2 +
3 +use crate::{auth_app::cli::Commands, debug, util::CommandResult};
4 +use anyhow::{anyhow, Result};
5 +use console::style;
6 +use dialoguer::{theme::ColorfulTheme, Input};
7 +use smbpndk_model::{AuthAppCreate, Config};
8 +use smbpndk_networking::{create_auth_app, delete_auth_app, get_auth_app, get_auth_apps};
9 +use spinners::Spinner;
10
11 pub(crate) mod cli;
12
13 pub async fn process_auth_app(commands: Commands) -> Result<CommandResult> {
14 match commands {
15 Commands::New {} => {
9 - todo!()
16 + let project_name = Input::<String>::with_theme(&ColorfulTheme::default())
17 + .with_prompt("Project name")
18 + .interact()
19 + .unwrap();
20 + let description = Input::<String>::with_theme(&ColorfulTheme::default())
21 + .with_prompt("Description")
22 + .interact()
23 + .unwrap();
24 +
25 + let spinner = Spinner::new(
26 + spinners::Spinners::SimpleDotsScrolling,
27 + style("Creating project...").green().bold().to_string(),
28 + );
29 +
30 + match create_auth_app(AuthAppCreate {
31 + name: project_name.clone(),
32 + description: description.clone(),
33 + })
34 + .await
35 + {
36 + Ok(_) => Ok(CommandResult {
37 + spinner,
38 + symbol: "✅".to_owned(),
39 + msg: format!("Creating a project {project_name}."),
40 + }),
41 + Err(e) => {
42 + println!("Error: {e:#?}");
43 + Ok(CommandResult {
44 + spinner,
45 + symbol: "❌".to_owned(),
46 + msg: format!("Failed to create a project {project_name}."),
47 + })
48 + }
49 + }
50 }
51 Commands::List {} => {
12 - todo!()
52 + let spinner = Spinner::new(
53 + spinners::Spinners::SimpleDotsScrolling,
54 + style("Loading...").green().bold().to_string(),
55 + );
56 +
57 + // Get all
58 + match get_auth_apps().await {
59 + Ok(auth_apps) => {
60 + println!("auth_apps: {auth_apps:#?}");
61 + println!(
62 + "{0: <5} | {1: <20} | {2: <30} | {3: <30}",
63 + "ID", "Name", "Created at", "Updated at"
64 + );
65 + for project in auth_apps {
66 + println!(
67 + "{0: <5} | {1: <20} | {2: <30} | {3: <30}",
68 + project.id, project.name, project.created_at, project.updated_at
69 + );
70 + }
71 + Ok(CommandResult {
72 + spinner,
73 + symbol: "✅".to_owned(),
74 + msg: "Showing all projects.".to_owned(),
75 + })
76 + }
77 + Err(e) => {
78 + println!("Error: {e:#?}");
79 + Ok(CommandResult {
80 + spinner,
81 + symbol: "❌".to_owned(),
82 + msg: "Failed to get all projects.".to_owned(),
83 + })
84 + }
85 + }
86 }
87 Commands::Show { id } => {
15 - todo!()
88 + let spinner = Spinner::new(
89 + spinners::Spinners::SimpleDotsScrolling,
90 + style("Loading...").green().bold().to_string(),
91 + );
92 + // Get Detail
93 + match get_auth_app(id).await {
94 + Ok(_) => Ok(CommandResult {
95 + spinner,
96 + symbol: "✅".to_owned(),
97 + msg: "Showing all projects.".to_owned(),
98 + }),
99 + Err(e) => {
100 + println!("Error: {e:#?}");
101 + Ok(CommandResult {
102 + spinner,
103 + symbol: "❌".to_owned(),
104 + msg: "Failed to get all projects.".to_owned(),
105 + })
106 + }
107 + }
108 }
109 Commands::Delete { id } => {
18 - todo!()
110 + let spinner = Spinner::new(
111 + spinners::Spinners::SimpleDotsScrolling,
112 + style("Loading...").green().bold().to_string(),
113 + );
114 + // Get Detail
115 + match delete_auth_app(id).await {
116 + Ok(_) => Ok(CommandResult {
117 + spinner,
118 + symbol: "✅".to_owned(),
119 + msg: "Showing all projects.".to_owned(),
120 + }),
121 + Err(e) => {
122 + println!("Error: {e:#?}");
123 + Ok(CommandResult {
124 + spinner,
125 + symbol: "❌".to_owned(),
126 + msg: "Failed to get all projects.".to_owned(),
127 + })
128 + }
129 + }
130 }
131 Commands::Use { id } => {
21 - todo!()
132 + let project = get_auth_app(id).await?;
133 +
134 + let config = Config {
135 + current_project: None,
136 + current_auth_app: Some(project),
137 + };
138 +
139 + let spinner = Spinner::new(
140 + spinners::Spinners::SimpleDotsScrolling,
141 + style("Loading...").green().bold().to_string(),
142 + );
143 + match home::home_dir() {
144 + Some(path) => {
145 + debug!(path.to_str().unwrap());
146 + let mut file = OpenOptions::new()
147 + .create(true)
148 + .write(true)
149 + .open([path.to_str().unwrap(), "/.smb/config.json"].join(""))?;
150 + let json = serde_json::to_string(&config)?;
151 + file.write_all(json.as_bytes())?;
152 +
153 + Ok(CommandResult {
154 + spinner,
155 + symbol: "✅".to_owned(),
156 + msg: "Use project successful.".to_string(),
157 + })
158 + }
159 + None => {
160 + let error = anyhow!("Failed to get home directory.");
161 + Err(error)
162 + }
163 + }
164 }
165 }
166 }
smbpndk-cli/src/projects/crud/mod.rs
+2 -30
@@ -1,25 +1,9 @@
1 use crate::{constants::BASE_URL, debug};
2 use anyhow::{anyhow, Result};
3 use reqwest::Client;
4 -use serde::{Deserialize, Serialize};
4
6 -#[derive(Deserialize, Debug, Serialize)]
7 -pub struct Project {
8 - pub id: i32,
9 - pub name: String,
10 - pub created_at: String,
11 - pub updated_at: String,
12 -}
13 -#[derive(Serialize, Debug)]
14 -pub struct ProjectCreate {
15 - pub name: String,
16 - pub description: String,
17 -}
18 -
19 -#[derive(Deserialize, Debug, Serialize)]
20 -pub struct Config {
21 - pub(crate) current_project: Option<Project>,
22 -}
5 +use smbpndk_model::{Project, ProjectCreate};
6 +use smbpndk_networking::get_token;
7
8 pub async fn get_all() -> Result<Vec<Project>> {
9 // Get current token
@@ -111,15 +95,3 @@ pub async fn delete_project(id: String) -> Result<()> {
95 }
96 }
97 }
114 -
115 -async fn get_token() -> Result<String> {
116 - if let Some(mut path) = dirs::home_dir() {
117 - path.push(".smb/token");
118 - std::fs::read_to_string(path).map_err(|e| {
119 - debug!("Error while reading token: {}", &e);
120 - anyhow!("Are you logged in?")
121 - })
122 - } else {
123 - Err(anyhow!("Failed to get home directory."))
124 - }
125 -}
smbpndk-cli/src/projects/mod.rs
+3 -1
@@ -6,11 +6,12 @@ use anyhow::{anyhow, Result};
6 use clap::Subcommand;
7 use console::style;
8 use dialoguer::{theme::ColorfulTheme, Input};
9 +use smbpndk_model::{Config, ProjectCreate};
10 use spinners::Spinner;
11
12 use crate::{debug, util::CommandResult};
13
13 -use self::crud::{create_project, delete_project, get_all, get_project, Config, ProjectCreate};
14 +use self::crud::{create_project, delete_project, get_all, get_project};
15
16 #[derive(Subcommand)]
17 pub enum Commands {
@@ -159,6 +160,7 @@ pub async fn process_projects(commands: Commands) -> Result<CommandResult> {
160
161 let config = Config {
162 current_project: Some(project),
163 + current_auth_app: None,
164 };
165
166 let spinner = Spinner::new(
smbpndk-model/Cargo.toml new
+10
@@ -0,0 +1,10 @@
1 +[package]
2 +name = "smbpndk-model"
3 +version = "0.1.0"
4 +edition = "2021"
5 +
6 +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7 +
8 +[dependencies]
9 +serde = { version = "1", features = ["derive"] }
10 +serde_json = "1.0.82"
\ No newline at end of file
smbpndk-model/src/lib.rs new
+47
@@ -0,0 +1,47 @@
1 +use serde::{Deserialize, Serialize};
2 +
3 +#[derive(Serialize, Deserialize, Debug)]
4 +pub struct AuthApp {
5 + pub id: i32,
6 + pub name: String,
7 + pub created_at: String,
8 + pub updated_at: String,
9 +}
10 +
11 +#[derive(Serialize, Deserialize, Debug)]
12 +pub struct AuthAppCreate {
13 + pub name: String,
14 + pub description: String,
15 +}
16 +
17 +#[derive(Deserialize, Debug, Serialize)]
18 +pub struct Config {
19 + pub current_project: Option<Project>,
20 + pub current_auth_app: Option<AuthApp>,
21 +}
22 +
23 +#[derive(Deserialize, Debug, Serialize)]
24 +pub struct Project {
25 + pub id: i32,
26 + pub name: String,
27 + pub created_at: String,
28 + pub updated_at: String,
29 +}
30 +#[derive(Serialize, Debug)]
31 +pub struct ProjectCreate {
32 + pub name: String,
33 + pub description: String,
34 +}
35 +
36 +#[cfg(test)]
37 +mod tests {
38 + use super::*;
39 +
40 + #[test]
41 + fn it_parse_auth_app() {
42 + let data = r#"{
43 +
44 + }"#;
45 + //let auth_app = serde_json::from_value(data.into());
46 + }
47 +}
smbpndk-networking/Cargo.toml
+6 -1
@@ -2,6 +2,7 @@
2 name = "smbpndk-networking"
3 version = "0.1.0"
4 edition = "2021"
5 +readme = "README.md"
6
7 [dependencies]
8 reqwest = { version = "0.11", features = ["json"] }
@@ -10,4 +11,8 @@ serde_json = "1.0.82"
11 thiserror = "1.0.29"
12 async-trait = { version = "0.1.51", optional = true }
13 log = "0.4.14"
13 -maybe-async = { version = "0.2", features = ["is_sync"] }
\ No newline at end of file
14 +maybe-async = { version = "0.2", features = ["is_sync"] }
15 +home = "0.5.4"
16 +anyhow = "1.0.58"
17 +dirs = "4.0.0"
18 +smbpndk-model = { path = "../smbpndk-model" }
\ No newline at end of file
smbpndk-networking/README.md new
+3
@@ -0,0 +1,3 @@
1 +# smbpndk-networking
2 +
3 +This crate inspired by this excelent blog [post](https://nullderef.com/blog/web-api-client/).
\ No newline at end of file
smbpndk-networking/src/common.rs deleted
-60
@@ -1,60 +0,0 @@
1 -use std::collections::HashMap;
2 -use std::fmt;
3 -
4 -use maybe_async::maybe_async;
5 -use serde_json::Value;
6 -
7 -pub type Headers = HashMap<String, String>;
8 -pub type Query<'a> = HashMap<&'a str, &'a str>;
9 -pub type Form<'a> = HashMap<&'a str, &'a str>;
10 -
11 -/// This trait represents the interface to be implemented for an HTTP client,
12 -/// which is kept separate from the Spotify client for cleaner code. Thus, it
13 -/// also requires other basic traits that are needed for the Spotify client.
14 -///
15 -/// When a request doesn't need to pass parameters, the empty or default value
16 -/// of the payload type should be passed, like `json!({})` or `Query::new()`.
17 -/// This avoids using `Option<T>` because `Value` itself may be null in other
18 -/// different ways (`Value::Null`, an empty `Value::Object`...), so this removes
19 -/// redundancy and edge cases (a `Some(Value::Null), for example, doesn't make
20 -/// much sense).
21 -#[maybe_async]
22 -pub trait BaseHttpClient: Send + Default + Clone + fmt::Debug {
23 - type Error;
24 -
25 - // This internal function should always be given an object value in JSON.
26 - async fn get(
27 - &self,
28 - url: &str,
29 - headers: Option<&Headers>,
30 - payload: &Query,
31 - ) -> Result<String, Self::Error>;
32 -
33 - async fn post(
34 - &self,
35 - url: &str,
36 - headers: Option<&Headers>,
37 - payload: &Value,
38 - ) -> Result<String, Self::Error>;
39 -
40 - async fn post_form(
41 - &self,
42 - url: &str,
43 - headers: Option<&Headers>,
44 - payload: &Form<'_>,
45 - ) -> Result<String, Self::Error>;
46 -
47 - async fn put(
48 - &self,
49 - url: &str,
50 - headers: Option<&Headers>,
51 - payload: &Value,
52 - ) -> Result<String, Self::Error>;
53 -
54 - async fn delete(
55 - &self,
56 - url: &str,
57 - headers: Option<&Headers>,
58 - payload: &Value,
59 - ) -> Result<String, Self::Error>;
60 -}
smbpndk-networking/src/constants.rs new
+5
@@ -0,0 +1,5 @@
1 +#[cfg(debug_assertions)]
2 +pub const BASE_URL: &str = "http://localhost:3000";
3 +
4 +#[cfg(not(debug_assertions))]
5 +pub const BASE_URL: &str = "https://api.smbpndk.com";
smbpndk-networking/src/lib.rs
+78 -80
@@ -1,100 +1,98 @@
1 -mod common;
1 +mod constants;
2
3 -//use async_trait::async_trait;
4 -use common::{BaseHttpClient, Form, Headers, Query};
5 -use maybe_async::async_impl;
6 -use reqwest::Method;
7 -use reqwest::RequestBuilder;
8 -use serde_json::Value;
9 -use std::time::Duration;
3 +use anyhow::{anyhow, Result};
4 +use constants::BASE_URL;
5 +use log::debug;
6 +use reqwest::Client;
7 +use smbpndk_model::{AuthApp, AuthAppCreate};
8
11 -pub trait HttpClient: Send + Default {
12 - type Error;
13 -
14 - fn get(
15 - &self,
16 - url: &str,
17 - headers: Option<&Headers>,
18 - payload: &Query,
19 - ) -> Result<String, Self::Error>;
20 -
21 - fn post(
22 - &self,
23 - url: &str,
24 - headers: Option<&Headers>,
25 - payload: &Value,
26 - ) -> Result<String, Self::Error>;
9 +pub async fn get_token() -> Result<String> {
10 + if let Some(mut path) = dirs::home_dir() {
11 + path.push(".smb/token");
12 + std::fs::read_to_string(path).map_err(|e| {
13 + debug!("Error while reading token: {}", &e);
14 + anyhow!("Are you logged in?")
15 + })
16 + } else {
17 + Err(anyhow!("Failed to get home directory."))
18 + }
19 }
20
29 -#[derive(thiserror::Error, Debug)]
30 -pub enum ReqwestError {
31 - /// The request couldn't be completed because there was an error when trying
32 - /// to do so
33 - #[error("request: {0}")]
34 - Client(#[from] reqwest::Error),
21 +pub async fn get_auth_apps() -> Result<Vec<AuthApp>> {
22 + // Get current token
23 + let token = get_token().await.unwrap();
24
36 - /// The request was made, but the server returned an unsuccessful status
37 - /// code, such as 404 or 503. In some cases, the response may contain a
38 - /// custom message from Spotify with more information, which can be
39 - /// serialized into `rspotify_model::ApiError`.
40 - #[error("status code {}", reqwest::Response::status(.0))]
41 - StatusCode(reqwest::Response),
42 -}
25 + let response = Client::new()
26 + .get([BASE_URL, "v1/auth_apps"].join(""))
27 + .header("Authorization", token)
28 + .send()
29 + .await?;
30
44 -pub struct ReqwestClient {
45 - // An instance of reqwest::Client.
46 - client: reqwest::Client,
31 + match response.status() {
32 + reqwest::StatusCode::OK => {
33 + let projects: Vec<AuthApp> = response.json().await?;
34 + Ok(projects)
35 + }
36 + _ => Err(anyhow!("Failed to fetch auth apps.")),
37 + }
38 }
39
49 -impl Default for ReqwestClient {
50 - fn default() -> Self {
51 - let client = reqwest::ClientBuilder::new()
52 - .user_agent("smbpndk-cli")
53 - .timeout(Duration::from_secs(10))
54 - .build()
55 - .unwrap();
56 - Self { client }
40 +pub async fn get_auth_app(id: String) -> Result<AuthApp> {
41 + // Get current token
42 + let token = get_token().await.unwrap();
43 +
44 + let response = Client::new()
45 + .get([BASE_URL, "v1/auth_apps/", &id].join(""))
46 + .header("Authorization", token)
47 + .send()
48 + .await?;
49 +
50 + match response.status() {
51 + reqwest::StatusCode::OK => {
52 + let project: AuthApp = response.json().await?;
53 + println!("Project requested: {project:#?}");
54 + Ok(project)
55 + }
56 + _ => Err(anyhow!("Failed to request an auth app.")),
57 }
58 }
59
60 -impl ReqwestClient {
61 - async fn request<D>(
62 - &self,
63 - method: Method,
64 - url: &str,
65 - headers: Option<&Headers>,
66 - add_data: D,
67 - ) -> Result<String, ReqwestError>
68 - where
69 - D: Fn(RequestBuilder) -> RequestBuilder,
70 - {
71 - let mut request = self.client.request(method.clone(), url);
60 +pub async fn delete_auth_app(id: String) -> Result<()> {
61 + // Get current token
62 + let token = get_token().await?;
63
73 - // Setting the headers, if any
74 - if let Some(headers) = headers {
75 - // The headers need to be converted into a `reqwest::HeaderMap`,
76 - // which won't fail as long as its contents are ASCII. This is an
77 - // internal function, so the condition cannot be broken by the user
78 - // and will always be true.
79 - //
80 - // The content-type header will be set automatically.
81 - let headers = headers.try_into().unwrap();
64 + let response = Client::new()
65 + .delete([BASE_URL, "v1/auth_apps/", &id].join(""))
66 + .header("Authorization", token)
67 + .send()
68 + .await?;
69
83 - request = request.headers(headers);
70 + match response.status() {
71 + reqwest::StatusCode::OK => {
72 + debug!("Project deleted.");
73 + Ok(())
74 }
75 + _ => Err(anyhow!("Failed to delete a project.")),
76 + }
77 +}
78
86 - // Configuring the request for the specific type (get/post/put/delete)
87 - request = add_data(request);
79 +pub async fn create_auth_app(auth_app: AuthAppCreate) -> Result<AuthApp> {
80 + // Get current token
81 + let token = get_token().await?;
82
89 - // Finally performing the request and handling the response
90 - log::info!("Making request {:?}", request);
91 - let response = request.send().await?;
83 + let response = Client::new()
84 + .post([BASE_URL, "v1/auth_apps"].join(""))
85 + .json(&auth_app)
86 + .header("Authorization", token)
87 + .send()
88 + .await?;
89
93 - // Making sure that the status code is OK
94 - if response.status().is_success() {
95 - response.text().await.map_err(Into::into)
96 - } else {
97 - Err(ReqwestError::StatusCode(response))
90 + match response.status() {
91 + reqwest::StatusCode::CREATED => {
92 + let project: AuthApp = response.json().await?;
93 + println!("Project created: {project:#?}");
94 + Ok(project)
95 }
96 + _ => Err(anyhow!("Failed to create a project.")),
97 }
98 }