#![cfg_attr(docsrs, feature(doc_cfg))]
#[cfg(feature = "stream")]
use std::future::ready;
#[cfg(any(feature = "api", feature = "stream"))]
use std::sync::Arc;
mod auth;
mod events;
mod proto;
use crate::proto::social::mixi::application::{
r#const::v1::{LanguageCode, PostPublishingType},
model::v1::PostMask,
service::application_api::v1::{
self as application_api_v1, AddStampToPostRequest, CreatePostRequest, DeletePostRequest,
GetStampsRequest, InitiatePostMediaUploadRequest, SendChatMessageRequest,
},
};
#[cfg(feature = "api")]
use crate::proto::social::mixi::application::service::application_api::v1::{
AddStampToPostResponse, CreatePostResponse, DeletePostResponse, GetPostMediaStatusRequest,
GetPostMediaStatusResponse, GetPostsRequest, GetPostsResponse, GetStampsResponse,
GetUsersRequest, GetUsersResponse, InitiatePostMediaUploadResponse,
SendChatMessageResponse,
application_service_client::ApplicationServiceClient as RawApiClient,
};
#[cfg(feature = "stream")]
use crate::proto::social::mixi::application::service::application_stream::v1::application_service_client::ApplicationServiceClient as RawStreamClient;
#[cfg(feature = "api")]
use crate::auth::AuthError as AuthLayerError;
#[cfg(any(feature = "api", feature = "stream"))]
use crate::auth::Authenticator as AuthenticatorTrait;
#[cfg(feature = "stream")]
use crate::events::StreamWatcher as EventStreamWatcher;
use thiserror::Error;
#[cfg(feature = "api")]
use tonic::{
IntoRequest, Request, Response, Status,
body::Body as TransportBody,
client::GrpcService,
codegen::{Body, Bytes as TonicBytes, StdError},
};
#[cfg(any(feature = "api", feature = "stream"))]
use tonic::transport::{Channel, Endpoint, Error as TransportError};
#[cfg(feature = "api")]
pub const DEFAULT_API_ENDPOINT: &str = "https://application-api.mixi.social";
#[cfg(feature = "stream")]
pub const DEFAULT_STREAM_ENDPOINT: &str = "https://application-stream.mixi.social";
pub use crate::auth::{AuthError, Authenticator};
#[cfg(feature = "client-credentials-auth")]
pub use crate::auth::{AuthenticatorBuilder, ClientCredentialsAuthenticator, DEFAULT_TOKEN_URL};
#[cfg(any(feature = "stream", feature = "webhook-core", feature = "testutil"))]
pub use crate::events::BoxError;
#[cfg(any(feature = "stream", feature = "webhook-core", feature = "testutil"))]
pub use crate::events::EventHandler;
#[cfg(feature = "webhook-axum")]
pub use crate::events::WebhookServer;
#[cfg(feature = "testutil")]
pub use crate::events::testutil;
#[cfg(feature = "webhook-core")]
pub use crate::events::{DispatchMode, WebhookError, WebhookService};
#[cfg(feature = "stream")]
pub use crate::events::{StreamWatcher, StreamWatcherError};
pub use crate::proto::{FILE_DESCRIPTOR_SET, social};
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum RequestValidationError {
#[error("in_reply_to_post_id and quoted_post_id cannot both be set")]
ConflictingPostTargets,
#[error("media_id_list can contain at most 4 entries")]
TooManyMediaIds,
#[error("room_id must not be empty")]
EmptyRoomId,
#[error("post_id must not be empty")]
EmptyPostId,
#[error("stamp_id must not be empty")]
EmptyStampId,
#[error("send chat message requires text or media_id")]
MissingChatPayload,
#[error("content_type must not be empty")]
EmptyContentType,
#[error("data_size must be greater than zero")]
EmptyUploadSize,
#[error("media_type must not be unspecified")]
UnspecifiedUploadType,
}
#[cfg(any(feature = "api", feature = "stream"))]
#[derive(Debug, Error)]
pub enum ClientBuildError {
#[error("channel and endpoint are mutually exclusive")]
ConflictingTransport,
#[error("failed to configure transport endpoint")]
Transport(#[source] TransportError),
}
#[cfg(feature = "api")]
pub struct ApiClient<T> {
authenticator: Arc<dyn AuthenticatorTrait>,
inner: RawApiClient<T>,
}
#[cfg(feature = "api")]
impl<T> ApiClient<T>
where
T: GrpcService<TransportBody> + Send + Sync,
T::Error: Into<StdError>,
T::Future: Send,
T::ResponseBody: Body<Data = TonicBytes> + Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + Send,
{
#[must_use]
pub fn new(inner: RawApiClient<T>, authenticator: Arc<dyn AuthenticatorTrait>) -> Self {
Self {
authenticator,
inner,
}
}
#[must_use]
pub const fn inner(&self) -> &RawApiClient<T> {
&self.inner
}
#[must_use]
pub const fn inner_mut(&mut self) -> &mut RawApiClient<T> {
&mut self.inner
}
#[must_use]
pub fn into_inner(self) -> RawApiClient<T> {
self.inner
}
pub async fn get_users(
&mut self,
request: impl IntoRequest<GetUsersRequest> + Send,
) -> Result<Response<GetUsersResponse>, Status> {
let request = self.authorize_request(request).await?;
self.inner.get_users(request).await
}
pub async fn get_posts(
&mut self,
request: impl IntoRequest<GetPostsRequest> + Send,
) -> Result<Response<GetPostsResponse>, Status> {
let request = self.authorize_request(request).await?;
self.inner.get_posts(request).await
}
pub async fn create_post(
&mut self,
request: impl IntoRequest<CreatePostRequest> + Send,
) -> Result<Response<CreatePostResponse>, Status> {
let request = self.authorize_request(request).await?;
self.inner.create_post(request).await
}
pub async fn delete_post(
&mut self,
request: impl IntoRequest<DeletePostRequest> + Send,
) -> Result<Response<DeletePostResponse>, Status> {
let request = self.authorize_request(request).await?;
self.inner.delete_post(request).await
}
pub async fn initiate_post_media_upload(
&mut self,
request: impl IntoRequest<InitiatePostMediaUploadRequest> + Send,
) -> Result<Response<InitiatePostMediaUploadResponse>, Status> {
let request = self.authorize_request(request).await?;
self.inner.initiate_post_media_upload(request).await
}
pub async fn get_post_media_status(
&mut self,
request: impl IntoRequest<GetPostMediaStatusRequest> + Send,
) -> Result<Response<GetPostMediaStatusResponse>, Status> {
let request = self.authorize_request(request).await?;
self.inner.get_post_media_status(request).await
}
pub async fn send_chat_message(
&mut self,
request: impl IntoRequest<SendChatMessageRequest> + Send,
) -> Result<Response<SendChatMessageResponse>, Status> {
let request = self.authorize_request(request).await?;
self.inner.send_chat_message(request).await
}
pub async fn get_stamps(
&mut self,
request: impl IntoRequest<GetStampsRequest> + Send,
) -> Result<Response<GetStampsResponse>, Status> {
let request = self.authorize_request(request).await?;
self.inner.get_stamps(request).await
}
pub async fn add_stamp_to_post(
&mut self,
request: impl IntoRequest<AddStampToPostRequest> + Send,
) -> Result<Response<AddStampToPostResponse>, Status> {
let request = self.authorize_request(request).await?;
self.inner.add_stamp_to_post(request).await
}
async fn authorize_request<R: Send>(
&self,
request: impl IntoRequest<R> + Send,
) -> Result<Request<R>, Status> {
let mut request = request.into_request();
self.authenticator
.authorize(request.metadata_mut())
.await
.map_err(|error| auth_error_to_status(&error))?;
Ok(request)
}
}
#[cfg(feature = "api")]
pub struct ApiClientBuilder {
authenticator: Arc<dyn AuthenticatorTrait>,
channel: Option<Channel>,
endpoint: Option<String>,
}
#[cfg(feature = "api")]
impl ApiClientBuilder {
#[must_use]
pub fn new(authenticator: Arc<dyn AuthenticatorTrait>) -> Self {
Self {
authenticator,
channel: None,
endpoint: None,
}
}
#[must_use]
pub fn with_channel(mut self, channel: Channel) -> Self {
self.channel = Some(channel);
self
}
#[must_use]
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub async fn build(self) -> Result<ApiClient<Channel>, ClientBuildError> {
let channel = resolve_channel(self.channel, self.endpoint, DEFAULT_API_ENDPOINT).await?;
let raw_client = RawApiClient::new(channel);
Ok(ApiClient::new(raw_client, self.authenticator))
}
}
#[cfg(feature = "stream")]
pub struct StreamClientBuilder {
authenticator: Arc<dyn AuthenticatorTrait>,
channel: Option<Channel>,
endpoint: Option<String>,
}
#[cfg(feature = "stream")]
impl StreamClientBuilder {
#[must_use]
pub fn new(authenticator: Arc<dyn AuthenticatorTrait>) -> Self {
Self {
authenticator,
channel: None,
endpoint: None,
}
}
#[must_use]
pub fn with_channel(mut self, channel: Channel) -> Self {
self.channel = Some(channel);
self
}
#[must_use]
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub async fn build(self) -> Result<EventStreamWatcher, ClientBuildError> {
let watcher = match (self.channel, self.endpoint) {
(Some(_), Some(_)) => Err(ClientBuildError::ConflictingTransport),
(Some(channel), None) => {
let raw_client = RawStreamClient::new(channel);
Ok(EventStreamWatcher::new(raw_client, self.authenticator))
}
(None, endpoint) => {
let endpoint = Endpoint::new(resolve_endpoint(endpoint, DEFAULT_STREAM_ENDPOINT))
.map_err(ClientBuildError::Transport)?;
let raw_client = events::http_stream_client(endpoint.uri().clone());
Ok(EventStreamWatcher::new(raw_client, self.authenticator))
}
};
ready(watcher).await
}
}
#[derive(Clone, Debug)]
pub struct CreatePostRequestBuilder {
request: CreatePostRequest,
}
impl CreatePostRequestBuilder {
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self {
request: CreatePostRequest {
text: text.into(),
in_reply_to_post_id: None,
quoted_post_id: None,
media_id_list: Vec::new(),
post_mask: None,
publishing_type: None,
},
}
}
#[must_use]
pub fn in_reply_to_post_id(mut self, post_id: impl Into<String>) -> Self {
self.request.in_reply_to_post_id = Some(post_id.into());
self
}
#[must_use]
pub fn quoted_post_id(mut self, post_id: impl Into<String>) -> Self {
self.request.quoted_post_id = Some(post_id.into());
self
}
#[must_use]
pub fn push_media_id(mut self, media_id: impl Into<String>) -> Self {
self.request.media_id_list.push(media_id.into());
self
}
#[must_use]
pub fn media_ids<I, S>(mut self, media_ids: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.request.media_id_list = media_ids.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn post_mask(mut self, post_mask: PostMask) -> Self {
self.request.post_mask = Some(post_mask);
self
}
#[must_use]
pub const fn publishing_type(mut self, publishing_type: PostPublishingType) -> Self {
self.request.publishing_type = Some(publishing_type as i32);
self
}
pub fn build(self) -> Result<CreatePostRequest, RequestValidationError> {
if self.request.in_reply_to_post_id.is_some() && self.request.quoted_post_id.is_some() {
return Err(RequestValidationError::ConflictingPostTargets);
}
if self.request.media_id_list.len() > 4 {
return Err(RequestValidationError::TooManyMediaIds);
}
Ok(self.request)
}
}
#[derive(Clone, Debug)]
pub struct DeletePostRequestBuilder {
request: DeletePostRequest,
}
impl DeletePostRequestBuilder {
#[must_use]
pub fn new(post_id: impl Into<String>) -> Self {
Self {
request: DeletePostRequest {
post_id: post_id.into(),
},
}
}
#[must_use]
pub fn post_id(mut self, post_id: impl Into<String>) -> Self {
self.request.post_id = post_id.into();
self
}
pub fn build(self) -> Result<DeletePostRequest, RequestValidationError> {
if self.request.post_id.is_empty() {
return Err(RequestValidationError::EmptyPostId);
}
Ok(self.request)
}
}
#[derive(Clone, Debug)]
pub struct SendChatMessageRequestBuilder {
request: SendChatMessageRequest,
}
impl SendChatMessageRequestBuilder {
#[must_use]
pub fn new(room_id: impl Into<String>) -> Self {
Self {
request: SendChatMessageRequest {
room_id: room_id.into(),
text: None,
media_id: None,
},
}
}
#[must_use]
pub fn text(mut self, text: impl Into<String>) -> Self {
self.request.text = Some(text.into());
self
}
#[must_use]
pub fn media_id(mut self, media_id: impl Into<String>) -> Self {
self.request.media_id = Some(media_id.into());
self
}
pub fn build(self) -> Result<SendChatMessageRequest, RequestValidationError> {
if self.request.room_id.is_empty() {
return Err(RequestValidationError::EmptyRoomId);
}
if self.request.text.is_none() && self.request.media_id.is_none() {
return Err(RequestValidationError::MissingChatPayload);
}
Ok(self.request)
}
}
#[derive(Clone, Debug)]
pub struct InitiatePostMediaUploadRequestBuilder {
request: InitiatePostMediaUploadRequest,
}
impl InitiatePostMediaUploadRequestBuilder {
#[must_use]
pub fn new(
content_type: impl Into<String>,
data_size: u64,
media_type: application_api_v1::initiate_post_media_upload_request::Type,
) -> Self {
Self {
request: InitiatePostMediaUploadRequest {
content_type: content_type.into(),
data_size,
media_type: media_type as i32,
description: None,
},
}
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.request.description = Some(description.into());
self
}
pub fn build(self) -> Result<InitiatePostMediaUploadRequest, RequestValidationError> {
if self.request.content_type.is_empty() {
return Err(RequestValidationError::EmptyContentType);
}
if self.request.data_size == 0 {
return Err(RequestValidationError::EmptyUploadSize);
}
if self.request.media_type
== application_api_v1::initiate_post_media_upload_request::Type::Unspecified as i32
{
return Err(RequestValidationError::UnspecifiedUploadType);
}
Ok(self.request)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct GetStampsRequestBuilder {
request: GetStampsRequest,
}
impl GetStampsRequestBuilder {
#[must_use]
pub const fn new() -> Self {
Self {
request: GetStampsRequest {
official_stamp_language: None,
},
}
}
#[must_use]
pub const fn official_stamp_language(mut self, language: LanguageCode) -> Self {
self.request.official_stamp_language = Some(language as i32);
self
}
#[must_use]
pub const fn build(self) -> GetStampsRequest {
self.request
}
}
#[derive(Clone, Debug)]
pub struct AddStampToPostRequestBuilder {
request: AddStampToPostRequest,
}
impl AddStampToPostRequestBuilder {
#[must_use]
pub fn new(post_id: impl Into<String>, stamp_id: impl Into<String>) -> Self {
Self {
request: AddStampToPostRequest {
post_id: post_id.into(),
stamp_id: stamp_id.into(),
},
}
}
#[must_use]
pub fn post_id(mut self, post_id: impl Into<String>) -> Self {
self.request.post_id = post_id.into();
self
}
#[must_use]
pub fn stamp_id(mut self, stamp_id: impl Into<String>) -> Self {
self.request.stamp_id = stamp_id.into();
self
}
pub fn build(self) -> Result<AddStampToPostRequest, RequestValidationError> {
if self.request.post_id.is_empty() {
return Err(RequestValidationError::EmptyPostId);
}
if self.request.stamp_id.is_empty() {
return Err(RequestValidationError::EmptyStampId);
}
Ok(self.request)
}
}
#[cfg(feature = "api")]
fn auth_error_to_status(error: &AuthLayerError) -> Status {
Status::unauthenticated(error.to_string())
}
#[cfg(feature = "api")]
async fn resolve_channel(
channel: Option<Channel>,
endpoint: Option<String>,
default_endpoint: &str,
) -> Result<Channel, ClientBuildError> {
match (channel, endpoint) {
(Some(_), Some(_)) => Err(ClientBuildError::ConflictingTransport),
(Some(channel), None) => Ok(channel),
(None, endpoint) => {
let endpoint = Endpoint::new(resolve_endpoint(endpoint, default_endpoint))
.map_err(ClientBuildError::Transport)?;
endpoint
.connect()
.await
.map_err(ClientBuildError::Transport)
}
}
}
#[cfg(any(feature = "api", feature = "stream"))]
fn resolve_endpoint(endpoint: Option<String>, default_endpoint: &str) -> String {
endpoint.unwrap_or_else(|| default_endpoint.to_owned())
}
#[cfg(test)]
mod tests {
use crate::social::mixi::application::{
r#const::v1::{LanguageCode, PostMaskType, PostPublishingType},
model::v1::PostMask,
service::application_api::v1::initiate_post_media_upload_request::Type as UploadType,
};
#[cfg(feature = "api")]
use super::DEFAULT_API_ENDPOINT;
#[cfg(feature = "stream")]
use super::DEFAULT_STREAM_ENDPOINT;
#[cfg(any(feature = "api", feature = "stream"))]
use super::resolve_endpoint;
use super::{
AddStampToPostRequestBuilder, CreatePostRequestBuilder, DeletePostRequestBuilder,
GetStampsRequestBuilder, InitiatePostMediaUploadRequestBuilder, RequestValidationError,
SendChatMessageRequestBuilder,
};
#[cfg(feature = "api")]
#[test]
fn resolve_endpoint_defaults_to_official_api_endpoint() {
assert_eq!(
resolve_endpoint(None, DEFAULT_API_ENDPOINT),
DEFAULT_API_ENDPOINT
);
}
#[cfg(feature = "stream")]
#[test]
fn resolve_endpoint_defaults_to_official_stream_endpoint() {
assert_eq!(
resolve_endpoint(None, DEFAULT_STREAM_ENDPOINT),
DEFAULT_STREAM_ENDPOINT
);
}
#[cfg(feature = "api")]
#[test]
fn resolve_endpoint_prefers_explicit_override() {
let override_endpoint = String::from("https://override.example.test");
assert_eq!(
resolve_endpoint(Some(override_endpoint.clone()), DEFAULT_API_ENDPOINT),
override_endpoint
);
}
#[test]
fn create_post_builder_rejects_conflicting_targets() {
let result = CreatePostRequestBuilder::new("hello")
.in_reply_to_post_id("reply")
.quoted_post_id("quote")
.build();
assert_eq!(result, Err(RequestValidationError::ConflictingPostTargets));
}
#[test]
fn create_post_builder_rejects_too_many_media_ids() {
let result = CreatePostRequestBuilder::new("hello")
.media_ids(["1", "2", "3", "4", "5"])
.build();
assert_eq!(result, Err(RequestValidationError::TooManyMediaIds));
}
#[test]
fn create_post_builder_accepts_optional_fields() {
let result = CreatePostRequestBuilder::new("hello")
.push_media_id("media-id")
.post_mask(PostMask {
mask_type: PostMaskType::Sensitive as i32,
caption: String::from("spoilers"),
})
.publishing_type(PostPublishingType::NotPublishing)
.build();
assert!(result.is_ok());
}
#[test]
fn delete_post_builder_requires_post_id() {
let result = DeletePostRequestBuilder::new("").build();
assert_eq!(result, Err(RequestValidationError::EmptyPostId));
}
#[test]
fn delete_post_builder_accepts_post_id() {
let result = DeletePostRequestBuilder::new("post-id").build();
assert!(result.is_ok());
}
#[test]
fn send_chat_message_builder_requires_payload() {
let result = SendChatMessageRequestBuilder::new("room-id").build();
assert_eq!(result, Err(RequestValidationError::MissingChatPayload));
}
#[test]
fn send_chat_message_builder_requires_room_id() {
let result = SendChatMessageRequestBuilder::new("").text("hello").build();
assert_eq!(result, Err(RequestValidationError::EmptyRoomId));
}
#[test]
fn initiate_upload_builder_requires_non_empty_content_type() {
let result = InitiatePostMediaUploadRequestBuilder::new("", 128, UploadType::Image).build();
assert_eq!(result, Err(RequestValidationError::EmptyContentType));
}
#[test]
fn initiate_upload_builder_requires_non_zero_size() {
let result =
InitiatePostMediaUploadRequestBuilder::new("image/png", 0, UploadType::Image).build();
assert_eq!(result, Err(RequestValidationError::EmptyUploadSize));
}
#[test]
fn initiate_upload_builder_rejects_unspecified_type() {
let result =
InitiatePostMediaUploadRequestBuilder::new("image/png", 128, UploadType::Unspecified)
.build();
assert_eq!(result, Err(RequestValidationError::UnspecifiedUploadType));
}
#[test]
fn get_stamps_builder_sets_optional_language() {
let request = GetStampsRequestBuilder::new()
.official_stamp_language(LanguageCode::En)
.build();
assert_eq!(
request.official_stamp_language,
Some(LanguageCode::En as i32)
);
}
#[test]
fn add_stamp_to_post_builder_requires_post_id() {
let result = AddStampToPostRequestBuilder::new("", "stamp-id").build();
assert_eq!(result, Err(RequestValidationError::EmptyPostId));
}
#[test]
fn add_stamp_to_post_builder_requires_stamp_id() {
let result = AddStampToPostRequestBuilder::new("post-id", "").build();
assert_eq!(result, Err(RequestValidationError::EmptyStampId));
}
#[test]
fn add_stamp_to_post_builder_accepts_identifiers() {
let result = AddStampToPostRequestBuilder::new("post-id", "stamp-id").build();
assert!(result.is_ok());
}
}