Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,27 @@ The archived JSON matches the original search result. In tests, the key is suppl

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

### Image API

Upload JPG/JPEG, PNG, or WebP image (up to 500 KB) to use with supported search engines.

```java
Map<String, String> auth = new HashMap<>();
auth.put("api_key", "<SERPAPI_KEY>");
SerpApi client = new SerpApi(auth);

JsonObject upload = client.uploadImage(Path.of("/path/to/image.png"));

Map<String, String> parameter = new HashMap<>();
parameter.put("engine", "google_lens");
parameter.put("image_id", upload.get("image_id").getAsString());
JsonObject results = client.search(parameter);
```

`uploadImage` also accepts raw image data as a `byte[]`.

Uploaded image IDs expire after 10 minutes. See the [Image API documentation](https://serpapi.com/image-api).

### Account API

```java
Expand Down
21 changes: 21 additions & 0 deletions README.md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,27 @@ The archived JSON matches the original search result. In tests, the key is suppl

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

### Image API

Upload JPG/JPEG, PNG, or WebP image (up to 500 KB) to use with supported search engines.

```java
Map<String, String> auth = new HashMap<>();
auth.put("api_key", "<SERPAPI_KEY>");
SerpApi client = new SerpApi(auth);

JsonObject upload = client.uploadImage(Path.of("/path/to/image.png"));

Map<String, String> parameter = new HashMap<>();
parameter.put("engine", "google_lens");
parameter.put("image_id", upload.get("image_id").getAsString());
JsonObject results = client.search(parameter);
```

`uploadImage` also accepts raw image data as a `byte[]`.

Uploaded image IDs expire after 10 minutes. See the [Image API documentation](https://serpapi.com/image-api).

### Account API

```java
Expand Down
80 changes: 79 additions & 1 deletion src/main/java/serpapi/SerpApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.HashMap;

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

/**
* Upload an image to the Image API.
*
* <p>The returned {@code image_id} can be supplied to engines that support
* uploaded images, such as Google Lens. Uploaded images expire after 10
* minutes. Supported formats are JPG/JPEG, PNG, and WebP, up to 500 KB.</p>
*
* @param image local image path
* @return response containing the temporary {@code image_id}
* @throws SerpApiException wraps backend or connection errors
*/
public JsonObject uploadImage(Path image) throws SerpApiException {
return uploadImage(image, null);
}

/**
* Upload an image with additional multipart form fields. A custom
* {@code api_key} in {@code parameter} overrides the constructor API key.
*
* @param image local image path
* @param parameter additional Image API fields
* @return response containing the temporary {@code image_id}
* @throws SerpApiException wraps backend or connection errors
*/
public JsonObject uploadImage(Path image, Map<String, String> parameter) throws SerpApiException {
if (image == null) {
throw new IllegalArgumentException("image must not be null");
}
try {
return uploadImage(Files.readAllBytes(image), parameter);
} catch (IOException e) {
throw new SerpApiException(e);
}
}

/**
* Upload raw image data to the Image API.
*
* @param image raw image data
* @return response containing the temporary {@code image_id}
* @throws SerpApiException wraps backend or connection errors
*/
public JsonObject uploadImage(byte[] image) throws SerpApiException {
return uploadImage(image, null);
}

/**
* Upload raw image data with additional multipart form fields. A custom
* {@code api_key} in {@code parameter} overrides the constructor API key.
*
* @param image raw image data
* @param parameter additional Image API fields
* @return response containing the temporary {@code image_id}
* @throws SerpApiException wraps backend or connection errors
*/
public JsonObject uploadImage(byte[] image, Map<String, String> parameter)
throws SerpApiException {
if (image == null) {
throw new IllegalArgumentException("image must not be null");
}
Map<String, String> form = new HashMap<>();
if (this.parameter.containsKey("api_key")) {
form.put("api_key", this.parameter.get("api_key"));
}
if (parameter != null) {
form.putAll(parameter);
}

this.client.path = "/image";
return parseJson(this.client.postMultipart(form, image));
}

/***
* Return location using Location API
*
Expand Down Expand Up @@ -148,7 +223,10 @@ public JsonObject account() throws SerpApiException {
* @return JsonObject created by gson parser
*/
private JsonObject json(String endpoint, Map<String, String> parameter) throws SerpApiException {
String content = get(endpoint, "json", parameter);
return parseJson(get(endpoint, "json", parameter));
}

private JsonObject parseJson(String content) throws SerpApiException {
JsonElement element = gson.fromJson(content, JsonElement.class);
JsonObject result = element.getAsJsonObject();
// SerpApi reports some failures in the body of an HTTP 200 response, so the
Expand Down
70 changes: 70 additions & 0 deletions src/main/java/serpapi/SerpApiHttp.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import com.google.gson.Gson;
import com.google.gson.JsonObject;
Expand Down Expand Up @@ -94,6 +98,72 @@ public String get(Map<String, String> parameter) throws SerpApiException {
}
}

/**
* Upload image data as a multipart/form-data request.
*
* @param parameter multipart text fields
* @param image raw image data
* @return HTTP response body
* @throws SerpApiException wraps error or connection failures
*/
public String postMultipart(Map<String, String> parameter, byte[] image)
throws SerpApiException {
if (parameter == null) {
throw new IllegalArgumentException("parameter must not be null");
}
if (image == null) {
throw new IllegalArgumentException("image must not be null");
}
String boundary = "----SerpApiJava" + UUID.randomUUID();
List<HttpRequest.BodyPublisher> parts = new ArrayList<>();
for (Map.Entry<String, String> field : parameter.entrySet()) {
validateMultipartToken(field.getKey(), "field name");
String part = "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"" + escapeQuoted(field.getKey()) + "\"\r\n\r\n"
+ (field.getValue() == null ? "" : field.getValue()) + "\r\n";
parts.add(HttpRequest.BodyPublishers.ofByteArray(part.getBytes(StandardCharsets.UTF_8)));
}

String imageHeader = "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"image\"; filename=\"image\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n";
parts.add(HttpRequest.BodyPublishers.ofByteArray(imageHeader.getBytes(StandardCharsets.UTF_8)));
parts.add(HttpRequest.BodyPublishers.ofByteArray(image));
parts.add(HttpRequest.BodyPublishers.ofByteArray(
("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)));

URI uri = URI.create(BACKEND + path);
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofMillis(httpReadTimeout))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.concat(parts.toArray(new HttpRequest.BodyPublisher[0])))
.build();

try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
triggerSerpApiException(response.body());
}
return response.body();
} catch (IOException e) {
throw new SerpApiException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new SerpApiException(e);
}
}

private static void validateMultipartToken(String value, String description) {
if (value == null || value.contains("\r") || value.contains("\n")) {
throw new IllegalArgumentException(description + " must not be null or contain line breaks");
}
}

private static String escapeQuoted(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}

/**
* trigger a exception on error
* @param content raw JSON response from serpapi.com
Expand Down
128 changes: 128 additions & 0 deletions src/test/java/serpapi/ImageApiTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package serpapi;

import com.google.gson.JsonObject;
import com.sun.net.httpserver.HttpServer;
import org.junit.Test;

import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

import static org.junit.Assert.*;

/** Offline tests for Image API multipart upload support. */
public class ImageApiTest {

private static class RecordingHttp extends SerpApiHttp {
Map<String, String> recorded;
byte[] recordedImage;
String response = "{\"message\":\"Image uploaded successfully.\",\"image_id\":\"image-123\"}";

RecordingHttp() {
super("/search");
}

@Override
public String postMultipart(Map<String, String> parameter, byte[] image) {
recorded = parameter;
recordedImage = image;
return response;
}
}

private static SerpApi client(RecordingHttp http) {
Map<String, String> defaults = new HashMap<>();
defaults.put("api_key", "client-key");
defaults.put("engine", "google_lens");
SerpApi client = new SerpApi(defaults);
client.client = http;
return client;
}

@Test
public void uploadsPathAndReturnsImageId() throws Exception {
Path image = Files.createTempFile("serpapi-image-", ".png");
byte[] imageData = new byte[] {1, 2, 3};
Files.write(image, imageData);
try {
RecordingHttp http = new RecordingHttp();
JsonObject result = client(http).uploadImage(image);

assertEquals("image-123", result.get("image_id").getAsString());
assertArrayEquals(imageData, http.recordedImage);
assertEquals("/image", http.path);
assertEquals("client-key", http.recorded.get("api_key"));
assertFalse(http.recorded.containsKey("engine"));
} finally {
Files.deleteIfExists(image);
}
}

@Test
public void acceptsRawBytesAndCustomFormFields() throws Exception {
byte[] image = new byte[] {1, 2, 3};
Map<String, String> fields = new HashMap<>();
fields.put("api_key", "request-key");
fields.put("zero_trace", "true");
RecordingHttp http = new RecordingHttp();

JsonObject result = client(http).uploadImage(image, fields);

assertEquals("image-123", result.get("image_id").getAsString());
assertSame(image, http.recordedImage);
assertEquals("request-key", http.recorded.get("api_key"));
assertEquals("true", http.recorded.get("zero_trace"));
}

@Test
public void httpClientSendsMultipartBody() throws Exception {
AtomicReference<String> contentType = new AtomicReference<>();
AtomicReference<String> requestBody = new AtomicReference<>();
HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/image", exchange -> {
contentType.set(exchange.getRequestHeaders().getFirst("Content-Type"));
requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] response = "{\"image_id\":\"local-test\"}".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, response.length);
exchange.getResponseBody().write(response);
exchange.close();
});
server.start();

String originalBackend = SerpApiHttp.BACKEND;
byte[] image = "fake-png-data".getBytes(StandardCharsets.UTF_8);
try {
SerpApiHttp.BACKEND = "http://localhost:" + server.getAddress().getPort();
SerpApiHttp http = new SerpApiHttp("/image");
Map<String, String> fields = new HashMap<>();
fields.put("api_key", "test-key");

assertTrue(http.postMultipart(fields, image).contains("local-test"));
assertTrue(contentType.get().startsWith("multipart/form-data; boundary="));
assertTrue(requestBody.get().contains("name=\"api_key\"\r\n\r\ntest-key"));
assertTrue(requestBody.get().contains("name=\"image\"; filename=\"image\""));
assertTrue(requestBody.get().contains("Content-Type: application/octet-stream"));
assertTrue(requestBody.get().contains("fake-png-data"));
} finally {
SerpApiHttp.BACKEND = originalBackend;
server.stop(0);
}
}

@Test
public void raisesErrorReturnedByImageApi() {
RecordingHttp http = new RecordingHttp();
http.response = "{\"error\":\"Unsupported image format.\"}";

try {
client(http).uploadImage(new byte[0]);
fail("expected SerpApiException");
} catch (SerpApiException e) {
assertEquals("Unsupported image format.", e.getMessage());
}
}
}
Loading