Skip to content

Commit 3b9cec0

Browse files
authored
Add Image API support with uploadImage() (#11)
* 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. * 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.
1 parent 07c1dcb commit 3b9cec0

7 files changed

Lines changed: 391 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The following services are provided:
1717
* [Location API](https://serpapi.com/locations-api)
1818
* [Search Archive API](https://serpapi.com/search-archive-api)
1919
* [Account API](https://serpapi.com/account-api)
20+
* [Image API](https://serpapi.com/image-api)
2021

2122
SerpApi provides a [script builder](https://serpapi.com/playground) to get you started quickly.
2223

@@ -328,6 +329,8 @@ print_r($results->visual_matches);
328329
* source: [tests/ExampleSearchGoogleLensTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleLensTest.php)
329330
see: [https://serpapi.com/google-lens-api](https://serpapi.com/google-lens-api)
330331

332+
To search with a local image instead of a URL, upload it with the [Image API](#image-api).
333+
331334
### Search Google Play
332335
```php
333336
use SerpApi\Client;
@@ -907,6 +910,27 @@ $account = $client->account();
907910
print_r($account);
908911
```
909912

913+
### Image API
914+
915+
Upload a local image, then search with its `image_id` on engines that accept uploaded images, such as Google Lens:
916+
917+
```php
918+
use SerpApi\Client;
919+
920+
$client = new Client(getenv('SERPAPI_KEY'));
921+
$upload = $client->uploadImage('path/to/image.jpg');
922+
923+
$results = $client->search([
924+
'engine' => 'google_lens',
925+
'image_id' => $upload->image_id,
926+
]);
927+
print_r($results->visual_matches);
928+
```
929+
930+
`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.
931+
932+
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)
933+
910934
### HTML results
911935

912936
```php

‎README.md.erb‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ The following services are provided:
4141
* [Location API](https://serpapi.com/locations-api)
4242
* [Search Archive API](https://serpapi.com/search-archive-api)
4343
* [Account API](https://serpapi.com/account-api)
44+
* [Image API](https://serpapi.com/image-api)
4445

4546
SerpApi provides a [script builder](https://serpapi.com/playground) to get you started quickly.
4647

@@ -250,6 +251,8 @@ see: [https://serpapi.com/google-events-api](https://serpapi.com/google-events-a
250251
<%= snippet('tests/ExampleSearchGoogleLensTest.php') %>
251252
see: [https://serpapi.com/google-lens-api](https://serpapi.com/google-lens-api)
252253

254+
To search with a local image instead of a URL, upload it with the [Image API](#image-api).
255+
253256
### Search Google Play
254257
<%= snippet('tests/ExampleSearchGooglePlayTest.php') %>
255258
see: [https://serpapi.com/google-play-api](https://serpapi.com/google-play-api)
@@ -478,6 +481,27 @@ $account = $client->account();
478481
print_r($account);
479482
```
480483

484+
### Image API
485+
486+
Upload a local image, then search with its `image_id` on engines that accept uploaded images, such as Google Lens:
487+
488+
```php
489+
use SerpApi\Client;
490+
491+
$client = new Client(getenv('SERPAPI_KEY'));
492+
$upload = $client->uploadImage('path/to/image.jpg');
493+
494+
$results = $client->search([
495+
'engine' => 'google_lens',
496+
'image_id' => $upload->image_id,
497+
]);
498+
print_r($results->visual_matches);
499+
```
500+
501+
`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.
502+
503+
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)
504+
481505
### HTML results
482506

483507
```php

‎src/Client.php‎

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,73 @@ public function location(array $params = []): array
319319
return $this->get('/locations.json', 'json', $params);
320320
}
321321

322+
/**
323+
* Upload an image using the Image API.
324+
*
325+
* The returned `image_id` can be passed to Search API engines that accept
326+
* uploaded images, such as Google Lens. Image IDs expire after 10 minutes.
327+
*
328+
* The upload carries the same parameters as any other request, including
329+
* default search parameters set on the client, plus the image.
330+
*
331+
* @param string|resource $image Path to a JPG, PNG, or WebP file, or a readable stream;
332+
* seekable streams are uploaded from the beginning
333+
* @param array<string, mixed> $params Request parameters, such as `api_key` or `timeout`
334+
* @throws SerpApiException
335+
*/
336+
public function uploadImage($image, array $params = []): object
337+
{
338+
if (is_resource($image) && get_resource_type($image) === 'stream') {
339+
$copy = self::copyToTempFile($image);
340+
341+
try {
342+
$path = stream_get_meta_data($copy)['uri'];
343+
return $this->post('/image', $params, ['image' => new \CURLFile($path, '', 'image')]);
344+
} finally {
345+
fclose($copy);
346+
}
347+
}
348+
349+
if (!is_string($image) || $image === '') {
350+
throw new SerpApiException('image must be a file path or a stream');
351+
}
352+
353+
if (!is_file($image) || !is_readable($image)) {
354+
throw new SerpApiException("image is not a readable file: {$image}");
355+
}
356+
357+
// Without a post name, cURL would send the full local path as the file name.
358+
return $this->post('/image', $params, ['image' => new \CURLFile($image, '', basename($image))]);
359+
}
360+
361+
/**
362+
* cURL uploads files by path, and CURLStringFile requires PHP 8.1, so a
363+
* stream is uploaded from a temporary copy that is deleted once closed.
364+
*
365+
* @param resource $stream
366+
* @return resource
367+
* @throws SerpApiException
368+
*/
369+
private static function copyToTempFile($stream)
370+
{
371+
$copy = tmpfile();
372+
if ($copy === false) {
373+
throw new SerpApiException('Failed to create a temporary file for the image');
374+
}
375+
376+
if (stream_get_meta_data($stream)['seekable']) {
377+
rewind($stream);
378+
}
379+
380+
$copied = stream_copy_to_stream($stream, $copy);
381+
if ($copied === false || $copied === 0 || !fflush($copy)) {
382+
fclose($copy);
383+
throw new SerpApiException('Failed to read the image stream');
384+
}
385+
386+
return $copy;
387+
}
388+
322389
/**
323390
* Retrieve search result from the Search Archive API.
324391
*
@@ -347,6 +414,31 @@ public function searchArchive(string $searchId, string $format = 'json')
347414
* @throws SerpApiException
348415
*/
349416
private function get(string $endpoint, string $format = 'json', array $params = [], bool $assoc = false)
417+
{
418+
return $this->send($endpoint, $format, $params, $assoc);
419+
}
420+
421+
/**
422+
* Send a multipart/form-data POST. The request parameters travel as form
423+
* fields next to the files instead of in the URL.
424+
*
425+
* @param array<string, mixed> $params
426+
* @param array<string, \CURLFile> $files
427+
* @throws SerpApiException
428+
*/
429+
private function post(string $endpoint, array $params, array $files): object
430+
{
431+
return $this->send($endpoint, 'json', $params, false, $files);
432+
}
433+
434+
/**
435+
* @param array<string, mixed> $params
436+
* @param bool $assoc Decode JSON objects to associative arrays instead of stdClass
437+
* @param array<string, \CURLFile>|null $files Files to POST, or null for a GET
438+
* @return object|array<int|string, mixed>|string
439+
* @throws SerpApiException
440+
*/
441+
private function send(string $endpoint, string $format, array $params, bool $assoc, ?array $files = null)
350442
{
351443
if (!in_array($format, ['json', 'html', 'md'], true)) {
352444
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 =
367459

368460
$query = $this->buildQuery($params, (string) $apiKey, $format);
369461

370-
$url = self::BASE_URL . $endpoint . '?' . http_build_query($query);
462+
if ($files === null) {
463+
$requestResult = $this->request(self::BASE_URL . $endpoint . '?' . http_build_query($query), $timeout);
464+
} else {
465+
$form = array_merge(self::formFields($query), $files);
466+
$requestResult = $this->request(self::BASE_URL . $endpoint, $timeout, $form);
467+
}
371468

372-
$requestResult = $this->request($url, $timeout);
373469
$response = $requestResult['response'];
374470
$httpCode = $requestResult['http_code'];
375471
$curlError = $requestResult['curl_error'];
@@ -457,26 +553,65 @@ private function buildQuery(array $params, string $apiKey, string $format): arra
457553
}
458554

459555
/**
460-
* Perform the HTTP GET request.
556+
* Encode request parameters as form fields exactly as they would be in a
557+
* query string. cURL would otherwise send false as an empty string and
558+
* nested arrays as "Array".
559+
*
560+
* @param array<string, mixed> $query
561+
* @return array<string, string>
562+
*/
563+
private static function formFields(array $query): array
564+
{
565+
$fields = [];
566+
567+
foreach (explode('&', http_build_query($query, '', '&')) as $pair) {
568+
if ($pair === '') {
569+
continue;
570+
}
571+
572+
$parts = explode('=', $pair, 2);
573+
$fields[urldecode($parts[0])] = urldecode($parts[1] ?? '');
574+
}
575+
576+
return $fields;
577+
}
578+
579+
/**
580+
* Perform the HTTP request: a GET, or a multipart/form-data POST when
581+
* form fields are given.
461582
*
462583
* @internal Seam for tests to observe requests without network access;
463584
* not covered by the backwards compatibility promise.
464585
*
586+
* @param array<string, string|\CURLFile>|null $form
465587
* @return array{response: string|false, http_code: int, curl_error: string}
466588
* @throws SerpApiException
467589
*/
468-
protected function request(string $url, int $timeout): array
590+
protected function request(string $url, int $timeout, ?array $form = null): array
469591
{
470592
$ch = $this->acquireHandle();
471593

472594
try {
473-
$isConfigured = curl_setopt_array($ch, [
595+
$options = [
474596
CURLOPT_URL => $url,
475597
CURLOPT_RETURNTRANSFER => true,
476598
CURLOPT_USERAGENT => 'serpapi-php/' . self::VERSION,
477599
CURLOPT_FOLLOWLOCATION => true,
478600
CURLOPT_TIMEOUT => $timeout,
479-
]);
601+
CURLOPT_HTTPHEADER => [],
602+
];
603+
604+
// A shared handle keeps the method and headers of the previous request.
605+
if ($form === null) {
606+
$options[CURLOPT_HTTPGET] = true;
607+
} else {
608+
$options[CURLOPT_POSTFIELDS] = $form;
609+
// libcurl before 7.69 sends `Expect: 100-continue` with bodies over
610+
// 1 KB. SerpApi does not answer it, stalling each upload for a second.
611+
$options[CURLOPT_HTTPHEADER] = ['Expect:'];
612+
}
613+
614+
$isConfigured = curl_setopt_array($ch, $options);
480615

481616
if ($isConfigured === false) {
482617
throw new SerpApiException('Failed to configure cURL options: ' . curl_error($ch));
@@ -588,7 +723,7 @@ private function raiseParserError(
588723
$sanitizedSearchParams = $this->sanitizeSearchParams($searchParams);
589724

590725
throw new SerpApiException(
591-
'JSON parse error: ' . json_last_error_msg() . ' on get url: ' . self::BASE_URL . $endpoint,
726+
'JSON parse error: ' . json_last_error_msg() . ' from url: ' . self::BASE_URL . $endpoint,
592727
null,
593728
$sanitizedSearchParams,
594729
$responseStatus,

‎tests/ClientIntegrationTest.php‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,19 @@ public function testSearchArchive()
6767
$this->assertStringStartsWith('---', $archived_markdown);
6868
$this->assertStringContainsString('## Organic Results', $archived_markdown);
6969
}
70+
71+
public function testUploadImage()
72+
{
73+
$client = $this->serpApiClient();
74+
$upload = $client->uploadImage(__DIR__ . '/fixtures/mona-lisa.jpg');
75+
$this->assertNotEmpty($upload->image_id);
76+
77+
// The search is a GET on the same connection the upload was POSTed on.
78+
$response = $client->search([
79+
'engine' => 'google_lens',
80+
'image_id' => $upload->image_id,
81+
]);
82+
$this->assertEquals('Success', $response->search_metadata->status);
83+
$this->assertResponseHasProperty($response, 'visual_matches');
84+
}
7085
}

0 commit comments

Comments
 (0)