yandex_webmaster_api/
client.rs

1use reqwest_middleware::ClientBuilder;
2use serde_json::json;
3use serde_qs::ArrayFormat;
4use tracing::instrument;
5
6use crate::{
7    dto::*,
8    error::{Result, YandexApiErrorResponse, YandexWebmasterError},
9    middleware::AuthMiddleware,
10};
11
12/// Base URL for the Yandex Webmaster API
13const API_BASE_URL: &str = "https://api.webmaster.yandex.net/v4";
14
15/// Client for interacting with the Yandex Webmaster API
16#[derive(Debug, Clone)]
17pub struct YandexWebmasterClient {
18    client: reqwest_middleware::ClientWithMiddleware,
19    user_id: i64,
20    qs: serde_qs::Config,
21}
22
23impl YandexWebmasterClient {
24    /// Create OAuth url to get token
25    pub fn oauth_url(client_id: &str) -> String {
26        format!("https://oauth.yandex.ru/authorize?response_type=token&client_id={client_id}")
27    }
28
29    /// Creates a new Yandex Webmaster API client
30    ///
31    /// # Arguments
32    ///
33    /// * `oauth_token` - OAuth token for authentication
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if:
38    /// - The HTTP client cannot be created
39    /// - The user information cannot be fetched
40    /// - The OAuth token is invalid
41    #[instrument(skip(oauth_token))]
42    pub async fn new(oauth_token: String) -> Result<Self> {
43        // Build the HTTP client with middleware
44        let client = ClientBuilder::new(reqwest::Client::new())
45            .with(AuthMiddleware::new(oauth_token))
46            .build();
47
48        // Fetch user information
49        let user_response = Self::fetch_user(&client).await?;
50
51        tracing::info!(
52            user_id = user_response.user_id,
53            "Successfully authenticated"
54        );
55
56        Ok(Self {
57            client,
58            user_id: user_response.user_id,
59            qs: serde_qs::Config::new().array_format(ArrayFormat::Unindexed),
60        })
61    }
62
63    /// Creates a new Yandex Webmaster API client
64    ///
65    /// # Arguments
66    ///
67    /// * `oauth_token` - OAuth token for authentication
68    /// * `client` - Client builder with preconfigured middleware
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if:
73    /// - The HTTP client cannot be created
74    /// - The user information cannot be fetched
75    /// - The OAuth token is invalid
76    #[instrument(skip(oauth_token, client))]
77    pub async fn with_client(oauth_token: String, client: ClientBuilder) -> Result<Self> {
78        // Build the HTTP client with middleware
79        let client = client.with(AuthMiddleware::new(oauth_token)).build();
80
81        // Fetch user information
82        let user_response = Self::fetch_user(&client).await?;
83
84        tracing::info!(
85            user_id = user_response.user_id,
86            "Successfully authenticated"
87        );
88
89        Ok(Self {
90            client,
91            user_id: user_response.user_id,
92            qs: serde_qs::Config::new().array_format(ArrayFormat::Unindexed),
93        })
94    }
95
96    /// Fetches user information from the API
97    #[instrument(skip(client))]
98    async fn fetch_user(client: &reqwest_middleware::ClientWithMiddleware) -> Result<UserResponse> {
99        let url = format!("{}/user", API_BASE_URL);
100
101        tracing::debug!(url = %url, "Fetching user information");
102
103        let response = client.get(&url).send().await?;
104
105        if !response.status().is_success() {
106            return Err(Self::parse_error(response).await);
107        }
108
109        let user_response: UserResponse = response.json().await?;
110
111        Ok(user_response)
112    }
113
114    /// Returns the user ID
115    pub fn user_id(&self) -> i64 {
116        self.user_id
117    }
118
119    // ============================================================================
120    // Hosts Management
121    // ============================================================================
122
123    /// List all sites for the user
124    #[instrument(skip(self))]
125    pub async fn get_hosts(&self) -> Result<Vec<HostInfo>> {
126        let url = format!("{}/user/{}/hosts", API_BASE_URL, self.user_id);
127        let result: HostsResponse = self.get(&url).await?;
128        Ok(result.hosts)
129    }
130
131    /// Add a new site
132    #[instrument(skip(self))]
133    pub async fn add_host(
134        &self,
135        host_url: &str,
136        verification_type: VerificationType,
137    ) -> Result<AddHostResponse> {
138        let url = format!("{}/user/{}/hosts", API_BASE_URL, self.user_id);
139        self.post(
140            &url,
141            &json!({ "host_url": host_url.to_string(), "verification_type": verification_type }),
142        )
143        .await
144    }
145
146    /// Get information about a specific site
147    #[instrument(skip(self))]
148    pub async fn get_host(&self, host_id: &str) -> Result<FullHostInfo> {
149        let url = format!("{}/user/{}/hosts/{}", API_BASE_URL, self.user_id, host_id);
150        self.get(&url).await
151    }
152
153    /// Delete a site
154    #[instrument(skip(self))]
155    pub async fn delete_host(&self, host_id: &str) -> Result<()> {
156        let url = format!("{}/user/{}/hosts/{}", API_BASE_URL, self.user_id, host_id);
157        self.delete(&url).await
158    }
159
160    // ============================================================================
161    // Host Verification
162    // ============================================================================
163
164    /// Get verification status for a site
165    #[instrument(skip(self))]
166    pub async fn get_verification_status(&self, host_id: &str) -> Result<HostVerificationResponse> {
167        let url = format!(
168            "{}/user/{}/hosts/{}/verification",
169            API_BASE_URL, self.user_id, host_id
170        );
171        self.get(&url).await
172    }
173
174    /// Initiate verification procedure for a site
175    #[instrument(skip(self))]
176    pub async fn verify_host(
177        &self,
178        host_id: &str,
179        verification_type: ExplicitVerificationType,
180    ) -> Result<HostVerificationResponse> {
181        let serialized = serde_json::to_value(verification_type)?;
182        let verification_type = serialized.clone().as_str().unwrap_or("").to_owned();
183
184        let url = format!(
185            "{}/user/{}/hosts/{}/verification?verification_type={}",
186            API_BASE_URL, self.user_id, host_id, verification_type
187        );
188        self.post(&url, &()).await
189    }
190
191    /// Get list of verified owners for a site
192    #[instrument(skip(self))]
193    pub async fn get_owners(&self, host_id: &str) -> Result<Vec<Owner>> {
194        let url = format!(
195            "{}/user/{}/hosts/{}/owners",
196            API_BASE_URL, self.user_id, host_id
197        );
198        let result: OwnersResponse = self.get(&url).await?;
199        Ok(result.users)
200    }
201
202    // ============================================================================
203    // Site Statistics
204    // ============================================================================
205
206    /// Get site summary statistics
207    #[instrument(skip(self))]
208    pub async fn get_host_summary(&self, host_id: &str) -> Result<HostSummaryResponse> {
209        let url = format!(
210            "{}/user/{}/hosts/{}/summary",
211            API_BASE_URL, self.user_id, host_id
212        );
213        self.get(&url).await
214    }
215
216    /// Get site quality index history
217    #[instrument(skip(self))]
218    pub async fn get_sqi_history(
219        &self,
220        host_id: &str,
221        req: SqiHistoryRequest,
222    ) -> Result<Vec<SqiPoint>> {
223        let url = format!(
224            "{}/user/{}/hosts/{}/sqi-history?{}",
225            API_BASE_URL,
226            self.user_id,
227            host_id,
228            self.qs.serialize_string(&req)?
229        );
230        let result: SqiHistoryResponse = self.get(&url).await?;
231        Ok(result.points)
232    }
233
234    // ============================================================================
235    // Search Queries
236    // ============================================================================
237
238    /// Get popular search queries for a site
239    #[instrument(skip(self))]
240    pub async fn get_popular_queries(
241        &self,
242        host_id: &str,
243        request: &PopularQueriesRequest,
244    ) -> Result<PopularQueriesResponse> {
245        let url = format!(
246            "{}/user/{}/hosts/{}/search-queries/popular?{}",
247            API_BASE_URL,
248            self.user_id,
249            host_id,
250            self.qs.serialize_string(request)?
251        );
252        self.get(&url).await
253    }
254
255    /// Get overall query statistics history
256    #[instrument(skip(self))]
257    pub async fn get_query_analytics(
258        &self,
259        host_id: &str,
260        request: &QueryAnalyticsRequest,
261    ) -> Result<QueryAnalyticsResponse> {
262        let url = format!(
263            "{}/user/{}/hosts/{}/search-queries/all/history?{}",
264            API_BASE_URL,
265            self.user_id,
266            host_id,
267            self.qs.serialize_string(request)?
268        );
269        self.get(&url).await
270    }
271
272    /// Get statistics for a specific query
273    #[instrument(skip(self))]
274    pub async fn get_query_history(
275        &self,
276        host_id: &str,
277        query_id: &str,
278        request: &QueryHistoryRequest,
279    ) -> Result<QueryHistoryResponse> {
280        let url = format!(
281            "{}/user/{}/hosts/{}/search-queries/{}/history?{}",
282            API_BASE_URL,
283            self.user_id,
284            host_id,
285            query_id,
286            self.qs.serialize_string(request)?
287        );
288        self.get(&url).await
289    }
290
291    // ============================================================================
292    // Sitemaps
293    // ============================================================================
294
295    /// Get list of all sitemap files
296    #[instrument(skip(self))]
297    pub async fn get_sitemaps(
298        &self,
299        host_id: &str,
300        request: &GetSitemapsRequest,
301    ) -> Result<SitemapsResponse> {
302        let url = format!(
303            "{}/user/{}/hosts/{}/sitemaps?{}",
304            API_BASE_URL,
305            self.user_id,
306            host_id,
307            self.qs.serialize_string(request)?
308        );
309        self.get(&url).await
310    }
311
312    /// Get details of a specific sitemap
313    #[instrument(skip(self))]
314    pub async fn get_sitemap(&self, host_id: &str, sitemap_id: &str) -> Result<SitemapInfo> {
315        let url = format!(
316            "{}/user/{}/hosts/{}/sitemaps/{}",
317            API_BASE_URL, self.user_id, host_id, sitemap_id
318        );
319        self.get(&url).await
320    }
321
322    /// Get list of user-submitted sitemaps
323    #[instrument(skip(self))]
324    pub async fn get_user_sitemaps(
325        &self,
326        host_id: &str,
327        request: &GetUserSitemapsRequest,
328    ) -> Result<UserSitemapsResponse> {
329        let url = format!(
330            "{}/user/{}/hosts/{}/user-added-sitemaps?{}",
331            API_BASE_URL,
332            self.user_id,
333            host_id,
334            self.qs.serialize_string(request)?
335        );
336        self.get(&url).await
337    }
338
339    /// Add a new sitemap file
340    #[instrument(skip(self))]
341    pub async fn add_sitemap(&self, host_id: &str, url: &str) -> Result<AddSitemapResponse> {
342        let body = json!({ "url": url.to_string() });
343        let url = format!(
344            "{}/user/{}/hosts/{}/user-added-sitemaps",
345            API_BASE_URL, self.user_id, host_id
346        );
347        self.post(&url, &body).await
348    }
349
350    /// Get user-submitted sitemap details
351    #[instrument(skip(self))]
352    pub async fn get_user_sitemap(
353        &self,
354        host_id: &str,
355        sitemap_id: &str,
356    ) -> Result<UserSitemapInfo> {
357        let url = format!(
358            "{}/user/{}/hosts/{}/user-added-sitemaps/{}",
359            API_BASE_URL, self.user_id, host_id, sitemap_id
360        );
361        self.get(&url).await
362    }
363
364    /// Delete a user-submitted sitemap
365    #[instrument(skip(self))]
366    pub async fn delete_sitemap(&self, host_id: &str, sitemap_id: &str) -> Result<()> {
367        let url = format!(
368            "{}/user/{}/hosts/{}/user-added-sitemaps/{}",
369            API_BASE_URL, self.user_id, host_id, sitemap_id
370        );
371        self.delete(&url).await
372    }
373
374    // ============================================================================
375    // Indexing
376    // ============================================================================
377
378    /// Get indexing history
379    #[instrument(skip(self))]
380    pub async fn get_indexing_history(
381        &self,
382        host_id: &str,
383        request: &IndexingHistoryRequest,
384    ) -> Result<IndexingHistoryResponse> {
385        let url = format!(
386            "{}/user/{}/hosts/{}/indexing/history?{}",
387            API_BASE_URL,
388            self.user_id,
389            host_id,
390            self.qs.serialize_string(request)?
391        );
392        self.get(&url).await
393    }
394
395    /// Get sample indexed pages
396    #[instrument(skip(self))]
397    pub async fn get_indexing_samples(
398        &self,
399        host_id: &str,
400        request: &GetIndexingSamplesRequest,
401    ) -> Result<IndexingSamplesResponse> {
402        let url = format!(
403            "{}/user/{}/hosts/{}/indexing/samples?{}",
404            API_BASE_URL,
405            self.user_id,
406            host_id,
407            self.qs.serialize_string(request)?
408        );
409        self.get(&url).await
410    }
411
412    /// Get pages in search history
413    #[instrument(skip(self))]
414    pub async fn get_search_urls_history(
415        &self,
416        host_id: &str,
417        request: &IndexingHistoryRequest,
418    ) -> Result<SearchUrlsHistoryResponse> {
419        let url = format!(
420            "{}/user/{}/hosts/{}/search-urls/in-search/history?{}",
421            API_BASE_URL,
422            self.user_id,
423            host_id,
424            self.qs.serialize_string(request)?
425        );
426        self.get(&url).await
427    }
428
429    /// Get sample pages in search
430    #[instrument(skip(self))]
431    pub async fn get_search_urls_samples(
432        &self,
433        host_id: &str,
434        request: &GetSearchUrlsSamplesRequest,
435    ) -> Result<SearchUrlsSamplesResponse> {
436        let url = format!(
437            "{}/user/{}/hosts/{}/search-urls/in-search/samples?{}",
438            API_BASE_URL,
439            self.user_id,
440            host_id,
441            self.qs.serialize_string(request)?
442        );
443        self.get(&url).await
444    }
445
446    /// Get page appearance/removal history
447    #[instrument(skip(self))]
448    pub async fn get_search_events_history(
449        &self,
450        host_id: &str,
451        request: &IndexingHistoryRequest,
452    ) -> Result<SearchEventsHistoryResponse> {
453        let url = format!(
454            "{}/user/{}/hosts/{}/search-urls/events/history?{}",
455            API_BASE_URL,
456            self.user_id,
457            host_id,
458            self.qs.serialize_string(request)?
459        );
460        self.get(&url).await
461    }
462
463    /// Get sample page changes
464    #[instrument(skip(self))]
465    pub async fn get_search_events_samples(
466        &self,
467        host_id: &str,
468        request: &GetSearchEventsSamplesRequest,
469    ) -> Result<SearchEventsSamplesResponse> {
470        let url = format!(
471            "{}/user/{}/hosts/{}/search-urls/events/samples?{}",
472            API_BASE_URL,
473            self.user_id,
474            host_id,
475            self.qs.serialize_string(request)?
476        );
477        self.get(&url).await
478    }
479
480    // ============================================================================
481    // Important URLs
482    // ============================================================================
483
484    /// Get list of important URLs
485    #[instrument(skip(self))]
486    pub async fn get_important_urls(&self, host_id: &str) -> Result<ImportantUrlsResponse> {
487        let url = format!(
488            "{}/user/{}/hosts/{}/important-urls",
489            API_BASE_URL, self.user_id, host_id
490        );
491        self.get(&url).await
492    }
493
494    /// Get important URLs history
495    #[instrument(skip(self))]
496    pub async fn get_important_urls_history(
497        &self,
498        host_id: &str,
499        url_param: &str,
500    ) -> Result<ImportantUrlHistoryResponse> {
501        let url = format!(
502            "{}/user/{}/hosts/{}/important-urls/history?url={}",
503            API_BASE_URL,
504            self.user_id,
505            host_id,
506            urlencoding::encode(url_param)
507        );
508        self.get(&url).await
509    }
510
511    // ============================================================================
512    // Recrawl Management
513    // ============================================================================
514
515    /// Request page recrawl
516    #[instrument(skip(self))]
517    pub async fn recrawl_urls(&self, host_id: &str, url: &str) -> Result<RecrawlResponse> {
518        let body = json!({ "url": url });
519        let url = format!(
520            "{}/user/{}/hosts/{}/recrawl/queue",
521            API_BASE_URL, self.user_id, host_id
522        );
523        self.post(&url, &body).await
524    }
525
526    /// Get list of recrawl tasks
527    #[instrument(skip(self))]
528    pub async fn get_recrawl_tasks(
529        &self,
530        host_id: &str,
531        request: &GetRecrawlTasksRequest,
532    ) -> Result<RecrawlTasksResponse> {
533        let url = format!(
534            "{}/user/{}/hosts/{}/recrawl/queue?{}",
535            API_BASE_URL,
536            self.user_id,
537            host_id,
538            self.qs.serialize_string(request)?
539        );
540        self.get(&url).await
541    }
542
543    /// Get recrawl task status
544    #[instrument(skip(self))]
545    pub async fn get_recrawl_task(&self, host_id: &str, task_id: &str) -> Result<RecrawlTask> {
546        let url = format!(
547            "{}/user/{}/hosts/{}/recrawl/queue/{}",
548            API_BASE_URL, self.user_id, host_id, task_id
549        );
550        self.get(&url).await
551    }
552
553    /// Get recrawl quota
554    #[instrument(skip(self))]
555    pub async fn get_recrawl_quota(&self, host_id: &str) -> Result<RecrawlQuotaResponse> {
556        let url = format!(
557            "{}/user/{}/hosts/{}/recrawl/quota",
558            API_BASE_URL, self.user_id, host_id
559        );
560        self.get(&url).await
561    }
562
563    // ============================================================================
564    // Links
565    // ============================================================================
566
567    /// Get broken internal links samples
568    #[instrument(skip(self))]
569    pub async fn get_broken_links(
570        &self,
571        host_id: &str,
572        request: &BrokenLinksRequest,
573    ) -> Result<BrokenLinksResponse> {
574        let url = format!(
575            "{}/user/{}/hosts/{}/links/internal/broken/samples?{}",
576            API_BASE_URL,
577            self.user_id,
578            host_id,
579            self.qs.serialize_string(request)?
580        );
581        self.get(&url).await
582    }
583
584    /// Get broken links history
585    #[instrument(skip(self))]
586    pub async fn get_broken_links_history(
587        &self,
588        host_id: &str,
589        request: &BrokenLinkHistoryRequest,
590    ) -> Result<BrokenLinkHistoryResponse> {
591        let url = format!(
592            "{}/user/{}/hosts/{}/links/internal/broken/history?{}",
593            API_BASE_URL,
594            self.user_id,
595            host_id,
596            self.qs.serialize_string(request)?
597        );
598        self.get(&url).await
599    }
600
601    /// Get external backlinks samples
602    #[instrument(skip(self))]
603    pub async fn get_external_links(
604        &self,
605        host_id: &str,
606        request: &ExternalLinksRequest,
607    ) -> Result<ExternalLinksResponse> {
608        let url = format!(
609            "{}/user/{}/hosts/{}/links/external/samples?{}",
610            API_BASE_URL,
611            self.user_id,
612            host_id,
613            self.qs.serialize_string(request)?
614        );
615        self.get(&url).await
616    }
617
618    /// Get backlinks history
619    #[instrument(skip(self))]
620    pub async fn get_external_links_history(
621        &self,
622        host_id: &str,
623    ) -> Result<ExternalLinksHistoryResponse> {
624        let url = format!(
625            "{}/user/{}/hosts/{}/links/external/history?indicator=LINKS_TOTAL_COUNT",
626            API_BASE_URL, self.user_id, host_id
627        );
628        self.get(&url).await
629    }
630
631    // ============================================================================
632    // Diagnostics
633    // ============================================================================
634
635    /// Get site diagnostic report
636    #[instrument(skip(self))]
637    pub async fn get_diagnostics(&self, host_id: &str) -> Result<DiagnosticsResponse> {
638        let url = format!(
639            "{}/user/{}/hosts/{}/diagnostics",
640            API_BASE_URL, self.user_id, host_id
641        );
642        self.get(&url).await
643    }
644
645    // ============================================================================
646    // Helper Methods
647    // ============================================================================
648
649    /// Generic GET request helper
650    #[instrument(skip(self))]
651    async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
652        tracing::debug!(url = %url, "Making GET request");
653
654        let response = self.client.get(url).send().await?;
655
656        Self::handle_response(response).await
657    }
658
659    /// Generic POST request helper
660    #[instrument(skip(self, body))]
661    async fn post<B: serde::Serialize, T: serde::de::DeserializeOwned>(
662        &self,
663        url: &str,
664        body: &B,
665    ) -> Result<T> {
666        tracing::debug!(url = %url, "Making POST request");
667
668        let json_body = serde_json::to_string(body)?;
669
670        let response = self
671            .client
672            .post(url)
673            .header("Content-Type", "application/json")
674            .body(json_body)
675            .send()
676            .await?;
677
678        Self::handle_response(response).await
679    }
680
681    /// Generic DELETE request helper
682    #[instrument(skip(self))]
683    async fn delete(&self, url: &str) -> Result<()> {
684        tracing::debug!(url = %url, "Making DELETE request");
685
686        let response = self.client.delete(url).send().await?;
687
688        if !response.status().is_success() {
689            return Err(Self::parse_error(response).await);
690        }
691
692        Ok(())
693    }
694
695    /// Parse API error response
696    #[instrument(skip(response))]
697    async fn parse_error(response: reqwest::Response) -> YandexWebmasterError {
698        let status = response.status();
699        let status_code = status.as_u16();
700
701        // Try to parse structured error response
702        match response.text().await {
703            Ok(error_text) => {
704                // Try to parse as structured Yandex API error
705                match serde_json::from_str::<YandexApiErrorResponse>(&error_text) {
706                    Ok(api_error) => {
707                        tracing::error!(
708                            status = %status,
709                            error_code = %api_error.error_code,
710                            error_message = %api_error.error_message,
711                            "Structured API error"
712                        );
713                        YandexWebmasterError::ApiError {
714                            status: status_code,
715                            response: api_error,
716                        }
717                    }
718                    Err(_) => {
719                        // Fallback to generic error
720                        tracing::error!(
721                            status = %status,
722                            error = %error_text,
723                            "API request failed with unstructured error"
724                        );
725                        YandexWebmasterError::GenericApiError(format!(
726                            "Status: {}, Error: {}",
727                            status, error_text
728                        ))
729                    }
730                }
731            }
732            Err(e) => {
733                tracing::error!(
734                    status = %status,
735                    error = %e,
736                    "Failed to read error response"
737                );
738                YandexWebmasterError::GenericApiError(format!(
739                    "Status: {}, Failed to read error response: {}",
740                    status, e
741                ))
742            }
743        }
744    }
745
746    /// Handle API response
747    #[instrument(skip(response))]
748    async fn handle_response<T: serde::de::DeserializeOwned>(
749        response: reqwest::Response,
750    ) -> Result<T> {
751        if !response.status().is_success() {
752            return Err(Self::parse_error(response).await);
753        }
754
755        let data: T = response.json().await?;
756        Ok(data)
757    }
758}