diff --git a/Cargo.lock b/Cargo.lock index e387416..02b58ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -786,7 +786,7 @@ dependencies = [ [[package]] name = "rbxcloud" -version = "0.7.0" +version = "0.8.0" dependencies = [ "anyhow", "base64 0.22.0", diff --git a/Cargo.toml b/Cargo.toml index 9330485..a4c3c07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rbxcloud" -version = "0.7.0" +version = "0.8.0" description = "CLI and SDK for the Roblox Open Cloud APIs" authors = ["Stephen Leitnick"] license = "MIT" diff --git a/README.md b/README.md index 05c68f2..b7ee37a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Possible use-cases: | | API v2 (Beta) | | -- | -- | -| :x: | Groups | +| :white_check_mark: | Groups | | :x: | Universes | | :x: | Places | | :x: | Instances | @@ -42,7 +42,7 @@ The goal of this project is to support all API endpoints that Roblox provides. ### Aftman Run the `aftman add` command within your project directory. This will add `rbxcloud` to the project's `aftman.toml` file (or create one if it doesn't yet exist). ```sh -$ aftman add Sleitnick/rbxcloud@0.7.0 +$ aftman add Sleitnick/rbxcloud@0.8.0 ``` ### From Release @@ -59,7 +59,7 @@ The library built for the CLI tool is available to use directly in Rust projects To use `rbxcloud` in a Rust project, simply add `rbxcloud` to the `Cargo.toml` dependency list. ```toml [dependencies] -rbxcloud = "0.7.0" +rbxcloud = "0.8.0" ``` Alternatively, use `cargo add`. diff --git a/docs/cli/cli-group.md b/docs/cli/cli-group.md new file mode 100644 index 0000000..21634a1 --- /dev/null +++ b/docs/cli/cli-group.md @@ -0,0 +1,61 @@ +# Group API + +## Get Group Info +Get information about a group. +``` +Usage: rbxcloud group get [OPTIONS] --group-id --api-key + +Options: + -a, --api-key Roblox Open Cloud API Key [env: RBXCLOUD_API_KEY=] + -g, --group-id Group ID + -p, --pretty Pretty-print the JSON response + -h, --help Print help +``` + +### Example +``` +$ rbxcloud group get -p -g 12345 -a MY_KEY +``` + +## Get Group Shout +Get a group's current shout and its metadata. +``` +Usage: rbxcloud group shout [OPTIONS] --group-id --api-key + +Options: + -a, --api-key Roblox Open Cloud API Key [env: RBXCLOUD_API_KEY=] + -g, --group-id Group ID + -p, --pretty Pretty-print the JSON response + -o, --only-message Only return the shout message string + -h, --help Print help +``` + +### Example +Get a group's shout and its metadata: +``` +$ rbxcloud group shout -p -g 12345 -a MY_KEY +``` + +Get a group's shout message only: +``` +$ rbxcloud group shout -p -g 12345 -a MY_KEY --only-message +``` + +## List Group Roles +List the roles of a given group. +``` +Usage: rbxcloud group roles [OPTIONS] --group-id --api-key + +Options: + -g, --group-id Group ID + -p, --pretty Pretty-print the JSON response + -m, --max-page-size Max items returned per page + -n, --next-page-token Next page token + -a, --api-key Roblox Open Cloud API Key [env: RBXCLOUD_API_KEY=] + -h, --help Print help +``` + +### Example +``` +$ rbxcloud group roles -p -g 12345 -a MY_KEY +``` diff --git a/docs/cli/cli-install.md b/docs/cli/cli-install.md index ab78c58..d6574ef 100644 --- a/docs/cli/cli-install.md +++ b/docs/cli/cli-install.md @@ -7,7 +7,7 @@ There are a few different ways to install the `rbxcloud` CLI. ### [Aftman](https://github.com/LPGhatguy/aftman) (Preferred) Run the `aftman add` command within your project directory. This will add `rbxcloud` to the project's `aftman.toml` file (or create one if it doesn't yet exist). ```sh -$ aftman add Sleitnick/rbxcloud@0.7.0 +$ aftman add Sleitnick/rbxcloud@0.8.0 ``` Next, run `aftman install` to install `rbxcloud`. @@ -17,7 +17,7 @@ Add `rbxcloud` under the `[tools]` section of your `foreman.toml` file. ```toml # foreman.toml [tools] -rbxcloud = { github = "Sleitnick/rbxcloud", version = "0.7.0" } +rbxcloud = { github = "Sleitnick/rbxcloud", version = "0.8.0" } ``` Next, run `foreman install` to install `rbxcloud`. diff --git a/docs/lib/lib-install.md b/docs/lib/lib-install.md index 89c002e..cfe7236 100644 --- a/docs/lib/lib-install.md +++ b/docs/lib/lib-install.md @@ -5,7 +5,7 @@ To use `rbxcloud` in a Rust project, simply add `rbxcloud` to the `Cargo.toml` dependency list. ```toml [dependencies] -rbxcloud = "0.7.0" +rbxcloud = "0.8.0" ``` Alternatively, use `cargo add`. diff --git a/examples/group-get-shout.rs b/examples/group-get-shout.rs new file mode 100644 index 0000000..3f65d52 --- /dev/null +++ b/examples/group-get-shout.rs @@ -0,0 +1,25 @@ +use rbxcloud::rbx::{ + error::Error, + v2::{group::GroupId, Client}, +}; + +async fn get_group_shout() -> Result { + // Inputs: + let api_key = "MY_API_KEY"; + let group_id = 9876543210; + + let client = Client::new(api_key); + let group = client.group(GroupId(group_id)); + + // Get the shout's content: + group.get_shout().await.map(|r| r.content) +} + +#[tokio::main] +async fn main() { + let shout_res = get_group_shout().await; + match shout_res { + Ok(shout) => println!("{shout}"), + Err(e) => eprintln!("{e:?}"), + } +} diff --git a/src/cli/group_cli.rs b/src/cli/group_cli.rs new file mode 100644 index 0000000..93b21a3 --- /dev/null +++ b/src/cli/group_cli.rs @@ -0,0 +1,196 @@ +use clap::{Args, Subcommand}; +use rbxcloud::rbx::v2::{group::GroupId, Client}; + +#[derive(Debug, Subcommand)] +pub enum GroupCommands { + /// Get info about the group + Get { + /// Group ID + #[clap(short, long, value_parser)] + group_id: u64, + + /// Pretty-print the JSON response + #[clap(short, long, value_parser, default_value_t = false)] + pretty: bool, + + /// Roblox Open Cloud API Key + #[clap(short, long, value_parser, env = "RBXCLOUD_API_KEY")] + api_key: String, + }, + + /// Get the current shout and other metadata + Shout { + /// Group ID + #[clap(short, long, value_parser)] + group_id: u64, + + /// Pretty-print the JSON response + #[clap(short, long, value_parser, default_value_t = false)] + pretty: bool, + + /// Only return the shout message string + #[clap(short, long, value_parser, default_value_t = false)] + only_message: bool, + + /// Roblox Open Cloud API Key + #[clap(short, long, value_parser, env = "RBXCLOUD_API_KEY")] + api_key: String, + }, + + /// List the roles of a group + Roles { + /// Group ID + #[clap(short, long, value_parser)] + group_id: u64, + + /// Pretty-print the JSON response + #[clap(short, long, value_parser, default_value_t = false)] + pretty: bool, + + /// Max items returned per page + #[clap(short, long, value_parser)] + max_page_size: Option, + + /// Next page token + #[clap(short, long, value_parser)] + next_page_token: Option, + + /// Roblox Open Cloud API Key + #[clap(short, long, value_parser, env = "RBXCLOUD_API_KEY")] + api_key: String, + }, + + /// List the memberships of a group + Memberships { + /// Group ID + #[clap(short, long, value_parser)] + group_id: u64, + + /// Pretty-print the JSON response + #[clap(short, long, value_parser, default_value_t = false)] + pretty: bool, + + /// Max items returned per page + #[clap(short, long, value_parser)] + max_page_size: Option, + + /// Filter + #[clap(short, long, value_parser)] + filter: Option, + + /// Next page token + #[clap(short, long, value_parser)] + next_page_token: Option, + + /// Roblox Open Cloud API Key + #[clap(short, long, value_parser, env = "RBXCLOUD_API_KEY")] + api_key: String, + }, +} + +#[derive(Debug, Args)] +pub struct Group { + #[clap(subcommand)] + command: GroupCommands, +} + +impl Group { + pub async fn run(self) -> anyhow::Result> { + match self.command { + GroupCommands::Get { + group_id, + api_key, + pretty, + } => { + let client = Client::new(&api_key); + let group = client.group(GroupId(group_id)); + let res = group.get_info().await; + match res { + Ok(group_info) => { + let r = if pretty { + serde_json::to_string_pretty(&group_info)? + } else { + serde_json::to_string(&group_info)? + }; + Ok(Some(r)) + } + Err(err) => Err(anyhow::anyhow!(err)), + } + } + + GroupCommands::Shout { + group_id, + pretty, + only_message, + api_key, + } => { + let client = Client::new(&api_key); + let group = client.group(GroupId(group_id)); + let res = group.get_shout().await; + match res { + Ok(group_info) => { + if only_message { + return Ok(Some(group_info.content)); + } + let r = if pretty { + serde_json::to_string_pretty(&group_info)? + } else { + serde_json::to_string(&group_info)? + }; + Ok(Some(r)) + } + Err(err) => Err(anyhow::anyhow!(err)), + } + } + + GroupCommands::Roles { + group_id, + api_key, + pretty, + max_page_size, + next_page_token, + } => { + let client = Client::new(&api_key); + let group = client.group(GroupId(group_id)); + let res = group.list_roles(max_page_size, next_page_token).await; + match res { + Ok(group_info) => { + let r = if pretty { + serde_json::to_string_pretty(&group_info)? + } else { + serde_json::to_string(&group_info)? + }; + Ok(Some(r)) + } + Err(err) => Err(anyhow::anyhow!(err)), + } + } + + GroupCommands::Memberships { + group_id, + api_key, + pretty, + max_page_size, + next_page_token, + filter, + } => { + let client = Client::new(&api_key); + let group = client.group(GroupId(group_id)); + let res = group + .list_memberships(max_page_size, filter, next_page_token) + .await; + match res { + Ok(group_info) => { + let r = if pretty { + serde_json::to_string_pretty(&group_info)? + } else { + serde_json::to_string(&group_info)? + }; + Ok(Some(r)) + } + Err(err) => Err(anyhow::anyhow!(err)), + } + } + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index be6cb22..1938b34 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,13 +1,14 @@ mod assets_cli; mod datastore_cli; mod experience_cli; +mod group_cli; mod messaging_cli; mod ordered_datastore_cli; use clap::{Parser, Subcommand}; use self::{ - assets_cli::Assets, datastore_cli::DataStore, experience_cli::Experience, + assets_cli::Assets, datastore_cli::DataStore, experience_cli::Experience, group_cli::Group, messaging_cli::Messaging, ordered_datastore_cli::OrderedDataStore, }; @@ -34,6 +35,9 @@ pub enum Command { /// Access the Roblox OrderedDataStore API OrderedDatastore(OrderedDataStore), + + /// Access the Roblox Group API + Group(Group), } impl Cli { @@ -44,6 +48,7 @@ impl Cli { Command::Messaging(command) => command.run().await, Command::Datastore(command) => command.run().await, Command::OrderedDatastore(command) => command.run().await, + Command::Group(command) => command.run().await, } } } diff --git a/src/rbx/mod.rs b/src/rbx/mod.rs index f79d4fd..767d4de 100644 --- a/src/rbx/mod.rs +++ b/src/rbx/mod.rs @@ -4,3 +4,4 @@ pub mod error; pub(crate) mod util; pub mod v1; +pub mod v2; diff --git a/src/rbx/v1/mod.rs b/src/rbx/v1/mod.rs index c54fd06..c2dea12 100644 --- a/src/rbx/v1/mod.rs +++ b/src/rbx/v1/mod.rs @@ -1,4 +1,4 @@ -//! Access into Roblox APIs. +//! Access into Roblox v1 APIs. //! //! Most usage should go through the `RbxCloud` struct. pub mod assets; diff --git a/src/rbx/v2/group.rs b/src/rbx/v2/group.rs new file mode 100644 index 0000000..4b223ff --- /dev/null +++ b/src/rbx/v2/group.rs @@ -0,0 +1,251 @@ +use serde::{Deserialize, Serialize}; + +use crate::rbx::{error::Error, util::QueryString}; + +use super::http_err::handle_http_err; + +#[derive(Debug, Clone, Copy)] +pub struct GroupId(pub u64); + +impl std::fmt::Display for GroupId { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +pub struct GetGroupParams { + pub api_key: String, + pub group_id: GroupId, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GetGroupResponse { + pub path: String, + pub create_time: String, + pub update_time: String, + pub id: String, + pub display_name: String, + pub description: String, + pub owner: Option, + pub member_count: u64, + pub public_entry_allowed: bool, + pub locked: bool, + pub verified: bool, +} + +pub struct GetGroupShoutParams { + pub api_key: String, + pub group_id: GroupId, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GetGroupShoutResponse { + pub path: String, + pub create_time: String, + pub update_time: String, + pub content: String, + pub poster: Option, +} + +pub struct ListGroupRolesParams { + pub api_key: String, + pub group_id: GroupId, + pub max_page_size: Option, + pub page_token: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GroupRolePermission { + pub view_wall_posts: bool, + pub create_wall_posts: bool, + pub delete_wall_posts: bool, + pub view_group_shout: bool, + pub create_group_shout: bool, + pub change_rank: bool, + pub accept_requests: bool, + pub exile_members: bool, + pub manage_relationships: bool, + pub view_audit_log: bool, + pub spend_group_funds: bool, + pub advertise_group: bool, + pub create_avatar_items: bool, + pub manage_avatar_items: bool, + pub manage_group_universes: bool, + pub view_universe_analytics: bool, + pub create_api_keys: bool, + pub manage_api_keys: bool, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GroupRole { + pub path: String, + pub create_time: Option, + pub update_time: Option, + pub id: String, + pub display_name: String, + pub description: Option, + pub rank: u32, + pub member_count: Option, + pub permissions: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ListGroupRolesResponse { + pub group_roles: Vec, + pub next_page_token: Option, +} + +pub struct ListGroupMembershipsParams { + pub api_key: String, + pub group_id: GroupId, + pub max_page_size: Option, + pub page_token: Option, + pub filter: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GroupMembership { + pub path: String, + pub create_time: String, + pub update_time: String, + pub user: String, + pub role: String, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ListGroupMembershipsResponse { + pub group_memberships: Vec, + pub next_page_token: Option, +} + +pub async fn get_group(params: &GetGroupParams) -> Result { + let client = reqwest::Client::new(); + + let url = format!( + "https://apis.roblox.com/cloud/v2/groups/{groupId}", + groupId = ¶ms.group_id, + ); + + let res = client + .get(url) + .header("x-api-key", ¶ms.api_key) + .send() + .await?; + + let status = res.status(); + + if !status.is_success() { + let code = status.as_u16(); + return handle_http_err(code); + } + + let body = res.json::().await?; + Ok(body) +} + +pub async fn get_group_shout(params: &GetGroupShoutParams) -> Result { + let client = reqwest::Client::new(); + + let url = format!( + "https://apis.roblox.com/cloud/v2/groups/{groupId}/shout", + groupId = ¶ms.group_id, + ); + + let res = client + .get(url) + .header("x-api-key", ¶ms.api_key) + .send() + .await?; + + let status = res.status(); + + if !status.is_success() { + let code = status.as_u16(); + return handle_http_err(code); + } + + let body = res.json::().await?; + Ok(body) +} + +pub async fn list_group_roles( + params: &ListGroupRolesParams, +) -> Result { + let client = reqwest::Client::new(); + + let url = format!( + "https://apis.roblox.com/cloud/v2/groups/{groupId}/roles", + groupId = ¶ms.group_id, + ); + + let mut query: QueryString = vec![]; + if let Some(max_page_size) = ¶ms.max_page_size { + query.push(("maxPageSize", max_page_size.to_string())) + } + if let Some(page_token) = ¶ms.page_token { + query.push(("pageToken", page_token.clone())); + } + + let res = client + .get(url) + .header("x-api-key", ¶ms.api_key) + .query(&query) + .send() + .await?; + + let status = res.status(); + + if !status.is_success() { + let code = status.as_u16(); + return handle_http_err(code); + } + + let body = res.json::().await?; + Ok(body) +} + +pub async fn list_group_memberships( + params: &ListGroupMembershipsParams, +) -> Result { + let client = reqwest::Client::new(); + + let url = format!( + "https://apis.roblox.com/cloud/v2/groups/{groupId}/memberships", + groupId = ¶ms.group_id, + ); + + let mut query: QueryString = vec![]; + if let Some(max_page_size) = ¶ms.max_page_size { + query.push(("maxPageSize", max_page_size.to_string())) + } + if let Some(page_token) = ¶ms.page_token { + query.push(("pageToken", page_token.clone())); + } + if let Some(filter) = ¶ms.filter { + query.push(("filter", filter.clone())); + } + + let res = client + .get(url) + .header("x-api-key", ¶ms.api_key) + .query(&query) + .send() + .await?; + + let status = res.status(); + + if !status.is_success() { + let code = status.as_u16(); + return handle_http_err(code); + } + + let body = res.json::().await?; + Ok(body) +} diff --git a/src/rbx/v2/http_err.rs b/src/rbx/v2/http_err.rs new file mode 100644 index 0000000..5a09d0d --- /dev/null +++ b/src/rbx/v2/http_err.rs @@ -0,0 +1,46 @@ +use crate::rbx::error::Error; + +pub fn handle_http_err(code: u16) -> Result { + match code { + 400 => Err(Error::HttpStatusError { + code, + msg: "invalid argument".to_string(), + }), + 403 => Err(Error::HttpStatusError { + code, + msg: "permission denied".to_string(), + }), + 404 => Err(Error::HttpStatusError { + code, + msg: "not found".to_string(), + }), + 409 => Err(Error::HttpStatusError { + code, + msg: "aborted".to_string(), + }), + 429 => Err(Error::HttpStatusError { + code, + msg: "resource exhausted".to_string(), + }), + 499 => Err(Error::HttpStatusError { + code, + msg: "cancelled".to_string(), + }), + 500 => Err(Error::HttpStatusError { + code, + msg: "internal server error".to_string(), + }), + 501 => Err(Error::HttpStatusError { + code, + msg: "not implemented".to_string(), + }), + 503 => Err(Error::HttpStatusError { + code, + msg: "unavailable".to_string(), + }), + _ => Err(Error::HttpStatusError { + code, + msg: "unknown error".to_string(), + }), + } +} diff --git a/src/rbx/v2/mod.rs b/src/rbx/v2/mod.rs new file mode 100644 index 0000000..8c4ba16 --- /dev/null +++ b/src/rbx/v2/mod.rs @@ -0,0 +1,94 @@ +//! Access into Roblox v2 APIs. +//! +//! Most usage should go through the `Client` struct. + +use self::group::{ + GetGroupParams, GetGroupResponse, GetGroupShoutParams, GetGroupShoutResponse, GroupId, + ListGroupMembershipsParams, ListGroupMembershipsResponse, ListGroupRolesParams, + ListGroupRolesResponse, +}; +pub mod group; +pub(crate) mod http_err; + +use crate::rbx::error::Error; + +/// Access into the Roblox Open Cloud APIs. +/// +/// ```rust,no_run +/// use rbxcloud::rbx::v2::Client; +/// +/// let client = Client::new("API_KEY"); +/// ``` +#[derive(Debug)] +pub struct Client { + /// Roblox API key. + pub api_key: String, +} + +pub struct GroupClient { + pub api_key: String, + pub group_id: GroupId, +} + +impl GroupClient { + pub async fn get_info(&self) -> Result { + group::get_group(&GetGroupParams { + api_key: self.api_key.clone(), + group_id: self.group_id, + }) + .await + } + + pub async fn get_shout(&self) -> Result { + group::get_group_shout(&GetGroupShoutParams { + api_key: self.api_key.clone(), + group_id: self.group_id, + }) + .await + } + + pub async fn list_roles( + &self, + max_page_size: Option, + page_token: Option, + ) -> Result { + group::list_group_roles(&ListGroupRolesParams { + api_key: self.api_key.clone(), + group_id: self.group_id, + max_page_size, + page_token, + }) + .await + } + + pub async fn list_memberships( + &self, + max_page_size: Option, + filter: Option, + page_token: Option, + ) -> Result { + group::list_group_memberships(&ListGroupMembershipsParams { + api_key: self.api_key.clone(), + group_id: self.group_id, + max_page_size, + page_token, + filter, + }) + .await + } +} + +impl Client { + pub fn new(api_key: &str) -> Client { + Client { + api_key: api_key.to_string(), + } + } + + pub fn group(&self, group_id: GroupId) -> GroupClient { + GroupClient { + api_key: self.api_key.clone(), + group_id, + } + } +}