From 16cfba2048494968c56e3a1c168327b2f05ffd0b Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 15:37:07 +0200 Subject: [PATCH 1/2] Add Image API support with uploadImage() Uploads a JPG, PNG, or WebP image to POST /image as multipart/form-data and returns the decoded response, whose image_id can be passed to Google Lens and other engines that accept uploaded images. Mirrors upload_image in serpapi-ruby and serpapi-python: - accepts a file path or a stream; seekable streams are rewound first, as Ruby does, and uploaded from a temporary copy because cURL uploads by path and CURLStringFile needs PHP 8.1 - sends the same parameters as any other request (api_key, source, default search parameters) as form fields, so the key stays out of the URL; per-call api_key and timeout work as for search() - sends only the file's base name; without one, cURL would send the full local path - errors raise SerpApiException with the same context as searches The persistent cURL handle now resets the method on every GET. Without CURLOPT_HTTPGET, a search after an upload went out as a multipart POST. Uploads also drop the Expect: 100-continue header: libcurl before 7.69 sends it with bodies over 1 KB, SerpApi does not answer it, and each upload stalled for a second. The live integration test uploads a 27 KB public domain thumbnail of the Mona Lisa from Wikimedia Commons and searches it with Google Lens. --- README.md | 24 +++++ README.md.erb | 24 +++++ src/Client.php | 148 +++++++++++++++++++++++++++++-- tests/ClientIntegrationTest.php | 15 ++++ tests/ClientUploadImageTest.php | 149 ++++++++++++++++++++++++++++++++ tests/RecordingClient.php | 25 +++++- tests/fixtures/mona-lisa.jpg | Bin 0 -> 27254 bytes 7 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 tests/ClientUploadImageTest.php create mode 100644 tests/fixtures/mona-lisa.jpg 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..9f7e562 100644 --- a/src/Client.php +++ b/src/Client.php @@ -319,6 +319,72 @@ 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); + } + + if (stream_copy_to_stream($stream, $copy) === false || !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 +413,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 +458,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 +552,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 +722,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..3743e63 --- /dev/null +++ b/tests/ClientUploadImageTest.php @@ -0,0 +1,149 @@ +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 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 0000000000000000000000000000000000000000..23d11fb438801cf89a2391c9f352280bc92045b1 GIT binary patch literal 27254 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 Date: Thu, 22 Oct 2026 09:58:05 +0200 Subject: [PATCH 2/2] Reject empty image streams and fix the form separator stream_copy_to_stream returns 0, not false, for an empty or already consumed stream, such as a pipe that was read before. The upload then sent an empty file and failed with the API's error. It now raises SerpApiException before any request is sent. formFields() passes '&' to http_build_query explicitly, so a custom arg_separator.output in php.ini no longer breaks the split. --- src/Client.php | 5 +++-- tests/ClientUploadImageTest.php | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Client.php b/src/Client.php index 9f7e562..cfe8a23 100644 --- a/src/Client.php +++ b/src/Client.php @@ -377,7 +377,8 @@ private static function copyToTempFile($stream) rewind($stream); } - if (stream_copy_to_stream($stream, $copy) === false || !fflush($copy)) { + $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'); } @@ -563,7 +564,7 @@ private static function formFields(array $query): array { $fields = []; - foreach (explode('&', http_build_query($query)) as $pair) { + foreach (explode('&', http_build_query($query, '', '&')) as $pair) { if ($pair === '') { continue; } diff --git a/tests/ClientUploadImageTest.php b/tests/ClientUploadImageTest.php index 3743e63..e6f1da2 100644 --- a/tests/ClientUploadImageTest.php +++ b/tests/ClientUploadImageTest.php @@ -137,6 +137,19 @@ public function testUploadImageRejectsMissingFile() } } + 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');