using unified system
This commit is contained in:
16
komp-app/Cargo.toml
Normal file
16
komp-app/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "komp-app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Shared application services and rules for komp_ac clients"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
common = { path = "../common" }
|
||||
jiff = { version = "0.2.15", default-features = false, features = ["std", "tzdb-bundle-always"] }
|
||||
prost-types.workspace = true
|
||||
sanitise-file-name = "1"
|
||||
serde.workspace = true
|
||||
tonic.workspace = true
|
||||
uuid = { version = "1.23.3", features = ["v4"] }
|
||||
304
komp-app/src/auth.rs
Normal file
304
komp-app/src/auth.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
use anyhow::{Context, Result};
|
||||
use common::proto::komp_ac::auth::{
|
||||
AddRoleRequest, AssignUserRoleRequest, AuthResponse, AuthorizationSnapshot,
|
||||
ChangePasswordRequest, GetAuthorizationRequest, GrantPermissionRequest,
|
||||
GrantableObject, ListGrantableObjectsRequest, ListRolePermissionsRequest, ListRolesRequest,
|
||||
ListUsersRequest, LoginRequest, LoginResponse, LogoutRequest, PasswordOperationResponse,
|
||||
RegisterRequest, RemoveRoleRequest, ResetUserPasswordRequest, RevokePermissionRequest,
|
||||
RevokeUserSessionsRequest, Role, RolePermissions, UserSummary,
|
||||
auth_service_client::AuthServiceClient,
|
||||
};
|
||||
use tonic::transport::Channel;
|
||||
use tonic::Request;
|
||||
|
||||
use crate::transport::{DEFAULT_GRPC_ENDPOINT, authenticated_request, connect_channel};
|
||||
|
||||
pub fn validate_password_change(password: &str, confirmation: &str) -> Result<()> {
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("Password is required");
|
||||
}
|
||||
if password != confirmation {
|
||||
anyhow::bail!("Passwords do not match");
|
||||
}
|
||||
if password.len() < 8 {
|
||||
anyhow::bail!("Password must be at least 8 characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_optional_password(password: Option<&str>, confirmation: Option<&str>) -> Result<()> {
|
||||
let password = password.unwrap_or_default();
|
||||
let confirmation = confirmation.unwrap_or_default();
|
||||
if password.is_empty() && confirmation.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
validate_password_change(password, confirmation)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthClient {
|
||||
client: AuthServiceClient<Channel>,
|
||||
}
|
||||
|
||||
impl AuthClient {
|
||||
pub async fn new() -> Result<Self> {
|
||||
let endpoint = std::env::var("GRPC_ENDPOINT")
|
||||
.unwrap_or_else(|_| DEFAULT_GRPC_ENDPOINT.to_string());
|
||||
Self::connect(&endpoint).await
|
||||
}
|
||||
|
||||
pub async fn connect(endpoint: &str) -> Result<Self> {
|
||||
Self::with_channel(connect_channel(endpoint).await?).await
|
||||
}
|
||||
|
||||
pub async fn with_channel(channel: Channel) -> Result<Self> {
|
||||
Ok(Self {
|
||||
client: AuthServiceClient::new(channel),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn login(&mut self, identifier: String, password: String) -> Result<LoginResponse> {
|
||||
Ok(self
|
||||
.client
|
||||
.login(Request::new(LoginRequest {
|
||||
identifier,
|
||||
password,
|
||||
}))
|
||||
.await?
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
&mut self,
|
||||
username: String,
|
||||
email: String,
|
||||
password: Option<String>,
|
||||
password_confirmation: Option<String>,
|
||||
timezone: String,
|
||||
phone_country: String,
|
||||
) -> Result<AuthResponse> {
|
||||
validate_optional_password(password.as_deref(), password_confirmation.as_deref())?;
|
||||
Ok(self
|
||||
.client
|
||||
.register(Request::new(RegisterRequest {
|
||||
username,
|
||||
email,
|
||||
password: password.unwrap_or_default(),
|
||||
password_confirmation: password_confirmation.unwrap_or_default(),
|
||||
timezone,
|
||||
phone_country,
|
||||
}))
|
||||
.await?
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
&mut self,
|
||||
token: &str,
|
||||
current_password: String,
|
||||
new_password: String,
|
||||
new_password_confirmation: String,
|
||||
) -> Result<PasswordOperationResponse> {
|
||||
validate_password_change(&new_password, &new_password_confirmation)?;
|
||||
let request = authenticated_request(
|
||||
Some(token),
|
||||
ChangePasswordRequest {
|
||||
current_password,
|
||||
new_password,
|
||||
new_password_confirmation,
|
||||
},
|
||||
)?;
|
||||
Ok(self.client.change_password(request).await?.into_inner())
|
||||
}
|
||||
|
||||
pub async fn logout(&mut self, token: &str) -> Result<()> {
|
||||
self.client
|
||||
.logout(authenticated_request(Some(token), LogoutRequest {})?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_authorization(&mut self, token: &str) -> Result<AuthorizationSnapshot> {
|
||||
Ok(self
|
||||
.client
|
||||
.get_authorization(authenticated_request(Some(token), GetAuthorizationRequest {})?)
|
||||
.await?
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
pub async fn list_roles(&mut self, token: &str) -> Result<Vec<Role>> {
|
||||
Ok(self
|
||||
.client
|
||||
.list_roles(authenticated_request(Some(token), ListRolesRequest {})?)
|
||||
.await?
|
||||
.into_inner()
|
||||
.roles)
|
||||
}
|
||||
|
||||
pub async fn add_role(&mut self, token: &str, name: String, parent: String) -> Result<()> {
|
||||
self.client
|
||||
.add_role(authenticated_request(
|
||||
Some(token),
|
||||
AddRoleRequest { name, parent },
|
||||
)?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_role(&mut self, token: &str, name: String) -> Result<()> {
|
||||
self.client
|
||||
.remove_role(authenticated_request(Some(token), RemoveRoleRequest { name })?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn grant_permission(
|
||||
&mut self,
|
||||
token: &str,
|
||||
role: String,
|
||||
object: String,
|
||||
action: String,
|
||||
) -> Result<()> {
|
||||
self.client
|
||||
.grant_permission(authenticated_request(
|
||||
Some(token),
|
||||
GrantPermissionRequest {
|
||||
role,
|
||||
object,
|
||||
action,
|
||||
},
|
||||
)?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke_permission(
|
||||
&mut self,
|
||||
token: &str,
|
||||
role: String,
|
||||
object: String,
|
||||
action: String,
|
||||
) -> Result<()> {
|
||||
self.client
|
||||
.revoke_permission(authenticated_request(
|
||||
Some(token),
|
||||
RevokePermissionRequest {
|
||||
role,
|
||||
object,
|
||||
action,
|
||||
},
|
||||
)?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn try_revoke_permission(
|
||||
&mut self,
|
||||
token: &str,
|
||||
role: String,
|
||||
object: String,
|
||||
action: String,
|
||||
) -> Result<bool> {
|
||||
let request = authenticated_request(
|
||||
Some(token),
|
||||
RevokePermissionRequest {
|
||||
role,
|
||||
object,
|
||||
action,
|
||||
},
|
||||
)?;
|
||||
match self.client.revoke_permission(request).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(status) if status.code() == tonic::Code::NotFound => Ok(false),
|
||||
Err(status) => Err(status.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_role_permissions(
|
||||
&mut self,
|
||||
token: &str,
|
||||
role: String,
|
||||
) -> Result<RolePermissions> {
|
||||
Ok(self
|
||||
.client
|
||||
.list_role_permissions(authenticated_request(
|
||||
Some(token),
|
||||
ListRolePermissionsRequest { role },
|
||||
)?)
|
||||
.await?
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
pub async fn list_grantable_objects(
|
||||
&mut self,
|
||||
token: &str,
|
||||
target_role: String,
|
||||
) -> Result<Vec<GrantableObject>> {
|
||||
Ok(self
|
||||
.client
|
||||
.list_grantable_objects(authenticated_request(
|
||||
Some(token),
|
||||
ListGrantableObjectsRequest { target_role },
|
||||
)?)
|
||||
.await?
|
||||
.into_inner()
|
||||
.objects)
|
||||
}
|
||||
|
||||
pub async fn assign_user_role(
|
||||
&mut self,
|
||||
token: &str,
|
||||
username: String,
|
||||
role: String,
|
||||
) -> Result<()> {
|
||||
self.client
|
||||
.assign_user_role(authenticated_request(
|
||||
Some(token),
|
||||
AssignUserRoleRequest { username, role },
|
||||
)?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_users(&mut self, token: &str) -> Result<Vec<UserSummary>> {
|
||||
Ok(self
|
||||
.client
|
||||
.list_users(authenticated_request(Some(token), ListUsersRequest {})?)
|
||||
.await?
|
||||
.into_inner()
|
||||
.users)
|
||||
}
|
||||
|
||||
pub async fn revoke_user_sessions(&mut self, token: &str, username: String) -> Result<()> {
|
||||
self.client
|
||||
.revoke_user_sessions(authenticated_request(
|
||||
Some(token),
|
||||
RevokeUserSessionsRequest { username },
|
||||
)?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reset_user_password(
|
||||
&mut self,
|
||||
token: &str,
|
||||
username: String,
|
||||
new_password: String,
|
||||
new_password_confirmation: String,
|
||||
) -> Result<PasswordOperationResponse> {
|
||||
validate_password_change(&new_password, &new_password_confirmation)?;
|
||||
Ok(self
|
||||
.client
|
||||
.reset_user_password(authenticated_request(
|
||||
Some(token),
|
||||
ResetUserPasswordRequest {
|
||||
username,
|
||||
new_password,
|
||||
new_password_confirmation,
|
||||
},
|
||||
)?)
|
||||
.await
|
||||
.context("Failed to reset user password")?
|
||||
.into_inner())
|
||||
}
|
||||
}
|
||||
107
komp-app/src/csv.rs
Normal file
107
komp-app/src/csv.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use anyhow::{Result, bail};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
pub struct CsvRecordReader<R> {
|
||||
reader: R,
|
||||
buffer: String,
|
||||
}
|
||||
|
||||
impl<R: BufRead> CsvRecordReader<R> {
|
||||
pub fn new(reader: R) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
buffer: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_record(&mut self) -> Result<Option<Vec<String>>> {
|
||||
self.buffer.clear();
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes = self.reader.read_line(&mut line)?;
|
||||
if bytes == 0 {
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
break;
|
||||
}
|
||||
self.buffer.push_str(&line);
|
||||
if csv_record_complete(&self.buffer) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(parse_csv_record(
|
||||
self.buffer.trim_end_matches(['\r', '\n']),
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn csv_record_complete(record: &str) -> bool {
|
||||
let mut in_quotes = false;
|
||||
let mut chars = record.chars().peekable();
|
||||
while let Some(character) = chars.next() {
|
||||
if character == '"' {
|
||||
if in_quotes && chars.peek() == Some(&'"') {
|
||||
let _ = chars.next();
|
||||
} else {
|
||||
in_quotes = !in_quotes;
|
||||
}
|
||||
}
|
||||
}
|
||||
!in_quotes
|
||||
}
|
||||
|
||||
pub fn parse_csv_record(record: &str) -> Result<Vec<String>> {
|
||||
let mut fields = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut chars = record.chars().peekable();
|
||||
let mut in_quotes = false;
|
||||
while let Some(character) = chars.next() {
|
||||
match character {
|
||||
'"' if in_quotes && chars.peek() == Some(&'"') => {
|
||||
current.push('"');
|
||||
let _ = chars.next();
|
||||
}
|
||||
'"' => in_quotes = !in_quotes,
|
||||
',' if !in_quotes => {
|
||||
fields.push(current);
|
||||
current = String::new();
|
||||
}
|
||||
_ => current.push(character),
|
||||
}
|
||||
}
|
||||
if in_quotes {
|
||||
bail!("CSV record has an unterminated quote");
|
||||
}
|
||||
fields.push(current);
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
pub fn write_csv_record(writer: &mut impl Write, fields: &[String]) -> Result<()> {
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
if index > 0 {
|
||||
writer.write_all(b",")?;
|
||||
}
|
||||
if field.contains([',', '"', '\n', '\r']) {
|
||||
writer.write_all(b"\"")?;
|
||||
writer.write_all(field.replace('"', "\"\"").as_bytes())?;
|
||||
writer.write_all(b"\"")?;
|
||||
} else {
|
||||
writer.write_all(field.as_bytes())?;
|
||||
}
|
||||
}
|
||||
writer.write_all(b"\n")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_quoted_fields() {
|
||||
let row = parse_csv_record(r#"name,"hello, ""world""",42"#).unwrap();
|
||||
assert_eq!(row, vec!["name", "hello, \"world\"", "42"]);
|
||||
}
|
||||
}
|
||||
1330
komp-app/src/grpc.rs
Normal file
1330
komp-app/src/grpc.rs
Normal file
File diff suppressed because it is too large
Load Diff
444
komp-app/src/import_export.rs
Normal file
444
komp-app/src/import_export.rs
Normal file
@@ -0,0 +1,444 @@
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use common::proto::komp_ac::table_definition::{
|
||||
ProfileTreeResponse, profile_tree_response::Profile,
|
||||
};
|
||||
use common::proto::komp_ac::table_structure::{
|
||||
GetTableImportDescriptorResponse, TableStructureResponse,
|
||||
};
|
||||
use common::proto::komp_ac::tables_data::TableDataImportRow;
|
||||
use prost_types::{Value, value::Kind};
|
||||
use sanitise_file_name::{Options, sanitise_with_options};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::BufRead;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::csv::CsvRecordReader;
|
||||
use crate::grpc::GrpcClient;
|
||||
use crate::value::convert_input_value;
|
||||
|
||||
pub const CSV_IMPORT_PREVIEW_ROW_LIMIT: usize = 10;
|
||||
pub const CSV_EXPORT_PREVIEW_ROW_LIMIT: u64 = 20;
|
||||
pub const MAX_EXPORT_POSITION: u64 = i32::MAX as u64;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportTable {
|
||||
pub name: String,
|
||||
pub columns: Vec<String>,
|
||||
pub type_map: HashMap<String, String>,
|
||||
pub link_columns: HashSet<String>,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
impl ImportTable {
|
||||
pub fn from_descriptor(descriptor: GetTableImportDescriptorResponse) -> Self {
|
||||
let writable = descriptor
|
||||
.fields
|
||||
.into_iter()
|
||||
.filter(|field| field.writable)
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
name: descriptor.table_name,
|
||||
columns: writable.iter().map(|field| field.name.clone()).collect(),
|
||||
type_map: writable
|
||||
.iter()
|
||||
.map(|field| (field.name.clone(), field.storage_type.clone()))
|
||||
.collect(),
|
||||
link_columns: writable
|
||||
.iter()
|
||||
.filter(|field| field.link.is_some())
|
||||
.map(|field| field.name.clone())
|
||||
.collect(),
|
||||
revision: descriptor.table_revision,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportHeader {
|
||||
pub table_headers: Vec<String>,
|
||||
pub columns: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedImportTarget {
|
||||
pub profile_name: String,
|
||||
pub tables: Vec<ImportTable>,
|
||||
pub header: ImportHeader,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExportTable {
|
||||
pub name: String,
|
||||
pub columns: Vec<String>,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
pub fn export_table_header_row(tables: &[ExportTable]) -> Vec<String> {
|
||||
tables
|
||||
.iter()
|
||||
.flat_map(|table| std::iter::repeat_n(table.name.clone(), table.columns.len()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn export_column_header_row(tables: &[ExportTable]) -> Vec<String> {
|
||||
tables
|
||||
.iter()
|
||||
.flat_map(|table| table.columns.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn maximum_export_row_count(tables: &[ExportTable]) -> u64 {
|
||||
tables.iter().map(|table| table.count).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn validate_output_filename(filename: &str) -> Result<()> {
|
||||
if filename.is_empty() || filename.trim() != filename || filename.ends_with('.') {
|
||||
bail!("Output filename must not be empty or start/end with whitespace or a dot");
|
||||
}
|
||||
if sanitise_generated_filename(filename) != filename {
|
||||
bail!("Output filename '{filename}' is not portable across Windows, macOS, and Linux");
|
||||
}
|
||||
if Path::new(filename).components().count() != 1 {
|
||||
bail!("Output filename must not contain a directory path");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn sanitise_generated_filename(filename: &str) -> String {
|
||||
sanitise_with_options(filename, &portable_filename_options())
|
||||
}
|
||||
|
||||
fn portable_filename_options() -> Options<Option<char>> {
|
||||
Options {
|
||||
normalise_whitespace: false,
|
||||
trim_spaces_and_full_stops: false,
|
||||
trim_more_punctuation: false,
|
||||
six_measures_of_barley: "output",
|
||||
..Options::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_target_tables(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|table| !table.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn profile_header_row(profile_name: &str) -> Vec<String> {
|
||||
vec![profile_name.to_string()]
|
||||
}
|
||||
|
||||
pub fn table_header_row(tables: &[ImportTable]) -> Vec<String> {
|
||||
tables
|
||||
.iter()
|
||||
.flat_map(|table| std::iter::repeat_n(table.name.clone(), table.columns.len()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn column_header_row(tables: &[ImportTable]) -> Vec<String> {
|
||||
tables
|
||||
.iter()
|
||||
.flat_map(|table| table.columns.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parse_profile_header(row: &[String]) -> Option<String> {
|
||||
if row.len() == 1 && !row[0].trim().is_empty() {
|
||||
Some(row[0].trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profile_has_tables(profile: &Profile, table_names: &[String]) -> bool {
|
||||
table_names
|
||||
.iter()
|
||||
.all(|table_name| profile.tables.iter().any(|table| table.name == *table_name))
|
||||
}
|
||||
|
||||
pub fn consecutive_table_names(values: &[String]) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
for value in values {
|
||||
if value.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if names.last() != Some(value) {
|
||||
names.push(value.clone());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
pub async fn load_import_tables_for_profile(
|
||||
profile_name: &str,
|
||||
client: &mut GrpcClient,
|
||||
table_names: &[String],
|
||||
) -> Result<Vec<ImportTable>> {
|
||||
let mut tables = Vec::new();
|
||||
for table_name in table_names {
|
||||
let descriptor = client
|
||||
.get_table_import_descriptor(profile_name.to_string(), table_name.clone())
|
||||
.await?;
|
||||
tables.push(ImportTable::from_descriptor(descriptor));
|
||||
}
|
||||
Ok(tables)
|
||||
}
|
||||
|
||||
pub async fn resolve_import_target_from_reader<R: BufRead>(
|
||||
profile_tree: &ProfileTreeResponse,
|
||||
client: &mut GrpcClient,
|
||||
selected_profile: &str,
|
||||
selected_tables: &[String],
|
||||
reader: &mut CsvRecordReader<R>,
|
||||
) -> Result<ResolvedImportTarget> {
|
||||
let mut profile_name = selected_profile.trim().to_string();
|
||||
let mut first = reader
|
||||
.next_record()?
|
||||
.ok_or_else(|| anyhow!("CSV file is empty"))?;
|
||||
if let Some(header_profile) = parse_profile_header(&first) {
|
||||
if !profile_name.is_empty() && profile_name != header_profile {
|
||||
bail!(
|
||||
"CSV profile '{}' does not match selected profile '{}'",
|
||||
header_profile,
|
||||
profile_name
|
||||
);
|
||||
}
|
||||
profile_name = header_profile;
|
||||
first = reader
|
||||
.next_record()?
|
||||
.ok_or_else(|| anyhow!("CSV is missing a table or column header"))?;
|
||||
}
|
||||
|
||||
let profiles = candidate_profiles(profile_tree, &profile_name)?;
|
||||
let mut second = None;
|
||||
let mut candidates = Vec::new();
|
||||
if selected_tables.is_empty() {
|
||||
for profile in profiles {
|
||||
if let Some((tables, header)) = infer_multi_table_header(
|
||||
client,
|
||||
profile,
|
||||
&first,
|
||||
&mut second,
|
||||
reader,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
candidates.push((profile.name.clone(), tables, header));
|
||||
}
|
||||
for table in &profile.tables {
|
||||
let tables = load_import_tables_for_profile(
|
||||
&profile.name,
|
||||
client,
|
||||
&[table.name.clone()],
|
||||
)
|
||||
.await?;
|
||||
if first == column_header_row(&tables) {
|
||||
candidates.push((
|
||||
profile.name.clone(),
|
||||
tables,
|
||||
ImportHeader {
|
||||
table_headers: Vec::new(),
|
||||
columns: first.clone(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for profile in profiles {
|
||||
if !profile_has_tables(profile, selected_tables) {
|
||||
continue;
|
||||
}
|
||||
let tables = load_import_tables_for_profile(
|
||||
&profile.name,
|
||||
client,
|
||||
selected_tables,
|
||||
)
|
||||
.await?;
|
||||
if let Some(header) = match_selected_header(
|
||||
&first,
|
||||
&mut second,
|
||||
&tables,
|
||||
reader,
|
||||
)? {
|
||||
candidates.push((profile.name.clone(), tables, header));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (profile_name, tables, header) = match candidates.len() {
|
||||
0 => bail!("CSV header does not match any available profile and table"),
|
||||
1 => candidates.remove(0),
|
||||
_ => {
|
||||
let labels = candidates
|
||||
.iter()
|
||||
.map(|(profile_name, tables, _)| {
|
||||
format!(
|
||||
"{} / {}",
|
||||
profile_name,
|
||||
tables
|
||||
.iter()
|
||||
.map(|table| table.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
bail!(
|
||||
"CSV header is ambiguous. Select a profile and table. Matches: {}",
|
||||
labels.join("; ")
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ResolvedImportTarget {
|
||||
profile_name,
|
||||
tables,
|
||||
header,
|
||||
})
|
||||
}
|
||||
|
||||
fn candidate_profiles<'a>(
|
||||
profile_tree: &'a ProfileTreeResponse,
|
||||
selected_profile: &str,
|
||||
) -> Result<Vec<&'a Profile>> {
|
||||
if selected_profile.is_empty() {
|
||||
return Ok(profile_tree.profiles.iter().collect());
|
||||
}
|
||||
let profiles = profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.name == selected_profile)
|
||||
.collect::<Vec<_>>();
|
||||
if profiles.is_empty() {
|
||||
bail!("Selected profile '{}' is not available", selected_profile);
|
||||
}
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn read_second_header<'a, R: BufRead>(
|
||||
second: &'a mut Option<Vec<String>>,
|
||||
reader: &mut CsvRecordReader<R>,
|
||||
) -> Result<&'a Vec<String>> {
|
||||
if second.is_none() {
|
||||
*second = Some(
|
||||
reader
|
||||
.next_record()?
|
||||
.ok_or_else(|| anyhow!("CSV is missing the column header row"))?,
|
||||
);
|
||||
}
|
||||
Ok(second.as_ref().expect("second header was inserted"))
|
||||
}
|
||||
|
||||
fn match_selected_header<R: BufRead>(
|
||||
first: &[String],
|
||||
second: &mut Option<Vec<String>>,
|
||||
tables: &[ImportTable],
|
||||
reader: &mut CsvRecordReader<R>,
|
||||
) -> Result<Option<ImportHeader>> {
|
||||
if tables.len() == 1 {
|
||||
if first == table_header_row(tables) {
|
||||
let second = read_second_header(second, reader)?;
|
||||
return Ok(
|
||||
(second == &column_header_row(tables)).then(|| ImportHeader {
|
||||
table_headers: first.to_vec(),
|
||||
columns: second.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Ok((first == column_header_row(tables)).then(|| ImportHeader {
|
||||
table_headers: Vec::new(),
|
||||
columns: first.to_vec(),
|
||||
}));
|
||||
}
|
||||
if first != table_header_row(tables) {
|
||||
return Ok(None);
|
||||
}
|
||||
let second = read_second_header(second, reader)?;
|
||||
Ok(
|
||||
(second == &column_header_row(tables)).then(|| ImportHeader {
|
||||
table_headers: first.to_vec(),
|
||||
columns: second.clone(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn infer_multi_table_header<R: BufRead>(
|
||||
client: &mut GrpcClient,
|
||||
profile: &Profile,
|
||||
first: &[String],
|
||||
second: &mut Option<Vec<String>>,
|
||||
reader: &mut CsvRecordReader<R>,
|
||||
) -> Result<Option<(Vec<ImportTable>, ImportHeader)>> {
|
||||
let table_names = consecutive_table_names(first);
|
||||
if table_names.is_empty() || !profile_has_tables(profile, &table_names) {
|
||||
return Ok(None);
|
||||
}
|
||||
let tables = load_import_tables_for_profile(&profile.name, client, &table_names).await?;
|
||||
if first != table_header_row(&tables) {
|
||||
return Ok(None);
|
||||
}
|
||||
let second = read_second_header(second, reader)?;
|
||||
if second != &column_header_row(&tables) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some((
|
||||
tables,
|
||||
ImportHeader {
|
||||
table_headers: first.to_vec(),
|
||||
columns: second.clone(),
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn row_to_table_data(
|
||||
table: &ImportTable,
|
||||
header: &ImportHeader,
|
||||
row: &[String],
|
||||
) -> Result<TableDataImportRow> {
|
||||
let mut data = HashMap::new();
|
||||
for (index, target_column) in header.columns.iter().enumerate() {
|
||||
if header
|
||||
.table_headers
|
||||
.get(index)
|
||||
.filter(|name| !name.is_empty())
|
||||
.is_some_and(|name| name != &table.name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !table.columns.contains(target_column) {
|
||||
continue;
|
||||
}
|
||||
let Some(data_type) = table.type_map.get(target_column) else {
|
||||
continue;
|
||||
};
|
||||
let raw = row.get(index).map(String::as_str).unwrap_or("");
|
||||
let value = if table.link_columns.contains(target_column) && !raw.is_empty() {
|
||||
Value {
|
||||
kind: Some(Kind::StringValue(raw.to_string())),
|
||||
}
|
||||
} else {
|
||||
convert_input_value(raw, data_type, target_column).map_err(anyhow::Error::msg)?
|
||||
};
|
||||
data.insert(target_column.clone(), value);
|
||||
}
|
||||
Ok(TableDataImportRow { data })
|
||||
}
|
||||
|
||||
pub fn exportable_columns(schema: &TableStructureResponse) -> Vec<String> {
|
||||
schema
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| {
|
||||
!column.is_primary_key
|
||||
&& column.name != "id"
|
||||
&& column.name != "deleted"
|
||||
&& column.name != "created_at"
|
||||
&& column.name != "row_revision"
|
||||
})
|
||||
.map(|column| column.name.clone())
|
||||
.collect()
|
||||
}
|
||||
15
komp-app/src/lib.rs
Normal file
15
komp-app/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Product-specific application code shared by the komp_ac frontends.
|
||||
//!
|
||||
//! This crate deliberately has no dependency on Ratatui, Crossterm, Tauri, or
|
||||
//! a browser UI. Frontends own presentation and platform storage; this crate
|
||||
//! owns client behavior that must not drift between them.
|
||||
|
||||
pub mod auth;
|
||||
pub mod csv;
|
||||
pub mod grpc;
|
||||
pub mod import_export;
|
||||
pub mod navigation;
|
||||
mod search;
|
||||
pub mod session;
|
||||
pub mod transport;
|
||||
pub mod value;
|
||||
36
komp-app/src/navigation.rs
Normal file
36
komp-app/src/navigation.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use common::proto::komp_ac::tables_data::RowNavigationMode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UnfilteredNavigationMode {
|
||||
Position,
|
||||
#[default]
|
||||
Id,
|
||||
}
|
||||
|
||||
impl UnfilteredNavigationMode {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"id" => Some(Self::Id),
|
||||
"position" => Some(Self::Position),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Id => "id",
|
||||
Self::Position => "position",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UnfilteredNavigationMode> for RowNavigationMode {
|
||||
fn from(value: UnfilteredNavigationMode) -> Self {
|
||||
match value {
|
||||
UnfilteredNavigationMode::Id => Self::Id,
|
||||
UnfilteredNavigationMode::Position => Self::Position,
|
||||
}
|
||||
}
|
||||
}
|
||||
27
komp-app/src/search.rs
Normal file
27
komp-app/src/search.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::{
|
||||
SearchCountResponse, SearchRequest, SearchResponse, searcher_client::SearcherClient,
|
||||
};
|
||||
use tonic::transport::Channel;
|
||||
use tonic::Request;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SearchGrpc {
|
||||
client: SearcherClient<Channel>,
|
||||
}
|
||||
|
||||
impl SearchGrpc {
|
||||
pub fn new(channel: Channel) -> Self {
|
||||
Self {
|
||||
client: SearcherClient::new(channel),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search(&mut self, request: Request<SearchRequest>) -> Result<SearchResponse> {
|
||||
Ok(self.client.search(request).await?.into_inner())
|
||||
}
|
||||
|
||||
pub async fn count(&mut self, request: Request<SearchRequest>) -> Result<SearchCountResponse> {
|
||||
Ok(self.client.count(request).await?.into_inner())
|
||||
}
|
||||
}
|
||||
13
komp-app/src/session.rs
Normal file
13
komp-app/src/session.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StoredAuthData {
|
||||
pub access_token: String,
|
||||
pub user_id: String,
|
||||
pub role: String,
|
||||
pub username: String,
|
||||
pub timezone: String,
|
||||
pub phone_country: String,
|
||||
#[serde(default)]
|
||||
pub selected_profile: Option<String>,
|
||||
}
|
||||
125
komp-app/src/transport.rs
Normal file
125
komp-app/src/transport.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use jiff::Timestamp;
|
||||
use std::time::Duration;
|
||||
use tonic::metadata::{Ascii, MetadataValue};
|
||||
use tonic::transport::{Channel, Endpoint};
|
||||
use tonic::Request;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const DEFAULT_GRPC_ENDPOINT: &str = "http://[::1]:50051";
|
||||
|
||||
pub fn normalize_endpoint(endpoint_url: &str) -> Result<String> {
|
||||
let endpoint = endpoint_url.trim();
|
||||
if endpoint.is_empty() {
|
||||
bail!("Endpoint cannot be empty");
|
||||
}
|
||||
let scheme = endpoint
|
||||
.split_once("://")
|
||||
.map(|(scheme, _)| scheme.to_ascii_lowercase());
|
||||
if !matches!(scheme.as_deref(), Some("http") | Some("https")) {
|
||||
bail!("Expected an HTTP or HTTPS endpoint");
|
||||
}
|
||||
Endpoint::from_shared(endpoint.to_string()).context("Endpoint is not a valid URI")?;
|
||||
Ok(endpoint.to_string())
|
||||
}
|
||||
|
||||
pub async fn connect_channel(endpoint_url: &str) -> Result<Channel> {
|
||||
let endpoint_url = normalize_endpoint(endpoint_url)?;
|
||||
let endpoint = Endpoint::from_shared(endpoint_url)
|
||||
.context("Endpoint is not a valid URI")?
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.tcp_keepalive(Some(Duration::from_secs(30)))
|
||||
.keep_alive_while_idle(true)
|
||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||
.keep_alive_timeout(Duration::from_secs(5));
|
||||
endpoint
|
||||
.connect()
|
||||
.await
|
||||
.context("Failed to create gRPC channel")
|
||||
}
|
||||
|
||||
pub fn bearer(token: &str) -> Result<MetadataValue<Ascii>> {
|
||||
MetadataValue::try_from(format!("Bearer {token}"))
|
||||
.context("Failed to encode authorization header")
|
||||
}
|
||||
|
||||
pub fn authenticated_request<T>(auth_token: Option<&str>, message: T) -> Result<Request<T>> {
|
||||
let token = auth_token.context("Authentication is required for this request")?;
|
||||
let mut request = Request::new(message);
|
||||
request.metadata_mut().insert("authorization", bearer(token)?);
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PostOperation {
|
||||
pub idempotency_key: Uuid,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
impl PostOperation {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
idempotency_key: Uuid::new_v4(),
|
||||
created_at: Timestamp::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_metadata<T>(&self, request: &mut Request<T>) -> Result<()> {
|
||||
request.metadata_mut().insert(
|
||||
"idempotency-key",
|
||||
self.idempotency_key
|
||||
.to_string()
|
||||
.parse()
|
||||
.context("Failed to encode idempotency key")?,
|
||||
);
|
||||
request.metadata_mut().insert(
|
||||
"operation-created-at",
|
||||
self.created_at
|
||||
.to_string()
|
||||
.parse()
|
||||
.context("Failed to encode operation creation time")?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PostOperation {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retryable_write_status(code: tonic::Code) -> bool {
|
||||
matches!(
|
||||
code,
|
||||
tonic::Code::Cancelled
|
||||
| tonic::Code::Unknown
|
||||
| tonic::Code::DeadlineExceeded
|
||||
| tonic::Code::Internal
|
||||
| tonic::Code::Unavailable
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn authenticated_requests_have_bearer_metadata() {
|
||||
let request = authenticated_request(Some("access-token"), ()).unwrap();
|
||||
assert_eq!(
|
||||
request.metadata().get("authorization").unwrap(),
|
||||
"Bearer access-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_operations_have_stable_metadata() {
|
||||
let operation = PostOperation::new();
|
||||
let mut first = Request::new(());
|
||||
let mut second = Request::new(());
|
||||
operation.add_metadata(&mut first).unwrap();
|
||||
operation.add_metadata(&mut second).unwrap();
|
||||
assert_eq!(first.metadata(), second.metadata());
|
||||
}
|
||||
}
|
||||
81
komp-app/src/value.rs
Normal file
81
komp-app/src/value.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use common::decimal::{is_decimal_data_type, parse_decimal_exact};
|
||||
use common::proto::komp_ac::table_structure::TableStructureResponse;
|
||||
use prost_types::{NullValue, Value, value::Kind};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub const BOOLEAN_INPUT_VOCABULARY: &str =
|
||||
"must be one of true/t/yes/y/1 or false/f/no/n/0, in any case";
|
||||
|
||||
pub fn parse_boolean_input(raw: &str) -> Option<bool> {
|
||||
match raw.to_ascii_lowercase().as_str() {
|
||||
"true" | "t" | "yes" | "y" | "1" => Some(true),
|
||||
"false" | "f" | "no" | "n" | "0" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn convert_input_value(raw: &str, data_type: &str, field: &str) -> Result<Value, String> {
|
||||
let normalized = data_type.to_ascii_uppercase();
|
||||
let kind = if raw.is_empty() {
|
||||
Kind::NullValue(NullValue::NullValue.into())
|
||||
} else if matches!(normalized.as_str(), "BOOL" | "BOOLEAN") {
|
||||
parse_boolean_input(raw)
|
||||
.map(Kind::BoolValue)
|
||||
.ok_or_else(|| format!("Invalid boolean for '{field}': {BOOLEAN_INPUT_VOCABULARY}"))?
|
||||
} else if matches!(normalized.as_str(), "INT8" | "BIGINT" | "BIGSERIAL") {
|
||||
let value = raw
|
||||
.parse::<i64>()
|
||||
.map_err(|_| format!("Invalid big integer for '{field}': value must fit in 64 bits"))?;
|
||||
Kind::StringValue(value.to_string())
|
||||
} else if matches!(normalized.as_str(), "INT" | "INT4" | "INTEGER" | "SERIAL") {
|
||||
let value = raw
|
||||
.parse::<i32>()
|
||||
.map_err(|_| format!("Invalid integer for '{field}': value must fit in 32 bits"))?;
|
||||
Kind::NumberValue(f64::from(value))
|
||||
} else if is_decimal_data_type(&normalized) {
|
||||
let decimal = parse_decimal_exact(raw)
|
||||
.map_err(|error| format!("Invalid decimal for '{field}': {error}"))?;
|
||||
Kind::StringValue(decimal.to_string())
|
||||
} else {
|
||||
Kind::StringValue(raw.to_string())
|
||||
};
|
||||
Ok(Value { kind: Some(kind) })
|
||||
}
|
||||
|
||||
pub fn convert_and_validate_data(
|
||||
data: &HashMap<String, String>,
|
||||
schema: &TableStructureResponse,
|
||||
) -> Result<HashMap<String, Value>, String> {
|
||||
let type_map: HashMap<_, _> = schema
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| (column.name.as_str(), column.data_type.as_str()))
|
||||
.collect();
|
||||
data.iter()
|
||||
.map(|(field, raw)| {
|
||||
let data_type = type_map.get(field.as_str()).copied().unwrap_or("TEXT");
|
||||
convert_input_value(raw, data_type, field).map(|value| (field.clone(), value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preserves_large_integers_and_decimals_as_strings() {
|
||||
assert_eq!(
|
||||
convert_input_value("9223372036854775807", "BIGINT", "amount")
|
||||
.unwrap()
|
||||
.kind,
|
||||
Some(Kind::StringValue("9223372036854775807".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
convert_input_value("123456789012345678901.25", "NUMERIC", "amount")
|
||||
.unwrap()
|
||||
.kind,
|
||||
Some(Kind::StringValue("123456789012345678901.25".to_string()))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user