diff --git a/.github/workflows/serpapi-php.yml b/.github/workflows/serpapi-php.yml index 5db9789..97aee6d 100644 --- a/.github/workflows/serpapi-php.yml +++ b/.github/workflows/serpapi-php.yml @@ -41,7 +41,12 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress + - name: Run static analysis + # PHPStan needs PHP 7.4+; the library itself still supports 7.2. + if: matrix.php-versions >= '7.4' + run: composer run-script analyse + - name: Run test suite run: composer run-script test env: - API_KEY: ${{ secrets.API_KEY }} + SERPAPI_KEY: ${{ secrets.SERPAPI_KEY || secrets.API_KEY }} diff --git a/Makefile b/Makefile index 23d9c31..12c9530 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Default target -all: install readme test +all: install readme analyse test # Clean up the project clean: @@ -9,6 +9,10 @@ clean: install: composer install --prefer-dist --no-progress +# Run static analysis +analyse: + vendor/bin/phpstan analyse --no-progress --memory-limit=512M + # Run the tests test: vendor/bin/phpunit -c phpunit.xml @@ -16,3 +20,10 @@ test: # Generate README from ERB template readme: erb -T '-' README.md.erb > README.md + +# Run the demo scripts end to end +demo: + @for file in demo/*.php; do \ + echo "running demo: $$file"; \ + php $$file || exit 1; \ + done diff --git a/README.md b/README.md index bce33cc..8d9dd04 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ require 'vendor/autoload.php'; use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'q' => 'coffee', ]); @@ -86,7 +86,7 @@ The default engine is `google`. You can change it via the second constructor par ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY'), 'bing'); +$client = new Client(getenv('SERPAPI_KEY'), 'bing'); $results = $client->search(['q' => 'coffee']); ``` @@ -97,7 +97,7 @@ The default request timeout is 120 seconds. Customize it via the third construct ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY'), 'google', 30); +$client = new Client(getenv('SERPAPI_KEY'), 'google', 30); ``` ## Search API @@ -106,7 +106,7 @@ $client = new Client(getenv('API_KEY'), 'google', 30); ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google', 'tbm' => 'isch', @@ -123,7 +123,7 @@ see: [https://serpapi.com/search-api](https://serpapi.com/search-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_scholar', 'q' => 'coffee', @@ -139,7 +139,7 @@ see: [https://serpapi.com/google-scholar-api](https://serpapi.com/google-scholar ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_autocomplete', 'q' => 'coffee', @@ -155,7 +155,7 @@ see: [https://serpapi.com/google-autocomplete-api](https://serpapi.com/google-au ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_shopping', 'q' => 'coffee', @@ -171,7 +171,7 @@ see: [https://serpapi.com/google-shopping-api](https://serpapi.com/google-shoppi ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_maps', 'q' => 'pizza', @@ -189,7 +189,7 @@ see: [https://serpapi.com/google-maps-api](https://serpapi.com/google-maps-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_jobs', 'q' => 'coffee', @@ -205,7 +205,7 @@ see: [https://serpapi.com/google-jobs-api](https://serpapi.com/google-jobs-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_events', 'q' => 'Events in Austin', @@ -222,7 +222,7 @@ see: [https://serpapi.com/google-events-api](https://serpapi.com/google-events-a ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_lens', 'url' => 'https://i.imgur.com/5bGzZi7.jpg', @@ -240,7 +240,7 @@ see: [https://serpapi.com/google-lens-api](https://serpapi.com/google-lens-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_play', 'q' => 'kite', @@ -257,7 +257,7 @@ see: [https://serpapi.com/google-play-api](https://serpapi.com/google-play-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_local_services', 'q' => 'electrician', @@ -274,7 +274,7 @@ see: [https://serpapi.com/google-local-services-api](https://serpapi.com/google- ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'bing', 'q' => 'coffee', @@ -290,7 +290,7 @@ see: [https://serpapi.com/bing-search-api](https://serpapi.com/bing-search-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'baidu', 'q' => 'coffee', @@ -306,7 +306,7 @@ see: [https://serpapi.com/baidu-search-api](https://serpapi.com/baidu-search-api ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'yahoo', 'p' => 'coffee', @@ -322,7 +322,7 @@ see: [https://serpapi.com/yahoo-search-api](https://serpapi.com/yahoo-search-api ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'youtube', 'search_query' => 'coffee', @@ -338,7 +338,7 @@ see: [https://serpapi.com/youtube-search-api](https://serpapi.com/youtube-search ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'walmart', 'query' => 'coffee', @@ -354,7 +354,7 @@ see: [https://serpapi.com/walmart-search-api](https://serpapi.com/walmart-search ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'ebay', '_nkw' => 'water', @@ -370,7 +370,7 @@ see: [https://serpapi.com/ebay-search-api](https://serpapi.com/ebay-search-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'naver', 'query' => 'coffee', @@ -386,7 +386,7 @@ see: [https://serpapi.com/naver-search-api](https://serpapi.com/naver-search-api ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'home_depot', 'q' => 'table', @@ -402,7 +402,7 @@ see: [https://serpapi.com/home-depot-search-api](https://serpapi.com/home-depot- ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'apple_app_store', 'term' => 'coffee', @@ -418,7 +418,7 @@ see: [https://serpapi.com/apple-app-store](https://serpapi.com/apple-app-store) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'duckduckgo', 'q' => 'coffee', @@ -437,7 +437,7 @@ see: [https://serpapi.com/duckduckgo-search-api](https://serpapi.com/duckduckgo- ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $locations = $client->location(['q' => 'Austin', 'limit' => 3]); echo "Number of locations: " . count($locations) . "\n"; @@ -453,7 +453,7 @@ First, run a search and save the search ID: ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'q' => 'Coffee', 'location' => 'Austin, Texas', @@ -473,7 +473,7 @@ print_r($archived); ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $account = $client->account(); print_r($account); ``` @@ -483,7 +483,7 @@ print_r($account); ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $html = $client->html(['q' => 'Coffee']); echo strlen($html) . " bytes of HTML\n"; @@ -522,7 +522,7 @@ The `tests/` directory includes specifications which serve the dual purposes of Set your secret API key in your shell before running tests: ```bash -export API_KEY="your_secret_key" +export SERPAPI_KEY="your_secret_key" ``` Install dependencies and run the test suite: diff --git a/README.md.erb b/README.md.erb index 727b642..834c3b5 100644 --- a/README.md.erb +++ b/README.md.erb @@ -15,7 +15,7 @@ def snippet(path) property = source[/assertResponseHasProperty\(\$response,\s*'([^']+)'/, 1] || 'organic_results' code = "use SerpApi\\Client;\n\n" - code += "$client = new Client(getenv('API_KEY'));\n" + code += "$client = new Client(getenv('SERPAPI_KEY'));\n" code += "$results = $client->search(#{params});\n\n" code += "print_r($results->#{property});\n" @@ -72,7 +72,7 @@ require 'vendor/autoload.php'; use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'q' => 'coffee', ]); @@ -110,7 +110,7 @@ The default engine is `google`. You can change it via the second constructor par ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY'), 'bing'); +$client = new Client(getenv('SERPAPI_KEY'), 'bing'); $results = $client->search(['q' => 'coffee']); ``` @@ -121,7 +121,7 @@ The default request timeout is 120 seconds. Customize it via the third construct ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY'), 'google', 30); +$client = new Client(getenv('SERPAPI_KEY'), 'google', 30); ``` ## Search API @@ -213,7 +213,7 @@ see: [https://serpapi.com/duckduckgo-search-api](https://serpapi.com/duckduckgo- ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $locations = $client->location(['q' => 'Austin', 'limit' => 3]); echo "Number of locations: " . count($locations) . "\n"; @@ -229,7 +229,7 @@ First, run a search and save the search ID: ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'q' => 'Coffee', 'location' => 'Austin, Texas', @@ -249,7 +249,7 @@ print_r($archived); ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $account = $client->account(); print_r($account); ``` @@ -259,7 +259,7 @@ print_r($account); ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $html = $client->html(['q' => 'Coffee']); echo strlen($html) . " bytes of HTML\n"; @@ -298,7 +298,7 @@ The `tests/` directory includes specifications which serve the dual purposes of Set your secret API key in your shell before running tests: ```bash -export API_KEY="your_secret_key" +export SERPAPI_KEY="your_secret_key" ``` Install dependencies and run the test suite: diff --git a/composer.json b/composer.json index c815858..8f5b1d8 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,8 @@ "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^8.5.52 || ^9.6 || ^10.5 || ^11.5 || ^12.5 || ^13.0" + "phpunit/phpunit": "^8.5.52 || ^9.6 || ^10.5 || ^11.5 || ^12.5 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.1" }, "autoload": { "psr-4": { @@ -58,6 +59,7 @@ } }, "scripts": { - "test": "vendor/bin/phpunit -c phpunit.xml" + "test": "vendor/bin/phpunit -c phpunit.xml", + "analyse": "vendor/bin/phpstan analyse --no-progress --memory-limit=512M" } } diff --git a/demo/demo.php b/demo/demo.php new file mode 100644 index 0000000..bb0175f --- /dev/null +++ b/demo/demo.php @@ -0,0 +1,43 @@ + 'google', + 'api_key' => $api_key, +]); + +$results = $client->search(['q' => 'coffee']); + +if (empty($results->organic_results)) { + fwrite(STDERR, "no organic results found\n"); + exit(1); +} + +foreach ($results->organic_results as $result) { + printf("%d. %s\n %s\n", $result->position, $result->title, $result->link); +} + +$client->close(); +echo "done\n"; +exit(0); diff --git a/demo/demo_async.php b/demo/demo_async.php new file mode 100644 index 0000000..eeffb1e --- /dev/null +++ b/demo/demo_async.php @@ -0,0 +1,89 @@ + true` the backend accepts the search and returns + * immediately instead of waiting for the search engine. Submitting a batch + * first and collecting the results afterwards through the Search Archive API + * is much faster than running the searches one after another. + * + * Usage: + * export SERPAPI_KEY="your secret key" + * php demo/demo_async.php + */ + +require __DIR__ . '/../vendor/autoload.php'; + +use SerpApi\Client; + +/** + * Read search_metadata off a decoded response, failing loudly on anything + * unexpected rather than on a property access further down. + * + * @param object|array|string $response + */ +function search_metadata($response): object { + if (!is_object($response) || !isset($response->search_metadata)) { + throw new RuntimeException('response carries no search_metadata'); + } + + return $response->search_metadata; +} + +$api_key = getenv('SERPAPI_KEY'); +if (empty($api_key)) { + fwrite(STDERR, "SERPAPI_KEY environment variable must be set\n"); + exit(1); +} + +$companies = ['meta', 'amazon', 'apple', 'netflix', 'google']; + +// Persistent mode keeps a single connection open for the whole batch. +$client = new Client([ + 'engine' => 'google', + 'api_key' => $api_key, + 'async' => true, + 'persistent' => true, +]); + +// Submit every search without waiting for its results. +$pending = []; +foreach ($companies as $company) { + $result = $client->search(['q' => $company]); + $pending[search_metadata($result)->id] = $company; + echo "submitted: {$company}\n"; +} + +echo "\ncollecting ", count($pending), " results\n"; + +// Collect the results, putting back the searches still in progress. +$deadline = time() + 60; +while (!empty($pending) && time() < $deadline) { + foreach ($pending as $search_id => $company) { + $archived = $client->search_archive((string) $search_id); + $status = search_metadata($archived)->status; + + if ($status === 'Success' || $status === 'Cached') { + $count = is_object($archived) && isset($archived->organic_results) + ? count((array) $archived->organic_results) + : 0; + printf(" %-8s %s (%d organic results)\n", $company, $status, $count); + unset($pending[$search_id]); + } + } + + if (!empty($pending)) { + sleep(1); + } +} + +$client->close(); + +if (!empty($pending)) { + fwrite(STDERR, 'timed out waiting for: ' . implode(', ', $pending) . "\n"); + exit(1); +} + +echo "done\n"; +exit(0); diff --git a/demo/demo_persistent.php b/demo/demo_persistent.php new file mode 100644 index 0000000..05b1431 --- /dev/null +++ b/demo/demo_persistent.php @@ -0,0 +1,42 @@ +location(['q' => 'Austin', 'limit' => 1]); + } + + return microtime(true) - $start; +} + +$without = benchmark(new Client(['persistent' => false])); +printf("persistent off: %6.0f ms (%.1f req/s)\n", $without * 1000, REQUESTS / $without); + +$client = new Client(['persistent' => true]); +$with = benchmark($client); +$client->close(); +printf("persistent on: %6.0f ms (%.1f req/s)\n", $with * 1000, REQUESTS / $with); + +printf("\nspeedup: %.1fx\n", $without / $with); +echo "done\n"; +exit(0); diff --git a/demo/demo_suggest.php b/demo/demo_suggest.php new file mode 100644 index 0000000..078731b --- /dev/null +++ b/demo/demo_suggest.php @@ -0,0 +1,46 @@ + 'google_autocomplete', + 'api_key' => $api_key, + 'client' => 'safari', + 'hl' => 'en', + 'gl' => 'us', + 'persistent' => false, + 'timeout' => 10, +]); + +$results = $client->search(['q' => 'coffee']); + +if (empty($results->suggestions)) { + fwrite(STDERR, "no suggestions found\n"); + exit(1); +} + +foreach ($results->suggestions as $suggestion) { + echo ' - ', $suggestion->value, "\n"; +} + +echo "done\n"; +exit(0); diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3a67f13 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,17 @@ +parameters: + level: 6 + paths: + - src + - demo + + # composer.json promises PHP 7.2 through 8.5. Analysis runs against 8.x, + # where curl_init() returns a CurlHandle. The 7.x resource form of the same + # handle is exercised by the CI matrix instead. + phpVersion: 80100 + + ignoreErrors: + # The cURL handle is a resource on PHP 7 and a CurlHandle on PHP 8. Both + # arms of the union are reachable across the supported range, but PHPStan + # analyses a single version and so only ever sees one of them assigned. + - identifier: property.unusedType + path: src/Client.php diff --git a/src/Client.php b/src/Client.php index 243f6ef..b33278e 100644 --- a/src/Client.php +++ b/src/Client.php @@ -7,6 +7,9 @@ class Client { const BASE_URL = 'https://serpapi.com'; const DEFAULT_TIMEOUT = 120; + /** Client identifier reported to SerpApi for usage statistics. */ + const SOURCE = 'serpapi-php:' . self::VERSION; + /** @var string */ private $api_key; @@ -16,20 +19,98 @@ class Client { /** @var int */ private $timeout; + /** @var array Search parameters applied to every request */ + private $params = []; + + /** @var bool Reuse a single cURL handle, keeping the connection alive between requests */ + private $persistent = true; + + /** @var resource|\CurlHandle|null Shared cURL handle (a resource on PHP 7, a CurlHandle on PHP 8) */ + private $handle = null; + + /** @var bool Decode JSON responses to associative arrays instead of stdClass */ + private $assoc = false; + /** - * @param string $api_key + * Client-only configuration keys, never forwarded to the API as search parameters. + * + * @var array + */ + private static $option_keys = ['api_key', 'engine', 'timeout', 'persistent', 'assoc']; + + /** + * Client-only keys stripped from the query string of every request. + * + * @var array + */ + private static $client_only_keys = ['timeout', 'persistent', 'assoc']; + + /** + * Accepts either positional arguments or, like the Ruby client, a single + * associative array holding both configuration and default search parameters: + * + * new Client(['api_key' => '...', 'engine' => 'google', 'hl' => 'en']) + * + * Any key that is not `api_key`, `engine` or `timeout` becomes a default + * search parameter merged into every request, and can still be overridden + * per call. + * + * @param string|array $api_key API key, or a full configuration array * @param string $engine * @param int $timeout Request timeout in seconds + * @param array $params Default search parameters * @throws SerpApiException */ - public function __construct(string $api_key = '', string $engine = 'google', int $timeout = self::DEFAULT_TIMEOUT) { + public function __construct($api_key = '', string $engine = 'google', int $timeout = self::DEFAULT_TIMEOUT, array $params = []) { + if (is_array($api_key)) { + $config = $api_key; + $api_key = (string) $this->take($config, 'api_key', ''); + $engine = (string) $this->take($config, 'engine', $engine); + $timeout = (int) $this->take($config, 'timeout', $timeout); + $this->persistent = (bool) $this->take($config, 'persistent', true); + $this->assoc = (bool) $this->take($config, 'assoc', false); + $params = array_merge($config, $params); + } + if (empty($engine)) { throw new SerpApiException('engine must be present'); } - $this->api_key = $api_key; + $this->api_key = (string) $api_key; $this->engine = $engine; $this->timeout = $timeout; + $this->params = $this->without_option_keys($params); + } + + /** + * Pull a value out of a configuration array, removing it in the process. + * + * @param array $config + * @param mixed $default + * @return mixed + */ + private function take(array &$config, string $key, $default) { + if (!array_key_exists($key, $config) || $config[$key] === null) { + unset($config[$key]); + return $default; + } + + $value = $config[$key]; + unset($config[$key]); + + return $value; + } + + /** + * @param array $params + * @return array + */ + private function without_option_keys(array $params): array { + foreach (self::$option_keys as $key) { + unset($params[$key]); + } + + return $params; } /** @@ -60,13 +141,121 @@ public function get_engine(): string { return $this->engine; } + /** + * Get the request timeout in seconds. + */ + public function get_timeout(): int { + return $this->timeout; + } + + /** + * Get the default search parameters applied to every request, + * including `engine` and `api_key`. + * + * @return array + */ + public function get_params(): array { + $params = ['engine' => $this->engine]; + + if (!empty($this->api_key)) { + $params['api_key'] = $this->api_key; + } + + return array_merge($params, $this->params); + } + + /** + * Whether the client reuses a single connection across requests. + */ + public function is_persistent(): bool { + return $this->persistent; + } + + /** + * Whether JSON responses are decoded to associative arrays instead of stdClass. + */ + public function is_assoc(): bool { + return $this->assoc; + } + + /** + * Close the shared connection. Safe to call more than once; the client + * stays usable and opens a new connection on the next request. + */ + public function close(): void { + if ($this->handle === null) { + return; + } + + if (PHP_VERSION_ID < 80500) { + curl_close($this->handle); + } + + $this->handle = null; + } + + public function __destruct() { + $this->close(); + } + + /** + * Human readable representation with the API key masked. + */ + public function inspect(): string { + return sprintf( + '#<%s @engine=%s @timeout=%d @persistent=%s @api_key=%s>', + static::class, + $this->engine, + $this->timeout, + $this->persistent ? 'true' : 'false', + $this->masked_api_key() + ); + } + + /** + * Keeps `var_dump()` and debuggers from printing the API key in clear text. + * + * Note that `print_r()` and `var_export()` bypass this hook and read + * properties directly; use `inspect()` when dumping a client on purpose. + * + * @return array + */ + public function __debugInfo(): array { + return [ + 'engine' => $this->engine, + 'timeout' => $this->timeout, + 'persistent' => $this->persistent, + 'assoc' => $this->assoc, + 'api_key' => $this->masked_api_key(), + 'params' => $this->params, + ]; + } + + /** + * Show only the first and last 4 characters of the API key. + */ + private function masked_api_key(): string { + $length = strlen($this->api_key); + + if ($length === 0) { + return ''; + } + + if ($length <= 8) { + return '****'; + } + + return substr($this->api_key, 0, 4) . '****' . substr($this->api_key, -4); + } + /** * Run a search and return decoded JSON. * * @param array $params + * @return object|array stdClass, or an array when `assoc` is enabled * @throws SerpApiException */ - public function search(array $params = []): object { + public function search(array $params = []) { return $this->get('/search', 'json', $params); } @@ -83,9 +272,10 @@ public function html(array $params = []): string { /** * Get account information using Account API. * + * @return object|array stdClass, or an array when `assoc` is enabled * @throws SerpApiException */ - public function account(?string $api_key = null): object { + public function account(?string $api_key = null) { $params = empty($api_key) ? [] : ['api_key' => $api_key]; return $this->get('/account', 'json', $params); } @@ -94,7 +284,7 @@ public function account(?string $api_key = null): object { * Get locations using Location API. * * @param array $params - * @return array + * @return array> * @throws SerpApiException */ public function location(array $params = []): array { @@ -137,17 +327,7 @@ private function get(string $endpoint, string $format = 'json', array $params = throw new SerpApiException('api_key must be present'); } - $default_query = [ - 'engine' => $this->engine, - 'source' => 'php', - ]; - - if (!empty($api_key)) { - $default_query['api_key'] = $api_key; - } - - $query = array_merge($default_query, $params); - $query['output'] = $format; + $query = $this->query($params, $api_key, $format); $url = self::BASE_URL . $endpoint . '?' . http_build_query($query); @@ -168,15 +348,19 @@ private function get(string $endpoint, string $format = 'json', array $params = $this->raise_http_error($http_code, $endpoint, $query, null, null, 'html'); } - $decoded = json_decode($response); + $assoc = isset($params['assoc']) ? (bool) $params['assoc'] : $this->assoc; + + $decoded = json_decode($response, $assoc); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { $this->raise_parser_error($http_code, $endpoint, $query); } - $serpapi_error = (is_object($decoded) && isset($decoded->error)) ? $decoded->error : null; - $search_id = (is_object($decoded) && isset($decoded->search_metadata->id)) - ? (string) $decoded->search_metadata->id - : null; + $error = $this->dig($decoded, 'error'); + $serpapi_error = is_string($error) ? $error : null; + + $metadata = $this->dig($decoded, 'search_metadata'); + $id = $this->dig($metadata, 'id'); + $search_id = ($id === null) ? null : (string) $id; if ($http_code === 200) { if ($serpapi_error !== null) { @@ -189,15 +373,59 @@ private function get(string $endpoint, string $format = 'json', array $params = $this->raise_http_error($http_code, $endpoint, $query, $serpapi_error, $search_id, 'json'); } + /** + * Read a key from a decoded response, which is an object or an + * associative array depending on the `assoc` setting. + * + * @param mixed $data + * @return mixed null when absent + */ + private function dig($data, string $key) { + if (is_object($data)) { + return $data->{$key} ?? null; + } + + if (is_array($data)) { + return $data[$key] ?? null; + } + + return null; + } + + /** + * Build the query string parameters for a request. + * + * @param array $params + * @return array + */ + private function query(array $params, string $api_key, string $format): array { + $default_query = [ + 'engine' => $this->engine, + 'source' => self::SOURCE, + ]; + + if (!empty($api_key)) { + $default_query['api_key'] = $api_key; + } + + $query = array_merge($default_query, $this->params, $params); + $query['output'] = $format; + + foreach (self::$client_only_keys as $key) { + unset($query[$key]); + } + + return array_filter($query, static function ($value) { + return $value !== null; + }); + } + /** * @return array{response: string|false, http_code: int, curl_error: string} * @throws SerpApiException */ private function request(string $url): array { - $ch = curl_init(); - if ($ch === false) { - throw new SerpApiException('Failed to initialize cURL handle'); - } + $ch = $this->acquire_handle(); try { $is_configured = curl_setopt_array($ch, [ @@ -222,11 +450,48 @@ private function request(string $url): array { 'curl_error' => $curl_error, ]; } finally { - if (PHP_VERSION_ID < 80500) { - curl_close($ch); - } + $this->release_handle($ch); + } + } + + /** + * Return the shared handle in persistent mode, otherwise a fresh one. + * + * @return resource|\CurlHandle + * @throws SerpApiException + */ + private function acquire_handle() { + if ($this->persistent && $this->handle !== null) { + return $this->handle; + } + + $ch = curl_init(); + if ($ch === false) { + throw new SerpApiException('Failed to initialize cURL handle'); + } + + if ($this->persistent) { + $this->handle = $ch; + } + + return $ch; + } + + /** + * Keep the shared handle open so the underlying connection is reused; + * dispose of single use handles. + * + * @param resource|\CurlHandle $ch + */ + private function release_handle($ch): void { + if ($this->persistent && $ch === $this->handle) { + return; + } - $ch = null; + // curl_close() is a no-op since PHP 8.0 and deprecated in 8.5; + // the handle is released by the garbage collector instead. + if (PHP_VERSION_ID < 80500) { + curl_close($ch); } } diff --git a/tests/ClientAssocTest.php b/tests/ClientAssocTest.php new file mode 100644 index 0000000..8d32dbc --- /dev/null +++ b/tests/ClientAssocTest.php @@ -0,0 +1,75 @@ +assertFalse((new Client('secret'))->is_assoc()); + } + + public function test_assoc_can_be_enabled() { + $client = new Client(['api_key' => 'secret', 'assoc' => true]); + $this->assertTrue($client->is_assoc()); + } + + public function test_assoc_is_not_sent_as_a_search_parameter() { + $client = new Client(['api_key' => 'secret', 'assoc' => true]); + + $this->assertArrayNotHasKey('assoc', $client->get_params()); + $this->assertArrayNotHasKey('assoc', $this->query($client)); + } + + public function test_per_call_assoc_is_not_sent_as_a_search_parameter() { + $query = $this->query(new Client('secret'), ['q' => 'coffee', 'assoc' => true]); + + $this->assertArrayNotHasKey('assoc', $query); + $this->assertEquals('coffee', $query['q']); + } + + public function test_client_only_keys_never_reach_the_query() { + $query = $this->query(new Client('secret'), [ + 'q' => 'coffee', + 'timeout' => 5, + 'persistent' => false, + 'assoc' => true, + ]); + + $this->assertEquals(['engine', 'source', 'api_key', 'q', 'output'], array_keys($query)); + } + + public function test_dig_reads_both_object_and_array_shapes() { + $client = new Client('secret'); + + foreach ([json_decode('{"error":"boom"}'), json_decode('{"error":"boom"}', true)] as $data) { + $this->assertEquals('boom', $this->dig($client, $data, 'error')); + $this->assertNull($this->dig($client, $data, 'missing')); + } + } + + public function test_dig_tolerates_scalar_and_null_responses() { + $client = new Client('secret'); + + $this->assertNull($this->dig($client, null, 'error')); + $this->assertNull($this->dig($client, 'a string', 'error')); + } + + /** + * @param mixed $data + * @return mixed + */ + private function dig(Client $client, $data, string $key) { + $method = new \ReflectionMethod(Client::class, 'dig'); + + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + return $method->invoke($client, $data, $key); + } +} diff --git a/tests/ClientDefaultParamsTest.php b/tests/ClientDefaultParamsTest.php new file mode 100644 index 0000000..25b308d --- /dev/null +++ b/tests/ClientDefaultParamsTest.php @@ -0,0 +1,95 @@ + 'en', 'gl' => 'us']); + $query = $this->query($client, ['q' => 'coffee']); + + $this->assertEquals('en', $query['hl']); + $this->assertEquals('us', $query['gl']); + $this->assertEquals('coffee', $query['q']); + } + + public function test_per_search_params_override_defaults() { + $client = new Client('secret', 'google', 120, ['hl' => 'en']); + $query = $this->query($client, ['hl' => 'fr']); + + $this->assertEquals('fr', $query['hl']); + } + + public function test_null_params_are_dropped_from_the_query() { + $client = new Client('secret', 'google', 120, ['hl' => 'en']); + $query = $this->query($client, ['hl' => null]); + + $this->assertArrayNotHasKey('hl', $query); + } + + public function test_array_constructor_sets_configuration() { + $client = new Client([ + 'api_key' => 'secret', + 'engine' => 'bing', + 'timeout' => 30, + ]); + + $this->assertEquals('secret', $client->get_api_key()); + $this->assertEquals('bing', $client->get_engine()); + $this->assertEquals(30, $client->get_timeout()); + } + + public function test_array_constructor_treats_unknown_keys_as_default_params() { + $client = new Client([ + 'api_key' => 'secret', + 'engine' => 'google', + 'location' => 'Austin, TX', + 'no_cache' => true, + ]); + + $query = $this->query($client, ['q' => 'coffee']); + $this->assertEquals('Austin, TX', $query['location']); + $this->assertTrue($query['no_cache']); + } + + public function test_array_constructor_defaults_engine_to_google() { + $client = new Client(['api_key' => 'secret']); + $this->assertEquals('google', $client->get_engine()); + } + + public function test_array_constructor_rejects_empty_engine() { + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage('engine must be present'); + new Client(['api_key' => 'secret', 'engine' => '']); + } + + public function test_positional_constructor_remains_supported() { + $client = new Client('secret', 'bing', 30); + + $this->assertEquals('secret', $client->get_api_key()); + $this->assertEquals('bing', $client->get_engine()); + $this->assertEquals(30, $client->get_timeout()); + } + + public function test_get_params_exposes_engine_api_key_and_defaults() { + $client = new Client(['api_key' => 'secret', 'engine' => 'bing', 'hl' => 'en']); + $params = $client->get_params(); + + $this->assertEquals('bing', $params['engine']); + $this->assertEquals('secret', $params['api_key']); + $this->assertEquals('en', $params['hl']); + } + + public function test_get_params_omits_client_only_options() { + $client = new Client(['api_key' => 'secret', 'timeout' => 30]); + + $this->assertArrayNotHasKey('timeout', $client->get_params()); + $this->assertArrayNotHasKey('timeout', $this->query($client)); + } +} diff --git a/tests/ClientInspectTest.php b/tests/ClientInspectTest.php new file mode 100644 index 0000000..daa2eb0 --- /dev/null +++ b/tests/ClientInspectTest.php @@ -0,0 +1,68 @@ +assertStringNotContainsString(self::LONG_KEY, $client->inspect()); + } + + public function test_inspect_shows_first_and_last_four_characters() { + $client = new Client(self::LONG_KEY); + $this->assertStringContainsString('abcd****wxyz', $client->inspect()); + } + + public function test_inspect_reports_engine_and_timeout() { + $client = new Client(self::LONG_KEY, 'bing', 30); + $inspect = $client->inspect(); + + $this->assertStringContainsString('@engine=bing', $inspect); + $this->assertStringContainsString('@timeout=30', $inspect); + } + + public function test_short_api_key_is_fully_masked() { + $client = new Client('abcdef'); + $inspect = $client->inspect(); + + $this->assertStringNotContainsString('abcdef', $inspect); + $this->assertStringContainsString('****', $inspect); + } + + public function test_eight_character_api_key_is_fully_masked() { + $client = new Client('abcdefgh'); + $this->assertStringNotContainsString('abcdefgh', $client->inspect()); + } + + public function test_missing_api_key_renders_as_empty() { + $client = new Client(); + $this->assertStringContainsString('@api_key=>', $client->inspect()); + } + + public function test_var_dump_does_not_expose_the_api_key() { + $client = new Client(self::LONG_KEY); + + ob_start(); + var_dump($client); + $dump = (string) ob_get_clean(); + + $this->assertStringNotContainsString(self::LONG_KEY, $dump); + $this->assertStringContainsString('abcd****wxyz', $dump); + } + + public function test_debug_info_does_not_expose_the_api_key_in_default_params() { + $client = new Client(['api_key' => self::LONG_KEY, 'hl' => 'en']); + $info = $client->__debugInfo(); + + $this->assertEquals('abcd****wxyz', $info['api_key']); + $this->assertEquals(['hl' => 'en'], $info['params']); + } +} diff --git a/tests/ClientIntegrationTest.php b/tests/ClientIntegrationTest.php deleted file mode 100644 index 775b16e..0000000 --- a/tests/ClientIntegrationTest.php +++ /dev/null @@ -1,51 +0,0 @@ - */ - private $search_params; - - protected function setUp(): void { - parent::setUp(); - $this->search_params = [ - 'q' => 'Coffee', - 'location' => 'Austin,Texas', - ]; - } - - public function test_account() { - $client = $this->serpApiClient(); - $response = $client->account(); - $this->assertEquals($client->get_api_key(), $response->api_key); - } - - public function test_html() { - $client = $this->serpApiClient(); - $response = $client->html($this->search_params); - $this->assertGreaterThan(10000, strlen($response)); - } - - public function test_search() { - $client = $this->serpApiClient(); - $response = $client->search($this->search_params); - $this->assertEquals('Success', $response->search_metadata->status); - $this->assertResponseHasProperty($response, 'organic_results'); - $this->assertNotEmpty($response->organic_results); - } - - public function test_location() { - $client = $this->serpApiClient(); - $location_list = $client->location(['q' => 'Austin', 'limit' => 3]); - $this->assertCount(3, $location_list); - $this->assertStringContainsString('Austin', $location_list[0]->name); - $this->assertGreaterThan(0, $location_list[0]->google_id); - } - - public function test_search_archive() { - $client = $this->serpApiClient(); - $result = $client->search($this->search_params); - $archived_result = $client->search_archive($result->search_metadata->id); - $this->assertEquals($result->search_metadata->id, $archived_result->search_metadata->id); - } -} diff --git a/tests/ClientPersistentTest.php b/tests/ClientPersistentTest.php new file mode 100644 index 0000000..797b1e4 --- /dev/null +++ b/tests/ClientPersistentTest.php @@ -0,0 +1,116 @@ +setAccessible(true); + } + + return $method->invoke($client); + } + + /** + * @return resource|\CurlHandle|null + */ + private function handle(Client $client) { + $property = new \ReflectionProperty(Client::class, 'handle'); + + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + + return $property->getValue($client); + } + + public function test_persistent_is_enabled_by_default() { + $this->assertTrue((new Client('secret'))->is_persistent()); + } + + public function test_persistent_can_be_disabled() { + $client = new Client(['api_key' => 'secret', 'persistent' => false]); + $this->assertFalse($client->is_persistent()); + } + + public function test_persistent_is_not_sent_as_a_search_parameter() { + $client = new Client(['api_key' => 'secret', 'persistent' => false]); + $this->assertArrayNotHasKey('persistent', $client->get_params()); + } + + public function test_no_connection_is_opened_before_the_first_request() { + $this->assertNull($this->handle(new Client('secret'))); + } + + public function test_persistent_client_reuses_the_same_handle() { + $client = new Client('secret'); + + $first = $this->acquire($client); + $second = $this->acquire($client); + + $this->assertSame($first, $second); + $this->assertSame($first, $this->handle($client)); + } + + public function test_non_persistent_client_uses_a_fresh_handle_each_time() { + $client = new Client(['api_key' => 'secret', 'persistent' => false]); + + $first = $this->acquire($client); + $second = $this->acquire($client); + + $this->assertNotSame($first, $second); + $this->assertNull($this->handle($client), 'non persistent handles must not be retained'); + } + + public function test_close_releases_the_shared_handle() { + $client = new Client('secret'); + $this->acquire($client); + + $client->close(); + + $this->assertNull($this->handle($client)); + } + + public function test_close_is_idempotent() { + $client = new Client('secret'); + $this->acquire($client); + + $client->close(); + $client->close(); + + $this->assertNull($this->handle($client)); + } + + public function test_client_reconnects_after_close() { + $client = new Client('secret'); + $first = $this->acquire($client); + $client->close(); + + $second = $this->acquire($client); + + $this->assertNotSame($first, $second); + $this->assertSame($second, $this->handle($client)); + } + + public function test_inspect_reports_persistent_mode() { + $this->assertStringContainsString('@persistent=true', (new Client('secret'))->inspect()); + + $client = new Client(['api_key' => 'secret', 'persistent' => false]); + $this->assertStringContainsString('@persistent=false', $client->inspect()); + } +} diff --git a/tests/ClientQueryTest.php b/tests/ClientQueryTest.php new file mode 100644 index 0000000..9aac708 --- /dev/null +++ b/tests/ClientQueryTest.php @@ -0,0 +1,58 @@ + $params + * @return array + */ + protected function query(Client $client, array $params = [], string $api_key = 'secret', string $format = 'json'): array { + $method = new \ReflectionMethod(Client::class, 'query'); + + // Required before PHP 8.1, and a no-op deprecated in PHP 8.5. + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + return $method->invoke($client, $params, $api_key, $format); + } + + public function test_source_reports_library_name_and_version() { + $query = $this->query(new Client('secret')); + $this->assertEquals('serpapi-php:' . Client::VERSION, $query['source']); + } + + public function test_source_constant_matches_version() { + $this->assertStringStartsWith('serpapi-php:', Client::SOURCE); + $this->assertStringEndsWith(Client::VERSION, Client::SOURCE); + } + + public function test_query_includes_engine_api_key_and_output() { + $query = $this->query(new Client('secret', 'bing'), ['q' => 'coffee']); + $this->assertEquals('bing', $query['engine']); + $this->assertEquals('secret', $query['api_key']); + $this->assertEquals('coffee', $query['q']); + $this->assertEquals('json', $query['output']); + } + + public function test_query_omits_api_key_when_empty() { + $query = $this->query(new Client(), [], ''); + $this->assertArrayNotHasKey('api_key', $query); + } + + public function test_output_reflects_requested_format() { + $query = $this->query(new Client('secret'), [], 'secret', 'html'); + $this->assertEquals('html', $query['output']); + } +} diff --git a/tests/ExampleSearchAmazonTest.php b/tests/ExampleSearchAmazonTest.php new file mode 100644 index 0000000..786f763 --- /dev/null +++ b/tests/ExampleSearchAmazonTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'amazon', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'organic_results', 'Error on `amazon` engine: no `organic_results`'); + } +} diff --git a/tests/ExampleSearchGoogleAiOverviewTest.php b/tests/ExampleSearchGoogleAiOverviewTest.php new file mode 100644 index 0000000..2e1ffce --- /dev/null +++ b/tests/ExampleSearchGoogleAiOverviewTest.php @@ -0,0 +1,21 @@ +serpApiClient('google'); + $search = $google->search(['q' => 'what is coffee']); + + $page_token = $search->ai_overview->page_token ?? null; + if ($page_token === null) { + $this->markTestSkipped('Google returned no AI overview page_token for this query'); + } + + $client = $this->serpApiClient('google_ai_overview'); + $response = $client->search(['page_token' => $page_token]); + $this->assertResponseHasProperty($response, 'ai_overview', 'Error on `google_ai_overview` engine: no `ai_overview`'); + } +} diff --git a/tests/ExampleSearchGoogleFinanceTest.php b/tests/ExampleSearchGoogleFinanceTest.php new file mode 100644 index 0000000..f361137 --- /dev/null +++ b/tests/ExampleSearchGoogleFinanceTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_finance', + 'q' => 'GOOG:NASDAQ', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'markets', 'Error on `google_finance` engine: no `markets`'); + } +} diff --git a/tests/ExampleSearchGoogleFlightsTest.php b/tests/ExampleSearchGoogleFlightsTest.php new file mode 100644 index 0000000..a4a3083 --- /dev/null +++ b/tests/ExampleSearchGoogleFlightsTest.php @@ -0,0 +1,29 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + + // Dates are computed rather than hardcoded so the test does not expire. + $this->search_params = [ + 'engine' => 'google_flights', + 'departure_id' => 'PEK', + 'arrival_id' => 'AUS', + 'outbound_date' => date('Y-m-d', strtotime('+30 days')), + 'return_date' => date('Y-m-d', strtotime('+37 days')), + 'currency' => 'USD', + 'hl' => 'en', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'best_flights', 'Error on `google_flights` engine: no `best_flights`'); + } +} diff --git a/tests/ExampleSearchGoogleHotelsTest.php b/tests/ExampleSearchGoogleHotelsTest.php new file mode 100644 index 0000000..12b89ca --- /dev/null +++ b/tests/ExampleSearchGoogleHotelsTest.php @@ -0,0 +1,30 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + + // Dates are computed rather than hardcoded so the test does not expire. + $this->search_params = [ + 'engine' => 'google_hotels', + 'q' => 'Bali Resorts', + 'check_in_date' => date('Y-m-d', strtotime('+30 days')), + 'check_out_date' => date('Y-m-d', strtotime('+31 days')), + 'adults' => '2', + 'currency' => 'USD', + 'gl' => 'us', + 'hl' => 'en', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'properties', 'Error on `google_hotels` engine: no `properties`'); + } +} diff --git a/tests/ExampleSearchGoogleImagesLightTest.php b/tests/ExampleSearchGoogleImagesLightTest.php new file mode 100644 index 0000000..4458c16 --- /dev/null +++ b/tests/ExampleSearchGoogleImagesLightTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_images_light', + 'q' => 'Coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'images_results', 'Error on `google_images_light` engine: no `images_results`'); + } +} diff --git a/tests/ExampleSearchGoogleImagesTest.php b/tests/ExampleSearchGoogleImagesTest.php new file mode 100644 index 0000000..3fcd841 --- /dev/null +++ b/tests/ExampleSearchGoogleImagesTest.php @@ -0,0 +1,23 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_images', + 'tbm' => 'isch', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'images_results', 'Error on `google_images` engine: no `images_results`'); + } +} diff --git a/tests/ExampleSearchGoogleImmersiveProductTest.php b/tests/ExampleSearchGoogleImmersiveProductTest.php new file mode 100644 index 0000000..1bdca4f --- /dev/null +++ b/tests/ExampleSearchGoogleImmersiveProductTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_immersive_product', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'immersive_product_results', 'Error on `google_immersive_product` engine: no `immersive_product_results`'); + } +} diff --git a/tests/ExampleSearchGoogleLightSearchTest.php b/tests/ExampleSearchGoogleLightSearchTest.php new file mode 100644 index 0000000..8bf4512 --- /dev/null +++ b/tests/ExampleSearchGoogleLightSearchTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_light_search', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'organic_results', 'Error on `google_light_search` engine: no `organic_results`'); + } +} diff --git a/tests/ExampleSearchGoogleLightTest.php b/tests/ExampleSearchGoogleLightTest.php new file mode 100644 index 0000000..2fa94e7 --- /dev/null +++ b/tests/ExampleSearchGoogleLightTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_light', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'organic_results', 'Error on `google_light` engine: no `organic_results`'); + } +} diff --git a/tests/ExampleSearchGoogleNewsLightTest.php b/tests/ExampleSearchGoogleNewsLightTest.php new file mode 100644 index 0000000..0bc685e --- /dev/null +++ b/tests/ExampleSearchGoogleNewsLightTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_news_light', + 'q' => 'pizza', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'news_results', 'Error on `google_news_light` engine: no `news_results`'); + } +} diff --git a/tests/ExampleSearchGoogleNewsTest.php b/tests/ExampleSearchGoogleNewsTest.php new file mode 100644 index 0000000..1ac0c96 --- /dev/null +++ b/tests/ExampleSearchGoogleNewsTest.php @@ -0,0 +1,24 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_news', + 'q' => 'pizza', + 'gl' => 'us', + 'hl' => 'en', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'news_results', 'Error on `google_news` engine: no `news_results`'); + } +} diff --git a/tests/ExampleSearchGooglePatentsTest.php b/tests/ExampleSearchGooglePatentsTest.php new file mode 100644 index 0000000..494600a --- /dev/null +++ b/tests/ExampleSearchGooglePatentsTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_patents', + 'q' => '(Coffee)', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'organic_results', 'Error on `google_patents` engine: no `organic_results`'); + } +} diff --git a/tests/ExampleSearchGoogleProductTest.php b/tests/ExampleSearchGoogleProductTest.php new file mode 100644 index 0000000..2f26813 --- /dev/null +++ b/tests/ExampleSearchGoogleProductTest.php @@ -0,0 +1,23 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_product', + 'q' => 'coffee', + 'product_id' => '4887235756540435899', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'product_results', 'Error on `google_product` engine: no `product_results`'); + } +} diff --git a/tests/ExampleSearchGoogleReverseImageTest.php b/tests/ExampleSearchGoogleReverseImageTest.php new file mode 100644 index 0000000..1864dcc --- /dev/null +++ b/tests/ExampleSearchGoogleReverseImageTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_reverse_image', + 'image_url' => 'https://i.imgur.com/5bGzZi7.jpg', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'image_sizes', 'Error on `google_reverse_image` engine: no `image_sizes`'); + } +} diff --git a/tests/ExampleSearchGoogleTrendsTest.php b/tests/ExampleSearchGoogleTrendsTest.php new file mode 100644 index 0000000..32a366d --- /dev/null +++ b/tests/ExampleSearchGoogleTrendsTest.php @@ -0,0 +1,23 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_trends', + 'q' => 'coffee,milk,bread,pasta,steak', + 'data_type' => 'TIMESERIES', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'interest_over_time', 'Error on `google_trends` engine: no `interest_over_time`'); + } +} diff --git a/tests/ExampleSearchGoogleVideosTest.php b/tests/ExampleSearchGoogleVideosTest.php new file mode 100644 index 0000000..ad9e72b --- /dev/null +++ b/tests/ExampleSearchGoogleVideosTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_videos', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'organic_results', 'Error on `google_videos` engine: no `organic_results`'); + } +} diff --git a/tests/ExampleSearchYandexTest.php b/tests/ExampleSearchYandexTest.php new file mode 100644 index 0000000..8007ba4 --- /dev/null +++ b/tests/ExampleSearchYandexTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'yandex', + 'text' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'organic_results', 'Error on `yandex` engine: no `organic_results`'); + } +} diff --git a/tests/ExampleSearchYelpTest.php b/tests/ExampleSearchYelpTest.php new file mode 100644 index 0000000..e0522ee --- /dev/null +++ b/tests/ExampleSearchYelpTest.php @@ -0,0 +1,23 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'yelp', + 'find_desc' => 'Coffee', + 'find_loc' => 'New York, NY, USA', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'organic_results', 'Error on `yelp` engine: no `organic_results`'); + } +} diff --git a/tests/SerpApiTestCase.php b/tests/SerpApiTestCase.php index 7a390e7..1ac4371 100644 --- a/tests/SerpApiTestCase.php +++ b/tests/SerpApiTestCase.php @@ -14,7 +14,7 @@ protected function setUp(): void { $resolved = $this->resolveApiKey(); if ($resolved === null && $this->requiresApiKey()) { - $this->markTestSkipped('API_KEY is not set'); + $this->markTestSkipped('SERPAPI_KEY is not set'); return; } @@ -25,16 +25,25 @@ protected function requiresApiKey(): bool { return true; } - protected function resolveApiKey(): ?string { - $env = $_ENV['API_KEY'] ?? null; + /** + * Look up the secret key, preferring SERPAPI_KEY. API_KEY is the previous + * name, still accepted so existing setups keep working. + * + * @var array + */ + protected static $api_key_env_names = ['SERPAPI_KEY', 'API_KEY']; - if (!empty($env)) { - return $env; - } + protected function resolveApiKey(): ?string { + foreach (self::$api_key_env_names as $name) { + $env = $_ENV[$name] ?? null; + if (!empty($env)) { + return $env; + } - $value = getenv('API_KEY'); - if (!empty($value)) { - return $value; + $value = getenv($name); + if (!empty($value)) { + return $value; + } } return null;