From 837f983b031d5034bb5ae09cbdaaafd2c391ac9c Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Tue, 28 Jan 2025 18:09:30 +0100 Subject: [PATCH] Add API to create series in Tobira This adds: - a function to send a `create` request to Opencast - the graphQL endpoint to call that function from the frontend The function needs to be given ACL and a title as parameters, and can be given a description. It then constructs the metadata json that is expected by the Opencast endpoint and appends this and the serialized ACL information. The Opencast endpoint returns a new Opencast ID for the series, which in conjunction with the title and ACL can be used to "prefigure" the series in Tobira. Doing so allows a new series entry to be shown in the "My series" table without having to wait for sync. --- backend/src/api/model/series.rs | 57 ++++++++++++++++++++++++++++---- backend/src/api/mutation.rs | 11 +++++++ backend/src/sync/client.rs | 58 ++++++++++++++++++++++++++++++++- frontend/src/schema.graphql | 5 +++ 4 files changed, 123 insertions(+), 8 deletions(-) diff --git a/backend/src/api/model/series.rs b/backend/src/api/model/series.rs index 591427e9d..8ee37aa49 100644 --- a/backend/src/api/model/series.rs +++ b/backend/src/api/model/series.rs @@ -19,7 +19,8 @@ use crate::{ util::{impl_from_db, select}, }, model::{ExtraMetadata, Key}, - prelude::*, sync::client::{AclInput, OcEndpoint}, + prelude::*, + sync::client::{AclInput, OcEndpoint}, }; use self::acl::AclInputEntry; @@ -30,6 +31,7 @@ use super::{ realm::{NewRealm, RealmSpecifier, RemoveMountedSeriesOutcome, UpdatedRealmName}, shared::{ load_writable_for_user, + AclForDB, AssetMapping, Connection, LoadableAsset, @@ -138,23 +140,64 @@ impl Series { } } - pub(crate) async fn create(series: NewSeries, context: &Context) -> ApiResult { + pub(crate) async fn create( + series: NewSeries, + context: &Context, + acl: Option, + ) -> ApiResult { + let (read_roles, write_roles) = match &acl { + Some(roles) => (Some(&roles.read_roles), Some(&roles.write_roles)), + None => (None, None), + }; + let selection = Self::select(); let query = format!( - "insert into series (opencast_id, title, state, updated) \ - values ($1, $2, 'waiting', '-infinity') \ + "insert into series (opencast_id, title, state, updated, read_roles, write_roles) \ + values ($1, $2, 'waiting', '-infinity', $3, $4) \ returning {selection}", ); context.db(context.require_tobira_admin()?) - .query_one(&query, &[&series.opencast_id, &series.title]) + .query_one(&query, &[&series.opencast_id, &series.title, &read_roles, &write_roles]) .await? .pipe(|row| Self::from_row_start(&row)) .pipe(Ok) } + pub(crate) async fn create_in_oc( + title: &str, + description: Option<&str>, + acl: Vec, + context: &Context, + ) -> ApiResult { + let response = context + .oc_client + .create_series(&acl, &title, description) + .await + .map_err(|e| { + error!("Failed to send series creation request: {}", e); + err::opencast_unavailable!("Failed to send series creation request") + })?; + + let db_acl = Some(convert_acl_input(acl)); + + // If the request returned an Opencast identifier, the series was created successfully. + // The series is created in the database, so the user doesn't have to wait for sync to see + // the new series in the "My series" overview. + let series = Self::create( + NewSeries { + opencast_id: response.identifier, + title: title.to_owned(), + }, + context, + db_acl, + ).await?; + + Ok(series) + } + pub(crate) async fn announce(series: NewSeries, context: &Context) -> ApiResult { context.auth.required_trusted_external()?; - Self::create(series, context).await + Self::create(series, context, None).await } pub(crate) async fn add_mount_point( @@ -272,7 +315,7 @@ impl Series { } // Create series - let series = Series::create(series, context).await?; + let series = Series::create(series, context, None).await?; // Create realms let target_realm = { diff --git a/backend/src/api/mutation.rs b/backend/src/api/mutation.rs index 7ced648ba..22b611805 100644 --- a/backend/src/api/mutation.rs +++ b/backend/src/api/mutation.rs @@ -87,6 +87,17 @@ impl Mutation { Series::update_acl(id, acl, context).await } + /// Sends an http request to Opencast to create a new series, + /// and stores the series in Tobira's DB. + async fn create_series( + title: String, + description: Option, + acl: Vec, + context: &Context, + ) -> ApiResult { + Series::create_in_oc(&title, description.as_deref(), acl, context).await + } + /// Sets the order of all children of a specific realm. /// /// `childIndices` must contain at least one element, i.e. do not call this diff --git a/backend/src/sync/client.rs b/backend/src/sync/client.rs index 7da7a4e68..dbb2be290 100644 --- a/backend/src/sync/client.rs +++ b/backend/src/sync/client.rs @@ -217,6 +217,57 @@ impl OcClient { Ok(out.processing_state == "RUNNING") } + pub async fn create_series( + &self, + acl: &[AclInputEntry], + title: &str, + description: Option<&str>, + ) -> Result { + let access_policy: Vec = acl.iter() + .flat_map(|entry| entry.actions.iter() + .map(|action| AclInput { + allow: true, + action: action.clone(), + role: entry.role.clone(), + })) + .collect(); + + let metadata = serde_json::json!([{ + "label": "Opencast Series DublinCore", + "flavor": "dublincore/series", + "fields": [ + { + "id": "title", + "value": title + }, + { + "id": "description", + "value": description + }, + ] + }]); + + let params = Serializer::new(String::new()) + .append_pair("acl", &serde_json::to_string(&access_policy).expect("Failed to serialize")) + .append_pair("metadata", &serde_json::to_string(&metadata).expect("Failed to serialize")) + .finish(); + + let req = self.authed_req_builder(&self.external_api_node, "/api/series") + .method(http::Method::POST) + .header(http::header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(params.into()) + .expect("failed to build request"); + + let uri = req.uri().clone(); + let response = self.http_client.request(req).await + .with_context(|| format!("HTTP request failed (uri: '{uri}'"))?; + + let (out, _) = self.deserialize_response::(response, &uri).await?; + + Ok(out) + } + + fn build_authed_req(&self, node: &HttpHost, path_and_query: &str) -> (Uri, Request) { let req = self.authed_req_builder(node, path_and_query) .body(RequestBody::empty()) @@ -250,7 +301,7 @@ impl OcClient { let body = download_body(body).await .with_context(|| format!("failed to download body from '{uri}'"))?; - if parts.status != StatusCode::OK { + if parts.status != StatusCode::OK && parts.status != StatusCode::CREATED { trace!("HTTP response: {:#?}", parts); if parts.status == StatusCode::UNAUTHORIZED { bail!( @@ -287,6 +338,11 @@ pub(crate) struct AclInput { pub role: String, } +#[derive(Debug, Deserialize)] +pub(crate) struct CreateSeriesResponse { + pub identifier: String, +} + #[derive(Debug, Deserialize)] pub struct EventStatus { pub processing_state: String, diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index 44dae1232..d977e63fd 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -396,6 +396,11 @@ type Mutation { this means it might get overwritten again if the update in Opencast failed for some reason. """ updateSeriesAcl(id: ID!, acl: [AclInputEntry!]!): Series! + """ + Sends an http request to Opencast to create a new series, + and stores the series in Tobira's DB. + """ + createSeries(title: String!, description: String, acl: [AclInputEntry!]!): Series! """ Sets the order of all children of a specific realm.