Skip to content

Commit 216d2c0

Browse files
committed
Add Image API upload support
1 parent 86ff3e0 commit 216d2c0

5 files changed

Lines changed: 314 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,27 @@ The archived JSON matches the original search result. In tests, the key is suppl
145145

146146
[SerpApiTest.java](https://github.com/serpapi/serpapi-java/blob/master/src/test/java/serpapi/SerpApiTest.java)
147147

148+
### Image API
149+
150+
Upload JPG/JPEG, PNG, or WebP image (up to 500 KB) to use with supported search engines.
151+
152+
```java
153+
Map<String, String> auth = new HashMap<>();
154+
auth.put("api_key", "<SERPAPI_KEY>");
155+
SerpApi client = new SerpApi(auth);
156+
157+
JsonObject upload = client.uploadImage(Path.of("/path/to/image.png"));
158+
159+
Map<String, String> parameter = new HashMap<>();
160+
parameter.put("engine", "google_lens");
161+
parameter.put("image_id", upload.get("image_id").getAsString());
162+
JsonObject results = client.search(parameter);
163+
```
164+
165+
`uploadImage` also accepts raw image data as a `byte[]`.
166+
167+
Uploaded image IDs expire after 10 minutes. See the [Image API documentation](https://serpapi.com/image-api).
168+
148169
### Account API
149170

150171
```java

‎README.md.erb‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,27 @@ The archived JSON matches the original search result. In tests, the key is suppl
167167

168168
[SerpApiTest.java](https://github.com/serpapi/serpapi-java/blob/master/src/test/java/serpapi/SerpApiTest.java)
169169

170+
### Image API
171+
172+
Upload JPG/JPEG, PNG, or WebP image (up to 500 KB) to use with supported search engines.
173+
174+
```java
175+
Map<String, String> auth = new HashMap<>();
176+
auth.put("api_key", "<SERPAPI_KEY>");
177+
SerpApi client = new SerpApi(auth);
178+
179+
JsonObject upload = client.uploadImage(Path.of("/path/to/image.png"));
180+
181+
Map<String, String> parameter = new HashMap<>();
182+
parameter.put("engine", "google_lens");
183+
parameter.put("image_id", upload.get("image_id").getAsString());
184+
JsonObject results = client.search(parameter);
185+
```
186+
187+
`uploadImage` also accepts raw image data as a `byte[]`.
188+
189+
Uploaded image IDs expire after 10 minutes. See the [Image API documentation](https://serpapi.com/image-api).
190+
170191
### Account API
171192

172193
```java

‎src/main/java/serpapi/SerpApi.java‎

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import com.google.gson.JsonElement;
66
import com.google.gson.JsonObject;
77

8+
import java.io.IOException;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
811
import java.util.Map;
912
import java.util.HashMap;
1013

@@ -92,6 +95,78 @@ public JsonObject search(Map<String, String> parameter) throws SerpApiException
9295
return json("/search", parameter);
9396
}
9497

98+
/**
99+
* Upload an image to the Image API.
100+
*
101+
* <p>The returned {@code image_id} can be supplied to engines that support
102+
* uploaded images, such as Google Lens. Uploaded images expire after 10
103+
* minutes. Supported formats are JPG/JPEG, PNG, and WebP, up to 500 KB.</p>
104+
*
105+
* @param image local image path
106+
* @return response containing the temporary {@code image_id}
107+
* @throws SerpApiException wraps backend or connection errors
108+
*/
109+
public JsonObject uploadImage(Path image) throws SerpApiException {
110+
return uploadImage(image, null);
111+
}
112+
113+
/**
114+
* Upload an image with additional multipart form fields. A custom
115+
* {@code api_key} in {@code parameter} overrides the constructor API key.
116+
*
117+
* @param image local image path
118+
* @param parameter additional Image API fields
119+
* @return response containing the temporary {@code image_id}
120+
* @throws SerpApiException wraps backend or connection errors
121+
*/
122+
public JsonObject uploadImage(Path image, Map<String, String> parameter) throws SerpApiException {
123+
if (image == null) {
124+
throw new IllegalArgumentException("image must not be null");
125+
}
126+
try {
127+
return uploadImage(Files.readAllBytes(image), parameter);
128+
} catch (IOException e) {
129+
throw new SerpApiException(e);
130+
}
131+
}
132+
133+
/**
134+
* Upload raw image data to the Image API.
135+
*
136+
* @param image raw image data
137+
* @return response containing the temporary {@code image_id}
138+
* @throws SerpApiException wraps backend or connection errors
139+
*/
140+
public JsonObject uploadImage(byte[] image) throws SerpApiException {
141+
return uploadImage(image, null);
142+
}
143+
144+
/**
145+
* Upload raw image data with additional multipart form fields. A custom
146+
* {@code api_key} in {@code parameter} overrides the constructor API key.
147+
*
148+
* @param image raw image data
149+
* @param parameter additional Image API fields
150+
* @return response containing the temporary {@code image_id}
151+
* @throws SerpApiException wraps backend or connection errors
152+
*/
153+
public JsonObject uploadImage(byte[] image, Map<String, String> parameter)
154+
throws SerpApiException {
155+
if (image == null) {
156+
throw new IllegalArgumentException("image must not be null");
157+
}
158+
Map<String, String> form = new HashMap<>();
159+
if (this.parameter.containsKey("api_key")) {
160+
form.put("api_key", this.parameter.get("api_key"));
161+
}
162+
if (parameter != null) {
163+
form.putAll(parameter);
164+
}
165+
166+
this.client.path = "/image";
167+
return parseJson(this.client.postMultipart(form, image));
168+
}
169+
95170
/***
96171
* Return location using Location API
97172
*
@@ -148,7 +223,10 @@ public JsonObject account() throws SerpApiException {
148223
* @return JsonObject created by gson parser
149224
*/
150225
private JsonObject json(String endpoint, Map<String, String> parameter) throws SerpApiException {
151-
String content = get(endpoint, "json", parameter);
226+
return parseJson(get(endpoint, "json", parameter));
227+
}
228+
229+
private JsonObject parseJson(String content) throws SerpApiException {
152230
JsonElement element = gson.fromJson(content, JsonElement.class);
153231
JsonObject result = element.getAsJsonObject();
154232
// SerpApi reports some failures in the body of an HTTP 200 response, so the

‎src/main/java/serpapi/SerpApiHttp.java‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
import java.net.http.HttpRequest;
77
import java.net.http.HttpResponse;
88
import java.time.Duration;
9+
import java.nio.charset.StandardCharsets;
10+
import java.util.ArrayList;
11+
import java.util.List;
912
import java.util.Map;
13+
import java.util.UUID;
1014

1115
import com.google.gson.Gson;
1216
import com.google.gson.JsonObject;
@@ -94,6 +98,67 @@ public String get(Map<String, String> parameter) throws SerpApiException {
9498
}
9599
}
96100

101+
/**
102+
* Upload image data as a multipart/form-data request.
103+
*
104+
* @param parameter multipart text fields
105+
* @param image raw image data
106+
* @return HTTP response body
107+
* @throws SerpApiException wraps error or connection failures
108+
*/
109+
public String postMultipart(Map<String, String> parameter, byte[] image)
110+
throws SerpApiException {
111+
String boundary = "----SerpApiJava" + UUID.randomUUID();
112+
List<HttpRequest.BodyPublisher> parts = new ArrayList<>();
113+
114+
for (Map.Entry<String, String> field : parameter.entrySet()) {
115+
validateMultipartToken(field.getKey(), "field name");
116+
String part = "--" + boundary + "\r\n"
117+
+ "Content-Disposition: form-data; name=\"" + escapeQuoted(field.getKey()) + "\"\r\n\r\n"
118+
+ (field.getValue() == null ? "" : field.getValue()) + "\r\n";
119+
parts.add(HttpRequest.BodyPublishers.ofByteArray(part.getBytes(StandardCharsets.UTF_8)));
120+
}
121+
122+
String imageHeader = "--" + boundary + "\r\n"
123+
+ "Content-Disposition: form-data; name=\"image\"; filename=\"image\"\r\n"
124+
+ "Content-Type: application/octet-stream\r\n\r\n";
125+
parts.add(HttpRequest.BodyPublishers.ofByteArray(imageHeader.getBytes(StandardCharsets.UTF_8)));
126+
parts.add(HttpRequest.BodyPublishers.ofByteArray(image));
127+
parts.add(HttpRequest.BodyPublishers.ofByteArray(
128+
("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)));
129+
130+
URI uri = URI.create(BACKEND + path);
131+
HttpRequest request = HttpRequest.newBuilder()
132+
.uri(uri)
133+
.timeout(Duration.ofMillis(httpReadTimeout))
134+
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
135+
.POST(HttpRequest.BodyPublishers.concat(parts.toArray(new HttpRequest.BodyPublisher[0])))
136+
.build();
137+
138+
try {
139+
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
140+
if (response.statusCode() != 200) {
141+
triggerSerpApiException(response.body());
142+
}
143+
return response.body();
144+
} catch (IOException e) {
145+
throw new SerpApiException(e);
146+
} catch (InterruptedException e) {
147+
Thread.currentThread().interrupt();
148+
throw new SerpApiException(e);
149+
}
150+
}
151+
152+
private static void validateMultipartToken(String value, String description) {
153+
if (value == null || value.contains("\r") || value.contains("\n")) {
154+
throw new IllegalArgumentException(description + " must not be null or contain line breaks");
155+
}
156+
}
157+
158+
private static String escapeQuoted(String value) {
159+
return value.replace("\\", "\\\\").replace("\"", "\\\"");
160+
}
161+
97162
/**
98163
* trigger a exception on error
99164
* @param content raw JSON response from serpapi.com
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package serpapi;
2+
3+
import com.google.gson.JsonObject;
4+
import com.sun.net.httpserver.HttpServer;
5+
import org.junit.Test;
6+
7+
import java.net.InetSocketAddress;
8+
import java.nio.charset.StandardCharsets;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import java.util.HashMap;
12+
import java.util.Map;
13+
import java.util.concurrent.atomic.AtomicReference;
14+
15+
import static org.junit.Assert.*;
16+
17+
/** Offline tests for Image API multipart upload support. */
18+
public class ImageApiTest {
19+
20+
private static class RecordingHttp extends SerpApiHttp {
21+
Map<String, String> recorded;
22+
byte[] recordedImage;
23+
String response = "{\"message\":\"Image uploaded successfully.\",\"image_id\":\"image-123\"}";
24+
25+
RecordingHttp() {
26+
super("/search");
27+
}
28+
29+
@Override
30+
public String postMultipart(Map<String, String> parameter, byte[] image) {
31+
recorded = parameter;
32+
recordedImage = image;
33+
return response;
34+
}
35+
}
36+
37+
private static SerpApi client(RecordingHttp http) {
38+
Map<String, String> defaults = new HashMap<>();
39+
defaults.put("api_key", "client-key");
40+
defaults.put("engine", "google_lens");
41+
SerpApi client = new SerpApi(defaults);
42+
client.client = http;
43+
return client;
44+
}
45+
46+
@Test
47+
public void uploadsPathAndReturnsImageId() throws Exception {
48+
Path image = Files.createTempFile("serpapi-image-", ".png");
49+
byte[] imageData = new byte[] {1, 2, 3};
50+
Files.write(image, imageData);
51+
try {
52+
RecordingHttp http = new RecordingHttp();
53+
JsonObject result = client(http).uploadImage(image);
54+
55+
assertEquals("image-123", result.get("image_id").getAsString());
56+
assertArrayEquals(imageData, http.recordedImage);
57+
assertEquals("/image", http.path);
58+
assertEquals("client-key", http.recorded.get("api_key"));
59+
assertFalse(http.recorded.containsKey("engine"));
60+
} finally {
61+
Files.deleteIfExists(image);
62+
}
63+
}
64+
65+
@Test
66+
public void acceptsRawBytesAndCustomFormFields() throws Exception {
67+
byte[] image = new byte[] {1, 2, 3};
68+
Map<String, String> fields = new HashMap<>();
69+
fields.put("api_key", "request-key");
70+
fields.put("zero_trace", "true");
71+
RecordingHttp http = new RecordingHttp();
72+
73+
JsonObject result = client(http).uploadImage(image, fields);
74+
75+
assertEquals("image-123", result.get("image_id").getAsString());
76+
assertSame(image, http.recordedImage);
77+
assertEquals("request-key", http.recorded.get("api_key"));
78+
assertEquals("true", http.recorded.get("zero_trace"));
79+
}
80+
81+
@Test
82+
public void httpClientSendsMultipartBody() throws Exception {
83+
AtomicReference<String> contentType = new AtomicReference<>();
84+
AtomicReference<String> requestBody = new AtomicReference<>();
85+
HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
86+
server.createContext("/image", exchange -> {
87+
contentType.set(exchange.getRequestHeaders().getFirst("Content-Type"));
88+
requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
89+
byte[] response = "{\"image_id\":\"local-test\"}".getBytes(StandardCharsets.UTF_8);
90+
exchange.sendResponseHeaders(200, response.length);
91+
exchange.getResponseBody().write(response);
92+
exchange.close();
93+
});
94+
server.start();
95+
96+
String originalBackend = SerpApiHttp.BACKEND;
97+
byte[] image = "fake-png-data".getBytes(StandardCharsets.UTF_8);
98+
try {
99+
SerpApiHttp.BACKEND = "http://localhost:" + server.getAddress().getPort();
100+
SerpApiHttp http = new SerpApiHttp("/image");
101+
Map<String, String> fields = new HashMap<>();
102+
fields.put("api_key", "test-key");
103+
104+
assertTrue(http.postMultipart(fields, image).contains("local-test"));
105+
assertTrue(contentType.get().startsWith("multipart/form-data; boundary="));
106+
assertTrue(requestBody.get().contains("name=\"api_key\"\r\n\r\ntest-key"));
107+
assertTrue(requestBody.get().contains("name=\"image\"; filename=\"image\""));
108+
assertTrue(requestBody.get().contains("Content-Type: application/octet-stream"));
109+
assertTrue(requestBody.get().contains("fake-png-data"));
110+
} finally {
111+
SerpApiHttp.BACKEND = originalBackend;
112+
server.stop(0);
113+
}
114+
}
115+
116+
@Test
117+
public void raisesErrorReturnedByImageApi() {
118+
RecordingHttp http = new RecordingHttp();
119+
http.response = "{\"error\":\"Unsupported image format.\"}";
120+
121+
try {
122+
client(http).uploadImage(new byte[0]);
123+
fail("expected SerpApiException");
124+
} catch (SerpApiException e) {
125+
assertEquals("Unsupported image format.", e.getMessage());
126+
}
127+
}
128+
}

0 commit comments

Comments
 (0)