-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add strategy to post to dev.to (#55)
- Loading branch information
Showing
5 changed files
with
290 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
/** | ||
* @fileoverview Dev.to strategy for posting articles. | ||
* @author Nicholas C. Zakas | ||
*/ | ||
|
||
/* global fetch */ | ||
|
||
//----------------------------------------------------------------------------- | ||
// Type Definitions | ||
//----------------------------------------------------------------------------- | ||
|
||
/** | ||
* @typedef {Object} DevtoOptions | ||
* @property {string} apiKey The Dev.to API key. | ||
*/ | ||
|
||
/** | ||
* @typedef {Object} DevtoArticle | ||
* @property {string} title The title of the article. | ||
* @property {string} body_markdown The markdown content of the article. | ||
* @property {boolean} published Whether the article is published. | ||
* @property {string[]} tags The tags for the article. | ||
*/ | ||
|
||
/** | ||
* @typedef {Object} DevtoErrorResponse | ||
* @property {string} error The error message. | ||
* @property {string} status The error status. | ||
*/ | ||
|
||
//----------------------------------------------------------------------------- | ||
// Constants | ||
//----------------------------------------------------------------------------- | ||
|
||
const API_URL = "https://dev.to/api"; | ||
|
||
//----------------------------------------------------------------------------- | ||
// Helpers | ||
//----------------------------------------------------------------------------- | ||
|
||
/** | ||
* Posts an article to Dev.to. | ||
* @param {string} apiKey The Dev.to API key. | ||
* @param {string} content The content to post. | ||
* @returns {Promise<DevtoArticle>} A promise that resolves with the article data. | ||
*/ | ||
async function postArticle(apiKey, content) { | ||
const response = await fetch(`${API_URL}/articles`, { | ||
method: "POST", | ||
headers: { | ||
"Content-Type": "application/json", | ||
"api-key": apiKey, | ||
"User-Agent": "Crosspost v0.7.0", // x-release-please-version | ||
}, | ||
body: JSON.stringify({ | ||
article: { | ||
title: content.split(/\r?\n/g)[0], | ||
body_markdown: content, | ||
published: true, | ||
}, | ||
}), | ||
}); | ||
|
||
if (response.ok) { | ||
return /** @type {Promise<DevtoArticle>} */ (response.json()); | ||
} | ||
|
||
const errorBody = /** @type {DevtoErrorResponse} */ (await response.json()); | ||
|
||
throw new Error( | ||
`${response.status} ${response.statusText}: Failed to post article:\n${errorBody.status} - ${errorBody.error}`, | ||
); | ||
} | ||
|
||
//----------------------------------------------------------------------------- | ||
// Exports | ||
//----------------------------------------------------------------------------- | ||
|
||
/** | ||
* A strategy for posting articles to Dev.to. | ||
*/ | ||
export class DevtoStrategy { | ||
/** | ||
* The name of the strategy. | ||
* @type {string} | ||
* @readonly | ||
*/ | ||
name = "devto"; | ||
|
||
/** | ||
* The API key for Dev.to. | ||
* @type {string} | ||
*/ | ||
#apiKey; | ||
|
||
/** | ||
* Creates a new instance. | ||
* @param {DevtoOptions} options Options for the instance. | ||
* @throws {Error} When options are missing. | ||
*/ | ||
constructor(options) { | ||
const { apiKey } = options; | ||
|
||
if (!apiKey) { | ||
throw new TypeError("Missing apiKey."); | ||
} | ||
|
||
this.#apiKey = apiKey; | ||
} | ||
|
||
/** | ||
* Posts an article to Dev.to. | ||
* @param {string} message The message to post. | ||
* @returns {Promise<DevtoArticle>} A promise that resolves with the article data. | ||
*/ | ||
async post(message) { | ||
if (!message) { | ||
throw new TypeError("Missing message to post."); | ||
} | ||
|
||
return postArticle(this.#apiKey, message); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
/** | ||
* @fileoverview Tests for the DevtoStrategy class. | ||
* @author Nicholas C. Zakas | ||
*/ | ||
|
||
//----------------------------------------------------------------------------- | ||
// Imports | ||
//----------------------------------------------------------------------------- | ||
|
||
import assert from "node:assert"; | ||
import { DevtoStrategy } from "../../src/strategies/devto.js"; | ||
import { MockServer, FetchMocker } from "mentoss"; | ||
|
||
//----------------------------------------------------------------------------- | ||
// Data | ||
//----------------------------------------------------------------------------- | ||
|
||
const API_URL = "https://dev.to/api"; | ||
const API_KEY = "abc123"; | ||
|
||
const CREATE_ARTICLE_RESPONSE = { | ||
title: "Hello World", | ||
body_markdown: "Hello World\n\nThis is a test post.", | ||
published: true, | ||
tags: [], | ||
url: "https://dev.to/test/hello-world-123", | ||
canonical_url: "https://dev.to/test/hello-world-123", | ||
id: 123456, | ||
}; | ||
|
||
const server = new MockServer(API_URL); | ||
const fetchMocker = new FetchMocker({ | ||
servers: [server], | ||
}); | ||
|
||
//----------------------------------------------------------------------------- | ||
// Tests | ||
//----------------------------------------------------------------------------- | ||
|
||
describe("DevtoStrategy", () => { | ||
let options; | ||
|
||
beforeEach(() => { | ||
options = { | ||
apiKey: API_KEY, | ||
}; | ||
}); | ||
|
||
describe("constructor", () => { | ||
it("should throw a TypeError if apiKey is missing", () => { | ||
assert.throws( | ||
() => { | ||
new DevtoStrategy({ ...options, apiKey: undefined }); | ||
}, | ||
TypeError, | ||
"Missing apiKey.", | ||
); | ||
}); | ||
|
||
it("should create an instance if all options are provided", () => { | ||
const strategy = new DevtoStrategy(options); | ||
assert.strictEqual(strategy.name, "devto"); | ||
}); | ||
}); | ||
|
||
describe("post", () => { | ||
let strategy; | ||
|
||
beforeEach(() => { | ||
strategy = new DevtoStrategy(options); | ||
fetchMocker.mockGlobal(); | ||
}); | ||
|
||
afterEach(() => { | ||
fetchMocker.unmockGlobal(); | ||
server.clear(); | ||
}); | ||
|
||
it("should throw an Error if message is missing", async () => { | ||
await assert.rejects( | ||
async () => { | ||
await strategy.post(); | ||
}, | ||
TypeError, | ||
"Missing message to post.", | ||
); | ||
}); | ||
|
||
it("should successfully post an article", async () => { | ||
const content = "Hello World\n\nThis is a test post."; | ||
|
||
server.post( | ||
{ | ||
url: "/api/articles", | ||
headers: { | ||
"content-type": "application/json", | ||
"api-key": API_KEY, | ||
}, | ||
body: { | ||
article: { | ||
title: "Hello World", | ||
body_markdown: content, | ||
published: true, | ||
}, | ||
}, | ||
}, | ||
{ | ||
status: 201, | ||
headers: { | ||
"content-type": "application/json", | ||
}, | ||
body: CREATE_ARTICLE_RESPONSE, | ||
}, | ||
); | ||
|
||
const response = await strategy.post(content); | ||
assert.deepStrictEqual(response, CREATE_ARTICLE_RESPONSE); | ||
}); | ||
|
||
it("should handle post failure", async () => { | ||
server.post("/api/articles", { | ||
status: 422, | ||
body: { | ||
error: "Validation error", | ||
status: "422", | ||
}, | ||
}); | ||
|
||
await assert.rejects(async () => { | ||
await strategy.post("Hello World"); | ||
}, /422 Unprocessable Entity: Failed to post article/); | ||
}); | ||
}); | ||
}); |