use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::control::{Page, SortOrder};
use crate::Error;
#[derive(Clone)]
pub struct TeamsClient {
client: Client,
organization_id: String,
}
impl TeamsClient {
pub(crate) fn new(client: Client, organization_id: impl Into<String>) -> Self {
Self {
client,
organization_id: organization_id.into(),
}
}
pub fn organization_id(&self) -> &str {
&self.organization_id
}
pub fn list(&self) -> ListTeamsRequest {
ListTeamsRequest {
client: self.client.clone(),
organization_id: self.organization_id.clone(),
limit: None,
cursor: None,
sort: None,
}
}
#[cfg(feature = "rest")]
pub async fn create(&self, request: CreateTeamRequest) -> Result<TeamInfo, Error> {
let path = format!("/control/v1/organizations/{}/teams", self.organization_id);
self.client.inner().control_post(&path, &request).await
}
#[cfg(not(feature = "rest"))]
pub async fn create(&self, _request: CreateTeamRequest) -> Result<TeamInfo, Error> {
Err(Error::configuration(
"REST feature is required for control API",
))
}
#[cfg(feature = "rest")]
pub async fn get(&self, team_id: impl Into<String>) -> Result<TeamInfo, Error> {
let path = format!(
"/control/v1/organizations/{}/teams/{}",
self.organization_id,
team_id.into()
);
self.client.inner().control_get(&path).await
}
#[cfg(not(feature = "rest"))]
pub async fn get(&self, _team_id: impl Into<String>) -> Result<TeamInfo, Error> {
Err(Error::configuration(
"REST feature is required for control API",
))
}
#[cfg(feature = "rest")]
pub async fn update(
&self,
team_id: impl Into<String>,
request: UpdateTeamRequest,
) -> Result<TeamInfo, Error> {
let path = format!(
"/control/v1/organizations/{}/teams/{}",
self.organization_id,
team_id.into()
);
self.client.inner().control_patch(&path, &request).await
}
#[cfg(not(feature = "rest"))]
pub async fn update(
&self,
_team_id: impl Into<String>,
_request: UpdateTeamRequest,
) -> Result<TeamInfo, Error> {
Err(Error::configuration(
"REST feature is required for control API",
))
}
#[cfg(feature = "rest")]
pub async fn delete(&self, team_id: impl Into<String>) -> Result<(), Error> {
let path = format!(
"/control/v1/organizations/{}/teams/{}",
self.organization_id,
team_id.into()
);
self.client.inner().control_delete(&path).await
}
#[cfg(not(feature = "rest"))]
pub async fn delete(&self, _team_id: impl Into<String>) -> Result<(), Error> {
Err(Error::configuration(
"REST feature is required for control API",
))
}
#[cfg(feature = "rest")]
pub async fn add_member(
&self,
team_id: impl Into<String>,
user_id: impl Into<String>,
) -> Result<(), Error> {
#[derive(serde::Serialize)]
struct AddMemberBody {
user_id: String,
}
let path = format!(
"/control/v1/organizations/{}/teams/{}/members",
self.organization_id,
team_id.into()
);
let body = AddMemberBody {
user_id: user_id.into(),
};
self.client
.inner()
.control_post::<_, ()>(&path, &body)
.await
}
#[cfg(not(feature = "rest"))]
pub async fn add_member(
&self,
_team_id: impl Into<String>,
_user_id: impl Into<String>,
) -> Result<(), Error> {
Err(Error::configuration(
"REST feature is required for control API",
))
}
#[cfg(feature = "rest")]
pub async fn remove_member(
&self,
team_id: impl Into<String>,
user_id: impl Into<String>,
) -> Result<(), Error> {
let path = format!(
"/control/v1/organizations/{}/teams/{}/members/{}",
self.organization_id,
team_id.into(),
user_id.into()
);
self.client.inner().control_delete(&path).await
}
#[cfg(not(feature = "rest"))]
pub async fn remove_member(
&self,
_team_id: impl Into<String>,
_user_id: impl Into<String>,
) -> Result<(), Error> {
Err(Error::configuration(
"REST feature is required for control API",
))
}
pub fn list_members(&self, team_id: impl Into<String>) -> ListTeamMembersRequest {
ListTeamMembersRequest {
client: self.client.clone(),
organization_id: self.organization_id.clone(),
team_id: team_id.into(),
limit: None,
cursor: None,
}
}
}
impl std::fmt::Debug for TeamsClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TeamsClient")
.field("organization_id", &self.organization_id)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamInfo {
pub id: String,
pub organization_id: String,
pub name: String,
pub description: Option<String>,
pub member_count: u32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamMemberInfo {
pub user_id: String,
pub email: String,
pub name: Option<String>,
pub role: TeamRole,
pub joined_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TeamRole {
Owner,
Admin,
#[default]
Member,
}
impl std::fmt::Display for TeamRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TeamRole::Owner => write!(f, "owner"),
TeamRole::Admin => write!(f, "admin"),
TeamRole::Member => write!(f, "member"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreateTeamRequest {
pub name: String,
pub description: Option<String>,
}
impl CreateTeamRequest {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
}
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateTeamRequest {
pub name: Option<String>,
pub description: Option<String>,
}
impl UpdateTeamRequest {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
}
pub struct ListTeamsRequest {
client: Client,
organization_id: String,
limit: Option<usize>,
cursor: Option<String>,
sort: Option<SortOrder>,
}
impl ListTeamsRequest {
#[must_use]
pub fn limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
#[must_use]
pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
self.cursor = Some(cursor.into());
self
}
#[must_use]
pub fn sort(mut self, order: SortOrder) -> Self {
self.sort = Some(order);
self
}
#[cfg(feature = "rest")]
async fn execute(self) -> Result<Page<TeamInfo>, Error> {
let mut path = format!("/control/v1/organizations/{}/teams", self.organization_id);
let mut query_parts = Vec::new();
if let Some(limit) = self.limit {
query_parts.push(format!("limit={}", limit));
}
if let Some(cursor) = &self.cursor {
query_parts.push(format!("cursor={}", urlencoding::encode(cursor)));
}
if let Some(sort) = &self.sort {
query_parts.push(format!("sort={}", sort.as_str()));
}
if !query_parts.is_empty() {
path.push('?');
path.push_str(&query_parts.join("&"));
}
self.client.inner().control_get(&path).await
}
#[cfg(not(feature = "rest"))]
async fn execute(self) -> Result<Page<TeamInfo>, Error> {
Err(Error::configuration(
"REST feature is required for control API",
))
}
}
impl std::future::IntoFuture for ListTeamsRequest {
type Output = Result<Page<TeamInfo>, Error>;
type IntoFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(self.execute())
}
}
pub struct ListTeamMembersRequest {
client: Client,
organization_id: String,
team_id: String,
limit: Option<usize>,
cursor: Option<String>,
}
impl ListTeamMembersRequest {
#[must_use]
pub fn limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
#[must_use]
pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
self.cursor = Some(cursor.into());
self
}
#[cfg(feature = "rest")]
async fn execute(self) -> Result<Page<TeamMemberInfo>, Error> {
let mut path = format!(
"/control/v1/organizations/{}/teams/{}/members",
self.organization_id, self.team_id
);
let mut query_parts = Vec::new();
if let Some(limit) = self.limit {
query_parts.push(format!("limit={}", limit));
}
if let Some(cursor) = &self.cursor {
query_parts.push(format!("cursor={}", urlencoding::encode(cursor)));
}
if !query_parts.is_empty() {
path.push('?');
path.push_str(&query_parts.join("&"));
}
self.client.inner().control_get(&path).await
}
#[cfg(not(feature = "rest"))]
async fn execute(self) -> Result<Page<TeamMemberInfo>, Error> {
Err(Error::configuration(
"REST feature is required for control API",
))
}
}
impl std::future::IntoFuture for ListTeamMembersRequest {
type Output = Result<Page<TeamMemberInfo>, Error>;
type IntoFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(self.execute())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::BearerCredentialsConfig;
use crate::transport::mock::MockTransport;
use std::sync::Arc;
async fn create_test_client() -> Client {
let mock_transport = Arc::new(MockTransport::new());
Client::builder()
.url("https://api.example.com")
.credentials(BearerCredentialsConfig::new("test"))
.build_with_transport(mock_transport)
.await
.unwrap()
}
#[test]
fn test_team_role() {
assert_eq!(TeamRole::default(), TeamRole::Member);
assert_eq!(TeamRole::Owner.to_string(), "owner");
assert_eq!(TeamRole::Admin.to_string(), "admin");
assert_eq!(TeamRole::Member.to_string(), "member");
}
#[test]
fn test_create_team_request() {
let req = CreateTeamRequest::new("Engineering").with_description("Backend team");
assert_eq!(req.name, "Engineering");
assert_eq!(req.description, Some("Backend team".to_string()));
}
#[test]
fn test_update_team_request() {
let req = UpdateTeamRequest::new()
.with_name("New Name")
.with_description("New description");
assert_eq!(req.name, Some("New Name".to_string()));
assert_eq!(req.description, Some("New description".to_string()));
}
#[tokio::test]
async fn test_teams_client_accessors() {
let client = create_test_client().await;
let teams = TeamsClient::new(client, "org_test");
assert_eq!(teams.organization_id(), "org_test");
}
#[tokio::test]
async fn test_teams_client_debug() {
let client = create_test_client().await;
let teams = TeamsClient::new(client, "org_test");
let debug = format!("{:?}", teams);
assert!(debug.contains("TeamsClient"));
assert!(debug.contains("org_test"));
}
#[tokio::test]
async fn test_list_teams_request_builders() {
let client = create_test_client().await;
let teams = TeamsClient::new(client, "org_test");
let _request = teams
.list()
.limit(50)
.cursor("cursor_xyz")
.sort(SortOrder::Descending);
}
#[tokio::test]
async fn test_list_team_members_request_builders() {
let client = create_test_client().await;
let teams = TeamsClient::new(client, "org_test");
let _request = teams
.list_members("team_abc123")
.limit(50)
.cursor("cursor_xyz");
}
#[tokio::test]
async fn test_teams_client_clone() {
let client = create_test_client().await;
let teams = TeamsClient::new(client, "org_test");
let cloned = teams.clone();
assert_eq!(cloned.organization_id(), "org_test");
}
#[test]
fn test_team_info_serde() {
let json = r#"{
"id": "team_abc123",
"organization_id": "org_test",
"name": "Engineering",
"description": "Backend team",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"member_count": 5
}"#;
let team: TeamInfo = serde_json::from_str(json).unwrap();
assert_eq!(team.id, "team_abc123");
assert_eq!(team.name, "Engineering");
assert_eq!(team.description, Some("Backend team".to_string()));
assert_eq!(team.member_count, 5);
}
#[test]
fn test_team_info_clone() {
let team = TeamInfo {
id: "team_123".to_string(),
organization_id: "org_123".to_string(),
name: "Test Team".to_string(),
description: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
member_count: 0,
};
let cloned = team.clone();
assert_eq!(cloned.id, "team_123");
assert_eq!(cloned.name, "Test Team");
}
#[test]
fn test_team_member_info_serde() {
let json = r#"{
"user_id": "user_abc123",
"email": "test@example.com",
"name": "Alice",
"role": "admin",
"joined_at": "2024-01-01T00:00:00Z"
}"#;
let member: TeamMemberInfo = serde_json::from_str(json).unwrap();
assert_eq!(member.user_id, "user_abc123");
assert_eq!(member.email, "test@example.com");
assert_eq!(member.role, TeamRole::Admin);
}
#[test]
fn test_team_member_info_clone() {
let member = TeamMemberInfo {
user_id: "user_123".to_string(),
email: "test@test.com".to_string(),
name: Some("Test".to_string()),
role: TeamRole::Owner,
joined_at: chrono::Utc::now(),
};
let cloned = member.clone();
assert_eq!(cloned.user_id, "user_123");
assert_eq!(cloned.role, TeamRole::Owner);
}
#[test]
fn test_team_role_serde() {
let roles = vec![
(TeamRole::Owner, "\"owner\""),
(TeamRole::Admin, "\"admin\""),
(TeamRole::Member, "\"member\""),
];
for (role, expected) in roles {
let json = serde_json::to_string(&role).unwrap();
assert_eq!(json, expected);
let parsed: TeamRole = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, role);
}
}
#[test]
fn test_create_team_request_clone() {
let req = CreateTeamRequest::new("Test").with_description("Desc");
let cloned = req.clone();
assert_eq!(cloned.name, "Test");
assert_eq!(cloned.description, Some("Desc".to_string()));
}
#[test]
fn test_update_team_request_clone() {
let req = UpdateTeamRequest::new().with_name("NewName");
let cloned = req.clone();
assert_eq!(cloned.name, Some("NewName".to_string()));
}
}
#[cfg(all(test, feature = "rest"))]
mod wiremock_tests {
use super::*;
use crate::auth::BearerCredentialsConfig;
use crate::Client;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn create_mock_client(server: &MockServer) -> Client {
Client::builder()
.url(server.uri())
.insecure()
.credentials(BearerCredentialsConfig::new("test_token"))
.build()
.await
.unwrap()
}
#[tokio::test]
async fn test_list_teams() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/control/v1/organizations/org_123/teams"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [
{
"id": "team_1",
"organization_id": "org_123",
"name": "Engineering",
"description": "Backend team",
"member_count": 5,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z"
}
],
"page_info": {
"has_next": false,
"next_cursor": null,
"total_count": 1
}
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let teams = TeamsClient::new(client, "org_123");
let result = teams.list().await;
assert!(result.is_ok());
let page = result.unwrap();
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].name, "Engineering");
}
#[tokio::test]
async fn test_list_teams_with_filters() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/control/v1/organizations/org_123/teams"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [],
"page_info": {
"has_next": false,
"next_cursor": null,
"total_count": 0
}
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let teams = TeamsClient::new(client, "org_123");
let result = teams
.list()
.limit(10)
.cursor("cursor_abc")
.sort(SortOrder::Descending)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_create_team() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/control/v1/organizations/org_123/teams"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "team_new",
"organization_id": "org_123",
"name": "New Team",
"description": "A new team",
"member_count": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let teams = TeamsClient::new(client, "org_123");
let result = teams
.create(CreateTeamRequest::new("New Team").with_description("A new team"))
.await;
assert!(result.is_ok());
let team = result.unwrap();
assert_eq!(team.name, "New Team");
}
#[tokio::test]
async fn test_get_team() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/control/v1/organizations/org_123/teams/team_abc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "team_abc",
"organization_id": "org_123",
"name": "Test Team",
"description": "Test",
"member_count": 3,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z"
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let teams = TeamsClient::new(client, "org_123");
let result = teams.get("team_abc").await;
assert!(result.is_ok());
let team = result.unwrap();
assert_eq!(team.id, "team_abc");
}
#[tokio::test]
async fn test_update_team() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/control/v1/organizations/org_123/teams/team_abc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "team_abc",
"organization_id": "org_123",
"name": "Updated Team",
"description": "Updated description",
"member_count": 3,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-03T00:00:00Z"
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let teams = TeamsClient::new(client, "org_123");
let result = teams
.update(
"team_abc",
UpdateTeamRequest::new().with_name("Updated Team"),
)
.await;
assert!(result.is_ok());
let team = result.unwrap();
assert_eq!(team.name, "Updated Team");
}
#[tokio::test]
async fn test_delete_team() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/control/v1/organizations/org_123/teams/team_abc"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let teams = TeamsClient::new(client, "org_123");
let result = teams.delete("team_abc").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_add_member() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/control/v1/organizations/org_123/teams/team_abc/members",
))
.respond_with(ResponseTemplate::new(200).set_body_string("null"))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let teams = TeamsClient::new(client, "org_123");
let result = teams.add_member("team_abc", "user_xyz").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_remove_member() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/control/v1/organizations/org_123/teams/team_abc/members/user_xyz",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let teams = TeamsClient::new(client, "org_123");
let result = teams.remove_member("team_abc", "user_xyz").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_list_members() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/control/v1/organizations/org_123/teams/team_abc/members",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [
{
"user_id": "user_1",
"email": "user1@example.com",
"name": "User One",
"role": "owner",
"joined_at": "2024-01-01T00:00:00Z"
},
{
"user_id": "user_2",
"email": "user2@example.com",
"role": "member",
"joined_at": "2024-01-02T00:00:00Z"
}
],
"page_info": {
"has_next": false,
"next_cursor": null,
"total_count": 2
}
})))
.mount(&server)
.await;
let client = create_mock_client(&server).await;
let teams = TeamsClient::new(client, "org_123");
let result = teams.list_members("team_abc").await;
assert!(result.is_ok());
let page = result.unwrap();
assert_eq!(page.items.len(), 2);
assert_eq!(page.items[0].role, TeamRole::Owner);
}
}