using unified system

This commit is contained in:
Filipriec
2026-08-30 07:49:19 +02:00
parent 80d61760a2
commit 408187cb57
30 changed files with 2754 additions and 225 deletions

16
komp-app/Cargo.toml Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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;

View 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
View 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
View 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
View 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
View 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()))
);
}
}