diff --git a/module/core/format_tools/src/format/print.rs b/module/core/format_tools/src/format/print.rs index f1aa104c24..8ad31f189b 100644 --- a/module/core/format_tools/src/format/print.rs +++ b/module/core/format_tools/src/format/print.rs @@ -229,6 +229,8 @@ mod private #[ derive( Debug, Default ) ] pub struct RowDescriptor { + + /// Index of the row. pub irow : usize, /// Height of the row. diff --git a/module/move/gspread/Cargo.toml b/module/move/gspread/Cargo.toml index 8d1d86b4a3..21a63abb7b 100644 --- a/module/move/gspread/Cargo.toml +++ b/module/move/gspread/Cargo.toml @@ -18,6 +18,7 @@ name = "main" path = "src/bin/main.rs" [features] +with_online = [] default = [ "enabled" ] full = [ "enabled" ] enabled = [ @@ -44,6 +45,9 @@ error_tools = "0.19.0" derive_tools = { version = "0.32.0", features = ["full"] } serde_json = "1.0.132" regex = "1.11.1" +unicode-width = "0.2.0" [dev-dependencies] test_tools = { workspace = true } +httpmock = "0.7.0-rc.1" +reqwest = "0.12.9" \ No newline at end of file diff --git a/module/move/gspread/src/actions/gspread.rs b/module/move/gspread/src/actions/gspread.rs index 60b0fd980c..711547926d 100644 --- a/module/move/gspread/src/actions/gspread.rs +++ b/module/move/gspread/src/actions/gspread.rs @@ -10,13 +10,35 @@ mod private use error_tools::typed::Error; use derive_tools::AsRefStr; use crate::*; - use ser::DisplayFromStr; + use ser:: + { + DisplayFromStr, + JsonValue + }; + use std::collections::HashMap; + use google_sheets4::api:: + { + BatchUpdateValuesResponse, + BatchUpdateValuesRequest, + ValueRange + }; #[ ser::serde_as ] #[ derive( Debug, Error, AsRefStr, ser::Serialize ) ] #[ serde( tag = "type", content = "data" ) ] + + /// Represents errors that can occur while interacting with the Google Sheets API + /// or during related operations in the application. pub enum Error { + /// Represents an error returned by the Google Sheets API. + /// + /// # Details + /// This error occurs when the API returns a specific error message. + /// The error message from the Google Sheets API is stored and displayed. + /// + /// # Fields + /// - `google_sheets4::Error`: The raw error returned by the API. #[ error( "Google Sheets returned error:\n{0}" ) ] ApiError ( @@ -25,13 +47,75 @@ mod private google_sheets4::Error ), - #[ error( "Invalid URL format: {0}" ) ] + /// Represents an error that occurs while initializing Google Sheets Hub. + /// + /// # Details + /// This error indicates that the application failed to properly configure with the Google Sheets Hub. + /// + /// # Fields + /// - `String`: A detailed error message describing the issue. + #[ error( "Hub Error:\n{0}" ) ] + HubError + ( + String + ), + + /// Represents an error caused by an invalid URL format. + /// + /// # Details + /// This error occurs when the provided URL does not match the expected format + /// + /// # Fields + /// - `String`: The invalid URL or a message describing the issue. + #[ error( "Invalid URL format:\n{0}" ) ] InvalidUrl ( String ), + + /// Represents an error related to a cell in the spreadsheet. + /// + /// # Details + /// This error indicates that a cell was not got or updated + /// + /// # Fields + /// - `String`: A message describing the issue with the cell. + #[ error( "Cell error:\n{0}" ) ] + CellError + ( + String + ), + + /// Represents an error caused by invalid JSON input or parsing issues. + /// + /// # Details + /// This error occurs when the provided JSON data does not conform to the expected + /// structure or format. + /// + /// # Fields + /// - `String`: A detailed error message describing the JSON issue. + #[ error( "Invalid JSON format:\n{0}" ) ] + InvalidJSON + ( + String + ), + + /// Represents a generic parsing error. + /// + /// # Details + /// This error is raised when a string or other input cannot be parsed + /// into the expected format or structure. + /// + /// # Fields + /// - `String`: A message describing the parse error. + #[ error( "Parse error:\n{0}" ) ] + ParseError + ( + String + ) } + /// Retrive spreadsheet id from url pub fn get_spreadsheet_id_from_url ( url : &str @@ -53,6 +137,66 @@ mod private ) } + /// Function to update a row on a Google Sheet. + /// + /// It sends HTTP request to Google Sheets API and change row wich provided values. + /// + /// **Params** + /// - `spreadsheet_id` : Spreadsheet identifire. + /// - `sheet_name` : Sheet name. + /// - `row_key` : row's key. + /// - `row_key_val` : pairs of key value, where key is a column name and value is a new value. + /// + /// **Returns** + /// - `Result` + pub async fn update_row + ( + spreadsheet_id : &str, + sheet_name : &str, + row_key : &str, + row_key_val : HashMap< String, String > + ) -> Result< BatchUpdateValuesResponse > + { + let secret = Secret::read(); + let hub = hub(&secret) + .await + .map_err( | _ | { + Error::HubError( format!( "Failed to create a hub. Ensure that you have a .env file with Secrets" ) ) + })?; + + let mut value_ranges = Vec::with_capacity( row_key_val.len() ); + + for ( col_name, value ) in row_key_val { + value_ranges.push + ( + ValueRange + { + major_dimension: Some( String::from( "ROWS" ) ), + values: Some( vec![ vec![ JsonValue::String( value ) ] ] ), + range: Some( format!( "{}!{}{}", sheet_name, col_name, row_key ) ), + } + ) + } + + let req = BatchUpdateValuesRequest + { + value_input_option: Some( "USER_ENTERED".to_string() ), + data: Some( value_ranges ), + include_values_in_response: Some( true ), + ..Default::default() + }; + + match hub + .spreadsheets() + .values_batch_update( req, spreadsheet_id ) + .doit() + .await + { + Ok( ( _, response ) ) => Ok( response ), + Err( error ) => Err( Error::ApiError( error ) ), + } + } + pub type Result< T > = core::result::Result< T, Error >; } @@ -60,7 +204,9 @@ crate::mod_interface! { own use { + Error, Result, + update_row, get_spreadsheet_id_from_url, }; } \ No newline at end of file diff --git a/module/move/gspread/src/actions/gspread_cell_get.rs b/module/move/gspread/src/actions/gspread_cell_get.rs index 3a4d6b1be3..effa3a9170 100644 --- a/module/move/gspread/src/actions/gspread_cell_get.rs +++ b/module/move/gspread/src/actions/gspread_cell_get.rs @@ -6,8 +6,14 @@ mod private { + + use crate::*; - use actions::gspread::Result; + use actions::gspread:: + { + Error, + Result + }; use client::SheetsType; use ser::JsonValue; @@ -19,18 +25,19 @@ mod private cell_id : &str, ) -> Result< JsonValue > { - let result = hub + match hub .spreadsheets() .values_get( spreadsheet_id, format!( "{}!{}", table_name, cell_id ).as_str() ) .doit() - .await? - .1 - .values; - - match result + .await { - Some( values ) => Ok( values.get( 0 ).unwrap().get( 0 ).unwrap().clone() ), - None => Ok( JsonValue::Null.clone() ) + Ok( (_, response ) ) => + match response.values + { + Some( values ) => Ok( values.get( 0 ).unwrap().get( 0 ).unwrap().clone() ), + None => Ok( JsonValue::Null.clone() ) + } + Err( error ) => Err( Error::ApiError( error ) ) } } diff --git a/module/move/gspread/src/actions/gspread_cell_set.rs b/module/move/gspread/src/actions/gspread_cell_set.rs index 818a667f1c..5743a45f97 100644 --- a/module/move/gspread/src/actions/gspread_cell_set.rs +++ b/module/move/gspread/src/actions/gspread_cell_set.rs @@ -9,7 +9,11 @@ mod private { use google_sheets4::api::ValueRange; use crate::*; - use actions::gspread::Result; + use actions::gspread:: + { + Result, + Error + }; use client::SheetsType; use ser::JsonValue; @@ -30,17 +34,24 @@ mod private ..ValueRange::default() }; - let result = hub + match hub .spreadsheets() .values_update( value_range, spreadsheet_id, format!( "{}!{}", table_name, cell_id ).as_str() ) .value_input_option( "USER_ENTERED" ) .doit() - .await? - .1 - .updated_cells - .unwrap(); + .await + { + Ok( ( _, response) ) => + { + match response.updated_cells + { + Some( number ) => Ok( number ), + None => Err( Error::CellError( "Some problem with cell updating".to_string() ) ) + } + } + Err( error) => Err( Error::ApiError( error ) ) + } - Ok( result ) } } diff --git a/module/move/gspread/src/actions/gspread_cells_set.rs b/module/move/gspread/src/actions/gspread_cells_set.rs index a6528b6c4b..1099a613d4 100644 --- a/module/move/gspread/src/actions/gspread_cells_set.rs +++ b/module/move/gspread/src/actions/gspread_cells_set.rs @@ -5,35 +5,75 @@ mod private { use crate::*; - use google_sheets4::api:: + use actions::gspread:: { - BatchUpdateValuesRequest, - ValueRange - }; - use ser:: - { - Deserialize, - JsonValue + Error, + Result, + update_row }; + use ser:: Deserialize; use std::collections::HashMap; - /// Structure for --json value + /// Structure to keep rows key and new values for cells updating. #[ derive( Deserialize, Debug ) ] - struct ParsedJson + struct ParsedJson< 'a > { - #[ serde( flatten ) ] - columns : HashMap< String, String > + row_key : &'a str, + row_key_val : HashMap< String, String > } - /// Parse --json value - fn parse_json + /// Function to parse `--json` flag. + /// + /// It retirive `--select-row-by-key` flag from json and set it to `row_key` field. + /// Other pairs it set to `row_key_val` + /// + /// **Returns** + /// - `ParsedJson` object + fn parse_json< 'a > ( - json_str : &str - ) -> Result< ParsedJson, String > + json_str : &'a str, + select_row_by_key : &str, + ) -> Result< ParsedJson< 'a > > { - serde_json::from_str::< ParsedJson >( json_str ).map_err + let mut parsed_json: HashMap< String, String > = serde_json::from_str( json_str ) + .map_err( | error | Error::InvalidJSON( format!( "Failed to parse JSON: {}", error ) ) )?; + + let row_key = if let Some( row_key ) = parsed_json.remove( select_row_by_key ) + { + Box::leak( row_key.into_boxed_str() ) + } + else + { + return Err + ( + Error::InvalidJSON + ( + format!( "Key '{}' not found in JSON", select_row_by_key) + ) + ); + }; + + for ( col_name, _ ) in &parsed_json + { + if !col_name.chars().all( | c | c.is_alphabetic() && c.is_uppercase() ) + { + return Err + ( + Error::InvalidJSON + ( + format!( "Invalid column name: {}. Allowed only uppercase alphabetic letters (A-Z)", col_name ) + ) + ); + } + }; + + Ok ( - | err | format!( "Failed to parse JSON: {}", err ) + ParsedJson + { + row_key : row_key, + row_key_val : parsed_json + } ) } @@ -42,7 +82,7 @@ mod private fn check_select_row_by_key ( key : &str - ) -> Result< (), String > + ) -> Result< () > { let keys = vec![ "id" ]; if keys.contains( &key ) @@ -51,79 +91,42 @@ mod private } else { - Err( format!( "Invalid select_row_by_key: '{}'. Allowed keys: {:?}", key, keys ) ) - } - } - - fn is_all_uppercase_letters - ( - s : &str - ) -> Result< (), String > - { - if s.chars().all( | c | c.is_ascii_uppercase() ) - { - Ok( () ) - } - else - { - Err( format!( "The string '{}' contains invalid characters. Only uppercase letters (A-Z) are allowed.", s ) ) + Err + ( + Error::ParseError( format!( "Invalid select_row_by_key: '{}'. Allowed keys: {:?}", key, keys ) ) + ) } } pub async fn action ( - hub : &SheetsType, select_row_by_key : &str, json_str : &str, spreadsheet_id : &str, table_name : &str - ) -> Result< String, String > + ) -> Result< i32 > { check_select_row_by_key( select_row_by_key )?; - let mut pairs = parse_json( json_str )?; - - let row_id = pairs - .columns - .remove( select_row_by_key ) - .ok_or_else( || format!( "Key '{}' not found in JSON", select_row_by_key ) )?; - - let mut value_ranges= Vec::new(); - - for ( key, value ) in pairs.columns.into_iter() - { - is_all_uppercase_letters( key.as_str() )?; - value_ranges.push - ( - ValueRange - { - range: Some( format!( "{}!{}{}", table_name, key, row_id ) ), - values: Some( vec![ vec![ JsonValue::String( value.to_string() ) ] ] ), - ..Default::default() - } - ); - }; - - let req = BatchUpdateValuesRequest + match parse_json( json_str, select_row_by_key ) { - value_input_option: Some( "USER_ENTERED".to_string() ), - data: Some( value_ranges ), - include_values_in_response: Some( true ), - ..Default::default() - }; - - let result = hub - .spreadsheets() - .values_batch_update( req, spreadsheet_id ) - .doit() - .await; - - match result - { - Ok( _ ) => Ok( format!( "Cells were sucsessfully updated!" ) ), - Err( error ) => Err( format!( "{}", error ) ) + Ok( parsed_json ) => + match update_row( spreadsheet_id, table_name, parsed_json.row_key, parsed_json.row_key_val ).await + { + Ok( response ) => + { + match response.total_updated_cells + { + Some( val ) => Ok( val ), + None => Ok( 0 ), + } + }, + Err( error ) => Err( error ) + } + Err( error ) => Err( error ), } } + } crate::mod_interface! diff --git a/module/move/gspread/src/actions/gspread_get_header.rs b/module/move/gspread/src/actions/gspread_get_header.rs index 8f7b83c477..e8de1dc4bc 100644 --- a/module/move/gspread/src/actions/gspread_get_header.rs +++ b/module/move/gspread/src/actions/gspread_get_header.rs @@ -10,7 +10,11 @@ mod private use std::fmt; use crate::*; use client::SheetsType; - use actions::gspread::Result; + use actions::gspread:: + { + Error, + Result + }; use format_tools::AsTable; use util::display_table::display_header; use ser::JsonValue; @@ -37,19 +41,27 @@ mod private ( hub : &SheetsType, spreadsheet_id : &str, - table_name: &str) -> Result< Vec< Vec< JsonValue > > > + table_name : &str + ) -> Result< Vec< Vec< JsonValue > > > { - let result = hub + match hub .spreadsheets() .values_get( spreadsheet_id, format!( "{}!A1:Z1", table_name ).as_str() ) .doit() - .await? - .1 - .values - .unwrap_or_else( | | Vec::new() ); - - Ok( result ) + .await + { + Ok( ( _, response ) ) => + { + match response.values + { + Some( values ) => Ok( values ), + None => Ok( Vec::new() ) + } + }, + Err( error ) => Err( Error::ApiError( error ) ) + } } + } crate::mod_interface! diff --git a/module/move/gspread/src/actions/gspread_get_rows.rs b/module/move/gspread/src/actions/gspread_get_rows.rs index 3a083217ed..7f1f7a5c26 100644 --- a/module/move/gspread/src/actions/gspread_get_rows.rs +++ b/module/move/gspread/src/actions/gspread_get_rows.rs @@ -9,7 +9,11 @@ mod private { use crate::*; use client::SheetsType; - use actions::gspread::Result; + use actions::gspread:: + { + Error, + Result + }; use ser::JsonValue; pub async fn action @@ -19,16 +23,22 @@ mod private table_name : &str ) -> Result< Vec< Vec < JsonValue > > > { - let result = hub + match hub .spreadsheets() .values_get( spreadsheet_id, format!( "{}!A2:Z", table_name ).as_str() ) .doit() - .await? - .1 - .values - .unwrap_or_else( | | Vec::new() ); - - Ok( result ) + .await + { + Ok( ( _, response ) ) => + { + match response.values + { + Some( values ) => Ok( values ), + None => Ok( Vec::new() ) + } + }, + Err( error ) => Err( Error::ApiError( error ) ) + } } } diff --git a/module/move/gspread/src/commands/gspread.rs b/module/move/gspread/src/commands/gspread.rs index 8398aa3ec6..7150856df1 100644 --- a/module/move/gspread/src/commands/gspread.rs +++ b/module/move/gspread/src/commands/gspread.rs @@ -22,35 +22,53 @@ mod private #[ derive( Debug, Parser ) ] pub struct CommonArgs { - #[ arg( long ) ] + #[ arg( long, help = "Full URL of Google Sheet.\n\ + It has to be inside of '' to avoid parse errors.\n\ + Example: 'https://docs.google.com/spreadsheets/d/your_spreadsheet_id/edit?gid=0#gid=0'" ) ] pub url : String, - #[ arg( long ) ] + #[ arg( long, help = "Sheet name.\nExample: Sheet1" ) ] pub tab : String } #[ derive( Debug, Subcommand ) ] pub enum Command { - + + /// Command to get header of a sheet. Header is a first raw. + /// + /// Command example: + /// + /// gspread header + /// --url 'https://docs.google.com/spreadsheets/d/1EAEdegMpitv-sTuxt8mV8xQxzJE7h_J0MxQoyLH7xxU/edit?gid=0#gid=0' + /// --tab tab1 #[ command ( name = "header" ) ] Header ( CommonArgs ), + /// Command to get all raws of a sheet but not header. + /// + /// Command example: + /// + /// gspread rows + /// --url 'https://docs.google.com/spreadsheets/d/1EAEdegMpitv-sTuxt8mV8xQxzJE7h_J0MxQoyLH7xxU/edit?gid=0#gid=0' + /// --tab tab1 #[ command( name = "rows" ) ] Rows ( CommonArgs ), + /// Command to get or update a cell from a sheet. #[ command ( subcommand, name = "cell" ) ] Cell ( gspread_cell::Commands ), + /// Commands to set a new value to a cell or get a value from a cell. #[ command ( subcommand, name = "cells" ) ] Cells ( @@ -85,7 +103,8 @@ mod private Command::Cells( cells_command) => { - gspread_cells::command( hub, cells_command ).await; + // hub + gspread_cells::command( cells_command ).await; }, } diff --git a/module/move/gspread/src/commands/gspread_cell.rs b/module/move/gspread/src/commands/gspread_cell.rs index 057da2dd09..a0fd55f361 100644 --- a/module/move/gspread/src/commands/gspread_cell.rs +++ b/module/move/gspread/src/commands/gspread_cell.rs @@ -15,32 +15,57 @@ mod private #[ derive( Debug, Subcommand ) ] pub enum Commands { + /// Command to get a value from a sheet's cell + /// + /// Command example: + /// + /// gspread cell get + /// --url 'https://docs.google.com/spreadsheets/d/1EAEdegMpitv-sTuxt8mV8xQxzJE7h_J0MxQoyLH7xxU/edit?gid=0#gid=0' + /// --tab tab1 + /// --cell A1 #[ command( name = "get" ) ] Get { - #[ arg( long ) ] + #[ arg( long, help = "Full URL of Google Sheet.\n\ + It has to be inside of '' to avoid parse errors.\n\ + Example: 'https://docs.google.com/spreadsheets/d/your_spreadsheet_id/edit?gid=0#gid=0'" ) ] url : String, - #[ arg( long ) ] + #[ arg( long, help = "Sheet name.\nExample: Sheet1" ) ] tab : String, - #[ arg( long ) ] - cel : String, + #[ arg( long, help = "Cell id. You can set it in format:\n \ + - A1, where A is column name and 1 is row number\n\ + Example: --cell A4" ) ] + cell : String, }, + /// Command to set a new value to a sheet's cell. + /// + /// Command example: + /// + /// gspread cell set + /// --url 'https://docs.google.com/spreadsheets/d/1EAEdegMpitv-sTuxt8mV8xQxzJE7h_J0MxQoyLH7xxU/edit?gid=0#gid=0' + /// --tab tab1 + /// --cell A1 + /// --val 13 #[ command( name = "set" ) ] Set { - #[ arg( long ) ] + #[ arg( long, help = "Full URL of Google Sheet.\n\ + It has to be inside of '' to avoid parse errors.\n\ + Example: 'https://docs.google.com/spreadsheets/d/your_spreadsheet_id/edit?gid=0#gid=0'" ) ] url : String, - #[ arg( long ) ] + #[ arg( long, help = "Sheet name.\nExample: Sheet1" ) ] tab : String, - #[ arg( long ) ] - cel : String, + #[ arg( long, help = "Cell id. You can set it in format:\n \ + - A1, where A is column name and 1 is row number\n\ + Example: --cell A4" ) ] + cell : String, - #[ arg( long ) ] + #[ arg( long, help = "Value you want to set. It can be written on any language.\nExample: --val hello" ) ] val : String } } @@ -53,7 +78,7 @@ mod private { match commands { - Commands::Get { url, tab, cel } => + Commands::Get { url, tab, cell } => { let spreadsheet_id = match get_spreadsheet_id_from_url( url.as_str() ) { @@ -65,22 +90,21 @@ mod private } }; - let result = actions::gspread_cell_get::action + match actions::gspread_cell_get::action ( hub, spreadsheet_id, tab.as_str(), - cel.as_str() - ).await; - - match result + cell.as_str() + ) + .await { Ok( value ) => println!( "Value: {}", value ), - Err( error ) => println!( "Error: {}", error ), + Err( error ) => println!( "Error:\n{}", error ), } }, - Commands::Set { url, tab, cel, val } => + Commands::Set { url, tab, cell, val } => { let spreadsheet_id = match get_spreadsheet_id_from_url( url.as_str() ) { @@ -92,19 +116,18 @@ mod private } }; - let result = actions::gspread_cell_set::action + match actions::gspread_cell_set::action ( hub, spreadsheet_id, tab.as_str(), - cel.as_str(), + cell.as_str(), val.as_str() - ).await; - - match result + ) + .await { - Ok( value ) => println!( "Success: {:?}", value ), - Err( error ) => println!( "Error: {}", error ), + Ok( number ) => println!( "You successfully update {} cell!", number ), + Err( error ) => println!( "Error:\n{}", error ), } } diff --git a/module/move/gspread/src/commands/gspread_cells.rs b/module/move/gspread/src/commands/gspread_cells.rs index 13ecf1e378..dd53d80d10 100644 --- a/module/move/gspread/src/commands/gspread_cells.rs +++ b/module/move/gspread/src/commands/gspread_cells.rs @@ -13,19 +13,39 @@ mod private #[ derive( Debug, Subcommand ) ] pub enum Commands { + /// Command to set values range to a google sheet + /// + /// Command example: + /// + /// gspread cells set + /// --url 'https://docs.google.com/spreadsheets/d/1EAEdegMpitv-sTuxt8mV8xQxzJE7h_J0MxQoyLH7xxU/edit?gid=0#gid=0' + /// --tab tab1 + /// --select-row-by-key "id" + /// --json '{"id": "2", "A": "1", "B": "2"}' #[ command( name = "set" ) ] Set { - #[ arg( long ) ] + #[ arg( long, help = "Identifier of a row. Available identifiers: id (row's unique identifier).\n\ + Example: --select_row_by_key \"id\"" ) ] select_row_by_key : String, - - #[ arg( long ) ] + + #[ arg( long, help = "Value range. It must contain select_row_by_key. + The key is a column name (not a header name, but a column name, which can only contain Latin letters). + Every key and value must be a string. + Depending on the shell, different handling might be required.\n\ + Examples:\n\ + 1. --json '{\"id\": \"3\", \"A\": \"1\", \"B\": \"2\"}'\n\ + 2. --json \"{\"id\": \"3\", \"A\": \"1\", \"B\": \"2\"}\"\n\ + 3. --json '{\\\"id\\\": \\\"3\\\", \\\"A\\\": \\\"1\\\", \\\"B\\\": \\\"2\\\"}'\n\ + 4. --json \"{\\\"id\\\": \\\"3\\\", \\\"A\\\": \\\"1\\\", \\\"B\\\": \\\"2\\\"}\" " ) ] json : String, - #[ arg( long ) ] + #[ arg( long, help = "Full URL of Google Sheet.\n\ + It has to be inside of '' to avoid parse errors.\n\ + Example: 'https://docs.google.com/spreadsheets/d/your_spreadsheet_id/edit?gid=0#gid=0'" ) ] url : String, - #[ arg( long ) ] + #[ arg( long, help = "Sheet name.\nExample: Sheet1" ) ] tab : String } @@ -33,7 +53,7 @@ mod private pub async fn command ( - hub : &SheetsType, + // hub : &SheetsType, commands : Commands ) { @@ -51,19 +71,18 @@ mod private } }; - let result = actions::gspread_cells_set::action + match actions::gspread_cells_set::action ( - &hub, + // &hub, select_row_by_key.as_str(), json.as_str(), spreadsheet_id, tab.as_str() - ).await; - - match result + ) + .await { - Ok( msg ) => println!( "{}", msg ), - Err( error ) => println!( "{}", error ) + Ok( val ) => println!( "{} cells were sucsessfully updated!", val ), + Err( error ) => println!( "Error:\n{}", error ) } } } diff --git a/module/move/gspread/src/commands/gspread_header.rs b/module/move/gspread/src/commands/gspread_header.rs index 5048d3e4ed..6ee00db284 100644 --- a/module/move/gspread/src/commands/gspread_header.rs +++ b/module/move/gspread/src/commands/gspread_header.rs @@ -51,14 +51,13 @@ mod private } }; - let result = actions::gspread_get_header::action - ( - hub, - spreadsheet_id, - tab.as_str() - ).await; - - match result + match actions::gspread_get_header::action + ( + hub, + spreadsheet_id, + tab.as_str() + ) + .await { Ok( header ) => { @@ -66,10 +65,10 @@ mod private .into_iter() .map( | row | RowWrapper{ max_len: row.len(), row } ) .collect(); - - println!( "Header: \n {}", Report{ rows: header_wrapped } ); + + println!( "Header:\n{}", Report{ rows: header_wrapped } ); } - Err( error ) => println!( "Error: {}", error ), + Err( error ) => eprintln!( "Error:\n{}", error ), } } } diff --git a/module/move/gspread/src/commands/gspread_rows.rs b/module/move/gspread/src/commands/gspread_rows.rs index 426d7f2dde..6c526d0f78 100644 --- a/module/move/gspread/src/commands/gspread_rows.rs +++ b/module/move/gspread/src/commands/gspread_rows.rs @@ -50,26 +50,25 @@ mod private } }; - let result = actions::gspread_get_rows::action + match actions::gspread_get_rows::action ( hub, spreadsheet_id, tab.as_str() - ).await; - - match result + ) + .await { Ok( rows ) => { let max_len = rows.iter().map(|row| row.len()).max().unwrap_or(0); let rows_wrapped: Vec = rows - .into_iter() - .map(|row| RowWrapper { row, max_len }) - .collect(); + .into_iter() + .map(|row| RowWrapper { row, max_len }) + .collect(); - println!( "Rows: \n {}", Report{ rows: rows_wrapped } ); + println!( "Rows:\n{}", Report{ rows: rows_wrapped } ); } - Err( error ) => println!( "Error: {}", error ), + Err( error ) => eprintln!( "Error:\n{}", error ), } } } diff --git a/module/move/gspread/src/debug.rs b/module/move/gspread/src/debug.rs index 11f63d821e..7f1d303941 100644 --- a/module/move/gspread/src/debug.rs +++ b/module/move/gspread/src/debug.rs @@ -15,6 +15,9 @@ crate::mod_interface! { exposed use { - row_wrapper::RowWrapper, + row_wrapper:: + { + RowWrapper + } }; } diff --git a/module/move/gspread/src/debug/row_wrapper.rs b/module/move/gspread/src/debug/row_wrapper.rs index b8e1635ac7..7802773c47 100644 --- a/module/move/gspread/src/debug/row_wrapper.rs +++ b/module/move/gspread/src/debug/row_wrapper.rs @@ -1,59 +1,62 @@ -//! -//! Gspread wrapper for outputting data to console -//! -//! It is used for "header" and "rows" commands -//! - -use super::*; -use crate::*; -use ser::JsonValue; - - -#[ derive( Debug ) ] -pub struct RowWrapper -{ - pub row: Vec< JsonValue >, - pub max_len: usize -} - -impl Clone for RowWrapper -{ - fn clone( &self ) -> Self - { - Self - { - row: self.row.clone(), - max_len: self.max_len.clone() - } - } -} - -impl TableWithFields for RowWrapper {} -impl Fields< &'_ str, Option< Cow< '_, str > > > -for RowWrapper -{ - type Key< 'k > = &'k str; - type Val< 'v > = Option< Cow< 'v, str > >; - - fn fields( &self ) -> impl IteratorTrait< Item= ( &'_ str, Option > ) > - { - let mut dst = Vec::new(); - - for ( index, value ) in self.row.iter().enumerate() - { - let column_name = format!( "Column{}", index ); - let title = Box::leak( column_name.into_boxed_str() ) as &str; - dst.push( ( title, Some( Cow::Owned( value.to_string() ) ) ) ) - } - - //adding empty values for missing cells - for index in self.row.len()..self.max_len - { - let column_name = format!( "Column{}", index ); - let title = Box::leak( column_name.into_boxed_str() ) as &str; - dst.push( ( title, Some( Cow::Owned( "".to_string() ) ) ) ); - } - - dst.into_iter() - } +//! +//! Gspread wrapper for outputting data to console +//! +//! It is used for "header" and "rows" commands +//! +use super::*; +use crate::*; +use ser::JsonValue; + + +#[ derive( Debug ) ] +pub struct RowWrapper +{ + pub row: Vec< JsonValue >, + pub max_len: usize +} +impl Clone for RowWrapper +{ + fn clone( &self ) -> Self + { + Self + { + row: self.row.clone(), + max_len: self.max_len.clone() + } + } +} +impl TableWithFields for RowWrapper {} +impl Fields< &'_ str, Option< Cow< '_, str > > > +for RowWrapper +{ + type Key< 'k > = &'k str; + type Val< 'v > = Option< Cow< 'v, str > >; + fn fields( &self ) -> impl IteratorTrait< Item= ( &'_ str, Option > ) > + { + let mut dst = Vec::new(); + + for ( index, value ) in self.row.iter().enumerate() + { + let column_name = format!( "{} ", index ); + let title = Box::leak( column_name.into_boxed_str() ) as &str; + let cleaned: String = value + .to_string() + .chars() + .skip( 1 ) + .take( value.to_string().chars().count() - 2 ) + .collect(); + + dst.push( ( title, Some( Cow::Owned( cleaned ) ) ) ) + } + + //adding empty values for missing cells + for index in self.row.len()..self.max_len + { + let column_name = format!( "Column{}", index ); + let column_name = format!( "{}", index ); + let title = Box::leak( column_name.into_boxed_str() ) as &str; + dst.push( ( title, Some( Cow::Owned( "".to_string() ) ) ) ); + } + dst.into_iter() + } } \ No newline at end of file diff --git a/module/move/gspread/tests/inc/header_tests.rs b/module/move/gspread/tests/inc/header_tests.rs index 3009a63bb1..046d8e1d69 100644 --- a/module/move/gspread/tests/inc/header_tests.rs +++ b/module/move/gspread/tests/inc/header_tests.rs @@ -17,6 +17,7 @@ async fn setup() -> ( SheetsType, &'static str ) ( hub, spreadsheet_id ) } + #[ tokio::test ] async fn test_get_header() { diff --git a/module/move/gspread/tests/mock/cell_tests.rs b/module/move/gspread/tests/mock/cell_tests.rs new file mode 100644 index 0000000000..488eb60d02 --- /dev/null +++ b/module/move/gspread/tests/mock/cell_tests.rs @@ -0,0 +1,93 @@ +//! +//! Get and set cell tests. +//! In these examples: +//! - url is /v4/spreadsheets/{spreadsheet_id}}/values/{range} +//! - everything is fake: spreadsheet_id, sheet's name, range and response json +//! + +use httpmock::prelude::*; +use reqwest; + +#[ tokio::test ] +async fn test_get_cell_with_mock() +{ + let server = MockServer::start(); + let body = r#"{ "A2": "Steeve" }"#; + let mock = server.mock( | when, then | { + when.method( GET ) + .path( "/v4/spreadsheets/12345/values/tab2!R2C1" ); + then.status( 200 ) + .header( "Content-Type", "application/json" ) + .body( body ); + } ); + + let response = reqwest::get + ( + server.url + ( + "/v4/spreadsheets/12345/values/tab2!R2C1" + ) + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 200 ); +} + +#[ tokio::test ] +async fn test_get_cell_empty_with_mock() +{ + let server = MockServer::start(); + let mock = server.mock( | when, then | { + when.method( GET ) + .path( "/v4/spreadsheets/12345/values/tab2!R2C1" ); + then.status( 200 ) + .header( "Content-Type", "application/json" ) + .body( r#"{}"# ); + } ); + + let response = reqwest::get + ( + server.url + ( + "/v4/spreadsheets/12345/values/tab2!R2C1" + ) + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 200 ); +} + +#[ tokio::test ] +async fn test_set_cell_with_mock() +{ + let server = MockServer::start(); + let body = r#"A2": "Some value"#; + let mock = server.mock( | when, then | { + when.method( POST ) + .path( "/v4/spreadsheets/12345/values/tab2!R2C1" ) + .header("content-type", "application/json") + .body( body ); + // returns amount of updated cells + then.status( 201 ) + .header( "Content-Type", "application/json" ) + .body( "1" ); + } ); + + let response = reqwest::Client::new() + .post( server.url( "/v4/spreadsheets/12345/values/tab2!R2C1" ) ) + .header( "Content-Type", "application/json" ) + .body( body ) + .send() + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 201 ); +} \ No newline at end of file diff --git a/module/move/gspread/tests/mock/cells_tests.rs b/module/move/gspread/tests/mock/cells_tests.rs new file mode 100644 index 0000000000..b03b78fd71 --- /dev/null +++ b/module/move/gspread/tests/mock/cells_tests.rs @@ -0,0 +1,67 @@ +//! +//! Set cells tests. +//! In these examples: +//! - url is /v4/spreadsheets/{spreadsheet_id}}/values/{range} +//! - everything is fake: spreadsheet_id, sheet's name, range and response json +//! + +use httpmock::prelude::*; +use reqwest; + +#[ tokio::test ] +async fn test_set_cells_with_mock() +{ + let server = MockServer::start(); + let body = r#"{ "id": "2", "A": "new_val1", "B": "new_val2"}"#; + let mock = server.mock( | when, then | { + when.method( POST ) + .path( "/v4/spreadsheets/12345/values/tab3!A2:B2" ) + .header( "Content-Type", "application/json" ) + .body( body ); + // returns amount of updated cells + then.status( 201 ) + .header( "Content-Type", "application/json" ) + .body( "2" ); + } ); + + let response = reqwest::Client::new() + .post( server.url( "/v4/spreadsheets/12345/values/tab3!A2:B2" ) ) + .header( "Content-Type", "application/json" ) + .body( body ) + .send() + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 201 ); +} + +#[ tokio::test ] +async fn test_set_cells_wrong_row_with_mock() +{ + let server = MockServer::start(); + let body = r#"{ "id": "a", "A": "new_val1", "B": "new_val2"}"#; + let response_body = r#"{"error":{"code":400,"message":"Invalid data[0]: Unable to parse range: tab3!Aa","status":"INVALID_ARGUMENT"}}"#; + let mock = server.mock( | when, then | { + when.method( POST ) + .path( "/v4/spreadsheets/12345/values/tab3!Aa:Ba" ) + .header( "Content-Type", "application/json" ) + .body( body ); + then.status( 400 ) + .header( "Content-Type", "application/json" ) + .body( response_body ); + } ); + + let response = reqwest::Client::new() + .post( server.url( "/v4/spreadsheets/12345/values/tab3!Aa:Ba" ) ) + .header( "Content-Type", "application/json" ) + .body( body ) + .send() + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 400 ); +} \ No newline at end of file diff --git a/module/move/gspread/tests/mock/header_tests.rs b/module/move/gspread/tests/mock/header_tests.rs new file mode 100644 index 0000000000..5927b1a8a6 --- /dev/null +++ b/module/move/gspread/tests/mock/header_tests.rs @@ -0,0 +1,123 @@ +//! +//! Get header tests. +//! In these examples: +//! - url is /v4/spreadsheets/{spreadsheet_id}}/values/{range} +//! - everything is fake: spreadsheet_id, sheet's name, range and response json +//! + +use httpmock::prelude::*; +use reqwest; + + +#[ tokio::test ] +async fn test_get_header() +{ + let server = MockServer::start(); + let body = r#"{ "A1": "Name", "B1": "Surname", "C1": "Age" }"#; + let mock = server.mock( | when, then | { + when.method( GET ) + .path( "/v4/spreadsheets/12345/values/tab1!A1:Z1" ); + then.status( 200 ) + .header("Content-Type", "application/json" ) + .body( body ); + } ); + + let response = reqwest::get + ( + server.url + ( + "/v4/spreadsheets/12345/values/tab1!A1:Z1" + ) + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 200 ) + +} + +#[ tokio::test ] +async fn test_get_header_with_spaces_with_mock() +{ + let server = MockServer::start(); + let body = r#"{ "A1": "Name", "B1": "", "C1": "Age" }"#; + let mock = server.mock( | when, then | { + when.method( GET ) + .path( "/v4/spreadsheets/12345/values/tab1!A1:Z1" ); + then.status( 200 ) + .header("Content-Type", "application/json" ) + .body( body ); + } ); + + let response = reqwest::get + ( + server.url + ( + "/v4/spreadsheets/12345/values/tab1!A1:Z1" + ) + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 200 ) +} + +#[ tokio::test ] +async fn test_get_header_empty_with_mock() +{ + let server = MockServer::start(); + + let mock = server.mock( | when, then | { + when.method( GET ) + .path( "/v4/spreadsheets/12345/values/tab1!A1:Z1" ); + then.status( 200 ) + .header("Content-Type", "application/json" ) + .body( r#"{}"# ); + } ); + + let response = reqwest::get + ( + server.url + ( + "/v4/spreadsheets/12345/values/tab1!A1:Z1" + ) + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 200 ) +} + +#[ tokio::test ] +async fn test_get_header_with_empty_end_with_mock() +{ + let server = MockServer::start(); + let body = r#"{ "A1": "Name", "B1": "Surname" }"#; + let mock = server.mock( | when, then | { + when.method( GET ) + .path( "/v4/spreadsheets/12345/values/tab1!A1:Z1" ); + then.status( 200 ) + .header("Content-Type", "application/json" ) + .body( body ); + } ); + + let response = reqwest::get + ( + server.url + ( + "/v4/spreadsheets/12345/values/tab1!A1:Z1" + ) + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 200 ) +} \ No newline at end of file diff --git a/module/move/gspread/tests/mock/mod.rs b/module/move/gspread/tests/mock/mod.rs new file mode 100644 index 0000000000..1c6c49e281 --- /dev/null +++ b/module/move/gspread/tests/mock/mod.rs @@ -0,0 +1,8 @@ +#[ allow( unused_imports ) ] +use super::*; + +mod oauth_tests; +mod header_tests; +mod cell_tests; +mod cells_tests; +mod rows_tests; \ No newline at end of file diff --git a/module/move/gspread/tests/mock/oauth_tests.rs b/module/move/gspread/tests/mock/oauth_tests.rs new file mode 100644 index 0000000000..a22ef9a792 --- /dev/null +++ b/module/move/gspread/tests/mock/oauth_tests.rs @@ -0,0 +1,102 @@ +//! +//! OAuth2 tests. +//! + +use httpmock::prelude::*; +use reqwest; +use serde_json::json; + +#[ tokio::test ] +async fn oauth2_first_endpoint_with_mock() +{ + let server = MockServer::start(); + let mock = server.mock( | when, then | { + when.method( GET ) + .path( "/o/oauth2/auth" ) + .query_param( "scope", "https://www.googleapis.com/auth/drive.readonly" ) + .query_param( "access_type", "offline" ) + .query_param( "redirect_uri", "http://localhost:44444" ) + .query_param( "response_type", "code" ) + .query_param( "client_id", "YOUR_CLIENT_ID" ); + then.status( 302 ); + }); + + let response = reqwest::get + ( + server.url + ( + "/o/oauth2/auth?\ + scope=https://www.googleapis.com/auth/drive.readonly&\ + access_type=offline&\ + redirect_uri=http://localhost:44444&\ + response_type=code&\ + client_id=YOUR_CLIENT_ID" + ) + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 302 ); +} + + +#[ tokio::test ] +async fn oauth2_second_endpoint_with_mock() +{ + let server = MockServer::start(); + + // url == first endpoint + let mock = server.mock( | when, then | { + when.path( "/o/oauth2/auth" ) + .query_param( "scope", "https://..." ); + then.status( 302 ); + } ); + + // in real program at that point we have to open generated url and give access to our program from browser + let response = reqwest::get( server.url( "/o/oauth2/auth?scope=https://..." ) ).await.unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 302 ); +} + +#[ tokio::test ] +async fn oauth2_third_endpoint_with_mock() +{ + let server = MockServer::start(); + let body = r#"code=AUTHORIZATION_CODE&client_secret=YOUR_CLIENT_SECRET&"#; + let mock = server.mock( | when, then | { + when.method( POST ) + .path( "/token" ) + .header("Content-Type", "application/json" ) + .body( body ); + then.status( 200 ) + .header("Content-Type", "application/json" ) + .json_body + ( + json! + ( + { + "access_token" : "access_token", + "token_type" : "Bearer", + "expires_in" : "3600", + "scope" : "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email openid" + } + ) + ); + }); + + let response = reqwest::Client::new() + .post( server.url( "/token" ) ) + .header( "Content-Type", "application/json" ) + .body( body ) + .send() + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 200 ); +} \ No newline at end of file diff --git a/module/move/gspread/tests/mock/rows_tests.rs b/module/move/gspread/tests/mock/rows_tests.rs new file mode 100644 index 0000000000..05b43663b3 --- /dev/null +++ b/module/move/gspread/tests/mock/rows_tests.rs @@ -0,0 +1,93 @@ +//! +//! Get rows tests. +//! In these examples: +//! - url is /v4/spreadsheets/{spreadsheet_id}}/values/{range} +//! - everything is fake: spreadsheet_id, sheet's name, range and response json +//! + +use httpmock::prelude::*; +use reqwest; + +#[ tokio::test ] +async fn test_get_rows_with_mock() +{ + let server = MockServer::start(); + let body = r#"{"A2" : "Steeve", "B2": "John", "A3": "Seva", "B3": "Oleg" }"#; + let mock = server.mock( | when, then | { + when.method( GET ) + .path( "/v4/spreadsheets/12345/values/A2:B3" ); + then.status( 200 ) + .header( "Content-Type", "application/json" ) + .body( body ); + }); + + let response = reqwest::get + ( + server.url + ( + "/v4/spreadsheets/12345/values/A2:B3" + ) + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 200 ); +} + +#[ tokio::test ] +async fn test_get_rows_with_spaces_with_mock() +{ + let server = MockServer::start(); + let body = r#"{"A2" : "Steeve", "B2": "", "A3": "Seva", "B3": "Oleg" }"#; + let mock = server.mock( | when, then | { + when.method( GET ) + .path( "/v4/spreadsheets/12345/values/A2:B3" ); + then.status( 200 ) + .header( "Content-Type", "application/json" ) + .body( body ); + }); + + let response = reqwest::get + ( + server.url + ( + "/v4/spreadsheets/12345/values/A2:B3" + ) + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 200 ); +} + +#[ tokio::test ] +async fn test_get_rows_empty_with_mock() +{ + let server = MockServer::start(); + let body = r#"{}"#; + let mock = server.mock( | when, then | { + when.method( GET ) + .path( "/v4/spreadsheets/12345/values/A2:B3" ); + then.status( 200 ) + .header( "Content-Type", "application/json" ) + .body( body ); + }); + + let response = reqwest::get + ( + server.url + ( + "/v4/spreadsheets/12345/values/A2:B3" + ) + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!( response.status(), 200 ); +} \ No newline at end of file diff --git a/module/move/gspread/tests/smoke_test.rs b/module/move/gspread/tests/smoke_test.rs index c3163b32ed..28e533e551 100644 --- a/module/move/gspread/tests/smoke_test.rs +++ b/module/move/gspread/tests/smoke_test.rs @@ -1,4 +1,3 @@ - #[ test ] fn local_smoke_test() { diff --git a/module/move/gspread/tests/tests.rs b/module/move/gspread/tests/tests.rs index 201ae26926..31c81bb6b2 100644 --- a/module/move/gspread/tests/tests.rs +++ b/module/move/gspread/tests/tests.rs @@ -1,9 +1,10 @@ - - #[ allow( unused_imports ) ] use gspread as the_module; #[ allow( unused_imports ) ] use test_tools::exposed::*; -#[ cfg( feature = "enabled" ) ] -mod inc; \ No newline at end of file +#[ cfg( feature = "with_online" ) ] +mod inc; + +#[ cfg( feature = "default" ) ] +mod mock; \ No newline at end of file