feat: Autorefresh (#44)
* feat: use gotrue from source instead of docker hub image * test: fix test due to gotrue upgrade * fix: update prod docker-compose * chore: cargo fmt --all * chore: cargo fmt --all * feat: autorefresh * test: add test case and auto refresh scenario
This commit is contained in:
parent
939ea29c3b
commit
7345da7c46
|
|
@ -4,7 +4,7 @@ WORKDIR /app
|
||||||
RUN apt update && apt install lld clang -y
|
RUN apt update && apt install lld clang -y
|
||||||
|
|
||||||
FROM chef as planner
|
FROM chef as planner
|
||||||
COPY .. .
|
COPY . .
|
||||||
# Compute a lock-like file for our project
|
# Compute a lock-like file for our project
|
||||||
RUN cargo chef prepare --recipe-path recipe.json
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ FROM chef as builder
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
# Build our project dependencies
|
# Build our project dependencies
|
||||||
RUN cargo chef cook --release --recipe-path recipe.json
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
COPY .. .
|
COPY . .
|
||||||
ENV SQLX_OFFLINE true
|
ENV SQLX_OFFLINE true
|
||||||
# Build the project
|
# Build the project
|
||||||
RUN cargo build --release --bin appflowy_cloud
|
RUN cargo build --release --bin appflowy_cloud
|
||||||
|
|
@ -55,7 +55,7 @@ services:
|
||||||
- APP__GOTRUE__JWT_SECRET=${GOTRUE_JWT_SECRET}
|
- APP__GOTRUE__JWT_SECRET=${GOTRUE_JWT_SECRET}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: appflowy_cloud:${BACKEND_VERSION:-latest}
|
image: appflowy_cloud:${BACKEND_VERSION:-latest}
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use gotrue_entity::OAuthProvider;
|
use gotrue_entity::OAuthProvider;
|
||||||
use gotrue_entity::OAuthURL;
|
use gotrue_entity::OAuthURL;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
|
|
@ -112,10 +114,11 @@ impl Client {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn profile(&self) -> Result<AFUserProfileView, AppError> {
|
pub async fn profile(&mut self) -> Result<AFUserProfileView, AppError> {
|
||||||
let url = format!("{}/api/user/profile", self.base_url);
|
let url = format!("{}/api/user/profile", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
.http_client_with_auth(Method::GET, &url)?
|
.http_client_with_auth(Method::GET, &url)
|
||||||
|
.await?
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
AppResponse::<AFUserProfileView>::from_response(resp)
|
AppResponse::<AFUserProfileView>::from_response(resp)
|
||||||
|
|
@ -123,10 +126,11 @@ impl Client {
|
||||||
.into_data()
|
.into_data()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn workspaces(&self) -> Result<AFWorkspaces, AppError> {
|
pub async fn workspaces(&mut self) -> Result<AFWorkspaces, AppError> {
|
||||||
let url = format!("{}/api/user/workspaces", self.base_url);
|
let url = format!("{}/api/user/workspaces", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
.http_client_with_auth(Method::GET, &url)?
|
.http_client_with_auth(Method::GET, &url)
|
||||||
|
.await?
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
AppResponse::<AFWorkspaces>::from_response(resp)
|
AppResponse::<AFWorkspaces>::from_response(resp)
|
||||||
|
|
@ -169,10 +173,11 @@ impl Client {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sign_out(&self) -> Result<(), AppError> {
|
pub async fn sign_out(&mut self) -> Result<(), AppError> {
|
||||||
let url = format!("{}/api/user/sign_out", self.base_url);
|
let url = format!("{}/api/user/sign_out", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
.http_client_with_auth(Method::POST, &url)?
|
.http_client_with_auth(Method::POST, &url)
|
||||||
|
.await?
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
AppResponse::<()>::from_response(resp).await?.into_error()?;
|
AppResponse::<()>::from_response(resp).await?.into_error()?;
|
||||||
|
|
@ -186,7 +191,8 @@ impl Client {
|
||||||
"password": password,
|
"password": password,
|
||||||
});
|
});
|
||||||
let resp = self
|
let resp = self
|
||||||
.http_client_with_auth(Method::POST, &url)?
|
.http_client_with_auth(Method::POST, &url)
|
||||||
|
.await?
|
||||||
.json(&payload)
|
.json(&payload)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -199,30 +205,33 @@ impl Client {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_collab(&self, params: InsertCollabParams) -> Result<(), AppError> {
|
pub async fn create_collab(&mut self, params: InsertCollabParams) -> Result<(), AppError> {
|
||||||
let url = format!("{}/api/collab/", self.base_url);
|
let url = format!("{}/api/collab/", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
.http_client_with_auth(Method::POST, &url)?
|
.http_client_with_auth(Method::POST, &url)
|
||||||
|
.await?
|
||||||
.json(¶ms)
|
.json(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
AppResponse::<()>::from_response(resp).await?.into_error()
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_collab(&self, params: InsertCollabParams) -> Result<(), AppError> {
|
pub async fn update_collab(&mut self, params: InsertCollabParams) -> Result<(), AppError> {
|
||||||
let url = format!("{}/api/collab/", self.base_url);
|
let url = format!("{}/api/collab/", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
.http_client_with_auth(Method::PUT, &url)?
|
.http_client_with_auth(Method::PUT, &url)
|
||||||
|
.await?
|
||||||
.json(¶ms)
|
.json(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
AppResponse::<()>::from_response(resp).await?.into_error()
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_collab(&self, params: QueryCollabParams) -> Result<RawData, AppError> {
|
pub async fn get_collab(&mut self, params: QueryCollabParams) -> Result<RawData, AppError> {
|
||||||
let url = format!("{}/api/collab/", self.base_url);
|
let url = format!("{}/api/collab/", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
.http_client_with_auth(Method::GET, &url)?
|
.http_client_with_auth(Method::GET, &url)
|
||||||
|
.await?
|
||||||
.json(¶ms)
|
.json(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -231,10 +240,11 @@ impl Client {
|
||||||
.into_data()
|
.into_data()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_collab(&self, params: DeleteCollabParams) -> Result<(), AppError> {
|
pub async fn delete_collab(&mut self, params: DeleteCollabParams) -> Result<(), AppError> {
|
||||||
let url = format!("{}/api/collab/", self.base_url);
|
let url = format!("{}/api/collab/", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
.http_client_with_auth(Method::DELETE, &url)?
|
.http_client_with_auth(Method::DELETE, &url)
|
||||||
|
.await?
|
||||||
.json(¶ms)
|
.json(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -254,17 +264,34 @@ impl Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn http_client_with_auth(&self, method: Method, url: &str) -> Result<RequestBuilder, AppError> {
|
async fn http_client_with_auth(
|
||||||
match &self.token {
|
&mut self,
|
||||||
None => Err(ErrorCode::NotLoggedIn.into()),
|
method: Method,
|
||||||
Some(t) => {
|
url: &str,
|
||||||
let request_builder = self
|
) -> Result<RequestBuilder, AppError> {
|
||||||
.http_client
|
let token = self.token().ok_or(ErrorCode::NotLoggedIn)?;
|
||||||
.request(method, url)
|
|
||||||
.bearer_auth(&t.access_token);
|
// Refresh token if it's about to expire
|
||||||
Ok(request_builder)
|
let time_now_sec = SystemTime::now()
|
||||||
},
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
if time_now_sec + 60 > token.expires_at {
|
||||||
|
// Add 60 seconds buffer
|
||||||
|
self.refresh().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let access_token = self
|
||||||
|
.token()
|
||||||
|
.ok_or(ErrorCode::NotLoggedIn)?
|
||||||
|
.access_token
|
||||||
|
.as_str();
|
||||||
|
|
||||||
|
let request_builder = self
|
||||||
|
.http_client
|
||||||
|
.request(method, url)
|
||||||
|
.bearer_auth(access_token);
|
||||||
|
Ok(request_builder)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pub async fn change_password(
|
// pub async fn change_password(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::num::ParseIntError;
|
use std::num::ParseIntError;
|
||||||
|
use std::time::SystemTimeError;
|
||||||
use std::{borrow::Cow, str};
|
use std::{borrow::Cow, str};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -133,3 +134,9 @@ impl From<ParseIntError> for AppError {
|
||||||
AppError::new(ErrorCode::InvalidUrl, value.to_string())
|
AppError::new(ErrorCode::InvalidUrl, value.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<SystemTimeError> for AppError {
|
||||||
|
fn from(value: SystemTimeError) -> Self {
|
||||||
|
AppError::new(ErrorCode::Unhandled, value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use gotrue_entity::AccessTokenResponse;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
client::utils::{REGISTERED_EMAIL, REGISTERED_PASSWORD, REGISTERED_USER_MUTEX},
|
client::utils::{REGISTERED_EMAIL, REGISTERED_PASSWORD, REGISTERED_USER_MUTEX},
|
||||||
client_api_client,
|
client_api_client,
|
||||||
|
|
@ -11,5 +15,39 @@ async fn refresh_success() {
|
||||||
let password = ®ISTERED_PASSWORD;
|
let password = ®ISTERED_PASSWORD;
|
||||||
let mut c = client_api_client();
|
let mut c = client_api_client();
|
||||||
c.sign_in_password(email, password).await.unwrap();
|
c.sign_in_password(email, password).await.unwrap();
|
||||||
|
let old_token = c.token().unwrap().access_token.to_owned();
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
c.refresh().await.unwrap();
|
c.refresh().await.unwrap();
|
||||||
|
let new_token = c.token().unwrap().access_token.to_owned();
|
||||||
|
assert_ne!(old_token, new_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refresh_trigger() {
|
||||||
|
let _guard = REGISTERED_USER_MUTEX.lock().await;
|
||||||
|
|
||||||
|
let email = ®ISTERED_EMAIL;
|
||||||
|
let password = ®ISTERED_PASSWORD;
|
||||||
|
|
||||||
|
let mut c = client_api_client();
|
||||||
|
|
||||||
|
c.sign_in_password(email, password).await.unwrap();
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
|
let token = c.token().unwrap();
|
||||||
|
let old_access_token = token.access_token.to_owned();
|
||||||
|
|
||||||
|
// Set the token to be expired
|
||||||
|
unsafe {
|
||||||
|
let token_mut = token as *const AccessTokenResponse as *mut AccessTokenResponse;
|
||||||
|
token_mut.as_mut().unwrap().expires_at = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64
|
||||||
|
};
|
||||||
|
|
||||||
|
// querying that requires auth should trigger a refresh
|
||||||
|
let _workspaces = c.workspaces().await.unwrap();
|
||||||
|
let new_token = c.token().unwrap().access_token.to_owned();
|
||||||
|
|
||||||
|
assert_ne!(old_access_token, new_token);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use crate::client_api_client;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn sign_out_but_not_sign_in() {
|
async fn sign_out_but_not_sign_in() {
|
||||||
let c = client_api_client();
|
let mut c = client_api_client();
|
||||||
let res = c.sign_out().await;
|
let res = c.sign_out().await;
|
||||||
assert!(res.is_err());
|
assert!(res.is_err());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use client_api::Client;
|
||||||
|
|
||||||
mod storage_test;
|
mod storage_test;
|
||||||
|
|
||||||
pub(crate) async fn workspace_id_from_client(c: &Client) -> String {
|
pub(crate) async fn workspace_id_from_client(c: &mut Client) -> String {
|
||||||
c.workspaces()
|
c.workspaces()
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ async fn success_insert_collab_test() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let raw_data = "hello world".to_string().as_bytes().to_vec();
|
let raw_data = "hello world".to_string().as_bytes().to_vec();
|
||||||
let workspace_id = workspace_id_from_client(&c).await;
|
let workspace_id = workspace_id_from_client(&mut c).await;
|
||||||
let object_id = Uuid::new_v4().to_string();
|
let object_id = Uuid::new_v4().to_string();
|
||||||
c.create_collab(InsertCollabParams::new(
|
c.create_collab(InsertCollabParams::new(
|
||||||
1,
|
1,
|
||||||
|
|
@ -50,7 +50,7 @@ async fn success_delete_collab_test() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let raw_data = "hello world".to_string().as_bytes().to_vec();
|
let raw_data = "hello world".to_string().as_bytes().to_vec();
|
||||||
let workspace_id = workspace_id_from_client(&c).await;
|
let workspace_id = workspace_id_from_client(&mut c).await;
|
||||||
let object_id = Uuid::new_v4().to_string();
|
let object_id = Uuid::new_v4().to_string();
|
||||||
c.create_collab(InsertCollabParams::new(
|
c.create_collab(InsertCollabParams::new(
|
||||||
1,
|
1,
|
||||||
|
|
@ -88,7 +88,7 @@ async fn fail_insert_collab_with_empty_payload_test() {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let workspace_id = workspace_id_from_client(&c).await;
|
let workspace_id = workspace_id_from_client(&mut c).await;
|
||||||
let error = c
|
let error = c
|
||||||
.create_collab(InsertCollabParams::new(
|
.create_collab(InsertCollabParams::new(
|
||||||
1,
|
1,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ use storage::collab::FLUSH_PER_UPDATE;
|
||||||
async fn realtime_write_collab_test() {
|
async fn realtime_write_collab_test() {
|
||||||
let object_id = uuid::Uuid::new_v4().to_string();
|
let object_id = uuid::Uuid::new_v4().to_string();
|
||||||
let collab_type = CollabType::Document;
|
let collab_type = CollabType::Document;
|
||||||
let test_client = TestClient::new(&object_id, collab_type.clone()).await;
|
let mut test_client = TestClient::new(&object_id, collab_type.clone()).await;
|
||||||
|
|
||||||
// Edit the collab
|
// Edit the collab
|
||||||
for i in 0..=5 {
|
for i in 0..=5 {
|
||||||
|
|
@ -28,7 +28,7 @@ async fn realtime_write_collab_test() {
|
||||||
test_client.disconnect().await;
|
test_client.disconnect().await;
|
||||||
|
|
||||||
assert_collab_json(
|
assert_collab_json(
|
||||||
&test_client.api_client,
|
&mut test_client.api_client,
|
||||||
&object_id,
|
&object_id,
|
||||||
&collab_type,
|
&collab_type,
|
||||||
3,
|
3,
|
||||||
|
|
@ -48,7 +48,7 @@ async fn realtime_write_collab_test() {
|
||||||
async fn one_direction_peer_sync_test() {
|
async fn one_direction_peer_sync_test() {
|
||||||
let object_id = uuid::Uuid::new_v4().to_string();
|
let object_id = uuid::Uuid::new_v4().to_string();
|
||||||
let collab_type = CollabType::Document;
|
let collab_type = CollabType::Document;
|
||||||
let client_1 = TestClient::new(&object_id, collab_type.clone()).await;
|
let mut client_1 = TestClient::new(&object_id, collab_type.clone()).await;
|
||||||
let client_2 = TestClient::new(&object_id, collab_type.clone()).await;
|
let client_2 = TestClient::new(&object_id, collab_type.clone()).await;
|
||||||
|
|
||||||
// Edit the collab from client 1 and then the server will broadcast to client 2
|
// Edit the collab from client 1 and then the server will broadcast to client 2
|
||||||
|
|
@ -58,7 +58,7 @@ async fn one_direction_peer_sync_test() {
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_collab_json(
|
assert_collab_json(
|
||||||
&client_1.api_client,
|
&mut client_1.api_client,
|
||||||
&object_id,
|
&object_id,
|
||||||
&collab_type,
|
&collab_type,
|
||||||
5,
|
5,
|
||||||
|
|
@ -129,13 +129,13 @@ async fn multiple_collab_edit_test() {
|
||||||
let collab_type = CollabType::Document;
|
let collab_type = CollabType::Document;
|
||||||
|
|
||||||
let object_id_1 = uuid::Uuid::new_v4().to_string();
|
let object_id_1 = uuid::Uuid::new_v4().to_string();
|
||||||
let client_1 = TestClient::new(&object_id_1, collab_type.clone()).await;
|
let mut client_1 = TestClient::new(&object_id_1, collab_type.clone()).await;
|
||||||
|
|
||||||
let object_id_2 = uuid::Uuid::new_v4().to_string();
|
let object_id_2 = uuid::Uuid::new_v4().to_string();
|
||||||
let client_2 = TestClient::new(&object_id_2, collab_type.clone()).await;
|
let mut client_2 = TestClient::new(&object_id_2, collab_type.clone()).await;
|
||||||
|
|
||||||
let object_id_3 = uuid::Uuid::new_v4().to_string();
|
let object_id_3 = uuid::Uuid::new_v4().to_string();
|
||||||
let client_3 = TestClient::new(&object_id_3, collab_type.clone()).await;
|
let mut client_3 = TestClient::new(&object_id_3, collab_type.clone()).await;
|
||||||
|
|
||||||
client_1.collab.lock().insert("title", "I am client 1");
|
client_1.collab.lock().insert("title", "I am client 1");
|
||||||
client_2.collab.lock().insert("title", "I am client 2");
|
client_2.collab.lock().insert("title", "I am client 2");
|
||||||
|
|
@ -143,7 +143,7 @@ async fn multiple_collab_edit_test() {
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
assert_collab_json(
|
assert_collab_json(
|
||||||
&client_1.api_client,
|
&mut client_1.api_client,
|
||||||
&object_id_1,
|
&object_id_1,
|
||||||
&collab_type,
|
&collab_type,
|
||||||
3,
|
3,
|
||||||
|
|
@ -154,7 +154,7 @@ async fn multiple_collab_edit_test() {
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert_collab_json(
|
assert_collab_json(
|
||||||
&client_2.api_client,
|
&mut client_2.api_client,
|
||||||
&object_id_2,
|
&object_id_2,
|
||||||
&collab_type,
|
&collab_type,
|
||||||
3,
|
3,
|
||||||
|
|
@ -164,7 +164,7 @@ async fn multiple_collab_edit_test() {
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_collab_json(
|
assert_collab_json(
|
||||||
&client_3.api_client,
|
&mut client_3.api_client,
|
||||||
&object_id_3,
|
&object_id_3,
|
||||||
&collab_type,
|
&collab_type,
|
||||||
3,
|
3,
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ impl TestClient {
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn assert_collab_json(
|
pub async fn assert_collab_json(
|
||||||
client: &client_api::Client,
|
client: &mut client_api::Client,
|
||||||
object_id: &str,
|
object_id: &str,
|
||||||
collab_type: &CollabType,
|
collab_type: &CollabType,
|
||||||
secs: u64,
|
secs: u64,
|
||||||
|
|
@ -141,7 +141,7 @@ pub async fn assert_collab_json(
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn get_collab_json_from_server(
|
pub async fn get_collab_json_from_server(
|
||||||
client: &client_api::Client,
|
client: &mut client_api::Client,
|
||||||
object_id: &str,
|
object_id: &str,
|
||||||
collab_type: CollabType,
|
collab_type: CollabType,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue