Skip to content

Commit

Permalink
Add playlists to GraphQL API
Browse files Browse the repository at this point in the history
This only adds querying capabilities, no mutations yet.
  • Loading branch information
LukasKalbertodt committed May 16, 2024
1 parent 44d3816 commit f5eb20b
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 27 deletions.
1 change: 1 addition & 0 deletions backend/src/api/id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ define_kinds![
block = b"bl",
series = b"sr",
event = b"ev",
playlist = b"pl",
search_realm = b"rs",
search_event = b"es",
search_series = b"ss",
Expand Down
24 changes: 12 additions & 12 deletions backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,21 @@ use crate::{

#[derive(Debug)]
pub(crate) struct AuthorizedEvent {
key: Key,
series: Option<Key>,
opencast_id: String,
is_live: bool,
pub(crate) key: Key,
pub(crate) series: Option<Key>,
pub(crate) opencast_id: String,
pub(crate) is_live: bool,

title: String,
description: Option<String>,
created: DateTime<Utc>,
creators: Vec<String>,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) created: DateTime<Utc>,
pub(crate) creators: Vec<String>,

metadata: ExtraMetadata,
read_roles: Vec<String>,
write_roles: Vec<String>,
pub(crate) metadata: ExtraMetadata,
pub(crate) read_roles: Vec<String>,
pub(crate) write_roles: Vec<String>,

synced_data: Option<SyncedEventData>,
pub(crate) synced_data: Option<SyncedEventData>,
}

#[derive(Debug)]
Expand Down
1 change: 1 addition & 0 deletions backend/src/api/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub(crate) mod acl;
pub(crate) mod block;
pub(crate) mod event;
pub(crate) mod known_roles;
pub(crate) mod playlist;
pub(crate) mod realm;
pub(crate) mod search;
pub(crate) mod series;
Expand Down
163 changes: 163 additions & 0 deletions backend/src/api/model/playlist/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
use juniper::graphql_object;
use postgres_types::ToSql;

use crate::{
api::{
common::NotAllowed, err::ApiResult, Context, Id, Node
},
db::{types::Key, util::{impl_from_db, select}},
prelude::*,
};

use super::event::AuthorizedEvent;


#[derive(juniper::GraphQLUnion)]
#[graphql(Context = Context)]
pub(crate) enum Playlist {
Playlist(AuthorizedPlaylist),
NotAllowed(NotAllowed),
}

pub(crate) struct AuthorizedPlaylist {
pub(crate) key: Key,
opencast_id: String,
title: String,
description: Option<String>,

read_roles: Vec<String>,
#[allow(dead_code)] // TODO
write_roles: Vec<String>,
}


#[derive(juniper::GraphQLUnion)]
#[graphql(Context = Context)]
pub(crate) enum PlaylistEntry {
Event(AuthorizedEvent),
NotAllowed(NotAllowed),
Missing(Missing),
}

/// The data referred to by a playlist entry was not found.
pub(crate) struct Missing;
crate::api::util::impl_object_with_dummy_field!(Missing);


impl_from_db!(
AuthorizedPlaylist,
select: {
playlists.{ id, opencast_id, title, description, read_roles, write_roles },
},
|row| {
Self {
key: row.id(),
opencast_id: row.opencast_id(),
title: row.title(),
description: row.description(),
read_roles: row.read_roles(),
write_roles: row.write_roles(),
}
},
);

impl Playlist {
pub(crate) async fn load_by_id(id: Id, context: &Context) -> ApiResult<Option<Self>> {
if let Some(key) = id.key_for(Id::SERIES_KIND) {
Self::load_by_key(key, context).await
} else {
Ok(None)
}
}

pub(crate) async fn load_by_key(key: Key, context: &Context) -> ApiResult<Option<Self>> {
Self::load_by_any_id("id", &key, context).await
}

pub(crate) async fn load_by_opencast_id(id: String, context: &Context) -> ApiResult<Option<Self>> {
Self::load_by_any_id("opencast_id", &id, context).await
}

async fn load_by_any_id(
col: &str,
id: &(dyn ToSql + Sync),
context: &Context,
) -> ApiResult<Option<Self>> {
let selection = AuthorizedPlaylist::select();
let query = format!("select {selection} from playlists where {col} = $1");
context.db
.query_opt(&query, &[id])
.await?
.map(|row| {
let playlist = AuthorizedPlaylist::from_row_start(&row);
if context.auth.overlaps_roles(&playlist.read_roles) {
Playlist::Playlist(playlist)
} else {
Playlist::NotAllowed(NotAllowed)
}
})
.pipe(Ok)
}
}

/// Represents an Opencast series.
#[graphql_object(Context = Context)]
impl AuthorizedPlaylist {
fn id(&self) -> Id {
Node::id(self)
}

fn opencast_id(&self) -> &str {
&self.opencast_id
}

fn title(&self) -> &str {
&self.title
}

fn description(&self) -> Option<&str> {
self.description.as_deref()
}

async fn entries(&self, context: &Context) -> ApiResult<Vec<PlaylistEntry>> {
let (selection, mapping) = select!(
found: "events.id is not null",
event: AuthorizedEvent,
);
let query = format!("\
with entries as (\
select unnest(entries) as entry \
from playlists \
where id = $1\
),
event_ids as (\
select (entry).content_id as id \
from entries \
where (entry).type = 'event'\
)
select {selection} from event_ids \
left join events on events.opencast_id = event_ids.id\
");
context.db
.query_mapped(&query, dbargs![&self.key], |row| {
if !mapping.found.of::<bool>(&row) {
return PlaylistEntry::Missing(Missing);
}

let event = AuthorizedEvent::from_row(&row, mapping.event);
if !context.auth.overlaps_roles(&event.read_roles) {
return PlaylistEntry::NotAllowed(NotAllowed);
}

PlaylistEntry::Event(event)
})
.await?
.pipe(Ok)
}
}

impl Node for AuthorizedPlaylist {
fn id(&self) -> Id {
Id::playlist(self.key)
}
}
11 changes: 11 additions & 0 deletions backend/src/api/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use super::{
model::{
event::{AuthorizedEvent, Event},
known_roles::{self, KnownGroup, KnownUsersSearchOutcome},
playlist::Playlist,
realm::Realm,
search::{self, EventSearchOutcome, Filters, SearchOutcome, SeriesSearchOutcome},
series::Series,
Expand Down Expand Up @@ -66,6 +67,16 @@ impl Query {
Series::load_by_id(id, context).await
}

/// Returns a playlist by its Opencast ID.
async fn playlist_by_opencast_id(id: String, context: &Context) -> ApiResult<Option<Playlist>> {
Playlist::load_by_opencast_id(id, context).await
}

/// Returns a playlist by its ID.
async fn playlist_by_id(id: Id, context: &Context) -> ApiResult<Option<Playlist>> {
Playlist::load_by_id(id, context).await
}

/// Returns the current user.
fn current_user(context: &Context) -> Option<&User> {
match &context.auth {
Expand Down
55 changes: 40 additions & 15 deletions frontend/src/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,18 @@ type EventPageInfo {
endIndex: Int
}

input NewTextBlock {
content: String!
type Missing {
"""
Unused dummy field for this marker type. GraphQL requires all objects to
have at least one field. Always returns `null`.
"""
dummy: Boolean
}

input Filters {
itemType: ItemType
start: DateTimeUtc
end: DateTimeUtc
}

"Arbitrary metadata for events/series. Serialized as JSON object."
Expand All @@ -38,12 +48,6 @@ enum VideoListOrder {
ZA
}

input Filters {
itemType: ItemType
start: DateTimeUtc
end: DateTimeUtc
}

"A block just showing some text."
type TextBlock implements Block {
content: String!
Expand Down Expand Up @@ -99,10 +103,23 @@ input ChildIndex {
index: Int!
}

input NewTextBlock {
content: String!
}

type SyncedSeriesData {
description: String
}

"Represents an Opencast series."
type AuthorizedPlaylist {
id: ID!
opencastId: String!
title: String!
description: String
entries: [PlaylistEntry!]!
}

type EventConnection {
pageInfo: EventPageInfo!
items: [AuthorizedEvent!]!
Expand Down Expand Up @@ -158,9 +175,11 @@ type Series {
metadata: ExtraMetadata
syncedData: SyncedSeriesData
hostRealms: [Realm!]!
events(order: EventSortOrder = {column: "CREATED", direction: "DESCENDING"}): [AuthorizedEvent!]!
events: [AuthorizedEvent!]!
}

union PlaylistEntry = AuthorizedEvent | NotAllowed | Missing

union EventSearchOutcome = SearchUnavailable | EventSearchResults

"""
Expand All @@ -174,12 +193,6 @@ type KnownGroup {
large: Boolean!
}

input NewVideoBlock {
event: ID!
showTitle: Boolean!
showLink: Boolean!
}

type SyncedEventData implements Node {
updated: DateTimeUtc!
startTime: DateTimeUtc
Expand All @@ -191,6 +204,12 @@ type SyncedEventData implements Node {
captions: [Caption!]!
}

input NewVideoBlock {
event: ID!
showTitle: Boolean!
showLink: Boolean!
}

"A `Block`: a UI element that belongs to a realm."
interface Block {
id: ID!
Expand Down Expand Up @@ -298,6 +317,8 @@ input UpdateTextBlock {
content: String
}

union Playlist = AuthorizedPlaylist | NotAllowed

type Realm implements Node {
id: ID!
"""
Expand Down Expand Up @@ -542,6 +563,10 @@ type Query {
seriesByOpencastId(id: String!): Series
"Returns a series by its ID."
seriesById(id: ID!): Series
"Returns a playlist by its Opencast ID."
playlistByOpencastId(id: String!): Playlist
"Returns a playlist by its ID."
playlistById(id: ID!): Playlist
"Returns the current user."
currentUser: User
"Returns a new JWT that can be used to authenticate against Opencast for using the given service"
Expand Down

0 comments on commit f5eb20b

Please sign in to comment.