diff --git a/server/cli/src/cli.rs b/server/cli/src/cli.rs index ab71406054..957a2678b3 100644 --- a/server/cli/src/cli.rs +++ b/server/cli/src/cli.rs @@ -161,6 +161,10 @@ enum Action { }, /// Will generate a plugin bundle GeneratePluginBundle(GeneratePluginBundle), + /// Will insert generated plugin bundle + InstallPluginBundle(InstallPluginBundle), + /// Will generate and then install plugin bundle + GenerateAndInstallPluginBundle(GenerateAndInstallPluginBundle), UpsertReports { /// Optional reports json path. This needs to be of type ReportsData. If none supplied, will upload the standard generated reports #[clap(short, long)] @@ -552,6 +556,12 @@ async fn main() -> anyhow::Result<()> { Action::GeneratePluginBundle(arguments) => { generate_plugin_bundle(arguments)?; } + Action::InstallPluginBundle(arguments) => { + install_plugin_bundle(arguments).await?; + } + Action::GenerateAndInstallPluginBundle(arguments) => { + generate_and_install_plugin_bundle(arguments).await?; + } Action::ShowReport { path, config } => { let report_data: ReportData = generate_report_data(&path)?; diff --git a/server/cli/src/graphql/auth.rs b/server/cli/src/graphql/auth.rs new file mode 100644 index 0000000000..6996eb0205 --- /dev/null +++ b/server/cli/src/graphql/auth.rs @@ -0,0 +1,16 @@ +pub(super) const AUTH_QUERY: &str = r#" +query AuthToken($username: String!, $password: String) { + root: authToken(password: $password, username: $username) { + ... on AuthToken { + __typename + token + } + ... on AuthTokenError { + __typename + error { + description + } + } + } +} +"#; diff --git a/server/cli/src/graphql/mod.rs b/server/cli/src/graphql/mod.rs new file mode 100644 index 0000000000..2306b6216d --- /dev/null +++ b/server/cli/src/graphql/mod.rs @@ -0,0 +1,170 @@ +use std::{ + io::{self, Read}, + path::PathBuf, +}; + +use auth::AUTH_QUERY; +use reqwest::{multipart, RequestBuilder, Url}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +#[derive(Debug)] +pub struct Api { + url: Url, + token: String, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize, Serialize)] +struct GraphQlResponse { + data: Root, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize, Serialize)] +struct Root { + root: serde_json::Value, +} + +mod auth; +pub mod queries_mutations; + +#[derive(thiserror::Error, Debug)] +pub enum ApiError { + #[error("Error while sending request to {1}")] + SendingRequest(#[source] reqwest::Error, Url), + #[error("Error while getting text, status {1:?}")] + GettingText(#[source] reqwest::Error, reqwest::StatusCode), + #[error("Error parsing gql response: {1}")] + ParsingJson(#[source] serde_json::Error, String), + #[error("Error validating typename, expected typename {expected_typename}, result: {json}")] + ValidatingTypename { + expected_typename: String, + json: String, + }, + #[error("Error opening file, {1}")] + OpeningFile(#[source] io::Error, PathBuf), + #[error("Error reading file, {1}")] + ReadingFile(#[source] io::Error, PathBuf), +} +use service::UploadedFile; +use ApiError as Error; + +impl Api { + pub async fn new_with_token( + url: Url, + username: String, + password: String, + ) -> Result { + let result = _gql( + &url.join("graphql").unwrap(), + AUTH_QUERY, + serde_json::json! ({ + "username": username, + "password": password, + }), + None, + Some("AuthToken"), + ) + .await?; + + let token = result["token"].as_str().unwrap().to_string(); + + Ok(Api { url, token }) + } + + pub async fn gql( + &self, + query: &str, + variables: serde_json::Value, + expected_typename: Option<&str>, + ) -> Result { + _gql( + &self.url.join("graphql").unwrap(), + query, + variables, + Some(&self.token), + expected_typename, + ) + .await + } + + pub async fn upload_file(&self, path: PathBuf) -> Result { + let url = self.url.join("upload").unwrap(); + + let auth_cooke_value = format!(r#"auth={{"token": "{}"}}"#, self.token); + let built_request = reqwest::Client::new() + .post(url.clone()) + .header("Cookie", auth_cooke_value); + + // Add file to request + let mut file_handle = + std::fs::File::open(path.clone()).map_err(|e| Error::OpeningFile(e, path.clone()))?; + let mut file_bytes = Vec::new(); + file_handle + .read_to_end(&mut file_bytes) + .map_err(|e| Error::ReadingFile(e, path))?; + let file_part = multipart::Part::bytes(file_bytes).file_name("upload".to_string()); + let multipart_form = multipart::Form::new().part("files", file_part); + let built_request = built_request.multipart(multipart_form); + + // Send and return file_id + + send_and_parse(built_request, url).await + } +} + +async fn _gql( + url: &Url, + query: &str, + variables: serde_json::Value, + token: Option<&str>, + expected_typename: Option<&str>, +) -> Result { + let body = serde_json::json!({ + "query": query, + "variables": variables + }); + + let mut client = reqwest::Client::new().post(url.clone()); + + if let Some(token) = token { + client = client.bearer_auth(token) + }; + + let built_request = client.json(&body); + + let json_result: GraphQlResponse = send_and_parse(built_request, url.clone()).await?; + + let result = json_result.data.root; + + let Some(expected_typename) = expected_typename else { + return Ok(result); + }; + + if result["__typename"] != expected_typename { + return Err(Error::ValidatingTypename { + expected_typename: expected_typename.to_string(), + json: serde_json::to_string(&result).unwrap(), + }); + } + + Ok(result) +} + +async fn send_and_parse( + built_request: RequestBuilder, + url: Url, +) -> Result { + let response = built_request + .send() + .await + .map_err(|e| Error::SendingRequest(e, url))?; + + let status = response.status(); + let text_result = response + .text() + .await + .map_err(|e| Error::GettingText(e, status))?; + + Ok(serde_json::from_str(&text_result).map_err(|e| Error::ParsingJson(e, text_result))?) +} diff --git a/server/cli/src/graphql/queries_mutations.rs b/server/cli/src/graphql/queries_mutations.rs new file mode 100644 index 0000000000..8344e50209 --- /dev/null +++ b/server/cli/src/graphql/queries_mutations.rs @@ -0,0 +1,11 @@ +pub const INSTALL_PLUGINS: &'static str = r#" +mutation Query($fileId: String!) { + root: centralServer { + __typename + plugins { + installUploadedPlugin(fileId: $fileId) { + backendPluginCodes + } + } + } +}"#; diff --git a/server/cli/src/lib.rs b/server/cli/src/lib.rs index 0a8ae165c8..b9aa2b2a5d 100644 --- a/server/cli/src/lib.rs +++ b/server/cli/src/lib.rs @@ -3,3 +3,5 @@ mod refresh_dates; pub use refresh_dates::*; mod report_utils; pub use report_utils::*; +mod graphql; +pub use graphql::*; diff --git a/server/cli/src/plugins.rs b/server/cli/src/plugins.rs index 0088223519..ccceeb07e3 100644 --- a/server/cli/src/plugins.rs +++ b/server/cli/src/plugins.rs @@ -1,11 +1,15 @@ use std::{ffi::OsStr, fs, path::PathBuf}; use base64::{prelude::BASE64_STANDARD, Engine}; +use cli::{queries_mutations::INSTALL_PLUGINS, Api, ApiError}; +use log::info; use repository::{BackendPluginRow, PluginTypes, PluginVariantType}; +use reqwest::Url; use serde::Deserialize; +use serde_json::json; use service::backend_plugin::plugin_provider::PluginBundle; -use thiserror::Error as ThisError; +use thiserror::Error as ThisError; #[derive(ThisError, Debug)] pub(super) enum Error { #[error("Failed to read dir {0}")] @@ -24,6 +28,10 @@ pub(super) enum Error { FailedToSerializeBundle(#[source] serde_json::Error), #[error("Failed to write bundle file {0}")] FailedToWriteBundleFile(PathBuf, #[source] std::io::Error), + #[error(transparent)] + GqlError(#[from] ApiError), + #[error("Failed to remove temp file {1}")] + FiledToRemoveTempFile(std::io::Error, PathBuf), } #[derive(clap::Parser, Debug)] @@ -36,6 +44,37 @@ pub(super) struct GeneratePluginBundle { out_file: PathBuf, } +#[derive(clap::Parser, Debug)] +pub(super) struct InstallPluginBundle { + /// Path to bundle + #[clap(short, long)] + path: PathBuf, + /// Server url + #[clap(short, long)] + url: Url, + /// Username + #[clap(long)] + username: String, + /// Password + #[clap(long)] + password: String, +} + +#[derive(clap::Parser, Debug)] +pub(super) struct GenerateAndInstallPluginBundle { + /// Directory in which to search for plugins + #[clap(short, long)] + in_dir: PathBuf, + /// Server url + #[clap(short, long)] + url: Url, + /// Username + #[clap(long)] + username: String, + /// Password + #[clap(long)] + password: String, +} #[derive(Deserialize)] struct ManifestJson { code: String, @@ -125,3 +164,60 @@ fn process_manifest(bundle: &mut PluginBundle, path: &PathBuf) -> Result<(), Err Ok(()) } + +/// username, password, url should come from config, like in reports (in the new show command) +pub(super) async fn install_plugin_bundle( + InstallPluginBundle { + path, + url, + username, + password, + }: InstallPluginBundle, +) -> Result<(), Error> { + let api = Api::new_with_token(url.clone(), username, password).await?; + + let uploaded_file = api.upload_file(path).await?; + + let upload_result = api + .gql( + INSTALL_PLUGINS, + json!( { "fileId": uploaded_file.file_id} ), + Some("CentralServerMutationNode"), + ) + .await?; + + info!( + "Result:{}", + serde_json::to_string_pretty(&upload_result).unwrap() + ); + + Ok(()) +} + +/// username and password should come from config, like in reports (and url too) +pub(super) async fn generate_and_install_plugin_bundle( + GenerateAndInstallPluginBundle { + in_dir, + url, + username, + password, + }: GenerateAndInstallPluginBundle, +) -> Result<(), Error> { + let out_file = PathBuf::from("report_temp.json"); + + generate_plugin_bundle(GeneratePluginBundle { + in_dir, + out_file: out_file.clone(), + })?; + + install_plugin_bundle(InstallPluginBundle { + path: out_file.clone(), + url, + username, + password, + }) + .await?; + fs::remove_file(out_file.clone()).map_err(|e| Error::FiledToRemoveTempFile(e, out_file))?; + + Ok(()) +} diff --git a/server/service/src/backend_plugin/examples/amc/amc.js b/server/service/src/backend_plugin/examples/amc/amc.js index f7f9ee4674..157ee7d012 100644 --- a/server/service/src/backend_plugin/examples/amc/amc.js +++ b/server/service/src/backend_plugin/examples/amc/amc.js @@ -1,4 +1,10 @@ // To upload to server (from this dir): +// cargo run --bin remote_server_cli -- generate-and-install-plugin-bundle -i './service/src/backend_plugin/examples/amc' -u 'http://localhost:8000' --username 'test' --password 'pass' +// OR +// cargo run --bin remote_server_cli -- generate-plugin-bundle -i './service/src/backend_plugin/examples/amc' -o 'check.json' +// Can install via CLI +// cargo run --bin remote_server_cli -- install-plugin-bundle --path 'check.json' -u 'http://localhost:8000' --username 'test' --password 'pass' +// Or can install via curl // cargo run --bin remote_server_cli -- generate-plugin-bundle -i './service/src/backend_plugin/examples/amc' -o 'check.json' // curl -H 'Content-Type: application/json' --data '{"query":"query MyQuery {authToken(password: \"pass\", username: \"Admin\") {... on AuthToken {token}... on AuthTokenError {error {description}}}}","variables":{}}' 'http://localhost:8000/graphql' // TOKEN=token from above @@ -36,6 +42,9 @@ let plugins = { const sql_result = sql(sql_statement); const response = {}; + // Fill all item_ids with default + item_ids.forEach((itemId) => (response[itemId] = { average_monthly_consumption: 1 })); + sql_result.forEach(({ item_id, consumption }) => { response[item_id] = { average_monthly_consumption: consumption / (DAY_LOOKBACK / DAYS_IN_MONTH), diff --git a/server/service/src/lib.rs b/server/service/src/lib.rs index 80056d3631..372927fc2d 100644 --- a/server/service/src/lib.rs +++ b/server/service/src/lib.rs @@ -6,7 +6,7 @@ use repository::location::{LocationFilter, LocationRepository}; use repository::{EqualFilter, Pagination, PaginationOption, DEFAULT_PAGINATION_LIMIT}; use repository::{RepositoryError, StorageConnection}; use serde::de::DeserializeOwned; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use service_provider::ServiceContext; use settings::Settings; use static_files::{StaticFile, StaticFileCategory, StaticFileService}; @@ -343,7 +343,7 @@ fn check_item_variant_exists( return Ok(variant); } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct UploadedFile { pub file_id: String,