use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::envelope::Ack;
use crate::error::{ApiError, ValidationDetails};
use crate::ids::{GroupId, RoomId, UserId};
use crate::page::{Cursor, Page, PageRequest};
use crate::time::UnixMillis;
pub const MIN_GROUP_NAME_CHARS: usize = 3;
pub const MAX_GROUP_NAME_CHARS: usize = 32;
pub const MAX_DESCRIPTION_CHARS: usize = 500;
pub const DEFAULT_MAX_METADATA_BYTES: usize = 2048;
pub const MAX_QUERY_CHARS: usize = 32;
pub fn group_name_problem(name: &str) -> Option<String> {
let count = name.trim().chars().count();
if !(MIN_GROUP_NAME_CHARS..=MAX_GROUP_NAME_CHARS).contains(&count) {
return Some(format!("must be {MIN_GROUP_NAME_CHARS} to {MAX_GROUP_NAME_CHARS} characters"));
}
crate::text::name_problem(name).map(str::to_string)
}
pub fn description_problem(text: &str) -> Option<String> {
if text.chars().count() > MAX_DESCRIPTION_CHARS {
return Some(format!("is longer than {MAX_DESCRIPTION_CHARS} characters"));
}
if text.is_empty() {
return None;
}
crate::text::message_problem(text).map(str::to_string)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum GroupRole {
Owner,
Admin,
Member,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GroupInfo {
pub id: GroupId,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub open: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<UserId>,
pub members: u32,
pub max_members: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_room: Option<RoomId>,
pub created_at: UnixMillis,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<GroupRole>,
}
impl GroupInfo {
pub fn new(id: GroupId, name: impl Into<String>, members: u32, max_members: u32, created_at: UnixMillis) -> Self {
Self {
id,
name: name.into(),
description: None,
open: false,
metadata: None,
owner: None,
members,
max_members,
chat_room: None,
created_at,
role: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_open(mut self, open: bool) -> Self {
self.open = open;
self
}
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
pub fn with_owner(mut self, owner: UserId) -> Self {
self.owner = Some(owner);
self
}
pub fn with_chat_room(mut self, room: RoomId) -> Self {
self.chat_room = Some(room);
self
}
pub fn with_role(mut self, role: GroupRole) -> Self {
self.role = Some(role);
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GroupList {
pub groups: Vec<GroupInfo>,
}
impl GroupList {
pub fn new(groups: Vec<GroupInfo>) -> Self {
Self { groups }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GroupMember {
pub user: UserId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub role: GroupRole,
pub joined_at: UnixMillis,
}
impl GroupMember {
pub fn new(user: UserId, role: GroupRole, joined_at: UnixMillis) -> Self {
Self { user, name: None, role, joined_at }
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GroupInvite {
pub group: GroupInfo,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inviter: Option<UserId>,
pub created_at: UnixMillis,
}
impl GroupInvite {
pub fn new(group: GroupInfo, inviter: Option<UserId>, created_at: UnixMillis) -> Self {
Self { group, inviter, created_at }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CreateGroup {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub open: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
impl CreateGroup {
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into(), description: None, open: false, metadata: None }
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn open(mut self) -> Self {
self.open = true;
self
}
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if let Some(problem) = group_name_problem(&self.name) {
details.add("name", problem);
}
if let Some(problem) = self.description.as_deref().and_then(description_problem) {
details.add("description", problem);
}
details.into_result()
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UpdateGroup {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open: Option<bool>,
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
fn present<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<Value>, D::Error> {
Value::deserialize(deserializer).map(Some)
}
impl UpdateGroup {
pub fn new() -> Self {
Self::default()
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_open(mut self, open: bool) -> Self {
self.open = Some(open);
self
}
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if let Some(problem) = self.name.as_deref().and_then(group_name_problem) {
details.add("name", problem);
}
if let Some(problem) = self.description.as_deref().and_then(description_problem) {
details.add("description", problem);
}
details.into_result()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GroupQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<Cursor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
impl GroupQuery {
pub fn new() -> Self {
Self::default()
}
pub fn starting_with(prefix: impl Into<String>) -> Self {
Self { query: Some(prefix.into()), ..Self::default() }
}
pub fn after(mut self, cursor: Cursor) -> Self {
self.cursor = Some(cursor);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn page(&self) -> PageRequest {
PageRequest { cursor: self.cursor.clone(), limit: self.limit }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Invitee {
pub user: UserId,
}
impl Invitee {
pub fn new(user: UserId) -> Self {
Self { user }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RoleChange {
pub role: GroupRole,
}
impl RoleChange {
pub fn new(role: GroupRole) -> Self {
Self { role }
}
}
mod calls {
use super::*;
use crate::http_call::{payload_call, HttpCall, NoPayload, PathParams, PayloadKind, NO_PAYLOAD};
use crate::routes::{self, HttpMethod, Route};
payload_call!(CreateGroup, Post, routes::groups::LIST, true, Json, GroupInfo);
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListGroups {
pub query: GroupQuery,
}
impl ListGroups {
pub fn new() -> Self {
Self::default()
}
pub fn with_query(mut self, query: GroupQuery) -> Self {
self.query = query;
self
}
}
impl HttpCall for ListGroups {
type Payload = GroupQuery;
type Response = Page<GroupInfo>;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::groups::LIST, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &GroupQuery {
&self.query
}
fn from_parts(_params: &PathParams, query: GroupQuery) -> Result<Self, ApiError> {
Ok(Self::new().with_query(query))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct MyGroups {}
impl MyGroups {
pub fn new() -> Self {
Self {}
}
}
impl HttpCall for MyGroups {
type Payload = NoPayload;
type Response = GroupList;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::groups::MINE, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn from_parts(_params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListGroupInvites {
pub page: PageRequest,
}
impl ListGroupInvites {
pub fn new() -> Self {
Self::default()
}
pub fn with_page(mut self, page: PageRequest) -> Self {
self.page = page;
self
}
}
impl HttpCall for ListGroupInvites {
type Payload = PageRequest;
type Response = Page<GroupInvite>;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::groups::INVITES, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &PageRequest {
&self.page
}
fn from_parts(_params: &PathParams, page: PageRequest) -> Result<Self, ApiError> {
Ok(Self::new().with_page(page))
}
}
macro_rules! group_call {
($(#[$meta:meta])* $name:ident, $method:ident, $path:expr, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {
/// The group.
pub group: GroupId,
}
impl $name {
pub fn new(group: GroupId) -> Self {
Self { group }
}
}
impl HttpCall for $name {
type Payload = NoPayload;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::$method, $path, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("group", self.group)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("group")?))
}
}
};
}
group_call!(
GetGroup,
Get,
routes::groups::ONE,
GroupInfo
);
group_call!(
DeleteGroup,
Delete,
routes::groups::ONE,
Ack
);
group_call!(
JoinGroup,
Post,
routes::groups::JOIN,
GroupInfo
);
group_call!(
LeaveGroup,
Post,
routes::groups::LEAVE,
Ack
);
group_call!(
AcceptGroupInvite,
Post,
routes::groups::ACCEPT,
GroupInfo
);
group_call!(
DeclineGroupInvite,
Post,
routes::groups::DECLINE,
Ack
);
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListGroupMembers {
pub group: GroupId,
pub page: PageRequest,
}
impl ListGroupMembers {
pub fn new(group: GroupId) -> Self {
Self { group, page: PageRequest::first() }
}
pub fn with_page(mut self, page: PageRequest) -> Self {
self.page = page;
self
}
}
impl HttpCall for ListGroupMembers {
type Payload = PageRequest;
type Response = Page<GroupMember>;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::groups::MEMBERS, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &PageRequest {
&self.page
}
fn path_params(&self) -> PathParams {
PathParams::new().with("group", self.group)
}
fn from_parts(params: &PathParams, page: PageRequest) -> Result<Self, ApiError> {
Ok(Self::new(params.id("group")?).with_page(page))
}
}
macro_rules! group_body_call {
($(#[$meta:meta])* $name:ident, $method:ident, $path:expr, $body:ident, $field:ident, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct $name {
/// The group.
pub group: GroupId,
pub $field: $body,
}
impl $name {
pub fn new(group: GroupId, $field: $body) -> Self {
Self { group, $field }
}
}
impl HttpCall for $name {
type Payload = $body;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::$method, $path, true);
const PAYLOAD: PayloadKind = PayloadKind::Json;
fn payload(&self) -> &$body {
&self.$field
}
fn path_params(&self) -> PathParams {
PathParams::new().with("group", self.group)
}
fn from_parts(params: &PathParams, $field: $body) -> Result<Self, ApiError> {
Ok(Self::new(params.id("group")?, $field))
}
}
};
}
group_body_call!(
EditGroup,
Patch,
routes::groups::ONE,
UpdateGroup,
update,
GroupInfo
);
group_body_call!(
InviteToGroup,
Post,
routes::groups::GROUP_INVITES,
Invitee,
invitee,
Ack
);
group_body_call!(
TransferGroup,
Post,
routes::groups::TRANSFER,
Invitee,
to,
Ack
);
macro_rules! member_call {
($(#[$meta:meta])* $name:ident, $method:ident, $path:expr, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {
/// The group.
pub group: GroupId,
pub user: UserId,
}
impl $name {
pub fn new(group: GroupId, user: UserId) -> Self {
Self { group, user }
}
}
impl HttpCall for $name {
type Payload = NoPayload;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::$method, $path, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("group", self.group).with("user", self.user)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("group")?, params.id("user")?))
}
}
};
}
member_call!(
RevokeGroupInvite,
Delete,
routes::groups::INVITE,
Ack
);
member_call!(
KickMember,
Delete,
routes::groups::MEMBER,
Ack
);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SetMemberRole {
pub group: GroupId,
pub user: UserId,
pub change: RoleChange,
}
impl SetMemberRole {
pub fn new(group: GroupId, user: UserId, role: GroupRole) -> Self {
Self { group, user, change: RoleChange::new(role) }
}
}
impl HttpCall for SetMemberRole {
type Payload = RoleChange;
type Response = Ack;
const ROUTE: Route = Route::new(HttpMethod::Put, routes::groups::ROLE, true);
const PAYLOAD: PayloadKind = PayloadKind::Json;
fn payload(&self) -> &RoleChange {
&self.change
}
fn path_params(&self) -> PathParams {
PathParams::new().with("group", self.group).with("user", self.user)
}
fn from_parts(params: &PathParams, change: RoleChange) -> Result<Self, ApiError> {
Ok(Self::new(params.id("group")?, params.id("user")?, change.role))
}
}
}
pub use calls::{
AcceptGroupInvite, DeclineGroupInvite, DeleteGroup, EditGroup, GetGroup, InviteToGroup, JoinGroup, KickMember, LeaveGroup, ListGroupInvites,
ListGroupMembers, ListGroups, MyGroups, RevokeGroupInvite, SetMemberRole, TransferGroup,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_and_descriptions() {
assert!(group_name_problem("Night Owls").is_none());
assert!(group_name_problem("ab").is_some() && group_name_problem(&"x".repeat(MAX_GROUP_NAME_CHARS + 1)).is_some());
assert!(group_name_problem(" abc ").is_none(), "trimmed");
assert!(group_name_problem("a\u{202E}bc").is_some());
assert!(group_name_problem("ab\ncd").is_some());
assert!(description_problem("").is_none() && description_problem("We play\nat night").is_none());
assert!(description_problem(&"é".repeat(MAX_DESCRIPTION_CHARS + 1)).is_some());
}
#[test]
fn json_and_rules() {
let create = CreateGroup::new("Night Owls").open().with_metadata(serde_json::json!({"tag": "NO"}));
assert_eq!(serde_json::to_string(&create).ok().as_deref(), Some(r#"{"name":"Night Owls","open":true,"metadata":{"tag":"NO"}}"#));
assert!(create.validate().is_ok());
assert!(CreateGroup::new("x").validate().is_err());
assert_eq!(serde_json::from_str::<CreateGroup>(r#"{"name":"abc"}"#).ok().map(|c| c.open), Some(false));
assert!(UpdateGroup::new().with_description("").validate().is_ok());
assert!(UpdateGroup::new().with_name("x").validate().is_err());
let clear = serde_json::from_str::<UpdateGroup>(r#"{"metadata":null}"#).ok().and_then(|u| u.metadata);
assert_eq!(clear, Some(Value::Null), "a present null clears the metadata");
assert_eq!(serde_json::from_str::<UpdateGroup>("{}").ok().map(|u| u.metadata), Some(None));
assert_eq!(serde_json::to_string(&UpdateGroup::new().with_metadata(Value::Null)).ok().as_deref(), Some(r#"{"metadata":null}"#));
let info = GroupInfo::new(GroupId(5), "Night Owls", 2, 100, UnixMillis(1)).with_owner(UserId(42)).with_role(GroupRole::Admin);
assert_eq!(
serde_json::to_string(&info).ok().as_deref(),
Some(r#"{"id":5,"name":"Night Owls","open":false,"owner":42,"members":2,"max_members":100,"created_at":1,"role":"admin"}"#)
);
assert_eq!(serde_json::from_str::<GroupRole>(r#""officer""#).ok(), Some(GroupRole::Unknown));
assert_eq!(serde_json::to_string(&RoleChange::new(GroupRole::Member)).ok().as_deref(), Some(r#"{"role":"member"}"#));
assert_eq!(GroupQuery::starting_with("ni").with_limit(5).page().limit, Some(5));
}
}