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 fc2a984..9cbe30c 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..bc4998a 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,53 @@ 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 { + if (!parameters?.image) throw new InvalidArgumentError(); + + 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 a490688..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, }; @@ -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 85dceae..b6b3f0a 100644 --- a/tests/serpapi_test.ts +++ b/tests/serpapi_test.ts @@ -29,6 +29,7 @@ import { InvalidArgumentError, InvalidTimeoutError, MissingApiKeyError, + uploadImage, } from "../mod.ts"; loadSync({ export: true }); @@ -174,6 +175,89 @@ 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("with no image", () => { + assertRejects( + // @ts-expect-error Test runtime validation for JavaScript callers. + async () => await uploadImage({ api_key: "test_api_key" }), + InvalidArgumentError, + ); + }); + + 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); + } + }); + + it("rejects upon error response", async () => { + const apiError = '{"error":"Invalid image"}'; + const executeStub = stub( + _internals, + "uploadImage", + () => Promise.reject(apiError), + ); + config.api_key = "test_api_key"; + try { + const error = await uploadImage({ image }).catch((error) => error); + assertEquals(error, apiError); + } finally { + executeStub.restore(); + } + }); +}); + describe( "getLocations", {