Skip to main content

data_for_seo/
lib.rs

1//! Rust client for the [DataForSEO API v3](https://docs.dataforseo.com/v3/).
2//!
3//! # Quick start
4//!
5//! ```no_run
6//! use data_for_seo::DataForSeoClient;
7//!
8//! # async fn example() -> Result<(), data_for_seo::error::DataForSeoError> {
9//! let client = DataForSeoClient::new("login", "password");
10//! let response = client.backlinks().summary(vec![
11//!     data_for_seo::BacklinksApiSummaryPost::new("https://example.com/"),
12//! ]).await?;
13//! for result in response.results() {
14//!     println!("{:?}", result);
15//! }
16//! # Ok(())
17//! # }
18//! ```
19//!
20//! The client is organized by API domain, mirroring the structure of the
21//! DataForSEO documentation:
22//!
23//! - [`DataForSeoClient::serp`] — SERP API (Google, Bing, YouTube, ...)
24//! - [`DataForSeoClient::keywords_data`] — Keywords Data API
25//! - [`DataForSeoClient::dataforseo_labs`] — DataForSEO Labs API
26//! - [`DataForSeoClient::backlinks`] — Backlinks API
27//! - [`DataForSeoClient::on_page`] — OnPage API
28//! - [`DataForSeoClient::domain_analytics`] — Domain Analytics API
29//! - [`DataForSeoClient::content_analysis`] — Content Analysis API
30//! - [`DataForSeoClient::content_generation`] — Content Generation API
31//! - [`DataForSeoClient::merchant`] — Merchant API
32//! - [`DataForSeoClient::app_data`] — App Data API
33//! - [`DataForSeoClient::business_data`] — Business Data API
34//! - [`DataForSeoClient::ai_optimization`] — AI Optimization API
35//! - [`DataForSeoClient::appendix`] — Appendix (account data)
36//!
37//! # Response handling
38//!
39//! Every call returns [`DataForSeoApiResponse`], whose success value wraps the
40//! standard DataForSEO envelope. Use [`DataForSeoApiResponseData::results`],
41//! [`DataForSeoApiResponseData::tasks`], or
42//! [`DataForSeoApiResponseData::first_result`] to reach the payload without
43//! unwrapping nested `Option`s.
44//!
45//! # Sandbox
46//!
47//! [`DataForSeoClient::sandbox`] switches the client to the free
48//! [sandbox environment](https://docs.dataforseo.com/v3/appendix/sandbox/),
49//! which returns mock data for most endpoints.
50
51#![warn(missing_docs)]
52
53mod api;
54pub mod entity;
55pub mod error;
56pub use api::*;
57pub use entity::*;
58
59use crate::error::{
60    DataForSeoApiError, DataForSeoError, DataForSeoHttpError, DataForSeoSystemError,
61};
62use base64::Engine;
63use base64::prelude::BASE64_STANDARD;
64use serde_json::Value;
65
66/// Production API endpoint.
67pub const DEFAULT_BASE_URL: &str = "https://api.dataforseo.com";
68/// Sandbox endpoint — free of charge, returns mock data.
69/// See <https://docs.dataforseo.com/v3/appendix/sandbox/>.
70pub const SANDBOX_BASE_URL: &str = "https://sandbox.dataforseo.com";
71
72/// Entry point for all DataForSEO API calls.
73///
74/// Holds a shared [`reqwest::Client`] (connection pool) and the Basic-auth
75/// token. Cloning is cheap and clones share the connection pool.
76#[derive(Debug, Clone)]
77pub struct DataForSeoClient {
78    http: reqwest::Client,
79    base_url: String,
80    token: String,
81}
82
83impl DataForSeoClient {
84    /// Creates a client authenticated with the given API login and password.
85    pub fn new<L: Into<String>, P: Into<String>>(login: L, password: P) -> DataForSeoClient {
86        DataForSeoClient {
87            http: reqwest::Client::new(),
88            base_url: DEFAULT_BASE_URL.to_string(),
89            token: BASE64_STANDARD.encode(format!("{}:{}", login.into(), password.into())),
90        }
91    }
92
93    /// Creates a client from the `DATAFORSEO_LOGIN` and `DATAFORSEO_PASSWORD`
94    /// environment variables.
95    ///
96    /// Returns an error naming the missing variable when either is not set.
97    pub fn from_env() -> Result<DataForSeoClient, DataForSeoError> {
98        let login = std::env::var("DATAFORSEO_LOGIN")
99            .map_err(|_| DataForSeoSystemError::new("DATAFORSEO_LOGIN is not set"))?;
100        let password = std::env::var("DATAFORSEO_PASSWORD")
101            .map_err(|_| DataForSeoSystemError::new("DATAFORSEO_PASSWORD is not set"))?;
102        Ok(DataForSeoClient::new(login, password))
103    }
104
105    /// Overrides the base URL (e.g. [`SANDBOX_BASE_URL`]).
106    pub fn with_base_url<T: Into<String>>(mut self, base_url: T) -> Self {
107        self.base_url = base_url.into().trim_end_matches('/').to_string();
108        self
109    }
110
111    /// Switches the client to the free sandbox environment.
112    pub fn sandbox(self) -> Self {
113        self.with_base_url(SANDBOX_BASE_URL)
114    }
115
116    /// Replaces the underlying HTTP client (to customize timeouts, proxies, ...).
117    pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
118        self.http = http;
119        self
120    }
121}
122
123/// Result alias returned by every API method.
124pub type DataForSeoApiResponse<R> = Result<entity::DataForSeoApiResponseData<R>, DataForSeoError>;
125
126impl DataForSeoClient {
127    fn endpoint(&self, path: &str) -> String {
128        format!("{}{}", self.base_url, path)
129    }
130
131    async fn read_response<R>(response: reqwest::Response) -> DataForSeoApiResponse<R>
132    where
133        R: for<'de> serde::Deserialize<'de>,
134    {
135        let status = response.status().as_u16();
136        let body = match response.text().await {
137            Ok(v) => v,
138            Err(e) => return Err(DataForSeoHttpError::new(status, e.to_string()).into()),
139        };
140
141        let value = match serde_json::from_str::<Value>(&body) {
142            Ok(v) => v,
143            Err(_) => return Err(DataForSeoHttpError::new(status, body).into()),
144        };
145
146        if status >= 400 {
147            return Err(DataForSeoApiError::from_envelope(status, &value, body).into());
148        }
149
150        match serde_json::from_value::<entity::DataForSeoApiResponseData<R>>(value.clone()) {
151            Ok(v) => Ok(v),
152            Err(e) => Err(DataForSeoSystemError::new(format!(
153                "failed to deserialize response: {e} body={value}"
154            ))
155            .into()),
156        }
157    }
158
159    pub(crate) async fn http_get<R>(&self, path: &str) -> DataForSeoApiResponse<R>
160    where
161        R: for<'de> serde::Deserialize<'de>,
162    {
163        let request = self
164            .http
165            .get(self.endpoint(path))
166            .header("Authorization", format!("Basic {}", self.token));
167        match request.send().await {
168            Ok(response) => Self::read_response(response).await,
169            Err(e) => Err(DataForSeoSystemError::new(format!("request failed: {e}")).into()),
170        }
171    }
172
173    pub(crate) async fn http_post<P, R>(&self, path: &str, body: &P) -> DataForSeoApiResponse<R>
174    where
175        P: serde::Serialize,
176        R: for<'de> serde::Deserialize<'de>,
177    {
178        let request = self
179            .http
180            .post(self.endpoint(path))
181            .header("Authorization", format!("Basic {}", self.token))
182            .json(body);
183        match request.send().await {
184            Ok(response) => Self::read_response(response).await,
185            Err(e) => Err(DataForSeoSystemError::new(format!("request failed: {e}")).into()),
186        }
187    }
188}