diff --git a/README.md b/README.md index 2903e82..bdbe3d6 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The following services are provided: * [Location API](https://serpapi.com/locations-api) * [Search Archive API](https://serpapi.com/search-archive-api) * [Account API](https://serpapi.com/account-api) + * [Image API](https://serpapi.com/image-api) SerpApi provides a [script builder](https://serpapi.com/playground) to get you started quickly. @@ -328,6 +329,8 @@ print_r($results->visual_matches); * source: [tests/ExampleSearchGoogleLensTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleLensTest.php) see: [https://serpapi.com/google-lens-api](https://serpapi.com/google-lens-api) +To search with a local image instead of a URL, upload it with the [Image API](#image-api). + ### Search Google Play ```php use SerpApi\Client; @@ -907,6 +910,27 @@ $account = $client->account(); print_r($account); ``` +### Image API + +Upload a local image, then search with its `image_id` on engines that accept uploaded images, such as Google Lens: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$upload = $client->uploadImage('path/to/image.jpg'); + +$results = $client->search([ + 'engine' => 'google_lens', + 'image_id' => $upload->image_id, +]); +print_r($results->visual_matches); +``` + +`uploadImage` also accepts an open stream, such as `fopen('path/to/image.jpg', 'rb')`. Images must be JPG, PNG, or WebP files up to 500 KB, and an `image_id` expires after 10 minutes. + +see: [https://serpapi.com/image-api](https://serpapi.com/image-api) and [https://serpapi.com/google-lens-upload-an-image](https://serpapi.com/google-lens-upload-an-image) + ### HTML results ```php diff --git a/README.md.erb b/README.md.erb index 0d99cee..6202bdf 100644 --- a/README.md.erb +++ b/README.md.erb @@ -41,6 +41,7 @@ The following services are provided: * [Location API](https://serpapi.com/locations-api) * [Search Archive API](https://serpapi.com/search-archive-api) * [Account API](https://serpapi.com/account-api) + * [Image API](https://serpapi.com/image-api) SerpApi provides a [script builder](https://serpapi.com/playground) to get you started quickly. @@ -250,6 +251,8 @@ see: [https://serpapi.com/google-events-api](https://serpapi.com/google-events-a <%= snippet('tests/ExampleSearchGoogleLensTest.php') %> see: [https://serpapi.com/google-lens-api](https://serpapi.com/google-lens-api) +To search with a local image instead of a URL, upload it with the [Image API](#image-api). + ### Search Google Play <%= snippet('tests/ExampleSearchGooglePlayTest.php') %> see: [https://serpapi.com/google-play-api](https://serpapi.com/google-play-api) @@ -478,6 +481,27 @@ $account = $client->account(); print_r($account); ``` +### Image API + +Upload a local image, then search with its `image_id` on engines that accept uploaded images, such as Google Lens: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$upload = $client->uploadImage('path/to/image.jpg'); + +$results = $client->search([ + 'engine' => 'google_lens', + 'image_id' => $upload->image_id, +]); +print_r($results->visual_matches); +``` + +`uploadImage` also accepts an open stream, such as `fopen('path/to/image.jpg', 'rb')`. Images must be JPG, PNG, or WebP files up to 500 KB, and an `image_id` expires after 10 minutes. + +see: [https://serpapi.com/image-api](https://serpapi.com/image-api) and [https://serpapi.com/google-lens-upload-an-image](https://serpapi.com/google-lens-upload-an-image) + ### HTML results ```php diff --git a/src/Client.php b/src/Client.php index af3efc8..cfe8a23 100644 --- a/src/Client.php +++ b/src/Client.php @@ -319,6 +319,73 @@ public function location(array $params = []): array return $this->get('/locations.json', 'json', $params); } + /** + * Upload an image using the Image API. + * + * The returned `image_id` can be passed to Search API engines that accept + * uploaded images, such as Google Lens. Image IDs expire after 10 minutes. + * + * The upload carries the same parameters as any other request, including + * default search parameters set on the client, plus the image. + * + * @param string|resource $image Path to a JPG, PNG, or WebP file, or a readable stream; + * seekable streams are uploaded from the beginning + * @param array $params Request parameters, such as `api_key` or `timeout` + * @throws SerpApiException + */ + public function uploadImage($image, array $params = []): object + { + if (is_resource($image) && get_resource_type($image) === 'stream') { + $copy = self::copyToTempFile($image); + + try { + $path = stream_get_meta_data($copy)['uri']; + return $this->post('/image', $params, ['image' => new \CURLFile($path, '', 'image')]); + } finally { + fclose($copy); + } + } + + if (!is_string($image) || $image === '') { + throw new SerpApiException('image must be a file path or a stream'); + } + + if (!is_file($image) || !is_readable($image)) { + throw new SerpApiException("image is not a readable file: {$image}"); + } + + // Without a post name, cURL would send the full local path as the file name. + return $this->post('/image', $params, ['image' => new \CURLFile($image, '', basename($image))]); + } + + /** + * cURL uploads files by path, and CURLStringFile requires PHP 8.1, so a + * stream is uploaded from a temporary copy that is deleted once closed. + * + * @param resource $stream + * @return resource + * @throws SerpApiException + */ + private static function copyToTempFile($stream) + { + $copy = tmpfile(); + if ($copy === false) { + throw new SerpApiException('Failed to create a temporary file for the image'); + } + + if (stream_get_meta_data($stream)['seekable']) { + rewind($stream); + } + + $copied = stream_copy_to_stream($stream, $copy); + if ($copied === false || $copied === 0 || !fflush($copy)) { + fclose($copy); + throw new SerpApiException('Failed to read the image stream'); + } + + return $copy; + } + /** * Retrieve search result from the Search Archive API. * @@ -347,6 +414,31 @@ public function searchArchive(string $searchId, string $format = 'json') * @throws SerpApiException */ private function get(string $endpoint, string $format = 'json', array $params = [], bool $assoc = false) + { + return $this->send($endpoint, $format, $params, $assoc); + } + + /** + * Send a multipart/form-data POST. The request parameters travel as form + * fields next to the files instead of in the URL. + * + * @param array $params + * @param array $files + * @throws SerpApiException + */ + private function post(string $endpoint, array $params, array $files): object + { + return $this->send($endpoint, 'json', $params, false, $files); + } + + /** + * @param array $params + * @param bool $assoc Decode JSON objects to associative arrays instead of stdClass + * @param array|null $files Files to POST, or null for a GET + * @return object|array|string + * @throws SerpApiException + */ + private function send(string $endpoint, string $format, array $params, bool $assoc, ?array $files = null) { if (!in_array($format, ['json', 'html', 'md'], true)) { throw new SerpApiException("Unsupported format '$format'. Expected 'html', 'json', or 'md'."); @@ -367,9 +459,13 @@ private function get(string $endpoint, string $format = 'json', array $params = $query = $this->buildQuery($params, (string) $apiKey, $format); - $url = self::BASE_URL . $endpoint . '?' . http_build_query($query); + if ($files === null) { + $requestResult = $this->request(self::BASE_URL . $endpoint . '?' . http_build_query($query), $timeout); + } else { + $form = array_merge(self::formFields($query), $files); + $requestResult = $this->request(self::BASE_URL . $endpoint, $timeout, $form); + } - $requestResult = $this->request($url, $timeout); $response = $requestResult['response']; $httpCode = $requestResult['http_code']; $curlError = $requestResult['curl_error']; @@ -457,26 +553,65 @@ private function buildQuery(array $params, string $apiKey, string $format): arra } /** - * Perform the HTTP GET request. + * Encode request parameters as form fields exactly as they would be in a + * query string. cURL would otherwise send false as an empty string and + * nested arrays as "Array". + * + * @param array $query + * @return array + */ + private static function formFields(array $query): array + { + $fields = []; + + foreach (explode('&', http_build_query($query, '', '&')) as $pair) { + if ($pair === '') { + continue; + } + + $parts = explode('=', $pair, 2); + $fields[urldecode($parts[0])] = urldecode($parts[1] ?? ''); + } + + return $fields; + } + + /** + * Perform the HTTP request: a GET, or a multipart/form-data POST when + * form fields are given. * * @internal Seam for tests to observe requests without network access; * not covered by the backwards compatibility promise. * + * @param array|null $form * @return array{response: string|false, http_code: int, curl_error: string} * @throws SerpApiException */ - protected function request(string $url, int $timeout): array + protected function request(string $url, int $timeout, ?array $form = null): array { $ch = $this->acquireHandle(); try { - $isConfigured = curl_setopt_array($ch, [ + $options = [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_USERAGENT => 'serpapi-php/' . self::VERSION, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => $timeout, - ]); + CURLOPT_HTTPHEADER => [], + ]; + + // A shared handle keeps the method and headers of the previous request. + if ($form === null) { + $options[CURLOPT_HTTPGET] = true; + } else { + $options[CURLOPT_POSTFIELDS] = $form; + // libcurl before 7.69 sends `Expect: 100-continue` with bodies over + // 1 KB. SerpApi does not answer it, stalling each upload for a second. + $options[CURLOPT_HTTPHEADER] = ['Expect:']; + } + + $isConfigured = curl_setopt_array($ch, $options); if ($isConfigured === false) { throw new SerpApiException('Failed to configure cURL options: ' . curl_error($ch)); @@ -588,7 +723,7 @@ private function raiseParserError( $sanitizedSearchParams = $this->sanitizeSearchParams($searchParams); throw new SerpApiException( - 'JSON parse error: ' . json_last_error_msg() . ' on get url: ' . self::BASE_URL . $endpoint, + 'JSON parse error: ' . json_last_error_msg() . ' from url: ' . self::BASE_URL . $endpoint, null, $sanitizedSearchParams, $responseStatus, diff --git a/tests/ClientIntegrationTest.php b/tests/ClientIntegrationTest.php index 6c73272..2ad508e 100644 --- a/tests/ClientIntegrationTest.php +++ b/tests/ClientIntegrationTest.php @@ -67,4 +67,19 @@ public function testSearchArchive() $this->assertStringStartsWith('---', $archived_markdown); $this->assertStringContainsString('## Organic Results', $archived_markdown); } + + public function testUploadImage() + { + $client = $this->serpApiClient(); + $upload = $client->uploadImage(__DIR__ . '/fixtures/mona-lisa.jpg'); + $this->assertNotEmpty($upload->image_id); + + // The search is a GET on the same connection the upload was POSTed on. + $response = $client->search([ + 'engine' => 'google_lens', + 'image_id' => $upload->image_id, + ]); + $this->assertEquals('Success', $response->search_metadata->status); + $this->assertResponseHasProperty($response, 'visual_matches'); + } } diff --git a/tests/ClientUploadImageTest.php b/tests/ClientUploadImageTest.php new file mode 100644 index 0000000..e6f1da2 --- /dev/null +++ b/tests/ClientUploadImageTest.php @@ -0,0 +1,162 @@ +willReturn(self::UPLOADED); + $result = $client->uploadImage(self::IMAGE); + + $this->assertEquals('Image uploaded successfully.', $result->message); + $this->assertEquals('image-123', $result->image_id); + + $this->assertEquals('https://serpapi.com/image', end($client->urls), 'parameters must not leak into the URL'); + + $form = $client->lastForm(); + $this->assertEquals('secret', $form['api_key']); + $this->assertEquals(Client::SOURCE, $form['source']); + + $image = $form['image']; + $this->assertInstanceOf(\CURLFile::class, $image); + $this->assertEquals(self::IMAGE, $image->getFilename()); + $this->assertEquals('mona-lisa.jpg', $image->getPostFilename(), 'the local directory must not be sent'); + $this->assertCount(1, $client->uploads); + $this->assertEquals(sha1_file(self::IMAGE), sha1($client->uploads[0])); + } + + public function testUploadImageFromStreamSendsItFromTheBeginning() + { + $stream = fopen('php://memory', 'w+b'); + fwrite($stream, 'image data'); + fseek($stream, 5); + + $client = (new RecordingClient('secret'))->willReturn(self::UPLOADED); + $result = $client->uploadImage($stream); + + $this->assertEquals('image-123', $result->image_id); + $this->assertEquals(['image data'], $client->uploads); + + $image = $client->lastForm()['image']; + $this->assertEquals('image', $image->getPostFilename()); + $this->assertFalse(file_exists($image->getFilename()), 'the temporary copy must be deleted'); + $this->assertTrue(is_resource($stream), "the caller's stream must stay open"); + } + + public function testUploadImageSendsTheSameParametersAsASearch() + { + $client = (new RecordingClient([ + 'api_key' => 'secret', + 'hl' => 'en', + 'zero_trace' => true, + ]))->willReturn(self::UPLOADED); + $params = ['hl' => 'fr', 'no_cache' => false]; + + $client->search($params); + $query = $client->lastQuery(); + $this->assertNull($client->forms[0], 'a search must stay a GET'); + + $client->uploadImage(self::IMAGE, $params); + $form = $client->lastForm(); + unset($form['image']); + + $this->assertEquals($query, $form); + $this->assertEquals('1', $form['zero_trace']); + $this->assertEquals('0', $form['no_cache']); + } + + public function testUploadImageAcceptsPerCallApiKeyAndTimeout() + { + $client = (new RecordingClient(['api_key' => 'secret', 'gl' => 'us']))->willReturn(self::UPLOADED); + + $client->uploadImage(self::IMAGE, ['api_key' => 'other', 'timeout' => 7, 'gl' => null]); + + $form = $client->lastForm(); + $this->assertEquals('other', $form['api_key']); + $this->assertArrayNotHasKey('timeout', $form); + $this->assertArrayNotHasKey('gl', $form); + $this->assertEquals([7], $client->timeouts); + } + + public function testUploadImageRaisesSerpApiError() + { + $client = (new RecordingClient('secret'))->willReturn( + '{"error":"Invalid image format. Supported format: jpg, jpeg, png, webp"}', + 400 + ); + + try { + $client->uploadImage(self::IMAGE); + $this->fail('Expected SerpApiException'); + } catch (SerpApiException $e) { + $this->assertStringContainsString('from url: https://serpapi.com/image', $e->getMessage()); + $this->assertEquals('Invalid image format. Supported format: jpg, jpeg, png, webp', $e->getSerpApiError()); + $this->assertEquals(400, $e->getResponseStatus()); + $this->assertArrayNotHasKey('api_key', (array) $e->getSearchParams()); + $this->assertArrayNotHasKey('image', (array) $e->getSearchParams()); + } + } + + public function testUploadImageRequiresApiKey() + { + $client = new RecordingClient(''); + + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage('api_key must be present'); + + $client->uploadImage(self::IMAGE); + } + + public function testUploadImageRejectsMissingFile() + { + $client = new RecordingClient('secret'); + + try { + $client->uploadImage(__DIR__ . '/fixtures/missing.jpg'); + $this->fail('Expected SerpApiException'); + } catch (SerpApiException $e) { + $this->assertStringContainsString('image is not a readable file', $e->getMessage()); + $this->assertEmpty($client->urls, 'no request must be sent'); + } + } + + public function testUploadImageRejectsEmptyStream() + { + $client = new RecordingClient('secret'); + + try { + $client->uploadImage(fopen('php://memory', 'w+b')); + $this->fail('Expected SerpApiException'); + } catch (SerpApiException $e) { + $this->assertEquals('Failed to read the image stream', $e->getMessage()); + $this->assertEmpty($client->urls, 'no request must be sent'); + } + } + + public function testUploadImageRejectsOtherArguments() + { + $client = new RecordingClient('secret'); + + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage('image must be a file path or a stream'); + + $client->uploadImage(['path' => self::IMAGE]); + } +} diff --git a/tests/RecordingClient.php b/tests/RecordingClient.php index 5c3bacb..e007039 100644 --- a/tests/RecordingClient.php +++ b/tests/RecordingClient.php @@ -17,6 +17,12 @@ class RecordingClient extends Client /** @var array */ public $timeouts = []; + /** @var array|null> Form fields of each request, null for a GET */ + public $forms = []; + + /** @var array Contents of uploaded files, read while the request was in flight */ + public $uploads = []; + /** @var string */ private $body = '{"search_metadata":{"id":"abc123","status":"Success"}}'; @@ -31,10 +37,17 @@ public function willReturn(string $body, int $httpCode = 200): self return $this; } - protected function request(string $url, int $timeout): array + protected function request(string $url, int $timeout, ?array $form = null): array { $this->urls[] = $url; $this->timeouts[] = $timeout; + $this->forms[] = $form; + + foreach ($form ?? [] as $field) { + if ($field instanceof \CURLFile) { + $this->uploads[] = (string) file_get_contents($field->getFilename()); + } + } return [ 'response' => $this->body, @@ -62,4 +75,14 @@ public function lastPath(): string return (string) parse_url($url, PHP_URL_PATH); } + + /** + * Form fields of the most recent request. + * + * @return array + */ + public function lastForm(): array + { + return end($this->forms) ?: []; + } } diff --git a/tests/fixtures/mona-lisa.jpg b/tests/fixtures/mona-lisa.jpg new file mode 100644 index 0000000..23d11fb Binary files /dev/null and b/tests/fixtures/mona-lisa.jpg differ