From 4f01c123c3c7c91cef4350a97c18efb4c22ac0f7 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 9 Nov 2022 13:39:25 +0100 Subject: [PATCH] GraphQL schema documentation --- crates/graphql/schema.graphql | 190 ++++++++++++++++++- crates/graphql/src/lib.rs | 24 ++- crates/graphql/src/model/browser_sessions.rs | 25 ++- crates/graphql/src/model/compat_sessions.rs | 34 +++- crates/graphql/src/model/mod.rs | 30 +++ crates/graphql/src/model/oauth.rs | 30 ++- crates/graphql/src/model/users.rs | 57 ++++-- crates/storage/src/pagination.rs | 16 +- 8 files changed, 349 insertions(+), 57 deletions(-) diff --git a/crates/graphql/schema.graphql b/crates/graphql/schema.graphql index 98a55e3d..3d3eb1e6 100644 --- a/crates/graphql/schema.graphql +++ b/crates/graphql/schema.graphql @@ -1,13 +1,38 @@ -type Authentication { +""" +An authentication records when a user enter their credential in a browser +session. +""" +type Authentication implements CreationEvent & Node { + """ + ID of the object. + """ id: ID! + """ + When the object was created. + """ createdAt: DateTime! } -type BrowserSession { +""" +A browser session represents a logged in user in a browser. +""" +type BrowserSession implements Node & CreationEvent { + """ + ID of the object. + """ id: ID! + """ + The user logged in this session. + """ user: User! + """ + The most recent authentication of this session. + """ lastAuthentication: Authentication + """ + When the object was created. + """ createdAt: DateTime! } @@ -40,20 +65,62 @@ type BrowserSessionEdge { node: BrowserSession! } -type CompatSession { +""" +A compat session represents a client session which used the legacy Matrix +login API. +""" +type CompatSession implements Node & CreationEvent { + """ + ID of the object. + """ id: ID! + """ + The user authorized for this session. + """ user: User! + """ + The Matrix Device ID of this session. + """ deviceId: String! + """ + When the object was created. + """ createdAt: DateTime! + """ + When the session ended. + """ finishedAt: DateTime } -type CompatSsoLogin { +""" +A compat SSO login represents a login done through the legacy Matrix login +API, via the `m.login.sso` login method. +""" +type CompatSsoLogin implements Node { + """ + ID of the object. + """ id: ID! + """ + When the object was created. + """ createdAt: DateTime! + """ + The redirect URI used during the login. + """ redirectUri: Url! + """ + When the login was fulfilled, and the user was redirected back to the + client. + """ fulfilledAt: DateTime + """ + When the client exchanged the login token sent during the redirection. + """ exchangedAt: DateTime + """ + The compat session which was started by this login. + """ session: CompatSession } @@ -86,6 +153,13 @@ type CompatSsoLoginEdge { node: CompatSsoLogin! } +interface CreationEvent { + """ + When the object was created. + """ + createdAt: DateTime! +} + """ Implement the DateTime scalar @@ -96,21 +170,71 @@ scalar DateTime -type Oauth2Client { +interface Node { + """ + ID of the object. + """ id: ID! +} + +""" +An OAuth 2.0 client +""" +type Oauth2Client implements Node { + """ + ID of the object. + """ + id: ID! + """ + OAuth 2.0 client ID + """ clientId: String! + """ + Client name advertised by the client. + """ clientName: String + """ + Client URI advertised by the client. + """ clientUri: Url + """ + Terms of services URI advertised by the client. + """ tosUri: Url + """ + Privacy policy URI advertised by the client. + """ policyUri: Url + """ + List of redirect URIs used for authorization grants by the client. + """ redirectUris: [Url!]! } -type Oauth2Session { +""" +An OAuth 2.0 session represents a client session which used the OAuth APIs +to login. +""" +type Oauth2Session implements Node { + """ + ID of the object. + """ id: ID! + """ + OAuth 2.0 client used by this session. + """ client: Oauth2Client! + """ + Scope granted for this session. + """ scope: String! + """ + The browser session which started this OAuth 2.0 session. + """ browserSession: BrowserSession! + """ + User authorized for this session. + """ user: User! } @@ -165,7 +289,10 @@ type PageInfo { endCursor: String } -type Query { +""" +The query root of the GraphQL interface. +""" +type RootQuery { """ Get the current logged in browser session """ @@ -182,20 +309,60 @@ URL is a String implementing the [URL Standard](http://url.spec.whatwg.org/) """ scalar Url -type User { +""" +A user is an individual's account. +""" +type User implements Node { + """ + ID of the object. + """ id: ID! + """ + Username chosen by the user. + """ username: String! + """ + Primary email address of the user. + """ primaryEmail: UserEmail + """ + Get the list of compatibility SSO logins, chronologically sorted + """ compatSsoLogins(after: String, before: String, first: Int, last: Int): CompatSsoLoginConnection! + """ + Get the list of active browser sessions, chronologically sorted + """ browserSessions(after: String, before: String, first: Int, last: Int): BrowserSessionConnection! + """ + Get the list of emails, chronologically sorted + """ emails(after: String, before: String, first: Int, last: Int): UserEmailConnection! + """ + Get the list of OAuth 2.0 sessions, chronologically sorted + """ oauth2Sessions(after: String, before: String, first: Int, last: Int): Oauth2SessionConnection! } -type UserEmail { +""" +A user email address +""" +type UserEmail implements CreationEvent & Node { + """ + ID of the object. + """ id: ID! + """ + Email address + """ email: String! + """ + When the object was created. + """ createdAt: DateTime! + """ + When the email address was confirmed. Is `null` if the email was never + verified by the user. + """ confirmedAt: DateTime } @@ -212,6 +379,9 @@ type UserEmailConnection { A list of nodes. """ nodes: [UserEmail!]! + """ + Identifies the total count of items in the connection. + """ totalCount: Int! } @@ -230,6 +400,6 @@ type UserEmailEdge { } schema { - query: Query + query: RootQuery } diff --git a/crates/graphql/src/lib.rs b/crates/graphql/src/lib.rs index 2f488873..93037aa6 100644 --- a/crates/graphql/src/lib.rs +++ b/crates/graphql/src/lib.rs @@ -22,36 +22,40 @@ #![warn(clippy::pedantic)] #![allow(clippy::module_name_repetitions, clippy::missing_errors_doc)] -use async_graphql::{Context, EmptyMutation, EmptySubscription}; +use async_graphql::{Context, Description, EmptyMutation, EmptySubscription}; use mas_axum_utils::SessionInfo; +use model::CreationEvent; use sqlx::PgPool; -use self::model::{BrowserSession, User}; +use self::model::{BrowserSession, Node, User}; mod model; -pub type Schema = async_graphql::Schema; -pub type SchemaBuilder = async_graphql::SchemaBuilder; +pub type Schema = async_graphql::Schema; +pub type SchemaBuilder = async_graphql::SchemaBuilder; #[must_use] pub fn schema_builder() -> SchemaBuilder { - async_graphql::Schema::build(Query::new(), EmptyMutation, EmptySubscription) + async_graphql::Schema::build(RootQuery::new(), EmptyMutation, EmptySubscription) + .register_output_type::() + .register_output_type::() } -#[derive(Default)] -pub struct Query { +/// The query root of the GraphQL interface. +#[derive(Default, Description)] +pub struct RootQuery { _private: (), } -impl Query { +impl RootQuery { #[must_use] pub fn new() -> Self { Self::default() } } -#[async_graphql::Object] -impl Query { +#[async_graphql::Object(use_type_description)] +impl RootQuery { /// Get the current logged in browser session async fn current_browser_session( &self, diff --git a/crates/graphql/src/model/browser_sessions.rs b/crates/graphql/src/model/browser_sessions.rs index a0a3cda1..1ac79513 100644 --- a/crates/graphql/src/model/browser_sessions.rs +++ b/crates/graphql/src/model/browser_sessions.rs @@ -12,12 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -use async_graphql::{Object, ID}; +use async_graphql::{Description, Object, ID}; use chrono::{DateTime, Utc}; use mas_storage::PostgresqlBackend; use super::User; +/// A browser session represents a logged in user in a browser. +#[derive(Description)] pub struct BrowserSession(pub mas_data_model::BrowserSession); impl From> for BrowserSession { @@ -26,34 +28,43 @@ impl From> for BrowserSession } } -#[Object] +#[Object(use_type_description)] impl BrowserSession { - async fn id(&self) -> ID { + /// ID of the object. + pub async fn id(&self) -> ID { ID(self.0.data.to_string()) } + /// The user logged in this session. async fn user(&self) -> User { User(self.0.user.clone()) } + /// The most recent authentication of this session. async fn last_authentication(&self) -> Option { self.0.last_authentication.clone().map(Authentication) } - async fn created_at(&self) -> DateTime { + /// When the object was created. + pub async fn created_at(&self) -> DateTime { self.0.created_at } } +/// An authentication records when a user enter their credential in a browser +/// session. +#[derive(Description)] pub struct Authentication(pub mas_data_model::Authentication); -#[Object] +#[Object(use_type_description)] impl Authentication { - async fn id(&self) -> ID { + /// ID of the object. + pub async fn id(&self) -> ID { ID(self.0.data.to_string()) } - async fn created_at(&self) -> DateTime { + /// When the object was created. + pub async fn created_at(&self) -> DateTime { self.0.created_at } } diff --git a/crates/graphql/src/model/compat_sessions.rs b/crates/graphql/src/model/compat_sessions.rs index 6bb4159f..70789493 100644 --- a/crates/graphql/src/model/compat_sessions.rs +++ b/crates/graphql/src/model/compat_sessions.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use async_graphql::{Object, ID}; +use async_graphql::{Description, Object, ID}; use chrono::{DateTime, Utc}; use mas_data_model::CompatSsoLoginState; use mas_storage::PostgresqlBackend; @@ -20,47 +20,63 @@ use url::Url; use super::User; +/// A compat session represents a client session which used the legacy Matrix +/// login API. +#[derive(Description)] pub struct CompatSession(pub mas_data_model::CompatSession); -#[Object] +#[Object(use_type_description)] impl CompatSession { - async fn id(&self) -> ID { + /// ID of the object. + pub async fn id(&self) -> ID { ID(self.0.data.to_string()) } + /// The user authorized for this session. async fn user(&self) -> User { User(self.0.user.clone()) } + /// The Matrix Device ID of this session. async fn device_id(&self) -> &str { self.0.device.as_str() } - async fn created_at(&self) -> DateTime { + /// When the object was created. + pub async fn created_at(&self) -> DateTime { self.0.created_at } - async fn finished_at(&self) -> Option> { + /// When the session ended. + pub async fn finished_at(&self) -> Option> { self.0.finished_at } } +/// A compat SSO login represents a login done through the legacy Matrix login +/// API, via the `m.login.sso` login method. +#[derive(Description)] pub struct CompatSsoLogin(pub mas_data_model::CompatSsoLogin); -#[Object] +#[Object(use_type_description)] impl CompatSsoLogin { - async fn id(&self) -> ID { + /// ID of the object. + pub async fn id(&self) -> ID { ID(self.0.data.to_string()) } - async fn created_at(&self) -> DateTime { + /// When the object was created. + pub async fn created_at(&self) -> DateTime { self.0.created_at } + /// The redirect URI used during the login. async fn redirect_uri(&self) -> &Url { &self.0.redirect_uri } + /// When the login was fulfilled, and the user was redirected back to the + /// client. async fn fulfilled_at(&self) -> Option> { match &self.0.state { CompatSsoLoginState::Pending => None, @@ -69,6 +85,7 @@ impl CompatSsoLogin { } } + /// When the client exchanged the login token sent during the redirection. async fn exchanged_at(&self) -> Option> { match &self.0.state { CompatSsoLoginState::Pending | CompatSsoLoginState::Fulfilled { .. } => None, @@ -76,6 +93,7 @@ impl CompatSsoLogin { } } + /// The compat session which was started by this login. async fn session(&self) -> Option { match &self.0.state { CompatSsoLoginState::Pending => None, diff --git a/crates/graphql/src/model/mod.rs b/crates/graphql/src/model/mod.rs index 566c507a..5bdf1a75 100644 --- a/crates/graphql/src/model/mod.rs +++ b/crates/graphql/src/model/mod.rs @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +use async_graphql::{Interface, ID}; +use chrono::{DateTime, Utc}; + mod browser_sessions; mod compat_sessions; mod cursor; @@ -20,7 +23,34 @@ mod users; pub use self::{ browser_sessions::{Authentication, BrowserSession}, + compat_sessions::{CompatSession, CompatSsoLogin}, cursor::{Cursor, NodeCursor, NodeType}, oauth::{OAuth2Client, OAuth2Consent, OAuth2Session}, users::{User, UserEmail}, }; + +#[derive(Interface)] +#[graphql(field(name = "id", desc = "ID of the object.", type = "ID"))] +pub enum Node { + Authentication(Box), + BrowserSession(Box), + CompatSession(Box), + CompatSsoLogin(Box), + OAuth2Client(Box), + OAuth2Session(Box), + User(Box), + UserEmail(Box), +} + +#[derive(Interface)] +#[graphql(field( + name = "created_at", + desc = "When the object was created.", + type = "DateTime" +))] +pub enum CreationEvent { + Authentication(Box), + CompatSession(Box), + BrowserSession(Box), + UserEmail(Box), +} diff --git a/crates/graphql/src/model/oauth.rs b/crates/graphql/src/model/oauth.rs index a6d38630..f634001d 100644 --- a/crates/graphql/src/model/oauth.rs +++ b/crates/graphql/src/model/oauth.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use async_graphql::{Context, Object, ID}; +use async_graphql::{Context, Description, Object, ID}; use mas_storage::{oauth2::client::lookup_client, PostgresqlBackend}; use oauth2_types::scope::Scope; use sqlx::PgPool; @@ -21,75 +21,97 @@ use url::Url; use super::{BrowserSession, User}; +/// An OAuth 2.0 session represents a client session which used the OAuth APIs +/// to login. +#[derive(Description)] pub struct OAuth2Session(pub mas_data_model::Session); -#[Object] +#[Object(use_type_description)] impl OAuth2Session { + /// ID of the object. pub async fn id(&self) -> ID { ID(self.0.data.to_string()) } + /// OAuth 2.0 client used by this session. pub async fn client(&self) -> OAuth2Client { OAuth2Client(self.0.client.clone()) } + /// Scope granted for this session. pub async fn scope(&self) -> String { self.0.scope.to_string() } + /// The browser session which started this OAuth 2.0 session. pub async fn browser_session(&self) -> BrowserSession { BrowserSession(self.0.browser_session.clone()) } + /// User authorized for this session. pub async fn user(&self) -> User { User(self.0.browser_session.user.clone()) } } +/// An OAuth 2.0 client +#[derive(Description)] pub struct OAuth2Client(pub mas_data_model::Client); -#[Object] +#[Object(use_type_description)] impl OAuth2Client { + /// ID of the object. pub async fn id(&self) -> ID { ID(self.0.data.to_string()) } + /// OAuth 2.0 client ID pub async fn client_id(&self) -> &str { &self.0.client_id } + /// Client name advertised by the client. pub async fn client_name(&self) -> Option<&str> { self.0.client_name.as_deref() } + /// Client URI advertised by the client. pub async fn client_uri(&self) -> Option<&Url> { self.0.client_uri.as_ref() } + /// Terms of services URI advertised by the client. pub async fn tos_uri(&self) -> Option<&Url> { self.0.tos_uri.as_ref() } + /// Privacy policy URI advertised by the client. pub async fn policy_uri(&self) -> Option<&Url> { self.0.policy_uri.as_ref() } + /// List of redirect URIs used for authorization grants by the client. pub async fn redirect_uris(&self) -> &[Url] { &self.0.redirect_uris } } +/// An OAuth 2.0 consent represents the scope a user consented to grant to a +/// client. +#[derive(Description)] pub struct OAuth2Consent { scope: Scope, client_id: Ulid, } -#[Object] +#[Object(use_type_description)] impl OAuth2Consent { + /// Scope consented by the user for this client. pub async fn scope(&self) -> String { self.scope.to_string() } + /// OAuth 2.0 client for which the user granted access. pub async fn client(&self, ctx: &Context<'_>) -> Result { let mut conn = ctx.data::()?.acquire().await?; let client = lookup_client(&mut conn, self.client_id).await?; diff --git a/crates/graphql/src/model/users.rs b/crates/graphql/src/model/users.rs index f8a8396b..9b302208 100644 --- a/crates/graphql/src/model/users.rs +++ b/crates/graphql/src/model/users.rs @@ -14,7 +14,7 @@ use async_graphql::{ connection::{query, Connection, Edge, OpaqueCursor}, - Context, Object, ID, + Context, Description, Object, ID, }; use chrono::{DateTime, Utc}; use mas_storage::PostgresqlBackend; @@ -24,6 +24,8 @@ use super::{ compat_sessions::CompatSsoLogin, BrowserSession, Cursor, NodeCursor, NodeType, OAuth2Session, }; +#[derive(Description)] +/// A user is an individual's account. pub struct User(pub mas_data_model::User); impl From> for User { @@ -38,27 +40,34 @@ impl From> for User { } } -#[Object] +#[Object(use_type_description)] impl User { - async fn id(&self) -> ID { + /// ID of the object. + pub async fn id(&self) -> ID { ID(self.0.data.to_string()) } + /// Username chosen by the user. async fn username(&self) -> &str { &self.0.username } + /// Primary email address of the user. async fn primary_email(&self) -> Option { self.0.primary_email.clone().map(UserEmail) } + /// Get the list of compatibility SSO logins, chronologically sorted async fn compat_sso_logins( &self, ctx: &Context<'_>, + + #[graphql(desc = "Returns the elements in the list that come after the cursor.")] after: Option, + #[graphql(desc = "Returns the elements in the list that come before the cursor.")] before: Option, - first: Option, - last: Option, + #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option, + #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option, ) -> Result, async_graphql::Error> { let database = ctx.data::()?; @@ -96,13 +105,17 @@ impl User { .await } + /// Get the list of active browser sessions, chronologically sorted async fn browser_sessions( &self, ctx: &Context<'_>, + + #[graphql(desc = "Returns the elements in the list that come after the cursor.")] after: Option, + #[graphql(desc = "Returns the elements in the list that come before the cursor.")] before: Option, - first: Option, - last: Option, + #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option, + #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option, ) -> Result, async_graphql::Error> { let database = ctx.data::()?; @@ -140,13 +153,17 @@ impl User { .await } + /// Get the list of emails, chronologically sorted async fn emails( &self, ctx: &Context<'_>, + + #[graphql(desc = "Returns the elements in the list that come after the cursor.")] after: Option, + #[graphql(desc = "Returns the elements in the list that come before the cursor.")] before: Option, - first: Option, - last: Option, + #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option, + #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option, ) -> Result, async_graphql::Error> { let database = ctx.data::()?; @@ -188,13 +205,17 @@ impl User { .await } + /// Get the list of OAuth 2.0 sessions, chronologically sorted async fn oauth2_sessions( &self, ctx: &Context<'_>, + + #[graphql(desc = "Returns the elements in the list that come after the cursor.")] after: Option, + #[graphql(desc = "Returns the elements in the list that come before the cursor.")] before: Option, - first: Option, - last: Option, + #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option, + #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option, ) -> Result, async_graphql::Error> { let database = ctx.data::()?; @@ -233,22 +254,29 @@ impl User { } } +/// A user email address +#[derive(Description)] pub struct UserEmail(mas_data_model::UserEmail); -#[Object] +#[Object(use_type_description)] impl UserEmail { - async fn id(&self) -> ID { + /// ID of the object. + pub async fn id(&self) -> ID { ID(self.0.data.to_string()) } + /// Email address async fn email(&self) -> &str { &self.0.email } - async fn created_at(&self) -> DateTime { + /// When the object was created. + pub async fn created_at(&self) -> DateTime { self.0.created_at } + /// When the email address was confirmed. Is `null` if the email was never + /// verified by the user. async fn confirmed_at(&self) -> Option> { self.0.confirmed_at } @@ -258,6 +286,7 @@ pub struct UserEmailsPagination(mas_data_model::User); #[Object] impl UserEmailsPagination { + /// Identifies the total count of items in the connection. async fn total_count(&self, ctx: &Context<'_>) -> Result { let mut conn = ctx.data::()?.acquire().await?; let count = mas_storage::user::count_user_emails(&mut conn, &self.0).await?; diff --git a/crates/storage/src/pagination.rs b/crates/storage/src/pagination.rs index 0165e36c..0c898158 100644 --- a/crates/storage/src/pagination.rs +++ b/crates/storage/src/pagination.rs @@ -13,9 +13,16 @@ // limitations under the License. use sqlx::{Database, QueryBuilder}; +use thiserror::Error; use ulid::Ulid; use uuid::Uuid; +#[derive(Debug, Error)] +#[error("Either 'first' or 'last' must be specified")] +pub struct InvalidPagination; + +/// Add cursor-based pagination to a query, as used in paginated GraphQL +/// connections pub fn generate_pagination<'a, DB>( query: &mut QueryBuilder<'a, DB>, id_field: &'static str, @@ -23,7 +30,7 @@ pub fn generate_pagination<'a, DB>( after: Option, first: Option, last: Option, -) -> Result<(), anyhow::Error> +) -> Result<(), InvalidPagination> where DB: Database, Uuid: sqlx::Type + sqlx::Encode<'a, DB>, @@ -69,20 +76,21 @@ where .push(" DESC LIMIT ") .push_bind((count + 1) as i64); } else { - anyhow::bail!("Either 'first' or 'last' must be specified"); + return Err(InvalidPagination); } Ok(()) } +/// Process a page returned by a paginated query pub fn process_page( mut page: Vec, first: Option, last: Option, -) -> Result<(bool, bool, Vec), anyhow::Error> { +) -> Result<(bool, bool, Vec), InvalidPagination> { let limit = match (first, last) { (Some(count), _) | (_, Some(count)) => count, - _ => anyhow::bail!("Either 'first' or 'last' must be specified"), + _ => return Err(InvalidPagination), }; let is_full = page.len() == (limit + 1);