Skip to content

Commit

Permalink
Implement user profiles and rel=me verification (#248)
Browse files Browse the repository at this point in the history
* Implement user profiles and rel=me verification

* Review comments (I)

* Do not verify non-HTTPS URLs

* Implement `profile <entity>` and `update_profile <entity>` commands

* E2E test `profile` and `update_profile` commands

* Rebase

* Allow functions with >7 arguments in E2E testing

* Clippy

* Update README

* Typo

* Replace panic with early return

* Review comments (II)

* Move to partial profile for the update endpoint

* Update tests

* Fix tests

* Review comments (III)

* Review comments (IV)

* Add links to E2E tests

* Fix tests

* Update link arg to links

* Fix tests

* Clippy

* Update http endpoint to allow http users to represent all of the possible state

* Fix client impl

* Add docstring

* Clippy
  • Loading branch information
sofiaritz authored Jan 4, 2024
1 parent be64ec0 commit effc374
Show file tree
Hide file tree
Showing 22 changed files with 927 additions and 48 deletions.
298 changes: 298 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ reqwest = { version = "0.11.22", features = ["json"] }
rusqlite = { version = "0.27.0", features = ["bundled", "limits"] }
regex = { version = "1.10.2"}
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0.108" }
serde_json = { version = "1.0.108", features = ["raw_value"] }
serde_repr = { version = "0.1.17" }
sqlx = { version = "0.6.3", features = ["runtime-actix-native-tls", "postgres", "sqlite"] }
toml = { version = "0.8.8" }
Expand All @@ -33,6 +33,7 @@ prettytable-rs = { version = "0.10.0"}
urlencoding = { version = "2.1.3" }
actix-cors = { version = "0.6.5" }
url = { version = "2.5.0", features = ["serde"] }
scraper = { version = "0.18.1" }

[dev-dependencies]
assert_cmd = "2.0"
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ $ ayb client query marcua/test.sqlite "SELECT * FROM favorite_databases;"
DuckDB | 9

Rows: 3

$ ayb client update_profile marcua --display_name 'Adam Marcus' --link 'http://marcua.net'

Successfully updated profile

$ ayb client profile marcua
Display name | Description | Organization | Location | Links
--------------+-------------+--------------+----------+-------------------
Adam Marcus | | | | http://marcua.net
```

The command line invocations above are a thin wrapper around `ayb`'s HTTP API. Here are the same commands as above, but with `curl`:
Expand All @@ -121,9 +130,13 @@ $ curl -w "\n" -X POST http://127.0.0.1:5433/v1/marcua/test.sqlite/create -H "db

{"entity":"marcua","database":"test.sqlite","database_type":"sqlite"}

$ curl -w "\n" -X PATCH http://127.0.0.1:5433/v1/entity/marcua -H "authorization: Bearer <API_TOKEN_FROM_PREVIOUS_COMMAND>" -d "{\"display_name\": \"Adam Marcus\"}"

{}

$ curl -w "\n" -X GET http://localhost:5433/v1/entity/marcua -H "authorization: Bearer <API_TOKEN_FROM_PREVIOUS_COMMAND>"

{"slug":"marcua","databases":[{"slug":"test.sqlite","database_type":"sqlite"}]}
{"slug":"marcua","databases":[{"slug":"test.sqlite","database_type":"sqlite"}],"profile":{"display_name":"Adam Marcus"}}

$ curl -w "\n" -X POST http://127.0.0.1:5433/v1/marcua/test.sqlite/query -H "authorization: Bearer <API_TOKEN_FROM_PREVIOUS_COMMAND>" -d 'CREATE TABLE favorite_databases(name varchar, score integer);'

Expand Down
6 changes: 6 additions & 0 deletions migrations/postgres/20231223165738_user_profiles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ALTER TABLE entity
ADD display_name VARCHAR(35),
ADD description VARCHAR(100),
ADD organization VARCHAR(35),
ADD location VARCHAR(35),
ADD links JSONB;
5 changes: 5 additions & 0 deletions migrations/sqlite/20231223165738_user_profiles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE entity ADD display_name VARCHAR(35);
ALTER TABLE entity ADD description VARCHAR(100);
ALTER TABLE entity ADD organization VARCHAR(35);
ALTER TABLE entity ADD location VARCHAR(35);
ALTER TABLE entity ADD links JSONB;
81 changes: 76 additions & 5 deletions src/ayb_db/db_interfaces.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::ayb_db::models::{
APIToken, AuthenticationMethod, Database, Entity, InstantiatedAuthenticationMethod,
InstantiatedDatabase, InstantiatedEntity,
InstantiatedDatabase, InstantiatedEntity, PartialEntity,
};
use crate::error::AybError;
use async_trait::async_trait;
Expand All @@ -9,7 +9,7 @@ use sqlx::{
migrate,
postgres::PgPoolOptions,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
Pool, Postgres, Sqlite,
Pool, Postgres, QueryBuilder, Sqlite,
};
use std::str::FromStr;

Expand Down Expand Up @@ -39,6 +39,11 @@ pub trait AybDb: DynClone + Send + Sync {
) -> Result<InstantiatedDatabase, AybError>;
async fn get_entity_by_slug(&self, entity_slug: &str) -> Result<InstantiatedEntity, AybError>;
async fn get_entity_by_id(&self, entity_id: i32) -> Result<InstantiatedEntity, AybError>;
async fn update_entity_by_id(
&self,
entity: &PartialEntity,
entity_id: i32,
) -> Result<InstantiatedEntity, AybError>;
async fn list_authentication_methods(
&self,
entity: &InstantiatedEntity,
Expand Down Expand Up @@ -200,7 +205,12 @@ WHERE
SELECT
id,
slug,
entity_type
entity_type,
display_name,
description,
organization,
location,
links
FROM entity
WHERE slug = $1
"#,
Expand Down Expand Up @@ -228,7 +238,12 @@ WHERE slug = $1
SELECT
id,
slug,
entity_type
entity_type,
display_name,
description,
organization,
location,
links
FROM entity
WHERE id = $1
"#,
Expand All @@ -247,6 +262,62 @@ WHERE id = $1
Ok(entity)
}

async fn update_entity_by_id(&self, entity: &PartialEntity, entity_id: i32) -> Result<InstantiatedEntity, AybError> {
let mut query = QueryBuilder::new("UPDATE entity SET");
let mut prev_to_links = false;
let pairs = vec![
("description", &entity.description),
("organization", &entity.organization),
("location", &entity.location),
("display_name", &entity.display_name),
];

for (i, (key, current)) in pairs.iter().enumerate() {
let Some(current) = current else {
continue;
};

if i != 0 {
query.push(",");
}

// Keys are hardcoded an thus there is no risk of SQL injection
query.push(format!(" {} = ", key));
query.push_bind(current);
prev_to_links = true;
}

if let Some(links) = &entity.links {
if prev_to_links {
query.push(",");
}

query.push(" links = ");
if links.is_none() {
query.push("NULL");
} else {
query.push_bind(serde_json::to_value(links)?);
}
}

query.push(" WHERE entity.id = ");
query.push_bind(entity_id);
query.push(" RETURNING id, slug, entity_type, display_name, description, organization, location, links;");

let entity: InstantiatedEntity = query.build_query_as()
.fetch_one(&self.pool)
.await
.or_else(|err| match err {
sqlx::Error::RowNotFound => Err(AybError::RecordNotFound {
id: entity_id.to_string(),
record_type: "entity".into(),
}),
_ => Err(AybError::from(err)),
})?;

Ok(entity)
}

async fn get_or_create_entity(&self, entity: &Entity) -> Result<InstantiatedEntity, AybError> {
// Get or create logic inspired by https://stackoverflow.com/a/66337293
let mut tx = self.pool.begin().await?;
Expand All @@ -265,7 +336,7 @@ ON CONFLICT (slug) DO UPDATE
.await?;
let entity: InstantiatedEntity = sqlx::query_as(
r#"
SELECT id, slug, entity_type
SELECT id, slug, entity_type, display_name, description, organization, location, links
FROM entity
WHERE slug = $1;
"#,
Expand Down
47 changes: 47 additions & 0 deletions src/ayb_db/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,60 @@ pub struct InstantiatedDatabase {
pub struct Entity {
pub slug: String,
pub entity_type: i16,
pub display_name: Option<String>,
pub description: Option<String>,
pub organization: Option<String>,
pub location: Option<String>,
pub links: Option<Vec<Link>>,
}

/// The fields of this struct mean the following:
/// * `None` means that nothing should be changed
/// * `Some(None)` means that the value should be set to `NULL`
/// * `Some(Some(v))` means that the value should be set to `v`
#[derive(Debug)]
pub struct PartialEntity {
pub display_name: Option<Option<String>>,
pub description: Option<Option<String>>,
pub organization: Option<Option<String>>,
pub location: Option<Option<String>>,
pub links: Option<Option<Vec<Link>>>,
}

impl Default for PartialEntity {
fn default() -> Self {
Self::new()
}
}

impl PartialEntity {
pub fn new() -> Self {
Self {
display_name: None,
description: None,
organization: None,
location: None,
links: None,
}
}
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Link {
pub url: String,
pub verified: bool,
}

#[derive(Clone, Debug, FromRow, Serialize, Deserialize)]
pub struct InstantiatedEntity {
pub id: i32,
pub slug: String,
pub entity_type: i16,
pub display_name: Option<String>,
pub description: Option<String>,
pub organization: Option<String>,
pub location: Option<String>,
pub links: Option<sqlx::types::Json<Vec<Link>>>,
}

#[derive(Debug, Serialize, Deserialize)]
Expand Down
88 changes: 84 additions & 4 deletions src/bin/ayb.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use ayb::ayb_db::models::{DBType, EntityType};
use ayb::formatting::TabularFormatter;
use ayb::http::client::AybClient;
use ayb::http::config::{config_to_toml, default_server_config};
use ayb::http::server::run_server;
use ayb::http::structs::EntityDatabasePath;
use ayb::http::structs::{EntityDatabasePath, ProfileLinkUpdate};
use clap::builder::ValueParser;
use clap::{arg, command, value_parser, Command, ValueEnum};
use regex::Regex;
use std::collections::HashMap;
use std::path::PathBuf;

fn entity_database_parser(value: &str) -> Result<EntityDatabasePath, String> {
Expand Down Expand Up @@ -129,6 +131,31 @@ async fn main() -> std::io::Result<()> {
.default_value(OutputFormat::Table.to_str())
.required(false)),
)
.subcommand(
Command::new("profile")
.about("Show the profile of an entity")
.arg(arg!(<entity> "The entity to query")
.required(true))
.arg(
arg!(--format <type> "The format in which to output the result")
.value_parser(value_parser!(OutputFormat))
.default_value(OutputFormat::Table.to_str())
.required(false))
)
.subcommand(
Command::new("update_profile")
.about("Update the profile of an entity")
.arg(arg!(<entity> "The entity to update").required(true))
.arg(arg!(--display_name <value> "New display name").required(false))
.arg(arg!(--description <value> "New description").required(false))
.arg(arg!(--organization <value> "New organization").required(false))
.arg(arg!(--location <value> "New location").required(false))
.arg(
arg!(--links <value> "New links")
.required(false)
.num_args(0..)
)
)
)
.get_matches();

Expand Down Expand Up @@ -214,19 +241,72 @@ async fn main() -> std::io::Result<()> {
}
}
}
} else if let Some(matches) = matches.subcommand_matches("profile") {
if let (Some(entity), Some(format)) = (
matches.get_one::<String>("entity"),
matches.get_one::<OutputFormat>("format"),
) {
match client.entity_details(entity).await {
Ok(response) => match format {
OutputFormat::Table => response.profile.generate_table()?,
OutputFormat::Csv => response.profile.generate_csv()?,
},
Err(err) => println!("Error: {}", err),
}
}
} else if let Some(matches) = matches.subcommand_matches("update_profile") {
if let Some(entity) = matches.get_one::<String>("entity") {
let mut profile_update = HashMap::new();
if let Some(display_name) = matches.get_one::<String>("display_name").cloned() {
profile_update.insert("display_name".to_owned(), Some(display_name));
}

if let Some(description) = matches.get_one::<String>("description").cloned() {
profile_update.insert("description".to_owned(), Some(description));
}

if let Some(organization) = matches.get_one::<String>("organization").cloned() {
profile_update.insert("organization".to_owned(), Some(organization));
}

if let Some(location) = matches.get_one::<String>("location").cloned() {
profile_update.insert("location".to_owned(), Some(location));
}

if matches.get_many::<String>("links").is_some() {
profile_update.insert(
"links".to_owned(),
Some(serde_json::to_string(
&matches
.get_many::<String>("links")
.map(|v| v.into_iter().collect::<Vec<&String>>())
.map(|v| {
v.into_iter()
.map(|v| ProfileLinkUpdate { url: v.clone() })
.collect::<Vec<ProfileLinkUpdate>>()
}),
)?),
);
}

match client.update_profile(entity, &profile_update).await {
Ok(_) => println!("Successfully updated profile"),
Err(err) => println!("Error: {}", err),
}
}
} else if let Some(matches) = matches.subcommand_matches("list") {
if let (Some(entity), Some(format)) = (
matches.get_one::<String>("entity"),
matches.get_one::<OutputFormat>("format"),
) {
match client.list_databases(entity).await {
match client.entity_details(entity).await {
Ok(response) => {
if response.databases.is_empty() {
println!("No queryable databases owned by {}", entity);
} else {
match format {
OutputFormat::Table => response.generate_table()?,
OutputFormat::Csv => response.generate_csv()?,
OutputFormat::Table => response.databases.generate_table()?,
OutputFormat::Csv => response.databases.generate_csv()?,
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use sqlx;
use std::fmt::{Display, Formatter};
use std::string;
use toml;
use url;

#[derive(Debug, Deserialize, Error, Serialize)]
#[serde(tag = "type")]
Expand Down Expand Up @@ -154,3 +155,11 @@ impl From<toml::ser::Error> for AybError {
}
}
}

impl From<url::ParseError> for AybError {
fn from(cause: url::ParseError) -> Self {
AybError::Other {
message: format!("Failed to parse URL: {:?}", cause),
}
}
}
Loading

0 comments on commit effc374

Please sign in to comment.