Skip to main content

mixi2/
lib.rs

1//! Async Rust SDK for the mixi2 Application API.
2//!
3//! This crate combines the complete SDK surface into a single package:
4//! - `OAuth2` client-credentials authentication via [`ClientCredentialsAuthenticator`]
5//! - Authenticated unary gRPC access via [`ApiClient`] and [`ApiClientBuilder`]
6//! - Webhook and streaming event helpers via [`WebhookService`], [`WebhookServer`], and
7//!   [`StreamClientBuilder`]
8//! - Raw generated `prost` and `tonic` types under [`social`] plus [`FILE_DESCRIPTOR_SET`]
9//!
10//! The public API is async-only and designed for Tokio runtimes. Generated protobuf types remain
11//! visible so callers can keep full protocol fidelity, while the builders in this crate add
12//! validation only where the mixi2 API requires it.
13//!
14//! # Quick Start
15//!
16//! ```no_run
17//! # #[cfg(all(feature = "api", feature = "client-credentials-auth"))]
18//! use std::sync::Arc;
19//! # #[cfg(all(feature = "api", feature = "client-credentials-auth"))]
20//! use mixi2::{ApiClientBuilder, ClientCredentialsAuthenticator, GetStampsRequestBuilder};
21//! # #[cfg(all(feature = "api", feature = "client-credentials-auth"))]
22//! #[tokio::main]
23//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
24//!     let authenticator =
25//!         Arc::new(ClientCredentialsAuthenticator::new("client-id", "client-secret").await?);
26//!
27//!     let mut client = ApiClientBuilder::new(authenticator).build().await?;
28//!
29//!     let _stamps = client
30//!         .get_stamps(GetStampsRequestBuilder::new().build())
31//!         .await?;
32//!
33//!     Ok(())
34//! }
35//! # #[cfg(not(all(feature = "api", feature = "client-credentials-auth")))]
36//! # fn main() {}
37//! ```
38//!
39//! # Raw Protocol Types
40//!
41//! The generated protobuf and gRPC types are re-exported under [`social`]. Reach for them when you
42//! need direct access to the mixi2 schema or an RPC/request shape that does not need a convenience
43//! builder.
44//!
45//! # Event Delivery
46//!
47//! Use [`WebhookService`] and [`WebhookServer`] for signed webhook delivery, or
48//! [`StreamClientBuilder`] together with [`EventHandler`] for long-lived gRPC event streams.
49
50#![cfg_attr(docsrs, feature(doc_cfg))]
51
52#[cfg(feature = "stream")]
53use std::future::ready;
54#[cfg(any(feature = "api", feature = "stream"))]
55use std::sync::Arc;
56
57mod auth;
58mod events;
59mod proto;
60
61use crate::proto::social::mixi::application::{
62    r#const::v1::{LanguageCode, PostPublishingType},
63    model::v1::PostMask,
64    service::application_api::v1::{
65        self as application_api_v1, AddStampToPostRequest, CreatePostRequest, DeletePostRequest,
66        GetStampsRequest, InitiatePostMediaUploadRequest, SendChatMessageRequest,
67    },
68};
69#[cfg(feature = "api")]
70use crate::proto::social::mixi::application::service::application_api::v1::{
71    AddStampToPostResponse, CreatePostResponse, DeletePostResponse, GetPostMediaStatusRequest,
72    GetPostMediaStatusResponse, GetPostsRequest, GetPostsResponse, GetStampsResponse,
73    GetUsersRequest, GetUsersResponse, InitiatePostMediaUploadResponse,
74    SendChatMessageResponse,
75    application_service_client::ApplicationServiceClient as RawApiClient,
76};
77#[cfg(feature = "stream")]
78use crate::proto::social::mixi::application::service::application_stream::v1::application_service_client::ApplicationServiceClient as RawStreamClient;
79#[cfg(feature = "api")]
80use crate::auth::AuthError as AuthLayerError;
81#[cfg(any(feature = "api", feature = "stream"))]
82use crate::auth::Authenticator as AuthenticatorTrait;
83#[cfg(feature = "stream")]
84use crate::events::StreamWatcher as EventStreamWatcher;
85use thiserror::Error;
86#[cfg(feature = "api")]
87use tonic::{
88    IntoRequest, Request, Response, Status,
89    body::Body as TransportBody,
90    client::GrpcService,
91    codegen::{Body, Bytes as TonicBytes, StdError},
92};
93#[cfg(any(feature = "api", feature = "stream"))]
94use tonic::transport::{Channel, Endpoint, Error as TransportError};
95
96#[cfg(feature = "api")]
97/// Official mixi2 Application API endpoint for unary SDK clients.
98///
99/// This endpoint is used automatically when builders are created without an
100/// explicit `with_endpoint` or `with_channel` transport override.
101pub const DEFAULT_API_ENDPOINT: &str = "https://application-api.mixi.social";
102
103#[cfg(feature = "stream")]
104/// Official mixi2 Application stream endpoint for event streaming clients.
105///
106/// This endpoint is used automatically when stream builders are created without
107/// an explicit `with_endpoint` or `with_channel` transport override.
108pub const DEFAULT_STREAM_ENDPOINT: &str = "https://application-stream.mixi.social";
109
110pub use crate::auth::{AuthError, Authenticator};
111#[cfg(feature = "client-credentials-auth")]
112pub use crate::auth::{AuthenticatorBuilder, ClientCredentialsAuthenticator, DEFAULT_TOKEN_URL};
113#[cfg(any(feature = "stream", feature = "webhook-core", feature = "testutil"))]
114pub use crate::events::BoxError;
115#[cfg(any(feature = "stream", feature = "webhook-core", feature = "testutil"))]
116pub use crate::events::EventHandler;
117#[cfg(feature = "webhook-axum")]
118pub use crate::events::WebhookServer;
119#[cfg(feature = "testutil")]
120pub use crate::events::testutil;
121#[cfg(feature = "webhook-core")]
122pub use crate::events::{DispatchMode, WebhookError, WebhookService};
123#[cfg(feature = "stream")]
124pub use crate::events::{StreamWatcher, StreamWatcherError};
125pub use crate::proto::{FILE_DESCRIPTOR_SET, social};
126
127/// Validation errors returned by the high-level request builders.
128#[derive(Clone, Debug, Eq, Error, PartialEq)]
129pub enum RequestValidationError {
130    #[error("in_reply_to_post_id and quoted_post_id cannot both be set")]
131    ConflictingPostTargets,
132    #[error("media_id_list can contain at most 4 entries")]
133    TooManyMediaIds,
134    #[error("room_id must not be empty")]
135    EmptyRoomId,
136    #[error("post_id must not be empty")]
137    EmptyPostId,
138    #[error("stamp_id must not be empty")]
139    EmptyStampId,
140    #[error("send chat message requires text or media_id")]
141    MissingChatPayload,
142    #[error("content_type must not be empty")]
143    EmptyContentType,
144    #[error("data_size must be greater than zero")]
145    EmptyUploadSize,
146    #[error("media_type must not be unspecified")]
147    UnspecifiedUploadType,
148}
149
150#[cfg(any(feature = "api", feature = "stream"))]
151/// Transport setup errors returned by the top-level builders.
152#[derive(Debug, Error)]
153pub enum ClientBuildError {
154    #[error("channel and endpoint are mutually exclusive")]
155    ConflictingTransport,
156    #[error("failed to configure transport endpoint")]
157    Transport(#[source] TransportError),
158}
159
160#[cfg(feature = "api")]
161/// Authenticated façade over the raw unary gRPC client.
162pub struct ApiClient<T> {
163    authenticator: Arc<dyn AuthenticatorTrait>,
164    inner: RawApiClient<T>,
165}
166
167#[cfg(feature = "api")]
168impl<T> ApiClient<T>
169where
170    T: GrpcService<TransportBody> + Send + Sync,
171    T::Error: Into<StdError>,
172    T::Future: Send,
173    T::ResponseBody: Body<Data = TonicBytes> + Send + 'static,
174    <T::ResponseBody as Body>::Error: Into<StdError> + Send,
175{
176    /// Creates a new authenticated API client wrapper.
177    #[must_use]
178    pub fn new(inner: RawApiClient<T>, authenticator: Arc<dyn AuthenticatorTrait>) -> Self {
179        Self {
180            authenticator,
181            inner,
182        }
183    }
184
185    /// Returns a shared reference to the raw client.
186    #[must_use]
187    pub const fn inner(&self) -> &RawApiClient<T> {
188        &self.inner
189    }
190
191    /// Returns a mutable reference to the raw client.
192    #[must_use]
193    pub const fn inner_mut(&mut self) -> &mut RawApiClient<T> {
194        &mut self.inner
195    }
196
197    /// Consumes the wrapper and returns the raw client.
198    #[must_use]
199    pub fn into_inner(self) -> RawApiClient<T> {
200        self.inner
201    }
202
203    /// Calls `GetUsers`.
204    ///
205    /// # Errors
206    ///
207    /// Returns `Status::unauthenticated` when token acquisition fails, or the RPC status
208    /// returned by the upstream server.
209    pub async fn get_users(
210        &mut self,
211        request: impl IntoRequest<GetUsersRequest> + Send,
212    ) -> Result<Response<GetUsersResponse>, Status> {
213        let request = self.authorize_request(request).await?;
214        self.inner.get_users(request).await
215    }
216
217    /// Calls `GetPosts`.
218    ///
219    /// # Errors
220    ///
221    /// Returns `Status::unauthenticated` when token acquisition fails, or the RPC status
222    /// returned by the upstream server.
223    pub async fn get_posts(
224        &mut self,
225        request: impl IntoRequest<GetPostsRequest> + Send,
226    ) -> Result<Response<GetPostsResponse>, Status> {
227        let request = self.authorize_request(request).await?;
228        self.inner.get_posts(request).await
229    }
230
231    /// Calls `CreatePost`.
232    ///
233    /// # Errors
234    ///
235    /// Returns `Status::unauthenticated` when token acquisition fails, or the RPC status
236    /// returned by the upstream server.
237    pub async fn create_post(
238        &mut self,
239        request: impl IntoRequest<CreatePostRequest> + Send,
240    ) -> Result<Response<CreatePostResponse>, Status> {
241        let request = self.authorize_request(request).await?;
242        self.inner.create_post(request).await
243    }
244
245    /// Calls `DeletePost`.
246    ///
247    /// # Errors
248    ///
249    /// Returns `Status::unauthenticated` when token acquisition fails, or the RPC status
250    /// returned by the upstream server.
251    pub async fn delete_post(
252        &mut self,
253        request: impl IntoRequest<DeletePostRequest> + Send,
254    ) -> Result<Response<DeletePostResponse>, Status> {
255        let request = self.authorize_request(request).await?;
256        self.inner.delete_post(request).await
257    }
258
259    /// Calls `InitiatePostMediaUpload`.
260    ///
261    /// # Errors
262    ///
263    /// Returns `Status::unauthenticated` when token acquisition fails, or the RPC status
264    /// returned by the upstream server.
265    pub async fn initiate_post_media_upload(
266        &mut self,
267        request: impl IntoRequest<InitiatePostMediaUploadRequest> + Send,
268    ) -> Result<Response<InitiatePostMediaUploadResponse>, Status> {
269        let request = self.authorize_request(request).await?;
270        self.inner.initiate_post_media_upload(request).await
271    }
272
273    /// Calls `GetPostMediaStatus`.
274    ///
275    /// # Errors
276    ///
277    /// Returns `Status::unauthenticated` when token acquisition fails, or the RPC status
278    /// returned by the upstream server.
279    pub async fn get_post_media_status(
280        &mut self,
281        request: impl IntoRequest<GetPostMediaStatusRequest> + Send,
282    ) -> Result<Response<GetPostMediaStatusResponse>, Status> {
283        let request = self.authorize_request(request).await?;
284        self.inner.get_post_media_status(request).await
285    }
286
287    /// Calls `SendChatMessage`.
288    ///
289    /// # Errors
290    ///
291    /// Returns `Status::unauthenticated` when token acquisition fails, or the RPC status
292    /// returned by the upstream server.
293    pub async fn send_chat_message(
294        &mut self,
295        request: impl IntoRequest<SendChatMessageRequest> + Send,
296    ) -> Result<Response<SendChatMessageResponse>, Status> {
297        let request = self.authorize_request(request).await?;
298        self.inner.send_chat_message(request).await
299    }
300
301    /// Calls `GetStamps`.
302    ///
303    /// # Errors
304    ///
305    /// Returns `Status::unauthenticated` when token acquisition fails, or the RPC status
306    /// returned by the upstream server.
307    pub async fn get_stamps(
308        &mut self,
309        request: impl IntoRequest<GetStampsRequest> + Send,
310    ) -> Result<Response<GetStampsResponse>, Status> {
311        let request = self.authorize_request(request).await?;
312        self.inner.get_stamps(request).await
313    }
314
315    /// Calls `AddStampToPost`.
316    ///
317    /// # Errors
318    ///
319    /// Returns `Status::unauthenticated` when token acquisition fails, or the RPC status
320    /// returned by the upstream server.
321    pub async fn add_stamp_to_post(
322        &mut self,
323        request: impl IntoRequest<AddStampToPostRequest> + Send,
324    ) -> Result<Response<AddStampToPostResponse>, Status> {
325        let request = self.authorize_request(request).await?;
326        self.inner.add_stamp_to_post(request).await
327    }
328
329    async fn authorize_request<R: Send>(
330        &self,
331        request: impl IntoRequest<R> + Send,
332    ) -> Result<Request<R>, Status> {
333        let mut request = request.into_request();
334        self.authenticator
335            .authorize(request.metadata_mut())
336            .await
337            .map_err(|error| auth_error_to_status(&error))?;
338        Ok(request)
339    }
340}
341
342#[cfg(feature = "api")]
343/// Builder for an authenticated unary client.
344pub struct ApiClientBuilder {
345    authenticator: Arc<dyn AuthenticatorTrait>,
346    channel: Option<Channel>,
347    endpoint: Option<String>,
348}
349
350#[cfg(feature = "api")]
351impl ApiClientBuilder {
352    /// Creates a new builder for the given authenticator.
353    #[must_use]
354    pub fn new(authenticator: Arc<dyn AuthenticatorTrait>) -> Self {
355        Self {
356            authenticator,
357            channel: None,
358            endpoint: None,
359        }
360    }
361
362    /// Injects an already-connected channel.
363    #[must_use]
364    pub fn with_channel(mut self, channel: Channel) -> Self {
365        self.channel = Some(channel);
366        self
367    }
368
369    /// Connects to the given endpoint when `build` is called.
370    #[must_use]
371    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
372        self.endpoint = Some(endpoint.into());
373        self
374    }
375
376    /// Builds the authenticated unary client wrapper.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error when both channel and endpoint were configured, or when
381    /// endpoint connection setup fails.
382    pub async fn build(self) -> Result<ApiClient<Channel>, ClientBuildError> {
383        let channel = resolve_channel(self.channel, self.endpoint, DEFAULT_API_ENDPOINT).await?;
384        let raw_client = RawApiClient::new(channel);
385        Ok(ApiClient::new(raw_client, self.authenticator))
386    }
387}
388
389#[cfg(feature = "stream")]
390/// Builder for an authenticated stream watcher.
391pub struct StreamClientBuilder {
392    authenticator: Arc<dyn AuthenticatorTrait>,
393    channel: Option<Channel>,
394    endpoint: Option<String>,
395}
396
397#[cfg(feature = "stream")]
398impl StreamClientBuilder {
399    /// Creates a new builder for the given authenticator.
400    #[must_use]
401    pub fn new(authenticator: Arc<dyn AuthenticatorTrait>) -> Self {
402        Self {
403            authenticator,
404            channel: None,
405            endpoint: None,
406        }
407    }
408
409    /// Injects an already-connected channel.
410    #[must_use]
411    pub fn with_channel(mut self, channel: Channel) -> Self {
412        self.channel = Some(channel);
413        self
414    }
415
416    /// Connects to the given endpoint when `build` is called.
417    #[must_use]
418    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
419        self.endpoint = Some(endpoint.into());
420        self
421    }
422
423    /// Builds the stream watcher.
424    ///
425    /// # Errors
426    ///
427    /// Returns an error when both channel and endpoint were configured, or when
428    /// endpoint connection setup fails.
429    pub async fn build(self) -> Result<EventStreamWatcher, ClientBuildError> {
430        let watcher = match (self.channel, self.endpoint) {
431            (Some(_), Some(_)) => Err(ClientBuildError::ConflictingTransport),
432            (Some(channel), None) => {
433                let raw_client = RawStreamClient::new(channel);
434                Ok(EventStreamWatcher::new(raw_client, self.authenticator))
435            }
436            (None, endpoint) => {
437                let endpoint = Endpoint::new(resolve_endpoint(endpoint, DEFAULT_STREAM_ENDPOINT))
438                    .map_err(ClientBuildError::Transport)?;
439                let raw_client = events::http_stream_client(endpoint.uri().clone());
440                Ok(EventStreamWatcher::new(raw_client, self.authenticator))
441            }
442        };
443
444        ready(watcher).await
445    }
446}
447
448/// Builder for `CreatePostRequest`.
449#[derive(Clone, Debug)]
450pub struct CreatePostRequestBuilder {
451    request: CreatePostRequest,
452}
453
454impl CreatePostRequestBuilder {
455    /// Creates a new builder with the required post text field.
456    #[must_use]
457    pub fn new(text: impl Into<String>) -> Self {
458        Self {
459            request: CreatePostRequest {
460                text: text.into(),
461                in_reply_to_post_id: None,
462                quoted_post_id: None,
463                media_id_list: Vec::new(),
464                post_mask: None,
465                publishing_type: None,
466            },
467        }
468    }
469
470    /// Sets the reply target.
471    #[must_use]
472    pub fn in_reply_to_post_id(mut self, post_id: impl Into<String>) -> Self {
473        self.request.in_reply_to_post_id = Some(post_id.into());
474        self
475    }
476
477    /// Sets the quoted post target.
478    #[must_use]
479    pub fn quoted_post_id(mut self, post_id: impl Into<String>) -> Self {
480        self.request.quoted_post_id = Some(post_id.into());
481        self
482    }
483
484    /// Appends one media identifier.
485    #[must_use]
486    pub fn push_media_id(mut self, media_id: impl Into<String>) -> Self {
487        self.request.media_id_list.push(media_id.into());
488        self
489    }
490
491    /// Replaces the media identifier list.
492    #[must_use]
493    pub fn media_ids<I, S>(mut self, media_ids: I) -> Self
494    where
495        I: IntoIterator<Item = S>,
496        S: Into<String>,
497    {
498        self.request.media_id_list = media_ids.into_iter().map(Into::into).collect();
499        self
500    }
501
502    /// Sets the optional post mask.
503    #[must_use]
504    pub fn post_mask(mut self, post_mask: PostMask) -> Self {
505        self.request.post_mask = Some(post_mask);
506        self
507    }
508
509    /// Sets the optional publishing type.
510    #[must_use]
511    pub const fn publishing_type(mut self, publishing_type: PostPublishingType) -> Self {
512        self.request.publishing_type = Some(publishing_type as i32);
513        self
514    }
515
516    /// Finalizes the validated request.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error when both reply and quote targets are set, or when more than
521    /// four media identifiers are attached.
522    pub fn build(self) -> Result<CreatePostRequest, RequestValidationError> {
523        if self.request.in_reply_to_post_id.is_some() && self.request.quoted_post_id.is_some() {
524            return Err(RequestValidationError::ConflictingPostTargets);
525        }
526        if self.request.media_id_list.len() > 4 {
527            return Err(RequestValidationError::TooManyMediaIds);
528        }
529        Ok(self.request)
530    }
531}
532
533/// Builder for `DeletePostRequest`.
534#[derive(Clone, Debug)]
535pub struct DeletePostRequestBuilder {
536    request: DeletePostRequest,
537}
538
539impl DeletePostRequestBuilder {
540    /// Creates a new builder with the required post identifier.
541    #[must_use]
542    pub fn new(post_id: impl Into<String>) -> Self {
543        Self {
544            request: DeletePostRequest {
545                post_id: post_id.into(),
546            },
547        }
548    }
549
550    /// Overrides the target post identifier.
551    #[must_use]
552    pub fn post_id(mut self, post_id: impl Into<String>) -> Self {
553        self.request.post_id = post_id.into();
554        self
555    }
556
557    /// Finalizes the validated request.
558    ///
559    /// # Errors
560    ///
561    /// Returns an error when `post_id` is empty.
562    pub fn build(self) -> Result<DeletePostRequest, RequestValidationError> {
563        if self.request.post_id.is_empty() {
564            return Err(RequestValidationError::EmptyPostId);
565        }
566        Ok(self.request)
567    }
568}
569
570/// Builder for `SendChatMessageRequest`.
571#[derive(Clone, Debug)]
572pub struct SendChatMessageRequestBuilder {
573    request: SendChatMessageRequest,
574}
575
576impl SendChatMessageRequestBuilder {
577    /// Creates a new builder with the required room identifier.
578    #[must_use]
579    pub fn new(room_id: impl Into<String>) -> Self {
580        Self {
581            request: SendChatMessageRequest {
582                room_id: room_id.into(),
583                text: None,
584                media_id: None,
585            },
586        }
587    }
588
589    /// Sets the message text.
590    #[must_use]
591    pub fn text(mut self, text: impl Into<String>) -> Self {
592        self.request.text = Some(text.into());
593        self
594    }
595
596    /// Sets the optional media attachment.
597    #[must_use]
598    pub fn media_id(mut self, media_id: impl Into<String>) -> Self {
599        self.request.media_id = Some(media_id.into());
600        self
601    }
602
603    /// Finalizes the validated request.
604    ///
605    /// # Errors
606    ///
607    /// Returns an error when `room_id` is empty or both `text` and `media_id` are missing.
608    pub fn build(self) -> Result<SendChatMessageRequest, RequestValidationError> {
609        if self.request.room_id.is_empty() {
610            return Err(RequestValidationError::EmptyRoomId);
611        }
612        if self.request.text.is_none() && self.request.media_id.is_none() {
613            return Err(RequestValidationError::MissingChatPayload);
614        }
615        Ok(self.request)
616    }
617}
618
619/// Builder for `InitiatePostMediaUploadRequest`.
620#[derive(Clone, Debug)]
621pub struct InitiatePostMediaUploadRequestBuilder {
622    request: InitiatePostMediaUploadRequest,
623}
624
625impl InitiatePostMediaUploadRequestBuilder {
626    /// Creates a new builder with the required upload metadata.
627    #[must_use]
628    pub fn new(
629        content_type: impl Into<String>,
630        data_size: u64,
631        media_type: application_api_v1::initiate_post_media_upload_request::Type,
632    ) -> Self {
633        Self {
634            request: InitiatePostMediaUploadRequest {
635                content_type: content_type.into(),
636                data_size,
637                media_type: media_type as i32,
638                description: None,
639            },
640        }
641    }
642
643    /// Sets the optional description.
644    #[must_use]
645    pub fn description(mut self, description: impl Into<String>) -> Self {
646        self.request.description = Some(description.into());
647        self
648    }
649
650    /// Finalizes the validated request.
651    ///
652    /// # Errors
653    ///
654    /// Returns an error when `content_type` is empty, `data_size` is zero, or the
655    /// media type is unspecified.
656    pub fn build(self) -> Result<InitiatePostMediaUploadRequest, RequestValidationError> {
657        if self.request.content_type.is_empty() {
658            return Err(RequestValidationError::EmptyContentType);
659        }
660        if self.request.data_size == 0 {
661            return Err(RequestValidationError::EmptyUploadSize);
662        }
663        if self.request.media_type
664            == application_api_v1::initiate_post_media_upload_request::Type::Unspecified as i32
665        {
666            return Err(RequestValidationError::UnspecifiedUploadType);
667        }
668        Ok(self.request)
669    }
670}
671
672/// Builder for `GetStampsRequest`.
673#[derive(Clone, Copy, Debug, Default)]
674pub struct GetStampsRequestBuilder {
675    request: GetStampsRequest,
676}
677
678impl GetStampsRequestBuilder {
679    /// Creates a new builder.
680    #[must_use]
681    pub const fn new() -> Self {
682        Self {
683            request: GetStampsRequest {
684                official_stamp_language: None,
685            },
686        }
687    }
688
689    /// Sets the optional official stamp language.
690    #[must_use]
691    pub const fn official_stamp_language(mut self, language: LanguageCode) -> Self {
692        self.request.official_stamp_language = Some(language as i32);
693        self
694    }
695
696    /// Finalizes the request.
697    #[must_use]
698    pub const fn build(self) -> GetStampsRequest {
699        self.request
700    }
701}
702
703/// Builder for `AddStampToPostRequest`.
704#[derive(Clone, Debug)]
705pub struct AddStampToPostRequestBuilder {
706    request: AddStampToPostRequest,
707}
708
709impl AddStampToPostRequestBuilder {
710    /// Creates a new builder with the required identifiers.
711    #[must_use]
712    pub fn new(post_id: impl Into<String>, stamp_id: impl Into<String>) -> Self {
713        Self {
714            request: AddStampToPostRequest {
715                post_id: post_id.into(),
716                stamp_id: stamp_id.into(),
717            },
718        }
719    }
720
721    /// Overrides the target post identifier.
722    #[must_use]
723    pub fn post_id(mut self, post_id: impl Into<String>) -> Self {
724        self.request.post_id = post_id.into();
725        self
726    }
727
728    /// Overrides the stamp identifier.
729    #[must_use]
730    pub fn stamp_id(mut self, stamp_id: impl Into<String>) -> Self {
731        self.request.stamp_id = stamp_id.into();
732        self
733    }
734
735    /// Finalizes the validated request.
736    ///
737    /// # Errors
738    ///
739    /// Returns an error when either `post_id` or `stamp_id` is empty.
740    pub fn build(self) -> Result<AddStampToPostRequest, RequestValidationError> {
741        if self.request.post_id.is_empty() {
742            return Err(RequestValidationError::EmptyPostId);
743        }
744        if self.request.stamp_id.is_empty() {
745            return Err(RequestValidationError::EmptyStampId);
746        }
747        Ok(self.request)
748    }
749}
750
751#[cfg(feature = "api")]
752fn auth_error_to_status(error: &AuthLayerError) -> Status {
753    Status::unauthenticated(error.to_string())
754}
755
756#[cfg(feature = "api")]
757async fn resolve_channel(
758    channel: Option<Channel>,
759    endpoint: Option<String>,
760    default_endpoint: &str,
761) -> Result<Channel, ClientBuildError> {
762    match (channel, endpoint) {
763        (Some(_), Some(_)) => Err(ClientBuildError::ConflictingTransport),
764        (Some(channel), None) => Ok(channel),
765        (None, endpoint) => {
766            let endpoint = Endpoint::new(resolve_endpoint(endpoint, default_endpoint))
767                .map_err(ClientBuildError::Transport)?;
768            endpoint
769                .connect()
770                .await
771                .map_err(ClientBuildError::Transport)
772        }
773    }
774}
775
776#[cfg(any(feature = "api", feature = "stream"))]
777fn resolve_endpoint(endpoint: Option<String>, default_endpoint: &str) -> String {
778    endpoint.unwrap_or_else(|| default_endpoint.to_owned())
779}
780
781#[cfg(test)]
782mod tests {
783    use crate::social::mixi::application::{
784        r#const::v1::{LanguageCode, PostMaskType, PostPublishingType},
785        model::v1::PostMask,
786        service::application_api::v1::initiate_post_media_upload_request::Type as UploadType,
787    };
788
789    #[cfg(feature = "api")]
790    use super::DEFAULT_API_ENDPOINT;
791    #[cfg(feature = "stream")]
792    use super::DEFAULT_STREAM_ENDPOINT;
793    #[cfg(any(feature = "api", feature = "stream"))]
794    use super::resolve_endpoint;
795    use super::{
796        AddStampToPostRequestBuilder, CreatePostRequestBuilder, DeletePostRequestBuilder,
797        GetStampsRequestBuilder, InitiatePostMediaUploadRequestBuilder, RequestValidationError,
798        SendChatMessageRequestBuilder,
799    };
800
801    #[cfg(feature = "api")]
802    #[test]
803    fn resolve_endpoint_defaults_to_official_api_endpoint() {
804        assert_eq!(
805            resolve_endpoint(None, DEFAULT_API_ENDPOINT),
806            DEFAULT_API_ENDPOINT
807        );
808    }
809
810    #[cfg(feature = "stream")]
811    #[test]
812    fn resolve_endpoint_defaults_to_official_stream_endpoint() {
813        assert_eq!(
814            resolve_endpoint(None, DEFAULT_STREAM_ENDPOINT),
815            DEFAULT_STREAM_ENDPOINT
816        );
817    }
818
819    #[cfg(feature = "api")]
820    #[test]
821    fn resolve_endpoint_prefers_explicit_override() {
822        let override_endpoint = String::from("https://override.example.test");
823
824        assert_eq!(
825            resolve_endpoint(Some(override_endpoint.clone()), DEFAULT_API_ENDPOINT),
826            override_endpoint
827        );
828    }
829
830    #[test]
831    fn create_post_builder_rejects_conflicting_targets() {
832        let result = CreatePostRequestBuilder::new("hello")
833            .in_reply_to_post_id("reply")
834            .quoted_post_id("quote")
835            .build();
836
837        assert_eq!(result, Err(RequestValidationError::ConflictingPostTargets));
838    }
839
840    #[test]
841    fn create_post_builder_rejects_too_many_media_ids() {
842        let result = CreatePostRequestBuilder::new("hello")
843            .media_ids(["1", "2", "3", "4", "5"])
844            .build();
845
846        assert_eq!(result, Err(RequestValidationError::TooManyMediaIds));
847    }
848
849    #[test]
850    fn create_post_builder_accepts_optional_fields() {
851        let result = CreatePostRequestBuilder::new("hello")
852            .push_media_id("media-id")
853            .post_mask(PostMask {
854                mask_type: PostMaskType::Sensitive as i32,
855                caption: String::from("spoilers"),
856            })
857            .publishing_type(PostPublishingType::NotPublishing)
858            .build();
859
860        assert!(result.is_ok());
861    }
862
863    #[test]
864    fn delete_post_builder_requires_post_id() {
865        let result = DeletePostRequestBuilder::new("").build();
866
867        assert_eq!(result, Err(RequestValidationError::EmptyPostId));
868    }
869
870    #[test]
871    fn delete_post_builder_accepts_post_id() {
872        let result = DeletePostRequestBuilder::new("post-id").build();
873
874        assert!(result.is_ok());
875    }
876
877    #[test]
878    fn send_chat_message_builder_requires_payload() {
879        let result = SendChatMessageRequestBuilder::new("room-id").build();
880
881        assert_eq!(result, Err(RequestValidationError::MissingChatPayload));
882    }
883
884    #[test]
885    fn send_chat_message_builder_requires_room_id() {
886        let result = SendChatMessageRequestBuilder::new("").text("hello").build();
887
888        assert_eq!(result, Err(RequestValidationError::EmptyRoomId));
889    }
890
891    #[test]
892    fn initiate_upload_builder_requires_non_empty_content_type() {
893        let result = InitiatePostMediaUploadRequestBuilder::new("", 128, UploadType::Image).build();
894
895        assert_eq!(result, Err(RequestValidationError::EmptyContentType));
896    }
897
898    #[test]
899    fn initiate_upload_builder_requires_non_zero_size() {
900        let result =
901            InitiatePostMediaUploadRequestBuilder::new("image/png", 0, UploadType::Image).build();
902
903        assert_eq!(result, Err(RequestValidationError::EmptyUploadSize));
904    }
905
906    #[test]
907    fn initiate_upload_builder_rejects_unspecified_type() {
908        let result =
909            InitiatePostMediaUploadRequestBuilder::new("image/png", 128, UploadType::Unspecified)
910                .build();
911
912        assert_eq!(result, Err(RequestValidationError::UnspecifiedUploadType));
913    }
914
915    #[test]
916    fn get_stamps_builder_sets_optional_language() {
917        let request = GetStampsRequestBuilder::new()
918            .official_stamp_language(LanguageCode::En)
919            .build();
920
921        assert_eq!(
922            request.official_stamp_language,
923            Some(LanguageCode::En as i32)
924        );
925    }
926
927    #[test]
928    fn add_stamp_to_post_builder_requires_post_id() {
929        let result = AddStampToPostRequestBuilder::new("", "stamp-id").build();
930
931        assert_eq!(result, Err(RequestValidationError::EmptyPostId));
932    }
933
934    #[test]
935    fn add_stamp_to_post_builder_requires_stamp_id() {
936        let result = AddStampToPostRequestBuilder::new("post-id", "").build();
937
938        assert_eq!(result, Err(RequestValidationError::EmptyStampId));
939    }
940
941    #[test]
942    fn add_stamp_to_post_builder_accepts_identifiers() {
943        let result = AddStampToPostRequestBuilder::new("post-id", "stamp-id").build();
944
945        assert!(result.is_ok());
946    }
947}