Skip to content

Commit

Permalink
Support NPF media uploads (#183)
Browse files Browse the repository at this point in the history
  • Loading branch information
sirreal authored Aug 25, 2023
1 parent bf870c8 commit 304121b
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 46 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
/types/
/node_modules/
.DS_Store
*.log
*.swp
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The 4.0 release is a significant change that modernizes the library, adds NPF support, and removes a
dependency on the long-deprecated `request` library.

A few highlights for upgrading from version 3:
Some things to watch out for when migrating from v3:

The `createPost` and `editPost` methods were renamed to `createLegacyPost` and `editLegacyPost`.
`createPost` and `editPost` are now for working with NPF posts (via the `/posts` endpoint).
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,36 @@ const blogSubmissions = await client.blogSubmissions(blogName, options);

### Post Methods

#### Create a post with `createPost`

```js
// Create or reblog a post
await client.createPost(blogName, options);
```

To upload media with a created post, provide a `ReadStream` as the block media:

```js
await client.createPost(blogName, {
content: [
{
type: 'image',
// Node's fs module, e.g. `import fs from 'node:fs';`
media: fs.createReadStream(new URL('./image.jpg', import.meta.url)),
alt_text: '',
},
],
});
```
#### Create a post with `editPost`
// Edit a post
```js
await client.editPost(blogName, postId, options);
```
#### Create a post with `deletePost`
// Delete a given post
```js
await client.deletePost(blogName, postId);
```
Expand Down
60 changes: 51 additions & 9 deletions integration/write.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { URL } from 'node:url';
import { env } from 'node:process';
import { Client } from 'tumblr.js';
Expand Down Expand Up @@ -43,7 +44,9 @@ describe('oauth1 write requests', () => {

// Wait a bit between tests to not spam API.
afterEach(function () {
return new Promise((resolve) => setTimeout(() => resolve(undefined), this.timeout() - 100));
return new Promise((resolve) =>
setTimeout(() => resolve(undefined), Math.min(this.timeout() - 100, 1_000)),
);
});

describe('post creation and edition', () => {
Expand Down Expand Up @@ -95,7 +98,7 @@ describe('oauth1 write requests', () => {
);
});

test('creates a post with media', async () => {
test('creates a post with existing media', async () => {
const media = {
media_key: '9fb3517d95570cbd752caa77172f1ebb:60e936a44dbb258b-12',
type: 'image/jpeg',
Expand All @@ -116,7 +119,7 @@ describe('oauth1 write requests', () => {
alt_text: 'A mountain landsacpe',
attribution: {
type: 'link',
url: 'https://openverse.org/en-gb/image/38b9b781-390f-4fc4-929d-0ecb4a2985e3',
url: 'https://openverse.org/image/38b9b781-390f-4fc4-929d-0ecb4a2985e3',
},
},
],
Expand All @@ -130,6 +133,45 @@ describe('oauth1 write requests', () => {
}),
);
});

test('creates a post with media upload', async () => {
assert.isOk(
await client.createPost(blogName, {
content: [
...postContent,
{
type: 'image',
media: createReadStream(new URL('../test/fixtures/image.jpg', import.meta.url)),
caption: 'Arches National Park',
alt_text: 'A mountain landsacpe',
attribution: {
type: 'link',
url: 'https://openverse.org/image/38b9b781-390f-4fc4-929d-0ecb4a2985e3',
},
},
{
type: 'video',
media: createReadStream(new URL('../test/fixtures/video.mp4', import.meta.url)),
width: 92,
height: 69,
title:
'Phosphatidylinositol (4,5) Bisphosphate Controls T Cell Activation by Regulating T Cell Rigidity and Organization',
attribution: {
type: 'link',
url: 'https://commons.wikimedia.org/wiki/File:Phosphatidylinositol-(45)-Bisphosphate-Controls-T-Cell-Activation-by-Regulating-T-Cell-Rigidity-and-pone.0027227.s020.ogv',
},
},
],

tags: [
'tumblr.js-test',
`tumblr.js-version-${client.version}`,
'test-npf',
'test-npf-media-upload',
],
}),
);
});
});

describe('legacy post creation', () => {
Expand All @@ -147,25 +189,25 @@ describe('oauth1 write requests', () => {

describe('create photo post', () => {
it('via data', async () => {
const data = await readFile(new URL('../test/fixtures/image.jpg', import.meta.url));
const data = createReadStream(new URL('../test/fixtures/image.jpg', import.meta.url));

const res = await client.createLegacyPost(blogName, {
type: 'photo',
caption: `Arches National Park || Automated test post ${new Date().toISOString()}`,
link: 'https://openverse.org/en-gb/image/38b9b781-390f-4fc4-929d-0ecb4a2985e3',
link: 'https://openverse.org/image/38b9b781-390f-4fc4-929d-0ecb4a2985e3',
tags: `tumblr.js-test,tumblr.js-version-${client.version},test-legacy-photo-data`,
data: data,
});
assert.isOk(res);
});

it('via data[]', async () => {
const data = await readFile(new URL('../test/fixtures/image.jpg', import.meta.url));
const data = createReadStream(new URL('../test/fixtures/image.jpg', import.meta.url));

const res = await client.createLegacyPost(blogName, {
type: 'photo',
caption: `Arches National Park || Automated test post ${new Date().toISOString()}`,
link: 'https://openverse.org/en-gb/image/38b9b781-390f-4fc4-929d-0ecb4a2985e3',
link: 'https://openverse.org/image/38b9b781-390f-4fc4-929d-0ecb4a2985e3',
tags: `tumblr.js-test,tumblr.js-version-${client.version},test-legacy-photo-data[]`,
data: [data, data],
});
Expand All @@ -180,7 +222,7 @@ describe('oauth1 write requests', () => {
const res = await client.createLegacyPost(blogName, {
type: 'photo',
caption: `Arches National Park || Automated test post ${new Date().toISOString()}`,
link: 'https://openverse.org/en-gb/image/38b9b781-390f-4fc4-929d-0ecb4a2985e3',
link: 'https://openverse.org/image/38b9b781-390f-4fc4-929d-0ecb4a2985e3',
tags: `tumblr.js-test,tumblr.js-version-${client.version},test-legacy-photo-data64`,
data64: data,
});
Expand All @@ -189,7 +231,7 @@ describe('oauth1 write requests', () => {
});

it('creates an audio post with data', async () => {
const data = await readFile(new URL('../test/fixtures/audio.mp3', import.meta.url));
const data = createReadStream(new URL('../test/fixtures/audio.mp3', import.meta.url));

const res = await client.createLegacyPost(blogName, {
type: 'audio',
Expand Down
90 changes: 58 additions & 32 deletions lib/tumblr.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,14 @@ const http = require('node:http');
const https = require('node:https');
const { URL } = require('node:url');
const oauth = require('oauth');
const { ReadStream } = require('node:fs');

const CLIENT_VERSION = '4.0.0-alpha.0';
const API_BASE_URL = 'https://api.tumblr.com'; // deliberately no trailing slash

/**
* @typedef Options
* @property {string} [consumer_key] OAuth1 credential. Required for API key auth endpoints.
* @property {string} [consumer_secret] OAuth1 credential. Required for OAuth endpoints.
* @property {string} [token] OAuth1 credential. Required for OAuth endpoints.
* @property {string} [token_secret] OAuth1 credential. Required for Oauth endpoints.
* @property {string} [baseUrl] (optional) The API url if different from the default.
* @property {boolean} [returnPromises] **Deprecated** Methods will return promises if no callback is provided.
*/

/**
* Handles the response from a client reuest
*
* @callback TumblrClientCallback
* @param {?Error} err - error message
* @param {?Object} resp - response body
* @param {?http.IncomingMessage} [response] - raw response
* @returns {void}
*/

class TumblrClient {
/**
* @typedef {import('./types').TumblrClientCallback} TumblrClientCallback
* @typedef {Map<string, ReadonlyArray<string>|string>} RequestData
*
* @typedef {'text'|'quote'|'link'|'answer'|'video'|'audio'|'photo'|'chat'} PostType
Expand All @@ -56,9 +38,7 @@ class TumblrClient {
/**
* Creates a Tumblr API client using the given options
*
* @param {Options} [options] - client options
*
* @constructor
* @param {import('./types').Options} [options] - client options
*/
constructor(options) {
/**
Expand Down Expand Up @@ -266,10 +246,14 @@ class TumblrClient {
request.setHeader('Authorization', authHeader);
}

/** @type {undefined|FormData} */
let form;

if (data) {
// We use multipart/form-data if we have media to upload
if (data.has('data') || data.has('data64')) {
const form = new FormData();
// We may also send JSON data in a multipart/form-data JSON field
if (data.has('data') || data.has('data64') || data.has('json')) {
form = new FormData();

// Legacy photo posts may need special handling to transform the data array of images so form-data can handle it
let isLegacyPhotoPost = false;
Expand All @@ -289,9 +273,9 @@ class TumblrClient {
for (const [key, value] of data.entries()) {
// Legacy photo post creation has a special case to accept `data`.
if (isLegacyPhotoPost && key === 'data') {
(Array.isArray(value) ? value : [value]).forEach((arrValue, index) => {
for (const [index, arrValue] of (Array.isArray(value) ? value : [value]).entries()) {
form.append(`${key}[${index}]`, arrValue);
});
}
continue;
}

Expand Down Expand Up @@ -390,7 +374,13 @@ class TumblrClient {
callback?.(err, null);
});

request.end();
if (form) {
form.on('end', () => {
request.end();
});
} else {
request.end();
}
return /** @type {CB extends undefined ? Promise<any> : undefined} */ (promise);
}

Expand Down Expand Up @@ -485,6 +475,18 @@ class TumblrClient {
*
* @see {@link https://www.tumblr.com/docs/en/api/v2#posts---createreblog-a-post-neue-post-format|API Docs}
*
* @example
* await client.createPost(blogName, {
* content: [
* {
* type: 'image',
* // Node's fs module, e.g. `import fs from 'node:fs';`
* media: fs.createReadStream(new URL('./image.jpg', import.meta.url)),
* alt_text: '…',
* },
* ],
* });
*
* @param {string} blogIdentifier - blog name or URL
* @param {import('./types').NpfReblogParams | import('./types').NpfPostParams } params
* @param {TumblrClientCallback} [callback] **Deprecated** Omit the callback and use the promise form
Expand Down Expand Up @@ -516,12 +518,36 @@ class TumblrClient {
/**
* @param {import('./types').NpfReblogParams | import('./types').NpfPostParams } params
*/
#transformNpfParams(params) {
const transformed = {
#transformNpfParams({ tags, content, ...params }) {
/** @type {Map<string, ReadStream>} */
const mediaStreams = new Map();

const transformedContent = content.map((block, index) => {
if (block.media && block.media instanceof ReadStream) {
mediaStreams.set(String(index), block.media);
return {
...block,
media: { identifier: String(index) },
};
}
return block;
});

const transformedTags = Array.isArray(tags) && { tags: tags.join(',') };

const transformedParams = {
...params,
...(Array.isArray(params.tags) && { tags: params.tags.join(',') }),
...transformedTags,
content: transformedContent,
};

const transformed = mediaStreams.size
? {
json: JSON.stringify(transformedParams),
...Object.fromEntries(mediaStreams.entries()),
}
: transformedParams;

return transformed;
}

Expand Down Expand Up @@ -831,7 +857,7 @@ module.exports = {
/**
* Creates a Tumblr Client
*
* @param {Options} [options] - client options
* @param {import('./types').Options} [options] - client options
*
* @return {TumblrClient} {@link TumblrClient} instance
*
Expand Down
Loading

0 comments on commit 304121b

Please sign in to comment.