From 1d131e790c704358a9f276c7ac6e1353cf58cd90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sof=C3=ADa=20Aritz?= Date: Mon, 14 Oct 2024 17:34:10 +0200 Subject: [PATCH] Start working on Rust impl of identity-api --- identity-api-rs/.database/.gitkeep | 0 identity-api-rs/.gitignore | 9 ++ identity-api-rs/Cargo.toml | 11 ++ identity-api-rs/diesel.toml | 9 ++ identity-api-rs/migrations/.gitkeep | 0 .../down.sql | 8 ++ .../up.sql | 69 +++++++++++ identity-api-rs/src/database/mod.rs | 28 +++++ identity-api-rs/src/database/models.rs | 46 +++++++ identity-api-rs/src/database/schema.rs | 99 ++++++++++++++++ identity-api-rs/src/database/types.rs | 83 +++++++++++++ identity-api-rs/src/env.rs | 112 ++++++++++++++++++ identity-api-rs/src/main.rs | 46 +++++++ 13 files changed, 520 insertions(+) create mode 100644 identity-api-rs/.database/.gitkeep create mode 100644 identity-api-rs/.gitignore create mode 100644 identity-api-rs/Cargo.toml create mode 100644 identity-api-rs/diesel.toml create mode 100644 identity-api-rs/migrations/.gitkeep create mode 100644 identity-api-rs/migrations/2024-10-13-184137_users_and_entries/down.sql create mode 100644 identity-api-rs/migrations/2024-10-13-184137_users_and_entries/up.sql create mode 100644 identity-api-rs/src/database/mod.rs create mode 100644 identity-api-rs/src/database/models.rs create mode 100644 identity-api-rs/src/database/schema.rs create mode 100644 identity-api-rs/src/database/types.rs create mode 100644 identity-api-rs/src/env.rs create mode 100644 identity-api-rs/src/main.rs diff --git a/identity-api-rs/.database/.gitkeep b/identity-api-rs/.database/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/identity-api-rs/.gitignore b/identity-api-rs/.gitignore new file mode 100644 index 0000000..f518836 --- /dev/null +++ b/identity-api-rs/.gitignore @@ -0,0 +1,9 @@ +/target +Cargo.lock + +.database/* +!.database/.gitkeep + +.env +.env.* +!.env.example \ No newline at end of file diff --git a/identity-api-rs/Cargo.toml b/identity-api-rs/Cargo.toml new file mode 100644 index 0000000..4bee58b --- /dev/null +++ b/identity-api-rs/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "identity-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { version = "0.7" } +chrono = "0.4" +diesel = { version = "2.2", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono"] } +dotenvy = "0.15" +tokio = { version = "1", features = ["full"] } \ No newline at end of file diff --git a/identity-api-rs/diesel.toml b/identity-api-rs/diesel.toml new file mode 100644 index 0000000..fd7a1cd --- /dev/null +++ b/identity-api-rs/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/database/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] + +[migrations_directory] +dir = "migrations" diff --git a/identity-api-rs/migrations/.gitkeep b/identity-api-rs/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/identity-api-rs/migrations/2024-10-13-184137_users_and_entries/down.sql b/identity-api-rs/migrations/2024-10-13-184137_users_and_entries/down.sql new file mode 100644 index 0000000..1528d22 --- /dev/null +++ b/identity-api-rs/migrations/2024-10-13-184137_users_and_entries/down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS date_entries; +DROP TABLE IF EXISTS location_entries; +DROP TABLE IF EXISTS music_entries; +DROP TABLE IF EXISTS entries; +DROP TABLE IF EXISTS heirs; +DROP TABLE IF EXISTS session_keys; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS limits; \ No newline at end of file diff --git a/identity-api-rs/migrations/2024-10-13-184137_users_and_entries/up.sql b/identity-api-rs/migrations/2024-10-13-184137_users_and_entries/up.sql new file mode 100644 index 0000000..07a7209 --- /dev/null +++ b/identity-api-rs/migrations/2024-10-13-184137_users_and_entries/up.sql @@ -0,0 +1,69 @@ +CREATE TABLE IF NOT EXISTS limits ( + id varchar PRIMARY KEY NOT NULL, + current_asset_count integer NOT NULL, + max_asset_count integer NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id varchar PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL, + last_connected_at timestamp NOT NULL, + email varchar NOT NULL, + password varchar NOT NULL, + name varchar NOT NULL, + limits varchar NOT NULL, + assets varchar NOT NULL, + FOREIGN KEY (limits) REFERENCES limits (id) +); + +CREATE TABLE IF NOT EXISTS session_keys ( + key varchar PRIMARY KEY NOT NULL, + user_id varchar NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS heirs ( + id varchar PRIMARY KEY NOT NULL, + user_id varchar NOT NULL, + created_at timestamp NOT NULL, + name varchar NOT NULL, + email varchar, + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS entries ( + id varchar PRIMARY KEY NOT NULL, + user_id varchar NOT NULL, + created_at timestamp NOT NULL, + feelings text NOT NULL, + assets text NOT NULL, + title text, + description text, + kind varchar NOT NULL, + music_entry varchar, + location_entry varchar, + date_entry varchar, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (music_entry) REFERENCES music_entries (id), + FOREIGN KEY (location_entry) REFERENCES location_entries (id), + FOREIGN KEY (date_entry) REFERENCES date_entries (id) +); + +CREATE TABLE IF NOT EXISTS music_entries ( + id varchar PRIMARY KEY NOT NULL, + artist varchar NOT NULL, + title varchar NOT NULL, + links text NOT NULL, + universal_ids text NOT NULL +); + +CREATE TABLE IF NOT EXISTS location_entries ( + id varchar PRIMARY KEY NOT NULL, + location_text text, + location_coordinates varchar +); + +CREATE TABLE IF NOT EXISTS date_entries ( + id varchar PRIMARY KEY NOT NULL, + referenced_date timestamp NOT NULL +); \ No newline at end of file diff --git a/identity-api-rs/src/database/mod.rs b/identity-api-rs/src/database/mod.rs new file mode 100644 index 0000000..f689afe --- /dev/null +++ b/identity-api-rs/src/database/mod.rs @@ -0,0 +1,28 @@ +// Identity. Store your memories and mental belongings +// Copyright (C) 2024 Sofía Aritz +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use diesel::{Connection, SqliteConnection}; + +use crate::env::database_url; + +pub mod models; +pub mod schema; +pub mod types; + +pub fn establish_connection() -> SqliteConnection { + let url = database_url(); + SqliteConnection::establish(url).unwrap_or_else(|_| panic!("failed to connect to {}", url)) +} diff --git a/identity-api-rs/src/database/models.rs b/identity-api-rs/src/database/models.rs new file mode 100644 index 0000000..26249a8 --- /dev/null +++ b/identity-api-rs/src/database/models.rs @@ -0,0 +1,46 @@ +// Identity. Store your memories and mental belongings +// Copyright (C) 2024 Sofía Aritz +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use chrono::NaiveDateTime; +use diesel::prelude::*; + +use crate::database::schema; +use crate::database::types::List; + +#[derive(Queryable, Selectable)] +#[diesel(table_name = schema::date_entries)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct DateEntry { + id: String, + referenced_date: NaiveDateTime, +} + +#[derive(Queryable, Selectable)] +#[diesel(table_name = schema::entries)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct Entry { + id: String, + user_id: String, + created_at: NaiveDateTime, + feelings: List, + assets: List, + title: Option, + description: Option, + kind: String, + music_entry: Option, + location_entry: Option, + date_entry: Option, +} diff --git a/identity-api-rs/src/database/schema.rs b/identity-api-rs/src/database/schema.rs new file mode 100644 index 0000000..17e27cd --- /dev/null +++ b/identity-api-rs/src/database/schema.rs @@ -0,0 +1,99 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + date_entries (id) { + id -> Text, + referenced_date -> Timestamp, + } +} + +diesel::table! { + entries (id) { + id -> Text, + user_id -> Text, + created_at -> Timestamp, + feelings -> Text, + assets -> Text, + title -> Nullable, + description -> Nullable, + kind -> Text, + music_entry -> Nullable, + location_entry -> Nullable, + date_entry -> Nullable, + } +} + +diesel::table! { + heirs (id) { + id -> Text, + user_id -> Text, + created_at -> Timestamp, + name -> Text, + email -> Nullable, + } +} + +diesel::table! { + limits (id) { + id -> Text, + current_asset_count -> Integer, + max_asset_count -> Integer, + } +} + +diesel::table! { + location_entries (id) { + id -> Text, + location_text -> Nullable, + location_coordinates -> Nullable, + } +} + +diesel::table! { + music_entries (id) { + id -> Text, + artist -> Text, + title -> Text, + links -> Text, + universal_ids -> Text, + } +} + +diesel::table! { + session_keys (key) { + key -> Text, + user_id -> Text, + } +} + +diesel::table! { + users (id) { + id -> Text, + created_at -> Timestamp, + last_connected_at -> Timestamp, + email -> Text, + password -> Text, + name -> Text, + limits -> Text, + assets -> Text, + } +} + +diesel::joinable!(entries -> date_entries (date_entry)); +diesel::joinable!(entries -> location_entries (location_entry)); +diesel::joinable!(entries -> music_entries (music_entry)); +diesel::joinable!(entries -> users (user_id)); +diesel::joinable!(heirs -> users (user_id)); +diesel::joinable!(session_keys -> users (user_id)); +diesel::joinable!(users -> limits (limits)); + +diesel::allow_tables_to_appear_in_same_query!( + date_entries, + entries, + heirs, + limits, + location_entries, + music_entries, + session_keys, + users, +); diff --git a/identity-api-rs/src/database/types.rs b/identity-api-rs/src/database/types.rs new file mode 100644 index 0000000..dc14bbf --- /dev/null +++ b/identity-api-rs/src/database/types.rs @@ -0,0 +1,83 @@ +use diesel::{ + backend::Backend, deserialize::{FromSql, FromSqlRow}, serialize::ToSql, sql_types::Text, sqlite::Sqlite +}; + +#[derive(FromSqlRow, Debug, Clone)] +#[repr(transparent)] +pub struct List(pub Vec); + +impl ToString for List { + fn to_string(&self) -> String { + "[".to_owned() + + &self + .0 + .iter() + .map(|v| "\"".to_owned() + &v.replace("\"", "\\\"") + "\"") + .collect::>() + .join(",") + + "]" + } +} + +impl From for List { + fn from(mut value: String) -> Self { + if value.starts_with('[') { + value.remove(0); + } + + if value.ends_with(']') { + value.pop(); + } + + let char_len = value.chars().count(); + let mut values = Vec::new(); + let mut current = String::new(); + let mut writing_str = false; + let mut escape_next = false; + + for (i, ch) in value.chars().enumerate() { + if i == char_len - 1 { + values.push(current); + current = String::new(); + } else if escape_next { + current.push(ch); + escape_next = false; + } else if ch == '\\' { + escape_next = true; + } else if ch == '\"' { + writing_str = !writing_str; + } else if ch == ',' && !writing_str { + values.push(current); + current = String::new(); + } else if writing_str { + current.push(ch); + } + } + + List(values) + } +} + +impl FromSql for List +where + String: FromSql, +{ + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let str = >::from_sql(bytes)?; + Ok(List::from(str)) + } +} + +impl ToSql for List +where + String: ToSql, +{ + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> diesel::serialize::Result { + let val = self.to_string(); + out.set_value(val); + Ok(diesel::serialize::IsNull::No) + } +} diff --git a/identity-api-rs/src/env.rs b/identity-api-rs/src/env.rs new file mode 100644 index 0000000..8b0e325 --- /dev/null +++ b/identity-api-rs/src/env.rs @@ -0,0 +1,112 @@ +// Identity. Store your memories and mental belongings +// Copyright (C) 2024 Sofía Aritz +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::env; +use std::sync::OnceLock; +use std::time::Duration; + +const REQUIRED_ENV_VARIABLES: [&'static str; 4] = [ + "IDENTITY_API_JWT_SECRET", + "IDENTITY_API_ASSET_API_ENDPOINT", + "IDENTITY_API_JWT_ALG", + "DATABASE_URL", +]; + +#[derive(Debug)] +pub enum LoadEnvError { + DotenvyError(dotenvy::Error), + MissingVariable(String), +} + +pub fn load_env() -> Result<(), LoadEnvError> { + dotenvy::dotenv().map_err(|e| LoadEnvError::DotenvyError(e))?; + + for key in REQUIRED_ENV_VARIABLES { + if env::var(key).is_err() { + return Err(LoadEnvError::MissingVariable(key.into())); + } + } + + Ok(()) +} + +pub fn landing_message() -> &'static str { + static IDENTITY_API_LANDING_MESSAGE: OnceLock = OnceLock::new(); + IDENTITY_API_LANDING_MESSAGE.get_or_init(|| { + env::var("IDENTITY_API_LANDING_MESSAGE").unwrap_or(format!( + "{} v{}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION") + )) + }) +} + +pub fn asset_api_m2m_refresh_interval() -> &'static Duration { + static IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL: OnceLock = OnceLock::new(); + IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL.get_or_init(|| { + let millis: u64 = env::var("IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL_ms") + .map(|v| + v + .parse() + .expect("invalid environment variable value: `IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL_ms`") + ) + .unwrap_or(60 * 1000); + + Duration::from_millis(millis) + }) +} + +pub fn listen_port() -> &'static u16 { + static IDENTITY_API_LISTEN_PORT: OnceLock = OnceLock::new(); + IDENTITY_API_LISTEN_PORT.get_or_init(|| { + env::var("IDENTITY_API_LISTEN_PORT") + .map(|v| { + v.parse() + .expect("invalid environment variable value: `IDENTITY_API_LISTEN_PORT`") + }) + .unwrap_or(3000) + }) +} + +pub fn jwt_secret() -> &'static str { + static IDENTITY_API_JWT_SECRET: OnceLock = OnceLock::new(); + IDENTITY_API_JWT_SECRET.get_or_init(|| { + env::var("IDENTITY_API_JWT_SECRET") + .expect("environment variables were not loaded correctly") + }) +} + +pub fn jwt_alg() -> &'static str { + static IDENTITY_API_JWT_ALG: OnceLock = OnceLock::new(); + IDENTITY_API_JWT_ALG.get_or_init(|| { + env::var("IDENTITY_API_JWT_ALG").expect("environment variables were not loaded correctly") + }) +} + +pub fn asset_api_endpoint() -> &'static str { + static IDENTITY_API_ASSET_API_ENDPOINT: OnceLock = OnceLock::new(); + IDENTITY_API_ASSET_API_ENDPOINT.get_or_init(|| { + env::var("IDENTITY_API_ASSET_API_ENDPOINT") + .expect("environment variables were not loaded correctly") + }) +} + +pub fn database_url() -> &'static str { + static DATABASE_URL: OnceLock = OnceLock::new(); + DATABASE_URL.get_or_init(|| { + env::var("DATABASE_URL").expect("environment variables were not loaded correctly") + }) +} diff --git a/identity-api-rs/src/main.rs b/identity-api-rs/src/main.rs new file mode 100644 index 0000000..d50c277 --- /dev/null +++ b/identity-api-rs/src/main.rs @@ -0,0 +1,46 @@ +// Identity. Store your memories and mental belongings +// Copyright (C) 2024 Sofía Aritz +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{routing::get, Router}; +use env::LoadEnvError; + +mod database; +mod env; + +#[tokio::main] +async fn main() { + let _ = env::load_env().inspect_err(|e| match e { + LoadEnvError::DotenvyError(error) => panic!("failed to load .env file: {:?}", error), + LoadEnvError::MissingVariable(variable) => { + panic!("missing environment variable: `{variable}`") + } + }); + + let app = Router::new().route("/", get(landing)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") + .await + .expect("failed to bind"); + + println!("listening on http://{}", listener.local_addr().unwrap()); + axum::serve(listener, app) + .await + .expect("failed to serve app"); +} + +async fn landing() -> &'static str { + env::landing_message() +}