Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a store for user preferences #974

Merged
merged 1 commit into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@ members = [
"cvss",
"entity",
"migration",
"modules/importer",
"modules/ui",
"modules/analysis",
"modules/fundamental",
"modules/graphql",
"modules/importer",
"modules/ingestor",
"modules/storage",
"modules/graphql",
"modules/analysis",
"modules/ui",
"modules/user",
"server",
"trustd",
"test-context",
"trustd",
"xtask",
]

Expand Down Expand Up @@ -167,6 +168,7 @@ trustify-module-storage = { path = "modules/storage" }
trustify-module-graphql = { path = "modules/graphql" }
trustify-test-context = { path = "test-context" }
trustify-module-analysis = { path = "modules/analysis" }
trustify-module-user = { path = "modules/user" }

# These dependencies are active during both the build time and the run time. So they are normal dependencies
# as well as build-dependencies. However, we can't control feature flags for build dependencies the way we do
Expand Down
1 change: 1 addition & 0 deletions entity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub mod sbom_package_cpe_ref;
pub mod sbom_package_purl_ref;
pub mod source_document;
pub mod status;
pub mod user_preferences;
pub mod version_range;
pub mod version_scheme;
pub mod versioned_purl;
Expand Down
18 changes: 18 additions & 0 deletions entity/src/user_preferences.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user_preferences")]
pub struct Model {
#[sea_orm(primary_key)]
pub user_id: String,
#[sea_orm(primary_key)]
pub key: String,

pub revision: Uuid,
pub data: serde_json::Value,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
2 changes: 2 additions & 0 deletions migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ mod m0000670_version_cmp;
mod m0000680_fix_update_deprecated_advisory;
mod m0000690_alter_sbom_details;
mod m0000700_advisory_add_reserved;
mod m0000710_create_user_prefs;

pub struct Migrator;

Expand Down Expand Up @@ -181,6 +182,7 @@ impl MigratorTrait for Migrator {
Box::new(m0000680_fix_update_deprecated_advisory::Migration),
Box::new(m0000690_alter_sbom_details::Migration),
Box::new(m0000700_advisory_add_reserved::Migration),
Box::new(m0000710_create_user_prefs::Migration),
]
}
}
Expand Down
65 changes: 65 additions & 0 deletions migration/src/m0000710_create_user_prefs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(UserPreferences::Table)
.col(
ColumnDef::new(UserPreferences::UserId)
.string()
.not_null()
.to_owned(),
)
.col(
ColumnDef::new(UserPreferences::Key)
.string()
.not_null()
.to_owned(),
)
.col(
ColumnDef::new(UserPreferences::Revision)
.uuid()
.not_null()
.to_owned(),
)
.col(
ColumnDef::new(UserPreferences::Data)
.json_binary()
.not_null()
.to_owned(),
)
.primary_key(
Index::create()
.col(UserPreferences::UserId)
.col(UserPreferences::Key),
)
.to_owned(),
)
.await?;

Ok(())
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(UserPreferences::Table).to_owned())
.await?;

Ok(())
}
}

#[derive(DeriveIden)]
enum UserPreferences {
Table,
UserId,
Key,
Revision,
Data,
}
28 changes: 28 additions & 0 deletions modules/user/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "trustify-module-user"
version.workspace = true
edition.workspace = true
publish.workspace = true
license.workspace = true

[dependencies]
trustify-auth = { workspace = true }
trustify-common = { workspace = true }
trustify-entity = { workspace = true }

actix-web = { workspace = true }
anyhow = { workspace = true }
sea-orm = { workspace = true, features = ["sea-query-binder", "sqlx-postgres", "runtime-tokio-rustls", "macros", "debug-print"] }
sea-query = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
utoipa = { workspace = true, features = ["actix_extras", "time", "url"] }
utoipa-actix-web = { workspace = true }

[dev-dependencies]
trustify-test-context = { workspace = true }

actix-http = { workspace = true }
test-context = { workspace = true }
test-log = { workspace = true, features = ["log", "trace"] }
tokio = { workspace = true, features = ["full"] }
35 changes: 35 additions & 0 deletions modules/user/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# User management

## Store user preferences

This will create or replace the existing value.

```bash
http PUT localhost:8080/api/v1/userPreferences/foo key=value
```

## Get user preferences

```bash
http GET localhost:8080/api/v1/userPreferences/foo
```

## Delete user preferences

```bash
http DELETE localhost:8080/api/v1/userPreferences/foo
```

## Conditionally update user preferences

It is possible to update the value only if the expected precondition matches. Meaning that you can provide an
etag value as received from a `GET` or `PUT`, and the update will only take place if the revision/etag value hasn't
been changed yet by another update. If it did, the request will fail with a `412 Precondition failed` status and
appropriate action would be to re-fetch the data, apply the update again, and request another store operation. This
is also called "optimistic locking."

```bash
http PUT localhost:8080/api/v1/userPreferences/foo key=value 'If-Match:"<etag from get>"'
```

**NOTE:** Be sure that the `If-Match` value is actually quoted, e.g. `If-Match:"79e5d346-876f-42b6-b0d0-51ee1be73a4c"`
124 changes: 124 additions & 0 deletions modules/user/src/endpoints.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
use crate::service::{Error, UserPreferenceService};
use actix_web::{
delete, get,
http::header::{self, ETag, EntityTag, IfMatch},
put, web, HttpResponse, Responder,
};
use trustify_auth::authenticator::user::UserDetails;
use trustify_common::{db::Database, model::Revisioned};

/// mount the "user" module
pub fn configure(svc: &mut utoipa_actix_web::service_config::ServiceConfig, db: Database) {
svc.app_data(web::Data::new(UserPreferenceService::new(db)))
.service(set)
.service(get)
.service(delete);
}

#[utoipa::path(
tag = "userPreferences",
operation_id = "getUserPreferences",
params(
("key", Path, description = "The key to the user preferences"),
),
responses(
(
status = 200,
description = "User preference stored under this key",
body = serde_json::Value,
headers(
("etag" = String, description = "Revision ID")
)
),
(status = 404, description = "Unknown user preference key"),
)
)]
#[get("/v1/userPreference/{key}")]
/// Get user preferences
async fn get(
service: web::Data<UserPreferenceService>,
key: web::Path<String>,
user: UserDetails,
) -> Result<impl Responder, Error> {
Ok(match service.get(user.id, key.into_inner()).await? {
Some(Revisioned { value, revision }) => HttpResponse::Ok()
.append_header((header::ETAG, ETag(EntityTag::new_strong(revision))))
.json(value),
None => HttpResponse::NotFound().finish(),
})
}

#[utoipa::path(
tag = "userPreferences",
operation_id = "setUserPreferences",
request_body = serde_json::Value,
params(
("key", Path, description = "The key to the user preferences"),
("if-match" = Option<String>, Header, description = "The revision to update"),
),
responses(
(
status = 200,
description = "User preference stored under this key",
headers(
("etag" = String, description = "Revision ID")
)
),
(status = 412, description = "The provided If-Match revision did not match the actual revision")
)
)]
#[put("/v1/userPreference/{key}")]
/// Set user preferences
async fn set(
service: web::Data<UserPreferenceService>,
key: web::Path<String>,
user: UserDetails,
web::Header(if_match): web::Header<IfMatch>,
web::Json(data): web::Json<serde_json::Value>,
) -> Result<impl Responder, Error> {
let revision = match &if_match {
IfMatch::Any => None,
IfMatch::Items(items) => items.first().map(|etag| etag.tag()),
};

let Revisioned {
value: (),
revision,
} = service
.set(user.id, key.into_inner(), revision, data)
.await?;

Ok(HttpResponse::NoContent()
.append_header((header::ETAG, ETag(EntityTag::new_strong(revision))))
.finish())
}

#[utoipa::path(
tag = "userPreferences",
operation_id = "deleteUserPreferences",
request_body = serde_json::Value,
params(
("key", Path, description = "The key to the user preferences"),
("if-match" = Option<String>, Header, description = "The revision to delete"),
),
responses(
(status = 201, description = "User preferences are deleted"),
(status = 412, description = "The provided If-Match revision did not match the actual revision")
)
)]
#[delete("/v1/userPreference/{key}")]
/// Delete user preferences
async fn delete(
service: web::Data<UserPreferenceService>,
key: web::Path<String>,
user: UserDetails,
web::Header(if_match): web::Header<IfMatch>,
) -> Result<impl Responder, Error> {
let revision = match &if_match {
IfMatch::Any => None,
IfMatch::Items(items) => items.first().map(|etag| etag.tag()),
};

service.delete(user.id, key.into_inner(), revision).await?;
Ok(HttpResponse::NoContent().finish())
}
5 changes: 5 additions & 0 deletions modules/user/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#![recursion_limit = "256"]

pub mod endpoints;
pub mod service;
pub mod test;
Loading