Skip to content

Commit d52140d

Browse files
committed
Implement /image api
1 parent 9e409bd commit d52140d

8 files changed

Lines changed: 300 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ for a manual approach:
188188
- [getLocations](#getlocations)
189189
- [Parameters](#parameters-5)
190190
- [Examples](#examples-5)
191+
- [uploadImage](#uploadimage)
192+
- [Parameters](#parameters-6)
193+
- [Examples](#examples-6)
191194

192195
### getJson
193196

@@ -384,3 +387,26 @@ const locations = await getLocations({ limit: 3 });
384387
// callback
385388
getLocations({ limit: 3 }, console.log);
386389
```
390+
391+
### uploadImage
392+
393+
Upload an image for use with supported engines.
394+
395+
#### Parameters
396+
397+
- `parameters` **object**
398+
- `parameters.image` **(Uint8Array | ArrayBuffer | string)** binary image
399+
contents or file path
400+
- `parameters.api_key` **string?** API key
401+
- `parameters.timeout` **number?** timeout in milliseconds
402+
- `callback` **fn?** optional callback
403+
404+
#### Examples
405+
406+
```javascript
407+
const result = await uploadImage({
408+
api_key: API_KEY,
409+
image: "image.png",
410+
});
411+
console.log(result.image_id);
412+
```

‎deno.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"tasks": {
33
"docs:gen": "npx documentation readme src/serpapi.ts --section=Functions --shallow && deno fmt",
4-
"test": "deno test tests/ --allow-env --allow-read --allow-net",
4+
"test": "deno test tests/ --allow-env --allow-read --allow-write --allow-net",
55
"test:watch": "deno task test --watch",
66
"test:cov": "rm -rf cov_profile && deno task test --coverage=cov_profile && deno coverage cov_profile",
77
"npm": "deno run -A scripts/build_npm.ts"

‎mod.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export type {
1212
BaseResponse,
1313
EngineParameters,
1414
GetBySearchIdParameters,
15+
ImageApiParameters,
16+
ImageApiResponse,
1517
LocationsApiParameters,
1618
} from "./src/types.ts";
1719
export {
@@ -21,4 +23,5 @@ export {
2123
getJson,
2224
getJsonBySearchId,
2325
getLocations,
26+
uploadImage,
2427
} from "./src/serpapi.ts";

‎src/multipart.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Buffer } from "node:buffer";
2+
import { randomBytes } from "node:crypto";
3+
4+
/**
5+
* Multipart body construction adapted from the `form-data` project:
6+
* https://github.com/form-data/form-data/blob/v4.0.6/lib/form_data.js
7+
*
8+
* In particular, this follows its header parameter escaping, boundary
9+
* generation, CRLF placement, and buffer concatenation approach.
10+
*
11+
* Copyright (c) 2012 Felix Geisendörfer (felix@debuggable.com) and contributors
12+
* Licensed under the MIT License:
13+
* https://github.com/form-data/form-data/blob/v4.0.6/License
14+
*/
15+
16+
const CRLF = "\r\n";
17+
18+
type MultipartPart = {
19+
name: string;
20+
value: string | Uint8Array;
21+
filename?: string;
22+
contentType?: string;
23+
};
24+
25+
export type MultipartBody = {
26+
body: Buffer;
27+
contentType: string;
28+
};
29+
30+
/** Escape multipart header parameters according to the WHATWG encoding. */
31+
function escapeHeaderParameter(value: string): string {
32+
return value
33+
.replace(/\r/g, "%0D")
34+
.replace(/\n/g, "%0A")
35+
.replace(/"/g, "%22");
36+
}
37+
38+
function asBuffer(value: string | Uint8Array): Buffer {
39+
if (typeof value === "string") return Buffer.from(value, "utf8");
40+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
41+
}
42+
43+
export function createMultipartBody(parts: MultipartPart[]): MultipartBody {
44+
// Same boundary shape used by form-data: 26 hyphens and 24 random hex chars.
45+
const boundary = `--------------------------${
46+
randomBytes(12).toString("hex")
47+
}`;
48+
const buffers: Buffer[] = [];
49+
50+
for (const part of parts) {
51+
let header = `--${boundary}${CRLF}` +
52+
`Content-Disposition: form-data; name="${
53+
escapeHeaderParameter(part.name)
54+
}"`;
55+
56+
if (part.filename) {
57+
header += `; filename="${escapeHeaderParameter(part.filename)}"`;
58+
}
59+
header += CRLF;
60+
61+
if (part.contentType) {
62+
header += `Content-Type: ${part.contentType}${CRLF}`;
63+
}
64+
65+
buffers.push(
66+
Buffer.from(`${header}${CRLF}`, "utf8"),
67+
asBuffer(part.value),
68+
Buffer.from(CRLF, "utf8"),
69+
);
70+
}
71+
72+
buffers.push(Buffer.from(`--${boundary}--${CRLF}`, "utf8"));
73+
74+
return {
75+
body: Buffer.concat(buffers),
76+
contentType: `multipart/form-data; boundary=${boundary}`,
77+
};
78+
}

‎src/serpapi.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { InvalidArgumentError } from "./errors.ts";
2-
import {
2+
import { readFile } from "node:fs";
3+
import type {
34
AccountApiParameters,
45
BaseResponse,
56
EngineParameters,
67
GetBySearchIdParameters,
8+
ImageApiParameters,
9+
ImageApiResponse,
710
LocationsApiParameters,
811
} from "./types.ts";
912
import { _internals } from "./utils.ts";
@@ -323,3 +326,51 @@ export async function getLocations(
323326
callback?.(locations);
324327
return locations;
325328
}
329+
330+
/**
331+
* Upload an image using Image API
332+
*
333+
* Refer to https://serpapi.com/image-api for more details.
334+
*
335+
* @param {object} parameters
336+
* @param {Uint8Array|ArrayBuffer|string} parameters.image Binary image contents or file path.
337+
* @param {string=} [parameters.api_key] API key.
338+
* @param {number=} [parameters.timeout] Timeout in milliseconds.
339+
* @param {fn=} callback Optional callback.
340+
* @example
341+
* const result = await uploadImage({
342+
* api_key: API_KEY,
343+
* image: "image.png",
344+
* });
345+
* console.log(result.image_id);
346+
*/
347+
export async function uploadImage(
348+
parameters: ImageApiParameters,
349+
callback?: (result: ImageApiResponse) => void,
350+
): Promise<ImageApiResponse> {
351+
const key = validateApiKey(parameters.api_key);
352+
const timeout = validateTimeout(parameters.timeout);
353+
let image: Uint8Array | ArrayBuffer;
354+
if (typeof parameters.image === "string") {
355+
const path = parameters.image;
356+
image = await new Promise<Uint8Array>((resolve, reject) => {
357+
readFile(
358+
path,
359+
(error, data) => error ? reject(error) : resolve(data),
360+
);
361+
});
362+
} else {
363+
image = parameters.image;
364+
}
365+
const response = await _internals.uploadImage(
366+
image,
367+
{
368+
api_key: key,
369+
requestOptions: parameters.requestOptions,
370+
},
371+
timeout,
372+
);
373+
const result = JSON.parse(response) as ImageApiResponse;
374+
callback?.(result);
375+
return result;
376+
}

‎src/types.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type http from "node:http";
2+
13
// deno-lint-ignore no-explicit-any
24
export type EngineParameters = Record<string, any>;
35

@@ -18,3 +20,16 @@ export type LocationsApiParameters = {
1820
limit?: number;
1921
timeout?: number;
2022
};
23+
24+
export type ImageApiParameters = {
25+
image: Uint8Array | ArrayBuffer | string;
26+
api_key?: string;
27+
timeout?: number;
28+
requestOptions?: http.RequestOptions;
29+
};
30+
31+
export type ImageApiResponse = {
32+
message?: string;
33+
image_id?: string;
34+
error?: string;
35+
};

‎src/utils.ts‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import qs from "node:querystring";
55
import process from "node:process";
66
import { RequestTimeoutError } from "./errors.ts";
77
import { config } from "./config.ts";
8+
import { createMultipartBody } from "./multipart.ts";
89

910
/**
1011
* This `_internals` object is needed to support stubbing/spying of
@@ -15,6 +16,7 @@ import { config } from "./config.ts";
1516
*/
1617
export const _internals = {
1718
execute: execute,
19+
uploadImage: uploadImage,
1820
getHostnameAndPort: getHostnameAndPort,
1921
};
2022

@@ -80,7 +82,7 @@ export function execute(
8082
});
8183

8284
return new Promise((resolve, reject) => {
83-
let timer: number;
85+
let timer: ReturnType<typeof setTimeout>;
8486

8587
const handleResponse = (resp: http.IncomingMessage) => {
8688
resp.setEncoding("utf8");
@@ -122,3 +124,65 @@ export function execute(
122124
}
123125
});
124126
}
127+
128+
export function uploadImage(
129+
image: Uint8Array | ArrayBuffer,
130+
parameters: {
131+
api_key: string;
132+
requestOptions?: http.RequestOptions;
133+
},
134+
timeout: number,
135+
): Promise<string> {
136+
const bytes = image instanceof ArrayBuffer ? new Uint8Array(image) : image;
137+
const multipart = createMultipartBody([
138+
{ name: "api_key", value: parameters.api_key },
139+
{ name: "source", value: getSource() },
140+
{
141+
name: "image",
142+
value: bytes,
143+
filename: "image",
144+
contentType: "application/octet-stream",
145+
},
146+
]);
147+
148+
const customOptions = {
149+
...config.requestOptions,
150+
...parameters.requestOptions,
151+
};
152+
const options: http.RequestOptions = {
153+
...customOptions,
154+
..._internals.getHostnameAndPort(),
155+
path: "/image",
156+
method: "POST",
157+
headers: {
158+
...(customOptions.headers || {}),
159+
"Content-Type": multipart.contentType,
160+
"Content-Length": multipart.body.length,
161+
},
162+
};
163+
164+
return new Promise((resolve, reject) => {
165+
let timer: ReturnType<typeof setTimeout>;
166+
const req = https.request(options, (resp) => {
167+
resp.setEncoding("utf8");
168+
let data = "";
169+
resp.on("data", (chunk) => data += chunk);
170+
resp.on("end", () => {
171+
if (timer) clearTimeout(timer);
172+
if (resp.statusCode === 200) resolve(data);
173+
else reject(data);
174+
});
175+
});
176+
req.on("error", (error) => {
177+
if (timer) clearTimeout(timer);
178+
reject(error);
179+
});
180+
if (timeout > 0) {
181+
timer = setTimeout(() => {
182+
reject(new RequestTimeoutError());
183+
req.destroy();
184+
}, timeout);
185+
}
186+
req.end(multipart.body);
187+
});
188+
}

‎tests/serpapi_test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
InvalidArgumentError,
3636
InvalidTimeoutError,
3737
MissingApiKeyError,
38+
uploadImage,
3839
} from "../mod.ts";
3940

4041
loadSync({ export: true });
@@ -180,6 +181,65 @@ describe(
180181
},
181182
);
182183

184+
describe("uploadImage", () => {
185+
const image = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
186+
187+
afterEach(() => {
188+
config.api_key = null;
189+
});
190+
191+
async function assertImageUpload(input: Uint8Array | string) {
192+
const executeStub = stub(
193+
_internals,
194+
"uploadImage",
195+
() =>
196+
Promise.resolve(
197+
'{"message":"Image uploaded successfully.","image_id":"abc"}',
198+
),
199+
);
200+
config.api_key = "test_api_key";
201+
try {
202+
const result = await uploadImage({ image: input });
203+
assertEquals(result, {
204+
message: "Image uploaded successfully.",
205+
image_id: "abc",
206+
});
207+
assertSpyCalls(executeStub, 1);
208+
} finally {
209+
executeStub.restore();
210+
}
211+
}
212+
213+
it("with no api_key", () => {
214+
assertRejects(
215+
async () => await uploadImage({ image, api_key: "" }),
216+
MissingApiKeyError,
217+
);
218+
});
219+
220+
it("with invalid timeout", () => {
221+
config.api_key = "test_api_key";
222+
assertRejects(
223+
async () => await uploadImage({ image, timeout: 0 }),
224+
InvalidTimeoutError,
225+
);
226+
});
227+
228+
it("accepts image bytes", async () => {
229+
await assertImageUpload(image);
230+
});
231+
232+
it("accepts an image file path", async () => {
233+
const imagePath = await Deno.makeTempFile({ suffix: ".png" });
234+
await Deno.writeFile(imagePath, image);
235+
try {
236+
await assertImageUpload(imagePath);
237+
} finally {
238+
await Deno.remove(imagePath);
239+
}
240+
});
241+
});
242+
183243
describe(
184244
"getLocations",
185245
{

0 commit comments

Comments
 (0)