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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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);
```
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 4 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type { Config } from "./src/config.ts";
export { config } from "./src/config.ts";

export {
ImageApiError,
InvalidArgumentError,
InvalidTimeoutError,
MissingApiKeyError,
Expand All @@ -12,6 +13,8 @@ export type {
BaseResponse,
EngineParameters,
GetBySearchIdParameters,
ImageApiParameters,
ImageApiResponse,
LocationsApiParameters,
} from "./src/types.ts";
export {
Expand All @@ -21,4 +24,5 @@ export {
getJson,
getJsonBySearchId,
getLocations,
uploadImage,
} from "./src/serpapi.ts";
8 changes: 8 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
78 changes: 78 additions & 0 deletions src/multipart.ts

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To maintain supports for Node 7.x and newer, otherwise we could consider Node 18 which fetch is available natively

Original file line number Diff line number Diff line change
@@ -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}`,
};
}
64 changes: 62 additions & 2 deletions src/serpapi.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { InvalidArgumentError } from "./errors.ts";
import {
import { ImageApiError, InvalidArgumentError } from "./errors.ts";
import { readFile } from "node:fs";
import type {
AccountApiParameters,
BaseResponse,
EngineParameters,
GetBySearchIdParameters,
ImageApiParameters,
ImageApiResponse,
LocationsApiParameters,
} from "./types.ts";
import { _internals } from "./utils.ts";
Expand Down Expand Up @@ -323,3 +326,60 @@ 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<ImageApiResponse> {
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<Uint8Array>((resolve, reject) => {
readFile(
path,
(error, data) => error ? reject(error) : resolve(data),
);
});
} else {
image = parameters.image;
}
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;
}
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type http from "node:http";

// deno-lint-ignore no-explicit-any
export type EngineParameters = Record<string, any>;

Expand All @@ -18,3 +20,15 @@ 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;
};
66 changes: 65 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +16,7 @@ import { config } from "./config.ts";
*/
export const _internals = {
execute: execute,
uploadImage: uploadImage,
getHostnameAndPort: getHostnameAndPort,
};

Expand Down Expand Up @@ -80,7 +82,7 @@ export function execute(
});

return new Promise((resolve, reject) => {
let timer: number;
let timer: ReturnType<typeof setTimeout>;

const handleResponse = (resp: http.IncomingMessage) => {
resp.setEncoding("utf8");
Expand Down Expand Up @@ -122,3 +124,65 @@ export function execute(
}
});
}

export function uploadImage(
image: Uint8Array | ArrayBuffer,
parameters: {
api_key: string;
requestOptions?: http.RequestOptions;
},
timeout: number,
): Promise<string> {
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<typeof setTimeout>;
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);
});
}
Loading
Loading