diff --git a/.gitignore b/.gitignore index c365c3d..72be7bd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /Cargo.lock *.log sandbox/ +.env diff --git a/Cargo.lock b/Cargo.lock index 23030f2..c5db4f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -605,6 +605,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "0.8.4" @@ -892,6 +902,7 @@ dependencies = [ "lazy_static", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -1013,7 +1024,7 @@ dependencies = [ [[package]] name = "serpapi" -version = "1.2.0" +version = "1.3.0" dependencies = [ "criterion", "futures", @@ -1202,6 +1213,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.8" diff --git a/Cargo.toml b/Cargo.toml index 24cfb8c..5d117e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "serpapi" -version = "1.2.0" +version = "1.3.0" edition = "2021" description = "Integrate search data into your rust application. This library is the official wrapper for SerpApi. SerpApi supports Google, Google Maps, Google Shopping, Baidu, Yandex, Yahoo, eBay, App Stores, and more." readme = "README.md" @@ -25,8 +25,8 @@ include = [ serde_json = "1.0" # Asynchronous runtime tokio = { version = "1", features = ["full"] } -# HTTP client -reqwest = "0.11.7" +# HTTP client, multipart is required by the Image API upload +reqwest = { version = "0.11.7", features = ["multipart"] } [dev-dependencies] criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } diff --git a/README.md b/README.md index a165106..bdad9d0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Integrate search data into your AI workflow, RAG, fine-tuning, or Rust applicati To install in your Rust application, update Cargo.toml: ```sh -serpapi="1.2.0" +serpapi="1.3.0" ``` ## Quick start @@ -102,6 +102,33 @@ async fn main() -> Result<(), Box> { More examples are available below. +### Response formats + +Use `search` for structured results decoded into a `serde_json::Value`: + +```rust +let results = client.search(parameter).await?; +``` + +Use `md` for a token-efficient Markdown `String` optimized for LLMs and AI agents. +It returns clean headings, links and tables behind a YAML frontmatter while using +roughly half the tokens of the JSON output: + +```rust +let markdown = client.md(parameter).await?; +``` + +Use `html` when you need the raw response from the search engine: + +```rust +let raw_html = client.html(parameter).await?; +``` + +`md` and `html` always force the output format, so any `output` search parameter is ignored. +Archived results are also available as Markdown with `client.search_archive_md(&id).await?`. + +Learn more about [SerpApi Markdown output](https://serpapi.com/markdown-output). + #### Documentation * [Full documentation on SerpApi.com](https://serpapi.com) @@ -118,7 +145,7 @@ default.insert("api_key".to_string(), "your_secret_key".to_string()); let client = Client::new(default).unwrap(); let mut parameter = HashMap::::new(); parameter.insert("q".to_string(), "coffee".to_string()); -let markdown = client.markdown(parameter).await.expect("request"); +let markdown = client.md(parameter).await.expect("request"); ``` It returns the search results formatted as Markdown. @@ -136,6 +163,39 @@ let locations = data.as_array().unwrap(); It returns the first 3 locations matching Austin (Texas, Texas, Rochester). +### Image API + +Upload an image with the Image API, then use its `image_id` with a Search API engine +supporting uploaded images such as [Google Lens](https://serpapi.com/google-lens-upload-an-image). +Supported formats are jpg, jpeg, png and webp up to 500 KB. Uploaded image IDs expire after 10 minutes. + +```rust +let mut default = HashMap::::new(); +default.insert("api_key".to_string(), "your_secret_key".to_string()); +let client = Client::new(default).unwrap(); + +// upload an image from a file path +let upload = client.upload_image("./image.jpg", HashMap::new()).await?; +let image_id = upload["image_id"].as_str().expect("image id"); + +// search with google lens using the uploaded image +let mut parameter = HashMap::::new(); +parameter.insert("engine".to_string(), "google_lens".to_string()); +parameter.insert("image_id".to_string(), image_id.to_string()); +let results = client.search(parameter).await?; +let visual_matches = results["visual_matches"].as_array().unwrap(); +``` + +An in-memory image can be uploaded with `upload_image_bytes`: + +```rust +let upload = client.upload_image_bytes(image_bytes, "image.png", HashMap::new()).await?; +``` + +`upload_image` returns an error when the image is rejected by the API, for instance because of an unsupported format. + +[See Image API documentation](https://serpapi.com/image-api) ยท [See Google Lens image upload documentation](https://serpapi.com/google-lens-upload-an-image) + ### Search Archive API ```rust @@ -167,6 +227,12 @@ println!("{}", archived_results); assert_eq!(archive_id, search_id); ``` +The same archived search is available as Markdown: + +```rust +let markdown = client.search_archive_md(&id).await.expect("request"); +``` + ### Account API ```rust let client = Client::new(HashMap::::new()); @@ -178,11 +244,17 @@ let account = client.account(parameter).await.expect("request"); It returns your account information. ### Technical features +- Search results as JSON with `search`, Markdown with `md`, or raw search engine HTML with `html` +- Image upload for Google Lens and other engines with `upload_image` - Dynamic JSON decoding using Serde JSON - Asyncronous HTTP request handle method using tokio and reqwest - Async tests using Tokio ### Change log +- 1.3.0: + - Add Image API support with `client.upload_image(path, parameter)` and `client.upload_image_bytes(bytes, file_name, parameter)`. + - Requests are sent over https directly instead of following the http redirect. +- 1.2.1: Remove `markdown()` which called a non-existent endpoint, use `client.md(parameter)` and `client.search_archive_md(&id)` to return search results formatted as Markdown. - 1.2.0: Add `markdown` method to return search results formatted as Markdown. - 1.1.0: Always reuse the same client object instead of creating a new one for each search. - This is a breaking change for the API because the client must be unwrapped in the main function. @@ -1496,6 +1568,68 @@ Ok(()) * source code: [examples/google_images_search.rs](https://github.com/serpapi/serpapi-rust/blob/master/examples/google_images_search.rs) see: [https://serpapi.com/images-results](https://serpapi.com/images-results) +### Search google lens with an uploaded image +```rust +let mut default = HashMap::new(); +default.insert("api_key".to_string(), "your_secret_api_key".to_string()); +default.insert("engine".to_string(), "google_lens".to_string()); +// initialize the search engine +let client = Client::new(default).unwrap(); + +// upload the image given on the command line, +// or download a sample image when no path is provided: +// cargo run --example google_lens_upload_image -- ./image.jpg +println!("uploading..."); +let upload = match env::args().nth(1) { + Some(path) => client.upload_image(&path, HashMap::new()).await?, + None => { + let image = reqwest::get("https://i.imgur.com/5bGzZi7.jpg") + .await? + .bytes() + .await? + .to_vec(); + client + .upload_image_bytes(image, "image.jpg", HashMap::new()) + .await? + } +}; +let image_id = upload["image_id"].as_str().unwrap(); +println!(" - image uploaded with id: {}", image_id); + +// let's search with google lens using the uploaded image +let mut parameter = HashMap::new(); +parameter.insert("image_id".to_string(), image_id.to_string()); + +// search returns a JSON as serde_json::Value which can be accessed like a HashMap. +println!("waiting..."); +let results = client.search(parameter).await?; +println!("results received"); +println!("--- JSON ---"); +let status = &results["search_metadata"]["status"]; +if status != "Success" { + println!("search failed with status: {}", status); +} else { + println!("search is successfull"); + let visual_matches = results["visual_matches"].as_array().unwrap(); + println!(" - number of visual_matches: {}", visual_matches.len()); + println!( + " - visual_matches first result description: {}", + results["visual_matches"][0] + ); + println!( + " - async search completed with {}\n", + results["search_parameters"]["engine"] + ); +} + +print!("ok"); +Ok(()) + +``` + + * source code: [examples/google_lens_upload_image.rs](https://github.com/serpapi/serpapi-rust/blob/master/examples/google_lens_upload_image.rs) +see: [https://serpapi.com/google-lens-upload-an-image](https://serpapi.com/google-lens-upload-an-image) + ## License [MIT License](LICENSE) diff --git a/examples/google_lens_upload_image.rs b/examples/google_lens_upload_image.rs new file mode 100644 index 0000000..d540778 --- /dev/null +++ b/examples/google_lens_upload_image.rs @@ -0,0 +1,74 @@ +// search example for google_lens with an uploaded image +// +use serpapi::serpapi::Client; +use std::collections::HashMap; +use std::env; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Read your private API Key from an environment variable. + // Copy/paste from [https://serpapi.com/dashboard] to your shell: + // ```bash + // export API_key="paste_your_private_api_key" + // ``` + let api_key = match env::var_os("SERPAPI_KEY") { + Some(v) => v.into_string().unwrap(), + None => panic!("$SERPAPI_KEY environment variable is not set!"), + }; + + println!("let's initiliaze the client to search on google_lens"); + let mut default = HashMap::new(); + default.insert("api_key".to_string(), api_key); + default.insert("engine".to_string(), "google_lens".to_string()); + // initialize the search engine + let client = Client::new(default).unwrap(); + + // upload the image given on the command line, + // or download a sample image when no path is provided: + // cargo run --example google_lens_upload_image -- ./image.jpg + println!("uploading..."); + let upload = match env::args().nth(1) { + Some(path) => client.upload_image(&path, HashMap::new()).await?, + None => { + let image = reqwest::get("https://i.imgur.com/5bGzZi7.jpg") + .await? + .bytes() + .await? + .to_vec(); + client + .upload_image_bytes(image, "image.jpg", HashMap::new()) + .await? + } + }; + let image_id = upload["image_id"].as_str().unwrap(); + println!(" - image uploaded with id: {}", image_id); + + // let's search with google lens using the uploaded image + let mut parameter = HashMap::new(); + parameter.insert("image_id".to_string(), image_id.to_string()); + + // search returns a JSON as serde_json::Value which can be accessed like a HashMap. + println!("waiting..."); + let results = client.search(parameter).await?; + println!("results received"); + println!("--- JSON ---"); + let status = &results["search_metadata"]["status"]; + if status != "Success" { + println!("search failed with status: {}", status); + } else { + println!("search is successfull"); + let visual_matches = results["visual_matches"].as_array().unwrap(); + println!(" - number of visual_matches: {}", visual_matches.len()); + println!( + " - visual_matches first result description: {}", + results["visual_matches"][0] + ); + println!( + " - async search completed with {}\n", + results["search_parameters"]["engine"] + ); + } + + print!("ok"); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 34e812d..12aa087 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,4 +5,10 @@ //! SerpApi.com enables to do localized search, leverage advanced search engine features and a lot more... //! A completed documentation is available at [SerpApi](https://serpapi.com). //! +//! Search results are available as JSON with `search`, as [Markdown](https://serpapi.com/markdown-output) +//! optimized for LLMs and AI agents with `md`, or as raw search engine HTML with `html`. +//! +//! Images can be uploaded with `upload_image` using the [Image API](https://serpapi.com/image-api) +//! and searched with engines such as Google Lens. +//! pub mod serpapi; diff --git a/src/serpapi.rs b/src/serpapi.rs index 4515572..fb27738 100644 --- a/src/serpapi.rs +++ b/src/serpapi.rs @@ -4,6 +4,7 @@ //! Client wraps a custom HTTP client designed for SerpApi.com //! use std::collections::HashMap; +use std::path::Path; // model serpapi client // because of Rust designed we propose to create a new search everytime @@ -17,7 +18,9 @@ pub struct Client { pub http: reqwest::Client, } -const HOST: &str = "http://serpapi.com"; +// https is required: a plain http request is redirected with a 301 +// which turns the Image API multipart POST into a GET. +const HOST: &str = "https://serpapi.com"; impl Client { /// initialize a serp api client with default parameters. @@ -44,7 +47,7 @@ impl Client { /// async fn main() { /// let mut default = std::collections::HashMap::::new(); /// default.insert("engine".to_string(), "google".to_string()); - /// default.insert("api_key".to_string(), "secret_api_key".to_string()); + /// default.insert("api_key".to_string(), std::env::var("SERPAPI_KEY").expect("$SERPAPI_KEY is not set")); /// // initialize the serpapi client /// let client = Client::new(default).unwrap(); /// let mut parameter = HashMap::::new(); @@ -67,41 +70,47 @@ impl Client { Ok(results) } - // execute a search and return the result as raw HTML formatted as String + /// execute a search on serpapi.com + /// and return the results as Markdown formatted as String. + /// The Markdown output is optimized for LLMs and AI agents. + /// It holds a YAML frontmatter followed by headings, links and tables + /// using about half the tokens of the JSON output. + /// see: https://serpapi.com/markdown-output /// # Arguments - /// * `parameter` html search parameter + /// * `parameter` search parameter, the output is always set to md. + /// /// # Examples: - /// ``` + /// ```no_run /// use std::collections::HashMap; /// use serpapi::serpapi::Client; /// /// #[tokio::main] /// async fn main() { - /// let mut default = HashMap::::new(); - /// default.insert("engine".to_string(), "google".to_string()); - /// default.insert("api_key".to_string(), "secret_api_key".to_string()); - /// // initialize the search engine - /// let client = Client::new(default).unwrap(); - /// let mut parameter = HashMap::::new(); - /// parameter.insert("q".to_string(), "coffee".to_string()); - /// parameter.insert("location".to_string(), "Austin, TX, Texas, United States".to_string()); - /// let html = client.html(parameter).await.expect("request"); - /// assert!(html.len() > 100); + /// let mut default = HashMap::::new(); + /// default.insert("engine".to_string(), "google".to_string()); + /// default.insert("api_key".to_string(), "secret_api_key".to_string()); + /// // initialize the serpapi client + /// let client = Client::new(default).unwrap(); + /// let mut parameter = HashMap::::new(); + /// parameter.insert("q".to_string(), "coffee".to_string()); + /// // md returns the search results as a Markdown String. + /// let markdown = client.md(parameter).await.expect("request"); + /// assert!(markdown.starts_with("---")); /// } /// ``` - pub async fn html( + pub async fn md( &self, parameter: HashMap, ) -> Result> { - let body = self.get("/html", parameter).await?; + let body = self.text("/search", force_output(parameter, "md")).await?; Ok(body) } - /// execute a search and return the result as Markdown formatted as String + // execute a search and return the result as raw HTML formatted as String /// # Arguments - /// * `parameter` markdown search parameter + /// * `parameter` html search parameter, the output is always set to html. /// # Examples: - /// ``` + /// ```no_run /// use std::collections::HashMap; /// use serpapi::serpapi::Client; /// @@ -114,15 +123,18 @@ impl Client { /// let client = Client::new(default).unwrap(); /// let mut parameter = HashMap::::new(); /// parameter.insert("q".to_string(), "coffee".to_string()); - /// let markdown = client.markdown(parameter).await.expect("request"); - /// assert!(markdown.len() > 100); + /// parameter.insert("location".to_string(), "Austin, TX, Texas, United States".to_string()); + /// let html = client.html(parameter).await.expect("request"); + /// assert!(html.len() > 100); /// } /// ``` - pub async fn markdown( + pub async fn html( &self, parameter: HashMap, ) -> Result> { - let body = self.get("/markdown", parameter).await?; + let body = self + .text("/search", force_output(parameter, "html")) + .await?; Ok(body) } @@ -152,6 +164,72 @@ impl Client { Ok(results) } + /// Upload an image using the Image API. + /// The returned `image_id` can be supplied to Search API engines + /// supporting uploaded images, such as Google Lens. + /// Supported formats: jpg, jpeg, png and webp up to 500 KB. + /// The `image_id` expires after 10 minutes. + /// see: https://serpapi.com/image-api + /// # Arguments + /// * `path` image file path + /// * `parameter` request parameter, such as an `api_key` overriding the client default + /// # Examples + /// ```no_run + /// use std::collections::HashMap; + /// use serpapi::serpapi::Client; + /// + /// #[tokio::main] + /// async fn main() { + /// let mut default = HashMap::::new(); + /// default.insert("api_key".to_string(), "secret_api_key".to_string()); + /// let client = Client::new(default).unwrap(); + /// let upload = client.upload_image("./image.jpg", HashMap::new()).await.expect("upload"); + /// let image_id = upload["image_id"].as_str().expect("image id"); + /// // search with google lens using the uploaded image + /// let mut parameter = HashMap::::new(); + /// parameter.insert("engine".to_string(), "google_lens".to_string()); + /// parameter.insert("image_id".to_string(), image_id.to_string()); + /// let results = client.search(parameter).await.expect("request"); + /// // let visual_matches = results["visual_matches"].as_array().unwrap(); + /// } + /// ``` + pub async fn upload_image>( + &self, + path: P, + parameter: HashMap, + ) -> Result> { + let path = path.as_ref(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("image"); + let image = tokio::fs::read(path).await?; + self.upload_image_bytes(image, file_name, parameter).await + } + + /// Upload an in-memory image using the Image API. + /// see: Client::upload_image + /// # Arguments + /// * `image` binary content of the image + /// * `file_name` file name including the extension, such as "image.png" + /// * `parameter` request parameter, such as an `api_key` overriding the client default + pub async fn upload_image_bytes( + &self, + image: Vec, + file_name: &str, + parameter: HashMap, + ) -> Result> { + let part = reqwest::multipart::Part::bytes(image) + .file_name(file_name.to_string()) + .mime_str(image_mime_type(file_name))?; + let form = reqwest::multipart::Form::new().part("image", part); + let results = self.post_multipart("/image", parameter, form).await?; + if let Some(error) = results["error"].as_str() { + return Err(format!("image upload failed with: {}", error).into()); + } + Ok(results) + } + // Retrieve search result from the Search Archive API pub async fn search_archive( &self, @@ -165,6 +243,21 @@ impl Client { Ok(results) } + /// Retrieve a search result from the Search Archive API as Markdown. + /// see: https://serpapi.com/markdown-output + /// # Arguments + /// * `search_id` from the original search: `results["search_metadata"]["id"]` + pub async fn search_archive_md( + &self, + search_id: &str, + ) -> Result> { + let mut endpoint = "/searches/".to_string(); + endpoint.push_str(search_id); + endpoint.push_str(".md"); + let body = self.text(&endpoint, HashMap::new()).await?; + Ok(body) + } + // Get account information using Account API pub async fn account( &self, @@ -181,15 +274,84 @@ impl Client { ) -> Result> { let body = self.get(endpoint, parameter).await?; //debug: println!("Body:\n{}", body); - let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + // a non JSON body means the output is html or md, see: Client::html and Client::md + let value: serde_json::Value = serde_json::from_str(&body)?; Ok(value) } + /// execute a request and return the body as String. + /// SerpApi reports errors as JSON even when html or md output is requested, + /// so a JSON response to a text request is reported as an error. + /// # Arguments + /// * `endpoint` HTTP service URI + /// * `parameter` search parameter + pub async fn text( + &self, + endpoint: &str, + parameter: HashMap, + ) -> Result> { + let (content_type, body) = self.raw(endpoint, parameter).await?; + if content_type.starts_with("application/json") { + return Err(format!("search failed on {} with: {}", endpoint, body).into()); + } + Ok(body) + } + pub async fn get( &self, endpoint: &str, parameter: HashMap, ) -> Result> { + let (_content_type, body) = self.raw(endpoint, parameter).await?; + Ok(body) + } + + /// execute a request and return the content type along with the body. + async fn raw( + &self, + endpoint: &str, + parameter: HashMap, + ) -> Result<(String, String), Box> { + let query = self.query(parameter); + let mut url = HOST.to_string(); + url.push_str(endpoint); + let res = self.http.get(url).query(&query).send().await?; + let status = res.status(); + let content_type = res + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_lowercase(); + let body = res.text().await?; + if !status.is_success() { + return Err(format!("{} failed with HTTP {}: {}", endpoint, status, body).into()); + } + Ok((content_type, body)) + } + + /// execute a multipart/form-data POST request and decode the JSON response. + /// the request parameters are sent as form fields alongside the given form parts. + async fn post_multipart( + &self, + endpoint: &str, + parameter: HashMap, + mut form: reqwest::multipart::Form, + ) -> Result> { + for (key, value) in self.query(parameter) { + form = form.text(key, value); + } + let mut url = HOST.to_string(); + url.push_str(endpoint); + let res = self.http.post(url).multipart(form).send().await?; + let body = res.text().await?; + let value: serde_json::Value = serde_json::from_str(&body)?; + Ok(value) + } + + /// merge the client default parameter with the request parameter. + /// the request parameter takes precedence over the client default. + fn query(&self, parameter: HashMap) -> HashMap { let mut query = HashMap::::new(); query.insert("source".to_string(), "rust".to_string()); for (key, value) in self.parameter.iter() { @@ -200,11 +362,28 @@ impl Client { for (key, value) in parameter.iter() { query.insert(key.to_string(), value.to_string()); } + query + } +} - let mut url = HOST.to_string(); - url.push_str(endpoint); - let res = self.http.get(url).query(&query).send().await?; - let body = res.text().await?; - Ok(body) +/// guess the image MIME type from the file extension. +/// the Image API supports jpg, jpeg, png and webp. +fn image_mime_type(file_name: &str) -> &'static str { + let extension = Path::new(file_name) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_lowercase()); + match extension.as_deref() { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("png") => "image/png", + Some("webp") => "image/webp", + _ => "application/octet-stream", } } + +/// force the output format whatever the caller provides. +/// the format drives the return type: json -> serde_json::Value, html / md -> String. +fn force_output(mut parameter: HashMap, format: &str) -> HashMap { + parameter.insert("output".to_string(), format.to_string()); + parameter +} diff --git a/tests/serpapi-test.rs b/tests/serpapi-test.rs index 86bd56b..572a98a 100644 --- a/tests/serpapi-test.rs +++ b/tests/serpapi-test.rs @@ -30,6 +30,8 @@ async fn search() { // search returns a JSON as serde_json::Value which can be accessed like a HashMap. let results = client.search(parameter).await.expect("request"); + assert_eq!(results["search_metadata"]["status"], "Success"); + assert_eq!(results["search_parameters"]["q"], "coffee"); let organic_results = results["organic_results"].as_array().unwrap(); assert!(organic_results.len() > 1); @@ -53,7 +55,9 @@ async fn html() { "Austin, TX, Texas, United States".to_string(), ); let html = client.html(parameter).await.expect("request"); - assert!(html.len() > 100); + assert!(html.contains("")); + assert!(html.contains("coffee")); } #[tokio::test] @@ -62,12 +66,35 @@ async fn markdown() { default.insert("engine".to_string(), "google".to_string()); default.insert("api_key".to_string(), api_key()); + // initialize the search engine + let client = Client::new(default).unwrap(); + + let mut parameter = HashMap::::new(); + parameter.insert("q".to_string(), "coffee".to_string()); + parameter.insert( + "location".to_string(), + "Austin, TX, Texas, United States".to_string(), + ); + // md returns the search results as a Markdown String. + let markdown = client.md(parameter).await.expect("request"); + // the Markdown output starts with a YAML frontmatter + assert!(markdown.starts_with("---\nsearch_metadata:")); + assert!(markdown.contains("status: Success")); + assert!(markdown.contains("q: coffee")); +} + +#[tokio::test] +async fn markdown_ignores_the_output_parameter() { + let mut default = HashMap::::new(); + default.insert("engine".to_string(), "google".to_string()); + default.insert("api_key".to_string(), api_key()); let client = Client::new(default).unwrap(); let mut parameter = HashMap::::new(); parameter.insert("q".to_string(), "coffee".to_string()); - let markdown = client.markdown(parameter).await.expect("request"); - assert!(markdown.len() > 100); + parameter.insert("output".to_string(), "json".to_string()); + let markdown = client.md(parameter).await.expect("request"); + assert!(markdown.starts_with("---\nsearch_metadata:")); } #[tokio::test] @@ -82,6 +109,74 @@ async fn location() { assert!(locations[0]["name"].as_str().unwrap().contains("Austin")); } +// smallest valid 1x1 transparent PNG +const PNG_1X1: [u8; 67] = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, +]; + +#[tokio::test] +async fn upload_image_from_file() { + let mut default = HashMap::::new(); + default.insert("api_key".to_string(), api_key()); + let client = Client::new(default).unwrap(); + + let path = std::env::temp_dir().join("serpapi-rust-upload.png"); + std::fs::write(&path, PNG_1X1).expect("write image"); + + let upload = client + .upload_image(&path, HashMap::new()) + .await + .expect("upload"); + std::fs::remove_file(&path).ok(); + + assert_eq!(upload["message"], "Image uploaded successfully."); + assert!(!upload["image_id"].as_str().expect("image id").is_empty()); +} + +#[tokio::test] +async fn upload_image_from_bytes() { + let client = Client::new(HashMap::::new()).unwrap(); + // the api_key is provided as a request parameter instead of a client default + let mut parameter = HashMap::::new(); + parameter.insert("api_key".to_string(), api_key()); + + let upload = client + .upload_image_bytes(PNG_1X1.to_vec(), "image.png", parameter) + .await + .expect("upload"); + assert!(!upload["image_id"].as_str().expect("image id").is_empty()); +} + +#[tokio::test] +async fn upload_image_rejects_invalid_image() { + let mut default = HashMap::::new(); + default.insert("api_key".to_string(), api_key()); + let client = Client::new(default).unwrap(); + + let error = client + .upload_image_bytes( + b"invalid image data".to_vec(), + "invalid.txt", + HashMap::new(), + ) + .await + .expect_err("invalid image"); + assert!(error.to_string().contains("Invalid image format")); +} + +#[tokio::test] +async fn upload_image_missing_file() { + let client = Client::new(HashMap::::new()).unwrap(); + let result = client + .upload_image("/does/not/exist.png", HashMap::new()) + .await; + assert!(result.is_err()); +} + #[tokio::test] async fn account() { let client = Client::new(HashMap::::new()).unwrap(); @@ -107,17 +202,31 @@ async fn search_archive() { "Austin, TX, Texas, United States".to_string(), ); let initial_results = client.search(parameter).await.expect("request"); - let mut id = initial_results["search_metadata"]["id"].to_string(); - // remove extra quote " from string convertion - id = id.replace("\"", ""); - - println!("{}", initial_results["search_metadata"]); - assert_ne!(id, ""); + let id = initial_results["search_metadata"]["id"] + .as_str() + .expect("search id"); // search in archive - let archived_results = client.search_archive(&id).await.expect("request"); - let archive_id = archived_results["search_metadata"]["id"].as_str(); - let search_id = initial_results["search_metadata"]["id"].as_str(); - println!("{}", archived_results); - assert_eq!(archive_id, search_id); + let archived_results = client.search_archive(id).await.expect("request"); + assert_eq!(archived_results["search_metadata"]["id"].as_str(), Some(id)); +} + +#[tokio::test] +async fn search_archive_md() { + let mut default = HashMap::::new(); + default.insert("engine".to_string(), "google".to_string()); + default.insert("api_key".to_string(), api_key()); + let client = Client::new(default).unwrap(); + + let mut parameter = HashMap::::new(); + parameter.insert("q".to_string(), "coffee".to_string()); + let initial_results = client.search(parameter).await.expect("request"); + let id = initial_results["search_metadata"]["id"] + .as_str() + .expect("search id"); + + // search in archive as Markdown + let markdown = client.search_archive_md(id).await.expect("request"); + assert!(markdown.starts_with("---\nsearch_metadata:")); + assert!(markdown.contains(id)); }