From d52140d6d7f62735c36c01e9c78a2f34de01278b Mon Sep 17 00:00:00 2001 From: Terry Tan Date: Mon, 24 Aug 2026 18:41:21 +0800 Subject: [PATCH 1/2] Implement /image api --- README.md | 26 +++++++++++++++ deno.json | 2 +- mod.ts | 3 ++ src/multipart.ts | 78 +++++++++++++++++++++++++++++++++++++++++++ src/serpapi.ts | 53 ++++++++++++++++++++++++++++- src/types.ts | 15 +++++++++ src/utils.ts | 66 +++++++++++++++++++++++++++++++++++- tests/serpapi_test.ts | 60 +++++++++++++++++++++++++++++++++ 8 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 src/multipart.ts diff --git a/README.md b/README.md index a7ffff0..cebd190 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,9 @@ for a manual approach: - [getLocations](#getlocations) - [Parameters](#parameters-5) - [Examples](#examples-5) +- [uploadImage](#uploadimage) + - [Parameters](#parameters-6) + - [Examples](#examples-6) ### getJson @@ -384,3 +387,26 @@ const locations = await getLocations({ limit: 3 }); // callback getLocations({ limit: 3 }, console.log); ``` + +### uploadImage + +Upload an image for use with supported engines. + +#### Parameters + +- `parameters` **object** + - `parameters.image` **(Uint8Array | ArrayBuffer | string)** binary image + contents or file path + - `parameters.api_key` **string?** API key + - `parameters.timeout` **number?** timeout in milliseconds +- `callback` **fn?** optional callback + +#### Examples + +```javascript +const result = await uploadImage({ + api_key: API_KEY, + image: "image.png", +}); +console.log(result.image_id); +``` diff --git a/deno.json b/deno.json index 4021446..322f48b 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "tasks": { "docs:gen": "npx documentation readme src/serpapi.ts --section=Functions --shallow && deno fmt", - "test": "deno test tests/ --allow-env --allow-read --allow-net", + "test": "deno test tests/ --allow-env --allow-read --allow-write --allow-net", "test:watch": "deno task test --watch", "test:cov": "rm -rf cov_profile && deno task test --coverage=cov_profile && deno coverage cov_profile", "npm": "deno run -A scripts/build_npm.ts" diff --git a/mod.ts b/mod.ts index 3eaa3f4..3b944cd 100644 --- a/mod.ts +++ b/mod.ts @@ -12,6 +12,8 @@ export type { BaseResponse, EngineParameters, GetBySearchIdParameters, + ImageApiParameters, + ImageApiResponse, LocationsApiParameters, } from "./src/types.ts"; export { @@ -21,4 +23,5 @@ export { getJson, getJsonBySearchId, getLocations, + uploadImage, } from "./src/serpapi.ts"; diff --git a/src/multipart.ts b/src/multipart.ts new file mode 100644 index 0000000..48e93d5 --- /dev/null +++ b/src/multipart.ts @@ -0,0 +1,78 @@ +import { Buffer } from "node:buffer"; +import { randomBytes } from "node:crypto"; + +/** + * Multipart body construction adapted from the `form-data` project: + * https://github.com/form-data/form-data/blob/v4.0.6/lib/form_data.js + * + * In particular, this follows its header parameter escaping, boundary + * generation, CRLF placement, and buffer concatenation approach. + * + * Copyright (c) 2012 Felix Geisendörfer (felix@debuggable.com) and contributors + * Licensed under the MIT License: + * https://github.com/form-data/form-data/blob/v4.0.6/License + */ + +const CRLF = "\r\n"; + +type MultipartPart = { + name: string; + value: string | Uint8Array; + filename?: string; + contentType?: string; +}; + +export type MultipartBody = { + body: Buffer; + contentType: string; +}; + +/** Escape multipart header parameters according to the WHATWG encoding. */ +function escapeHeaderParameter(value: string): string { + return value + .replace(/\r/g, "%0D") + .replace(/\n/g, "%0A") + .replace(/"/g, "%22"); +} + +function asBuffer(value: string | Uint8Array): Buffer { + if (typeof value === "string") return Buffer.from(value, "utf8"); + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); +} + +export function createMultipartBody(parts: MultipartPart[]): MultipartBody { + // Same boundary shape used by form-data: 26 hyphens and 24 random hex chars. + const boundary = `--------------------------${ + randomBytes(12).toString("hex") + }`; + const buffers: Buffer[] = []; + + for (const part of parts) { + let header = `--${boundary}${CRLF}` + + `Content-Disposition: form-data; name="${ + escapeHeaderParameter(part.name) + }"`; + + if (part.filename) { + header += `; filename="${escapeHeaderParameter(part.filename)}"`; + } + header += CRLF; + + if (part.contentType) { + header += `Content-Type: ${part.contentType}${CRLF}`; + } + + buffers.push( + Buffer.from(`${header}${CRLF}`, "utf8"), + asBuffer(part.value), + Buffer.from(CRLF, "utf8"), + ); + } + + buffers.push(Buffer.from(`--${boundary}--${CRLF}`, "utf8")); + + return { + body: Buffer.concat(buffers), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} diff --git a/src/serpapi.ts b/src/serpapi.ts index 9422ea7..e3daf4d 100644 --- a/src/serpapi.ts +++ b/src/serpapi.ts @@ -1,9 +1,12 @@ import { InvalidArgumentError } from "./errors.ts"; -import { +import { readFile } from "node:fs"; +import type { AccountApiParameters, BaseResponse, EngineParameters, GetBySearchIdParameters, + ImageApiParameters, + ImageApiResponse, LocationsApiParameters, } from "./types.ts"; import { _internals } from "./utils.ts"; @@ -323,3 +326,51 @@ export async function getLocations( callback?.(locations); return locations; } + +/** + * Upload an image using Image API + * + * Refer to https://serpapi.com/image-api for more details. + * + * @param {object} parameters + * @param {Uint8Array|ArrayBuffer|string} parameters.image Binary image contents or file path. + * @param {string=} [parameters.api_key] API key. + * @param {number=} [parameters.timeout] Timeout in milliseconds. + * @param {fn=} callback Optional callback. + * @example + * const result = await uploadImage({ + * api_key: API_KEY, + * image: "image.png", + * }); + * console.log(result.image_id); + */ +export async function uploadImage( + parameters: ImageApiParameters, + callback?: (result: ImageApiResponse) => void, +): Promise { + const key = validateApiKey(parameters.api_key); + const timeout = validateTimeout(parameters.timeout); + let image: Uint8Array | ArrayBuffer; + if (typeof parameters.image === "string") { + const path = parameters.image; + image = await new Promise((resolve, reject) => { + readFile( + path, + (error, data) => error ? reject(error) : resolve(data), + ); + }); + } else { + image = parameters.image; + } + const response = await _internals.uploadImage( + image, + { + api_key: key, + requestOptions: parameters.requestOptions, + }, + timeout, + ); + const result = JSON.parse(response) as ImageApiResponse; + callback?.(result); + return result; +} diff --git a/src/types.ts b/src/types.ts index c83e861..3d08d3e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type http from "node:http"; + // deno-lint-ignore no-explicit-any export type EngineParameters = Record; @@ -18,3 +20,16 @@ export type LocationsApiParameters = { limit?: number; timeout?: number; }; + +export type ImageApiParameters = { + image: Uint8Array | ArrayBuffer | string; + api_key?: string; + timeout?: number; + requestOptions?: http.RequestOptions; +}; + +export type ImageApiResponse = { + message?: string; + image_id?: string; + error?: string; +}; diff --git a/src/utils.ts b/src/utils.ts index 8bb21f9..082434c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,6 +5,7 @@ import qs from "node:querystring"; import process from "node:process"; import { RequestTimeoutError } from "./errors.ts"; import { config } from "./config.ts"; +import { createMultipartBody } from "./multipart.ts"; /** * This `_internals` object is needed to support stubbing/spying of @@ -15,6 +16,7 @@ import { config } from "./config.ts"; */ export const _internals = { execute: execute, + uploadImage: uploadImage, getHostnameAndPort: getHostnameAndPort, }; @@ -80,7 +82,7 @@ export function execute( }); return new Promise((resolve, reject) => { - let timer: number; + let timer: ReturnType; const handleResponse = (resp: http.IncomingMessage) => { resp.setEncoding("utf8"); @@ -122,3 +124,65 @@ export function execute( } }); } + +export function uploadImage( + image: Uint8Array | ArrayBuffer, + parameters: { + api_key: string; + requestOptions?: http.RequestOptions; + }, + timeout: number, +): Promise { + const bytes = image instanceof ArrayBuffer ? new Uint8Array(image) : image; + const multipart = createMultipartBody([ + { name: "api_key", value: parameters.api_key }, + { name: "source", value: getSource() }, + { + name: "image", + value: bytes, + filename: "image", + contentType: "application/octet-stream", + }, + ]); + + const customOptions = { + ...config.requestOptions, + ...parameters.requestOptions, + }; + const options: http.RequestOptions = { + ...customOptions, + ..._internals.getHostnameAndPort(), + path: "/image", + method: "POST", + headers: { + ...(customOptions.headers || {}), + "Content-Type": multipart.contentType, + "Content-Length": multipart.body.length, + }, + }; + + return new Promise((resolve, reject) => { + let timer: ReturnType; + const req = https.request(options, (resp) => { + resp.setEncoding("utf8"); + let data = ""; + resp.on("data", (chunk) => data += chunk); + resp.on("end", () => { + if (timer) clearTimeout(timer); + if (resp.statusCode === 200) resolve(data); + else reject(data); + }); + }); + req.on("error", (error) => { + if (timer) clearTimeout(timer); + reject(error); + }); + if (timeout > 0) { + timer = setTimeout(() => { + reject(new RequestTimeoutError()); + req.destroy(); + }, timeout); + } + req.end(multipart.body); + }); +} diff --git a/tests/serpapi_test.ts b/tests/serpapi_test.ts index 7daf994..0d5deda 100644 --- a/tests/serpapi_test.ts +++ b/tests/serpapi_test.ts @@ -35,6 +35,7 @@ import { InvalidArgumentError, InvalidTimeoutError, MissingApiKeyError, + uploadImage, } from "../mod.ts"; loadSync({ export: true }); @@ -180,6 +181,65 @@ describe( }, ); +describe("uploadImage", () => { + const image = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + + afterEach(() => { + config.api_key = null; + }); + + async function assertImageUpload(input: Uint8Array | string) { + const executeStub = stub( + _internals, + "uploadImage", + () => + Promise.resolve( + '{"message":"Image uploaded successfully.","image_id":"abc"}', + ), + ); + config.api_key = "test_api_key"; + try { + const result = await uploadImage({ image: input }); + assertEquals(result, { + message: "Image uploaded successfully.", + image_id: "abc", + }); + assertSpyCalls(executeStub, 1); + } finally { + executeStub.restore(); + } + } + + it("with no api_key", () => { + assertRejects( + async () => await uploadImage({ image, api_key: "" }), + MissingApiKeyError, + ); + }); + + it("with invalid timeout", () => { + config.api_key = "test_api_key"; + assertRejects( + async () => await uploadImage({ image, timeout: 0 }), + InvalidTimeoutError, + ); + }); + + it("accepts image bytes", async () => { + await assertImageUpload(image); + }); + + it("accepts an image file path", async () => { + const imagePath = await Deno.makeTempFile({ suffix: ".png" }); + await Deno.writeFile(imagePath, image); + try { + await assertImageUpload(imagePath); + } finally { + await Deno.remove(imagePath); + } + }); +}); + describe( "getLocations", { From 018e7b1d1af9e44703a0ef8562c2b6ad21fa3afb Mon Sep 17 00:00:00 2001 From: Terry Tan Date: Mon, 24 Aug 2026 19:15:47 +0800 Subject: [PATCH 2/2] Improve error handling --- mod.ts | 1 + src/errors.ts | 8 ++++++++ src/serpapi.ts | 27 ++++++++++++++++++--------- src/types.ts | 5 ++--- tests/serpapi_test.ts | 19 +++++++++++++++++++ 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/mod.ts b/mod.ts index 3b944cd..1b52bfd 100644 --- a/mod.ts +++ b/mod.ts @@ -2,6 +2,7 @@ export type { Config } from "./src/config.ts"; export { config } from "./src/config.ts"; export { + ImageApiError, InvalidArgumentError, InvalidTimeoutError, MissingApiKeyError, diff --git a/src/errors.ts b/src/errors.ts index be53b73..9012ef2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -21,6 +21,14 @@ export class InvalidTimeoutError extends Error { } } +export class ImageApiError extends Error { + constructor(message: string) { + super(message); + this.name = "ImageApiError"; + Object.setPrototypeOf(this, ImageApiError.prototype); + } +} + export class RequestTimeoutError extends Error { constructor() { super("The request was timed out"); diff --git a/src/serpapi.ts b/src/serpapi.ts index e3daf4d..17b9c51 100644 --- a/src/serpapi.ts +++ b/src/serpapi.ts @@ -1,4 +1,4 @@ -import { InvalidArgumentError } from "./errors.ts"; +import { ImageApiError, InvalidArgumentError } from "./errors.ts"; import { readFile } from "node:fs"; import type { AccountApiParameters, @@ -362,14 +362,23 @@ export async function uploadImage( } else { image = parameters.image; } - const response = await _internals.uploadImage( - image, - { - api_key: key, - requestOptions: parameters.requestOptions, - }, - timeout, - ); + let response: string; + try { + response = await _internals.uploadImage( + image, + { + api_key: key, + requestOptions: parameters.requestOptions, + }, + timeout, + ); + } catch (error) { + let message = "Image upload failed"; + try { + message = JSON.parse(String(error)).error || message; + } catch { /* */ } + throw new ImageApiError(message); + } const result = JSON.parse(response) as ImageApiResponse; callback?.(result); return result; diff --git a/src/types.ts b/src/types.ts index 3d08d3e..9e54c3a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,7 +29,6 @@ export type ImageApiParameters = { }; export type ImageApiResponse = { - message?: string; - image_id?: string; - error?: string; + message: string; + image_id: string; }; diff --git a/tests/serpapi_test.ts b/tests/serpapi_test.ts index 0d5deda..b9c4c34 100644 --- a/tests/serpapi_test.ts +++ b/tests/serpapi_test.ts @@ -32,6 +32,7 @@ import { getJson, getJsonBySearchId, getLocations, + ImageApiError, InvalidArgumentError, InvalidTimeoutError, MissingApiKeyError, @@ -238,6 +239,24 @@ describe("uploadImage", () => { await Deno.remove(imagePath); } }); + + it("throws ImageApiError", async () => { + const executeStub = stub( + _internals, + "uploadImage", + () => Promise.reject('{"error":"Invalid image"}'), + ); + config.api_key = "test_api_key"; + try { + await assertRejects( + async () => await uploadImage({ image }), + ImageApiError, + "Invalid image", + ); + } finally { + executeStub.restore(); + } + }); }); describe(