diff --git a/README.md b/README.md index 42906a6..742d9e2 100644 --- a/README.md +++ b/README.md @@ -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 auth = new HashMap<>(); +auth.put("api_key", ""); +SerpApi client = new SerpApi(auth); + +JsonObject upload = client.uploadImage(Path.of("/path/to/image.png")); + +Map 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 diff --git a/README.md.erb b/README.md.erb index f5b4455..dec63cd 100644 --- a/README.md.erb +++ b/README.md.erb @@ -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 auth = new HashMap<>(); +auth.put("api_key", ""); +SerpApi client = new SerpApi(auth); + +JsonObject upload = client.uploadImage(Path.of("/path/to/image.png")); + +Map 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 diff --git a/src/main/java/serpapi/SerpApi.java b/src/main/java/serpapi/SerpApi.java index af7df9c..789232c 100644 --- a/src/main/java/serpapi/SerpApi.java +++ b/src/main/java/serpapi/SerpApi.java @@ -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; @@ -92,6 +95,78 @@ public JsonObject search(Map parameter) throws SerpApiException return json("/search", parameter); } + /** + * Upload an image to the Image API. + * + *

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.

+ * + * @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 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 parameter) + throws SerpApiException { + if (image == null) { + throw new IllegalArgumentException("image must not be null"); + } + Map 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 * @@ -148,7 +223,10 @@ public JsonObject account() throws SerpApiException { * @return JsonObject created by gson parser */ private JsonObject json(String endpoint, Map 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 diff --git a/src/main/java/serpapi/SerpApiHttp.java b/src/main/java/serpapi/SerpApiHttp.java index 23cc8f4..060d8e0 100644 --- a/src/main/java/serpapi/SerpApiHttp.java +++ b/src/main/java/serpapi/SerpApiHttp.java @@ -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; @@ -94,6 +98,72 @@ public String get(Map 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 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 parts = new ArrayList<>(); + for (Map.Entry 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 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 diff --git a/src/test/java/serpapi/ImageApiTest.java b/src/test/java/serpapi/ImageApiTest.java new file mode 100644 index 0000000..412b3a3 --- /dev/null +++ b/src/test/java/serpapi/ImageApiTest.java @@ -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 recorded; + byte[] recordedImage; + String response = "{\"message\":\"Image uploaded successfully.\",\"image_id\":\"image-123\"}"; + + RecordingHttp() { + super("/search"); + } + + @Override + public String postMultipart(Map parameter, byte[] image) { + recorded = parameter; + recordedImage = image; + return response; + } + } + + private static SerpApi client(RecordingHttp http) { + Map 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 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 contentType = new AtomicReference<>(); + AtomicReference 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 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()); + } + } +}