use reqwest_middleware::ClientBuilder;
use serde_json::json;
use serde_qs::ArrayFormat;
use tracing::instrument;
use crate::{
dto::*,
error::{Result, YandexApiErrorResponse, YandexWebmasterError},
middleware::AuthMiddleware,
};
const API_BASE_URL: &str = "https://api.webmaster.yandex.net/v4";
#[derive(Debug, Clone)]
pub struct YandexWebmasterClient {
client: reqwest_middleware::ClientWithMiddleware,
user_id: i64,
qs: serde_qs::Config,
}
impl YandexWebmasterClient {
pub fn oauth_url(client_id: &str) -> String {
format!("https://oauth.yandex.ru/authorize?response_type=token&client_id={client_id}")
}
#[instrument(skip(oauth_token))]
pub async fn new(oauth_token: String) -> Result<Self> {
let client = ClientBuilder::new(reqwest::Client::new())
.with(AuthMiddleware::new(oauth_token))
.build();
let user_response = Self::fetch_user(&client).await?;
tracing::info!(
user_id = user_response.user_id,
"Successfully authenticated"
);
Ok(Self {
client,
user_id: user_response.user_id,
qs: serde_qs::Config::new().array_format(ArrayFormat::Unindexed),
})
}
#[instrument(skip(oauth_token, client))]
pub async fn with_client(oauth_token: String, client: ClientBuilder) -> Result<Self> {
let client = client.with(AuthMiddleware::new(oauth_token)).build();
let user_response = Self::fetch_user(&client).await?;
tracing::info!(
user_id = user_response.user_id,
"Successfully authenticated"
);
Ok(Self {
client,
user_id: user_response.user_id,
qs: serde_qs::Config::new().array_format(ArrayFormat::Unindexed),
})
}
#[instrument(skip(client))]
async fn fetch_user(client: &reqwest_middleware::ClientWithMiddleware) -> Result<UserResponse> {
let url = format!("{}/user", API_BASE_URL);
tracing::debug!(url = %url, "Fetching user information");
let response = client.get(&url).send().await?;
if !response.status().is_success() {
return Err(Self::parse_error(response).await);
}
let user_response: UserResponse = response.json().await?;
Ok(user_response)
}
pub fn user_id(&self) -> i64 {
self.user_id
}
#[instrument(skip(self))]
pub async fn get_hosts(&self) -> Result<Vec<HostInfo>> {
let url = format!("{}/user/{}/hosts", API_BASE_URL, self.user_id);
let result: HostsResponse = self.get(&url).await?;
Ok(result.hosts)
}
#[instrument(skip(self))]
pub async fn add_host(
&self,
host_url: &str,
verification_type: VerificationType,
) -> Result<AddHostResponse> {
let url = format!("{}/user/{}/hosts", API_BASE_URL, self.user_id);
self.post(
&url,
&json!({ "host_url": host_url.to_string(), "verification_type": verification_type }),
)
.await
}
#[instrument(skip(self))]
pub async fn get_host(&self, host_id: &str) -> Result<FullHostInfo> {
let url = format!("{}/user/{}/hosts/{}", API_BASE_URL, self.user_id, host_id);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn delete_host(&self, host_id: &str) -> Result<()> {
let url = format!("{}/user/{}/hosts/{}", API_BASE_URL, self.user_id, host_id);
self.delete(&url).await
}
#[instrument(skip(self))]
pub async fn get_verification_status(&self, host_id: &str) -> Result<HostVerificationResponse> {
let url = format!(
"{}/user/{}/hosts/{}/verification",
API_BASE_URL, self.user_id, host_id
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn verify_host(
&self,
host_id: &str,
verification_type: ExplicitVerificationType,
) -> Result<HostVerificationResponse> {
let serialized = serde_json::to_value(verification_type)?;
let verification_type = serialized.clone().as_str().unwrap_or("").to_owned();
let url = format!(
"{}/user/{}/hosts/{}/verification?verification_type={}",
API_BASE_URL, self.user_id, host_id, verification_type
);
self.post(&url, &()).await
}
#[instrument(skip(self))]
pub async fn get_owners(&self, host_id: &str) -> Result<Vec<Owner>> {
let url = format!(
"{}/user/{}/hosts/{}/owners",
API_BASE_URL, self.user_id, host_id
);
let result: OwnersResponse = self.get(&url).await?;
Ok(result.users)
}
#[instrument(skip(self))]
pub async fn get_host_summary(&self, host_id: &str) -> Result<HostSummaryResponse> {
let url = format!(
"{}/user/{}/hosts/{}/summary",
API_BASE_URL, self.user_id, host_id
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_sqi_history(
&self,
host_id: &str,
req: SqiHistoryRequest,
) -> Result<Vec<SqiPoint>> {
let url = format!(
"{}/user/{}/hosts/{}/sqi-history?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(&req)?
);
let result: SqiHistoryResponse = self.get(&url).await?;
Ok(result.points)
}
#[instrument(skip(self))]
pub async fn get_popular_queries(
&self,
host_id: &str,
request: &PopularQueriesRequest,
) -> Result<PopularQueriesResponse> {
let url = format!(
"{}/user/{}/hosts/{}/search-queries/popular?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_query_analytics(
&self,
host_id: &str,
request: &QueryAnalyticsRequest,
) -> Result<QueryAnalyticsResponse> {
let url = format!(
"{}/user/{}/hosts/{}/search-queries/all/history?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_query_history(
&self,
host_id: &str,
query_id: &str,
request: &QueryHistoryRequest,
) -> Result<QueryHistoryResponse> {
let url = format!(
"{}/user/{}/hosts/{}/search-queries/{}/history?{}",
API_BASE_URL,
self.user_id,
host_id,
query_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_sitemaps(
&self,
host_id: &str,
request: &GetSitemapsRequest,
) -> Result<SitemapsResponse> {
let url = format!(
"{}/user/{}/hosts/{}/sitemaps?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_sitemap(&self, host_id: &str, sitemap_id: &str) -> Result<SitemapInfo> {
let url = format!(
"{}/user/{}/hosts/{}/sitemaps/{}",
API_BASE_URL, self.user_id, host_id, sitemap_id
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_user_sitemaps(
&self,
host_id: &str,
request: &GetUserSitemapsRequest,
) -> Result<UserSitemapsResponse> {
let url = format!(
"{}/user/{}/hosts/{}/user-added-sitemaps?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn add_sitemap(&self, host_id: &str, url: &str) -> Result<AddSitemapResponse> {
let body = json!({ "url": url.to_string() });
let url = format!(
"{}/user/{}/hosts/{}/user-added-sitemaps",
API_BASE_URL, self.user_id, host_id
);
self.post(&url, &body).await
}
#[instrument(skip(self))]
pub async fn get_user_sitemap(
&self,
host_id: &str,
sitemap_id: &str,
) -> Result<UserSitemapInfo> {
let url = format!(
"{}/user/{}/hosts/{}/user-added-sitemaps/{}",
API_BASE_URL, self.user_id, host_id, sitemap_id
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn delete_sitemap(&self, host_id: &str, sitemap_id: &str) -> Result<()> {
let url = format!(
"{}/user/{}/hosts/{}/user-added-sitemaps/{}",
API_BASE_URL, self.user_id, host_id, sitemap_id
);
self.delete(&url).await
}
#[instrument(skip(self))]
pub async fn get_indexing_history(
&self,
host_id: &str,
request: &IndexingHistoryRequest,
) -> Result<IndexingHistoryResponse> {
let url = format!(
"{}/user/{}/hosts/{}/indexing/history?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_indexing_samples(
&self,
host_id: &str,
request: &GetIndexingSamplesRequest,
) -> Result<IndexingSamplesResponse> {
let url = format!(
"{}/user/{}/hosts/{}/indexing/samples?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_search_urls_history(
&self,
host_id: &str,
request: &IndexingHistoryRequest,
) -> Result<SearchUrlsHistoryResponse> {
let url = format!(
"{}/user/{}/hosts/{}/search-urls/in-search/history?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_search_urls_samples(
&self,
host_id: &str,
request: &GetSearchUrlsSamplesRequest,
) -> Result<SearchUrlsSamplesResponse> {
let url = format!(
"{}/user/{}/hosts/{}/search-urls/in-search/samples?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_search_events_history(
&self,
host_id: &str,
request: &IndexingHistoryRequest,
) -> Result<SearchEventsHistoryResponse> {
let url = format!(
"{}/user/{}/hosts/{}/search-urls/events/history?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_search_events_samples(
&self,
host_id: &str,
request: &GetSearchEventsSamplesRequest,
) -> Result<SearchEventsSamplesResponse> {
let url = format!(
"{}/user/{}/hosts/{}/search-urls/events/samples?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_important_urls(&self, host_id: &str) -> Result<ImportantUrlsResponse> {
let url = format!(
"{}/user/{}/hosts/{}/important-urls",
API_BASE_URL, self.user_id, host_id
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_important_urls_history(
&self,
host_id: &str,
url_param: &str,
) -> Result<ImportantUrlHistoryResponse> {
let url = format!(
"{}/user/{}/hosts/{}/important-urls/history?url={}",
API_BASE_URL,
self.user_id,
host_id,
urlencoding::encode(url_param)
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn recrawl_urls(&self, host_id: &str, url: &str) -> Result<RecrawlResponse> {
let body = json!({ "url": url });
let url = format!(
"{}/user/{}/hosts/{}/recrawl/queue",
API_BASE_URL, self.user_id, host_id
);
self.post(&url, &body).await
}
#[instrument(skip(self))]
pub async fn get_recrawl_tasks(
&self,
host_id: &str,
request: &GetRecrawlTasksRequest,
) -> Result<RecrawlTasksResponse> {
let url = format!(
"{}/user/{}/hosts/{}/recrawl/queue?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_recrawl_task(&self, host_id: &str, task_id: &str) -> Result<RecrawlTask> {
let url = format!(
"{}/user/{}/hosts/{}/recrawl/queue/{}",
API_BASE_URL, self.user_id, host_id, task_id
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_recrawl_quota(&self, host_id: &str) -> Result<RecrawlQuotaResponse> {
let url = format!(
"{}/user/{}/hosts/{}/recrawl/quota",
API_BASE_URL, self.user_id, host_id
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_broken_links(
&self,
host_id: &str,
request: &BrokenLinksRequest,
) -> Result<BrokenLinksResponse> {
let url = format!(
"{}/user/{}/hosts/{}/links/internal/broken/samples?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_broken_links_history(
&self,
host_id: &str,
request: &BrokenLinkHistoryRequest,
) -> Result<BrokenLinkHistoryResponse> {
let url = format!(
"{}/user/{}/hosts/{}/links/internal/broken/history?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_external_links(
&self,
host_id: &str,
request: &ExternalLinksRequest,
) -> Result<ExternalLinksResponse> {
let url = format!(
"{}/user/{}/hosts/{}/links/external/samples?{}",
API_BASE_URL,
self.user_id,
host_id,
self.qs.serialize_string(request)?
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_external_links_history(
&self,
host_id: &str,
) -> Result<ExternalLinksHistoryResponse> {
let url = format!(
"{}/user/{}/hosts/{}/links/external/history?indicator=LINKS_TOTAL_COUNT",
API_BASE_URL, self.user_id, host_id
);
self.get(&url).await
}
#[instrument(skip(self))]
pub async fn get_diagnostics(&self, host_id: &str) -> Result<DiagnosticsResponse> {
let url = format!(
"{}/user/{}/hosts/{}/diagnostics",
API_BASE_URL, self.user_id, host_id
);
self.get(&url).await
}
#[instrument(skip(self))]
async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
tracing::debug!(url = %url, "Making GET request");
let response = self.client.get(url).send().await?;
Self::handle_response(response).await
}
#[instrument(skip(self, body))]
async fn post<B: serde::Serialize, T: serde::de::DeserializeOwned>(
&self,
url: &str,
body: &B,
) -> Result<T> {
tracing::debug!(url = %url, "Making POST request");
let json_body = serde_json::to_string(body)?;
let response = self
.client
.post(url)
.header("Content-Type", "application/json")
.body(json_body)
.send()
.await?;
Self::handle_response(response).await
}
#[instrument(skip(self))]
async fn delete(&self, url: &str) -> Result<()> {
tracing::debug!(url = %url, "Making DELETE request");
let response = self.client.delete(url).send().await?;
if !response.status().is_success() {
return Err(Self::parse_error(response).await);
}
Ok(())
}
#[instrument(skip(response))]
async fn parse_error(response: reqwest::Response) -> YandexWebmasterError {
let status = response.status();
let status_code = status.as_u16();
match response.text().await {
Ok(error_text) => {
match serde_json::from_str::<YandexApiErrorResponse>(&error_text) {
Ok(api_error) => {
tracing::error!(
status = %status,
error_code = %api_error.error_code,
error_message = %api_error.error_message,
"Structured API error"
);
YandexWebmasterError::ApiError {
status: status_code,
response: api_error,
}
}
Err(_) => {
tracing::error!(
status = %status,
error = %error_text,
"API request failed with unstructured error"
);
YandexWebmasterError::GenericApiError(format!(
"Status: {}, Error: {}",
status, error_text
))
}
}
}
Err(e) => {
tracing::error!(
status = %status,
error = %e,
"Failed to read error response"
);
YandexWebmasterError::GenericApiError(format!(
"Status: {}, Failed to read error response: {}",
status, e
))
}
}
}
#[instrument(skip(response))]
async fn handle_response<T: serde::de::DeserializeOwned>(
response: reqwest::Response,
) -> Result<T> {
if !response.status().is_success() {
return Err(Self::parse_error(response).await);
}
let data: T = response.json().await?;
Ok(data)
}
}