diff --git a/README.md b/README.md index 3cc189e1..3d62bc23 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,20 @@ -### Installation +## Installation ```shell npm install imgur ``` -### Usage +## Usage -Require and instantiate with credentials: +### Import and instantiate with credentials: ```ts +// ESModule import { ImgurClient } from 'imgur'; +// CommonJS +const { ImgurClient } = require('imgur'); + let client; // if you already have an access token acquired @@ -31,3 +35,60 @@ If you don't have any credentials, you'll need to: 1. [Create an Imgur account](https://imgur.com/register) 1. [Register an application](https://api.imgur.com/#registerapp) + +### Upload one or more images and videos + +You can upload one or more files by simply passing a path to a file or array of paths to multiple files. + +```ts +// a single image via an absolute path +const response = await client.upload('/home/kai/dank-meme.jpg'); +console.log(response.link); + +// multiple images via an array of absolute paths +const responses = await client.upload([ + '/home/kai/dank-meme.jpg', + '/home/kai/another-dank-meme', +]); +responses.forEach((r) => console.log(r.link)); +``` + +If you want to provide metadata, such as a title, description, etc., then pass an object instead of a string: + +```ts +// a single image via an absolute path +const response = await client.upload({ + image: '/home/kai/dank-meme.jpg', + title: 'Meme', + description: 'Dank Meme', +}); +console.log(response.link); + +// multiple images via an array of absolute paths +const responses = await client.upload([ + { + image: '/home/kai/dank-meme.jpg', + title: 'Meme', + description: 'Dank Meme', + }, + { + image: '/home/kai/cat.mp4', + title: 'A Cat Movie', + description: 'Caturday', + }, +]); +responses.forEach((r) => console.log(r.link)); +``` + +Acceptable key/values match what [the Imgur API expects](https://apidocs.imgur.com/#c85c9dfc-7487-4de2-9ecd-66f727cf3139): + +| Key | Description | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `image` | A binary file, base64 data, or a URL for an image. (up to 10MB) | +| `video` | A binary file (up to 200MB) | +| `album` | The id of the album you want to add the image to. For anonymous albums, album should be the deletehash that is returned at creation | +| `type` | The type of the file that's being sent; `file`, `base64` or `url` | +| `name` | The name of the file. This is automatically detected, but you can override | +| `title` | The title of the image | +| `description` | The description of the image | +| `disable_audio` | `1` will remove the audio track from a video file | diff --git a/jest.setup.js b/jest.setup.js index 0b8e7386..f223b416 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -8,6 +8,7 @@ beforeEach(() => { mockfs({ '/home/user/meme.jpg': Buffer.from([8, 6, 7, 5, 3, 0, 9]), '/home/user/lol.jpg': Buffer.from([9, 0, 3, 5, 7, 6, 8]), + '/home/user/trailer.mp4': Buffer.from([9, 0, 3, 5, 7, 6, 8, 1, 2, 3, 4, 5]), }); }); diff --git a/src/__tests__/mocks/handlers/upload.js b/src/__tests__/mocks/handlers/upload.js index 6c52a262..15c31462 100644 --- a/src/__tests__/mocks/handlers/upload.js +++ b/src/__tests__/mocks/handlers/upload.js @@ -8,34 +8,51 @@ const BadRequestErrorResponse = { }, }; -const SuccessfulUploadResponse = { - data: { - id: 'JK9ybyj', - deletehash: 'j83zimv4VtDA0Xp', - link: 'https://i.imgur.com/JK9ybyj.jpg', - }, - success: true, - status: 200, -}; +function createResponse({ + id = 'JK9ybyj', + type = null, + title = null, + description = null, +}) { + return { + data: { + id, + deletehash: Array.from(id).reverse().join(''), + title, + description, + link: `https://i.imgur.com/${id}.${type === 'video' ? 'mp4' : 'jpg'}`, + }, + success: true, + status: 200, + }; +} export function postHandler(req, res, ctx) { - // image field is always required - if (!('image' in req.body)) { + const { + image = null, + video = null, + type = null, + title = null, + description = null, + } = req.body; + + // image or video field is always required + if (image !== null && video !== null) { return res(ctx.status(400), ctx.json(BadRequestErrorResponse)); } // type is optional when uploading a file, but required // for any other type - if ('type' in req.body) { + if (type !== null) { // only these types are allowed - if (!['file', 'url', 'base64'].includes(req.body.type)) { + if (!['file', 'url', 'base64'].includes(type)) { return res(ctx.status(400), ctx.json(BadRequestErrorResponse)); } // if type is not specified we assume we're uploading a file. // but we need to make sure a file was sent in the image field - } else if (typeof req.body.image !== 'object') { + } else if (typeof image !== 'object') { return res(ctx.status(400), ctx.json(BadRequestErrorResponse)); } - return res(ctx.json(SuccessfulUploadResponse)); + return res(ctx.json(createResponse({ image, video, title, description }))); } diff --git a/src/__tests__/uploadFile.js b/src/__tests__/uploadFile.js index 42f5139c..294353f0 100644 --- a/src/__tests__/uploadFile.js +++ b/src/__tests__/uploadFile.js @@ -6,9 +6,11 @@ test('upload one image image and receive response', async () => { const resp = await imgur.uploadFile('/home/user/meme.jpg'); expect(resp).toMatchInlineSnapshot(` Object { - "deletehash": "j83zimv4VtDA0Xp", + "deletehash": "jyby9KJ", + "description": null, "id": "JK9ybyj", "link": "https://i.imgur.com/JK9ybyj.jpg", + "title": null, } `); }); @@ -21,14 +23,18 @@ test('upload multiple images and receive response', async () => { expect(resp).toMatchInlineSnapshot(` Array [ Object { - "deletehash": "j83zimv4VtDA0Xp", + "deletehash": "jyby9KJ", + "description": null, "id": "JK9ybyj", "link": "https://i.imgur.com/JK9ybyj.jpg", + "title": null, }, Object { - "deletehash": "j83zimv4VtDA0Xp", + "deletehash": "jyby9KJ", + "description": null, "id": "JK9ybyj", "link": "https://i.imgur.com/JK9ybyj.jpg", + "title": null, }, ] `); diff --git a/src/client.ts b/src/client.ts index ec5908b6..578e5186 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,21 +1,29 @@ import { EventEmitter } from 'events'; -import got, { Options } from 'got'; +import { IncomingMessage } from 'http'; +import got, { ExtendOptions } from 'got'; import { getAuthorizationHeader, Credentials } from './helpers'; +import { getImage, upload, Payload } from './image'; + +type ImgurResponse = { + data?: any; + success?: boolean; + status?: number; +}; export class ImgurClient extends EventEmitter { constructor(readonly credentials: Credentials) { super(); } - async request(options: Options): Promise { + async request(options: ExtendOptions): Promise { try { - return await got(options); + return (await got(options)) as IncomingMessage; } catch (err) { throw new Error(err); } } - async authorizedRequest(options: Options): Promise { + async authorizedRequest(options: ExtendOptions): Promise { try { const authorization = await getAuthorizationHeader(this); const mergedOptions = got.mergeOptions(options, { @@ -23,9 +31,17 @@ export class ImgurClient extends EventEmitter { responseType: 'json', resolveBodyOnly: true, }); - return await this.request(mergedOptions); + return (await this.request(mergedOptions)) as ImgurResponse; } catch (err) { - throw new Error(err.message); + throw new Error(err); } } + + async getImage(imageHash: string) { + return getImage(this, imageHash); + } + + async upload(payload: string | string[] | Payload | Payload[]) { + return upload(this, payload); + } } diff --git a/src/helpers/endpoints.ts b/src/helpers/endpoints.ts index 7663dcfa..9fdff162 100644 --- a/src/helpers/endpoints.ts +++ b/src/helpers/endpoints.ts @@ -5,3 +5,4 @@ const API_BASE = `${HOST}/${API_VERSION}`; export const AUTHORIZE_ENDPOINT = `${HOST}/oauth2/authorize`; export const IMAGE_ENDPOINT = `${API_BASE}/image`; +export const UPLOAD_ENDPOINT = `${API_BASE}/upload`; diff --git a/src/image/getImage.ts b/src/image/getImage.ts index 764a6438..46567ea2 100644 --- a/src/image/getImage.ts +++ b/src/image/getImage.ts @@ -2,7 +2,7 @@ import { ImgurClient } from '../client'; import { IMAGE_ENDPOINT } from '../helpers'; type ImageResponse = { - data: { + data?: { id?: string; title?: string | null; description?: string | null; @@ -37,8 +37,8 @@ type ImageResponse = { showsAds?: boolean; }; }; - success: boolean; - status: number; + success?: boolean; + status?: number; }; export async function getImage( diff --git a/src/image/index.ts b/src/image/index.ts new file mode 100644 index 00000000..00140c15 --- /dev/null +++ b/src/image/index.ts @@ -0,0 +1,2 @@ +export * from './getImage'; +export * from './upload'; diff --git a/src/image/upload.test.ts b/src/image/upload.test.ts new file mode 100644 index 00000000..61914c3e --- /dev/null +++ b/src/image/upload.test.ts @@ -0,0 +1,148 @@ +import { ImgurClient } from '../client'; +import { upload } from './upload'; + +describe('test file uploads', () => { + test('upload one image via path string, receive one response', async () => { + const accessToken = 'abc123'; + const client = new ImgurClient({ accessToken }); + const response = await upload(client, '/home/user/meme.jpg'); + expect(response).toMatchInlineSnapshot(` + Object { + "data": Object { + "deletehash": "jyby9KJ", + "description": null, + "id": "JK9ybyj", + "link": "https://i.imgur.com/JK9ybyj.jpg", + "title": null, + }, + "status": 200, + "success": true, + } + `); + }); + + test('upload multiple images via array of path strings, receive multiple responses', async () => { + const accessToken = 'abc123'; + const client = new ImgurClient({ accessToken }); + const response = await upload(client, [ + '/home/user/meme.jpg', + '/home/user/lol.jpg', + ]); + expect(response).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "deletehash": "jyby9KJ", + "description": null, + "id": "JK9ybyj", + "link": "https://i.imgur.com/JK9ybyj.jpg", + "title": null, + }, + "status": 200, + "success": true, + }, + Object { + "data": Object { + "deletehash": "jyby9KJ", + "description": null, + "id": "JK9ybyj", + "link": "https://i.imgur.com/JK9ybyj.jpg", + "title": null, + }, + "status": 200, + "success": true, + }, + ] + `); + }); + + test('upload one image via payload type, receive one response', async () => { + const accessToken = 'abc123'; + const client = new ImgurClient({ accessToken }); + const response = await upload(client, { + image: '/home/user/meme.jpg', + title: 'dank meme', + description: 'the dankiest of dank memes', + }); + expect(response).toMatchInlineSnapshot(` + Object { + "data": Object { + "deletehash": "jyby9KJ", + "description": "the dankiest of dank memes", + "id": "JK9ybyj", + "link": "https://i.imgur.com/JK9ybyj.jpg", + "title": "dank meme", + }, + "status": 200, + "success": true, + } + `); + }); + + test('upload multiple images via an array of payload type, receive multiple response', async () => { + const accessToken = 'abc123'; + const client = new ImgurClient({ accessToken }); + const response = await upload(client, [ + { + image: '/home/user/meme.jpg', + title: 'dank meme', + description: 'the dankiest of dank memes', + }, + { + image: '/home/user/lol.jpg', + title: 'this is funny', + description: '🤣', + }, + ]); + expect(response).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "deletehash": "jyby9KJ", + "description": "the dankiest of dank memes", + "id": "JK9ybyj", + "link": "https://i.imgur.com/JK9ybyj.jpg", + "title": "dank meme", + }, + "status": 200, + "success": true, + }, + Object { + "data": Object { + "deletehash": "jyby9KJ", + "description": "🤣", + "id": "JK9ybyj", + "link": "https://i.imgur.com/JK9ybyj.jpg", + "title": "this is funny", + }, + "status": 200, + "success": true, + }, + ] + `); + }); + + test('upload a video, disable sound', async () => { + const accessToken = 'abc123'; + const client = new ImgurClient({ accessToken }); + const response = await upload(client, { + video: '/home/user/trailer.mp4', + title: 'trailer for my new stream', + description: 'yolo', + disable_audio: '1', + }); + expect(response).toMatchInlineSnapshot(` + Object { + "data": Object { + "deletehash": "jyby9KJ", + "description": "yolo", + "id": "JK9ybyj", + "link": "https://i.imgur.com/JK9ybyj.jpg", + "title": "trailer for my new stream", + }, + "status": 200, + "success": true, + } + `); + }); +}); diff --git a/src/image/upload.ts b/src/image/upload.ts new file mode 100644 index 00000000..0226ca26 --- /dev/null +++ b/src/image/upload.ts @@ -0,0 +1,57 @@ +import { ImgurClient } from '../client'; +import { UPLOAD_ENDPOINT } from '../helpers'; +import { createReadStream } from 'fs'; +import FormData from 'form-data'; + +export interface Payload { + image?: string; + video?: string; + type?: 'file' | 'url' | 'base64'; + name?: string; + title?: string; + description?: string; + album?: string; + disable_audio?: '1' | '0'; +} + +function createForm(file: string | Payload) { + const form = new FormData(); + + if (typeof file === 'string') { + form.append('image', createReadStream(file)); + return form; + } + + for (const [key, value] of Object.entries(file)) { + if (key === 'image' || key === 'video') { + form.append(key, createReadStream(value)); + } else { + form.append(key, value); + } + } + return form; +} + +export async function upload( + client: ImgurClient, + payload: string | string[] | Payload | Payload[] +) { + if (Array.isArray(payload)) { + const promises = payload.map((p: string | Payload) => { + const form = createForm(p); + return client.authorizedRequest({ + url: UPLOAD_ENDPOINT, + method: 'POST', + body: form, + }); + }); + return await Promise.all(promises); + } + + const form = createForm(payload); + return await client.authorizedRequest({ + url: UPLOAD_ENDPOINT, + method: 'POST', + body: form, + }); +}