Skip to content

Commit

Permalink
API: External IDs (#55)
Browse files Browse the repository at this point in the history
  • Loading branch information
Arthi-chaud authored Apr 8, 2024
2 parents cc69545 + 2db8c74 commit 411cbd9
Show file tree
Hide file tree
Showing 22 changed files with 817 additions and 17 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ RABBIT_PASS=

# Fill these values with random, secure strings
SCANNER_API_KEY=
MATCHER_API_KEY=
# Where can Blee find the video files
DATA_DIR=

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jobs:
ROCKET_DATABASES: '{sea_orm={url="postgresql://test:test@localhost:5432/test"}}'
CONFIG_DIR: "./data"
SCANNER_API_KEY: "API_KEY"
MATCHER_API_KEY: "API_KEY"
RABBIT_USER: test
RABBIT_PASS: test
RABBIT_HOST: localhost
Expand Down
1 change: 1 addition & 0 deletions api/api/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ use serde::{Deserialize, Serialize};
pub struct Config {
pub data_folder: String,
pub scanner_api_key: String,
pub matcher_api_key: String,
}
69 changes: 69 additions & 0 deletions api/api/src/controllers/external_ids.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use crate::database::Database;
use crate::dto::external_id::{ExternalIdFilter, ExternalIdResponse, NewExternalId};
use crate::dto::page::{Page, Pagination};
use crate::error_handling::{ApiError, ApiPageResult, ApiRawResult};
use crate::guards::MatcherAuthGuard;
use crate::services;
use rocket::response::status;
use rocket::serde::uuid::Uuid;
use rocket::{post, serde::json::Json};
use rocket_okapi::okapi::openapi3::OpenApi;
use rocket_okapi::settings::OpenApiSettings;
use rocket_okapi::{openapi, openapi_get_routes_spec};

pub fn get_routes_and_docs(settings: &OpenApiSettings) -> (Vec<rocket::Route>, OpenApi) {
openapi_get_routes_spec![settings: new_external_id, get_external_ids]
}
/// Create a new extra
#[openapi(tag = "External Id")]
#[post("/", format = "json", data = "<data>")]
async fn new_external_id(
db: Database<'_>,
data: Json<NewExternalId>,
_matcher: MatcherAuthGuard,
) -> ApiRawResult<status::Created<Json<ExternalIdResponse>>> {
if data.package_id.is_some() && data.artist_id.is_some()
|| (data.package_id.is_none() && data.artist_id.is_none())
{
return Err(ApiError::ValidationError(
"There should be exactly one parent ID set.".to_owned(),
));
}
if let Some(rating) = data.rating {
if rating < 0 || rating > 100 {
return Err(ApiError::ValidationError(
"Rating should be between 0 and 100.".to_owned(),
));
}
}
services::external_ids::create(&data.0, db.into_inner())
.await
.map_or_else(
|e| Err(ApiError::from(e)),
|v| Ok(status::Created::new("").body(Json(v.into()))),
)
}

/// Get many External IDs
#[openapi(tag = "Extras")]
#[get("/?<artist>&<package>&<pagination..>")]
async fn get_external_ids(
db: Database<'_>,
artist: Option<Uuid>,
package: Option<Uuid>,
pagination: Pagination,
) -> ApiPageResult<ExternalIdResponse> {
if artist.is_some() && package.is_some() {
return Err(ApiError::ValidationError(
"There should be exactly one parent ID set.".to_owned(),
));
}
services::external_ids::find_many(
&ExternalIdFilter { artist, package },
&pagination,
db.into_inner(),
)
.await
.map(|items| Page::from(items))
.map_or_else(|e| Err(ApiError::from(e)), |v| Ok(v))
}
1 change: 1 addition & 0 deletions api/api/src/controllers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod artists;
pub mod chapters;
pub mod external_ids;
pub mod extras;
pub mod files;
pub mod images;
Expand Down
3 changes: 0 additions & 3 deletions api/api/src/dto/artist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ pub struct ArtistResponse {
pub name: String,
#[schemars(example = "example_artist_slug")]
pub slug: String,
#[schemars(example = "example_description")]
pub description: Option<String>,
pub registered_at: NaiveDateTime,
#[schemars(example = "example_uuid")]
pub poster_id: Option<Uuid>,
Expand All @@ -47,7 +45,6 @@ impl From<artist::Model> for ArtistResponse {
id: value.id,
name: value.name,
slug: value.unique_slug,
description: value.description,
registered_at: value.registered_at.into(),
poster_id: value.poster_id,
}
Expand Down
62 changes: 62 additions & 0 deletions api/api/src/dto/external_id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use crate::swagger_examples::*;
use entity::external_id;
use rocket::serde::uuid::Uuid;
use rocket_okapi::okapi::schemars;
use rocket_okapi::okapi::schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// An External ID data type
#[derive(Serialize, JsonSchema, FromForm)]
pub struct ExternalIdResponse {
#[schemars(example = "example_uuid")]
pub id: Uuid,
pub url: String,
pub value: String,
#[schemars(example = "example_description")]
pub description: Option<String>,
#[schemars(example = "example_rating")]
pub rating: Option<i16>,
#[schemars(example = "example_provider_name")]
pub provider_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub artist_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub package_id: Option<Uuid>,
}

impl From<external_id::Model> for ExternalIdResponse {
fn from(value: external_id::Model) -> Self {
ExternalIdResponse {
id: value.id,
url: value.url,
value: value.value,
provider_name: value.provider_name,
description: value.description,
rating: value.rating,
artist_id: value.artist_id,
package_id: value.package_id,
}
}
}

/// DTO to create a new ExternalId
#[derive(Deserialize, Serialize, JsonSchema, Clone)]
#[serde(crate = "rocket::serde")]
pub struct NewExternalId {
pub url: String,
pub value: String,
#[schemars(example = "example_description")]
pub description: Option<String>,
#[schemars(example = "example_rating")]
pub rating: Option<i16>,
#[schemars(example = "example_provider_name")]
pub provider_name: String,
pub artist_id: Option<Uuid>,
pub package_id: Option<Uuid>,
}

/// Filters for External IDs
pub struct ExternalIdFilter {
pub artist: Option<Uuid>,
pub package: Option<Uuid>,
}
1 change: 1 addition & 0 deletions api/api/src/dto/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod artist;
pub mod chapter;
pub mod external_id;
pub mod extra;
pub mod file;
pub mod image;
Expand Down
3 changes: 0 additions & 3 deletions api/api/src/dto/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ pub struct PackageResponse {
#[schemars(example = "example_package_name")]
pub name: String,
pub slug: String,
#[schemars(example = "example_description")]
pub description: Option<String>,
#[schemars(example = "example_package_release_date")]
pub release_year: Option<NaiveDate>,
pub registered_at: NaiveDateTime,
Expand All @@ -45,7 +43,6 @@ impl From<package::Model> for PackageResponse {
id: value.id,
name: value.name,
slug: value.unique_slug,
description: value.description,
release_year: value.release_year,
registered_at: value.registered_at.into(),
artist_id: value.artist_id,
Expand Down
72 changes: 72 additions & 0 deletions api/api/src/guards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ use crate::config::Config;
/// The Request Guard for API Key, used by the Scanner to authenticate itself
pub struct ScannerAuthGuard;

/// The Request Guard for API Key, used by the Matcher to authenticate itself
pub struct MatcherAuthGuard;

#[derive(Debug)]
pub enum ApiKeyError {
Missing,
Expand All @@ -38,6 +41,26 @@ impl<'r> FromRequest<'r> for ScannerAuthGuard {
}
}

#[rocket::async_trait]
impl<'r> FromRequest<'r> for MatcherAuthGuard {
type Error = ApiKeyError;

async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let matcher_api_key = req
.rocket()
.state::<Config>()
.unwrap()
.matcher_api_key
.clone();

match req.headers().get_one("x-api-key") {
None => Outcome::Error((Status::Unauthorized, ApiKeyError::Missing)),
Some(key) if key == matcher_api_key => Outcome::Success(MatcherAuthGuard),
Some(_) => Outcome::Error((Status::BadRequest, ApiKeyError::Invalid)),
}
}
}

impl<'r> OpenApiFromRequest<'r> for ScannerAuthGuard {
fn from_request_input(
_gen: &mut OpenApiGenerator,
Expand Down Expand Up @@ -86,3 +109,52 @@ impl<'r> OpenApiFromRequest<'r> for ScannerAuthGuard {
})
}
}

impl<'r> OpenApiFromRequest<'r> for MatcherAuthGuard {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> rocket_okapi::Result<rocket_okapi::request::RequestHeaderInput> {
// SRC: https://github.com/GREsau/okapi/blob/1608ff7b92e3daca8cf05aa4594e1cf163e584a9/examples/secure_request_guard/src/api_key.rs
let security_scheme = SecurityScheme {
description: Some("Requires the Matcher's API key to access.".to_owned()),
data: SecuritySchemeData::ApiKey {
name: "x-api-key".to_owned(),
location: "header".to_owned(),
},
extensions: Object::default(),
};
// Add the requirement for this route/endpoint
// This can change between routes.
let mut security_req = SecurityRequirement::new();
// Each security requirement needs to be met before access is allowed.
security_req.insert("ApiKeyAuth".to_owned(), Vec::new());
// These vvvvvvv-----^^^^^^^^^^ values need to match exactly!
Ok(RequestHeaderInput::Security(
"ApiKeyAuth".to_owned(),
security_scheme,
security_req,
))
}

fn get_responses(_gen: &mut OpenApiGenerator) -> rocket_okapi::Result<Responses> {
Ok(Responses {
// Recommended and most strait forward.
// And easy to add or remove new responses.
responses: okapi::map! {
"400".to_owned() => RefOr::Object(Response {
description: "The Provided API Key or form is wrong or bad."
.to_string(),
..Default::default()
}),
"401".to_owned() => RefOr::Object(Response {
description: "An API Key is missing."
.to_string(),
..Default::default()
}),
},
..Default::default()
})
}
}
11 changes: 9 additions & 2 deletions api/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,19 @@ fn create_server() -> Rocket<Build> {
if scanner_api_key.is_empty() {
panic!("env variable `SCANNER_API_KEY` is empty.")
}
let matcher_api_key =
env::var("MATCHER_API_KEY").expect("env variable `MATCHER_API_KEY` not set.");
if matcher_api_key.is_empty() {
panic!("env variable `MATCHER_API_KEY` is empty.")
}
let figment = Figment::from(rocket::Config::figment()).merge(Serialized::defaults(Config {
data_folder: data_dir,
scanner_api_key: scanner_api_key,
scanner_api_key,
matcher_api_key,
}));
let rabbit_config = deadpool_amqprs::Config::new(
OpenConnectionArguments::new(
&env::var("RABBIT_HOST").unwrap(),
&env::var("RABBIT_HOST").unwrap_or("localhost".to_string()),
env::var("RABBIT_PORT")
.unwrap_or("5672".to_string())
.parse::<u16>()
Expand Down Expand Up @@ -90,6 +96,7 @@ fn create_server() -> Rocket<Build> {
building_rocket, "/".to_owned(), openapi_settings,
"/artists" => controllers::artists::get_routes_and_docs(&openapi_settings),
"/chapters" => controllers::chapters::get_routes_and_docs(&openapi_settings),
"/external_ids" => controllers::external_ids::get_routes_and_docs(&openapi_settings),
"/extras" => controllers::extras::get_routes_and_docs(&openapi_settings),
"/files" => controllers::files::get_routes_and_docs(&openapi_settings),
"/images" => controllers::images::get_routes_and_docs(&openapi_settings),
Expand Down
61 changes: 61 additions & 0 deletions api/api/src/services/external_ids.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
use entity::external_id;
use sea_orm::{
ColumnTrait, ConnectionTrait, DbErr, EntityTrait, QueryFilter, QuerySelect, QueryTrait, Set,
};

use crate::dto::{
external_id::{ExternalIdFilter, ExternalIdResponse, NewExternalId},
page::Pagination,
};

use super::{artist, package};

pub async fn create<'s, 'a, C>(
dto: &NewExternalId,
connection: &'a C,
) -> Result<external_id::Model, DbErr>
where
C: ConnectionTrait,
{
if let Some(artist_id) = dto.artist_id {
artist::find(&artist_id.to_string(), connection).await?;
} else if let Some(package_id) = dto.package_id {
package::find(&package_id.to_string(), connection).await?;
}
external_id::Entity::insert(external_id::ActiveModel {
description: Set(dto.description.clone()),
rating: Set(dto.rating),
provider_name: Set(dto.provider_name.clone()),
value: Set(dto.value.clone()),
url: Set(dto.url.clone()),
package_id: Set(dto.package_id),
artist_id: Set(dto.artist_id),
..Default::default()
})
.exec_with_returning(connection)
.await
}

pub async fn find_many<'a, C>(
filters: &ExternalIdFilter,
pagination: &Pagination,
connection: &'a C,
) -> Result<Vec<ExternalIdResponse>, DbErr>
where
C: ConnectionTrait,
{
let query = external_id::Entity::find()
.apply_if(filters.artist, |q, artist_uuid| {
q.filter(external_id::Column::ArtistId.eq(artist_uuid))
})
.apply_if(filters.package, |q, package_uuid| {
q.filter(external_id::Column::PackageId.eq(package_uuid))
})
.offset(pagination.skip)
.limit(pagination.take);

query
.all(connection)
.await
.map(|items| items.into_iter().map(|item| item.into()).collect())
}
1 change: 1 addition & 0 deletions api/api/src/services/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod artist;
pub mod chapter;
pub mod external_ids;
pub mod extra;
pub mod file;
pub mod housekeeping;
Expand Down
Loading

0 comments on commit 411cbd9

Please sign in to comment.