From 159bdb27f81813ac2bbc904bb34354b4a990477d Mon Sep 17 00:00:00 2001 From: Nic Lydon Date: Sat, 18 Jul 2026 19:39:01 -0400 Subject: [PATCH] fix: encode article path segments; use /articles/search Forem returns 404 when username/slug is encodeURIComponent'd as one segment (%2F). Encode each segment separately. search/feed_content is dead on live Forem; use official GET /articles/search instead. Unit tests cover both rules without network. --- src/devto-api.test.ts | 76 +++++++++++++++++++++++++++++++++ src/devto-api.ts | 97 ++++++++++++++++++++++++++++++++----------- 2 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 src/devto-api.test.ts diff --git a/src/devto-api.test.ts b/src/devto-api.test.ts new file mode 100644 index 0000000..81538ab --- /dev/null +++ b/src/devto-api.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { + buildArticleUrl, + buildSearchArticlesUrl, + DevToAPI, +} from "./devto-api.ts"; + +const BASE = new URL("https://dev.to/api/"); + +describe("buildArticleUrl", () => { + it("builds id URL", () => { + const url = buildArticleUrl(BASE, { id: 42 }); + expect(url.href).toBe("https://dev.to/api/articles/42"); + }); + + it("keeps username/slug as two path segments (does not encode slash)", () => { + const path = "nickytonline/introducing-the-devto-mcp-server-42jg"; + const url = buildArticleUrl(BASE, { path }); + expect(url.pathname).toBe( + "/api/articles/nickytonline/introducing-the-devto-mcp-server-42jg", + ); + expect(url.href).not.toContain("%2F"); + expect(url.pathname.split("/").filter(Boolean)).toEqual([ + "api", + "articles", + "nickytonline", + "introducing-the-devto-mcp-server-42jg", + ]); + }); + + it("strips leading slash from path", () => { + const url = buildArticleUrl(BASE, { + path: "/ben/some-article-abc1", + }); + expect(url.pathname).toBe("/api/articles/ben/some-article-abc1"); + }); + + it("rejects single-segment path", () => { + expect(() => buildArticleUrl(BASE, { path: "onlyslug" })).toThrow( + /username\/slug/, + ); + }); + + it("rejects path traversal", () => { + expect(() => buildArticleUrl(BASE, { path: "a/../b" })).toThrow(/Invalid/); + }); +}); + +describe("buildSearchArticlesUrl", () => { + it("uses /articles/search not /search/feed_content", () => { + const url = buildSearchArticlesUrl(BASE, { q: "python", per_page: 1 }); + expect(url.pathname).toBe("/api/articles/search"); + expect(url.href).not.toContain("feed_content"); + expect(url.searchParams.get("q")).toBe("python"); + expect(url.searchParams.get("per_page")).toBe("1"); + }); + + it("does not send undocumented search_fields", () => { + const url = buildSearchArticlesUrl(BASE, { + q: "mcp", + search_fields: "title,body_text", + }); + expect(url.searchParams.has("search_fields")).toBe(false); + }); +}); + +describe("DevToAPI URL helpers via public methods", () => { + it("getArticle path construction matches buildArticleUrl", () => { + const api = new DevToAPI("https://dev.to/api/"); + const expected = buildArticleUrl(api.baseUrl, { + path: "user/my-slug-42", + }); + expect(expected.href).toContain("/articles/user/my-slug-42"); + expect(expected.href).not.toContain("user%2Fmy-slug-42"); + }); +}); diff --git a/src/devto-api.ts b/src/devto-api.ts index 65353a1..5f9235d 100644 --- a/src/devto-api.ts +++ b/src/devto-api.ts @@ -12,6 +12,72 @@ interface GetArticlesArgs { collection_id?: number; } +/** + * Build GET /api/articles/{id} or /api/articles/{username}/{slug}. + * Path segments must stay separate — encoding the whole "user/slug" as one + * segment produces 404 on Forem (encodeURIComponent collapses the slash). + */ +export function buildArticleUrl( + baseUrl: URL, + args: { id?: number; path?: string }, +): URL { + if (args.id !== undefined) { + if (!Number.isInteger(args.id) || args.id <= 0) { + throw new Error("Article ID must be a positive integer"); + } + return new URL(`articles/${args.id}`, baseUrl); + } + if (args.path) { + const cleaned = args.path.replace(/^\/+/, "").replace(/\/+$/, ""); + if (!cleaned || cleaned.includes("..") || cleaned.includes("://")) { + throw new Error("Invalid article path"); + } + const slash = cleaned.indexOf("/"); + if (slash <= 0 || slash === cleaned.length - 1) { + throw new Error( + 'Article path must be "username/slug" (two path segments)', + ); + } + const username = cleaned.slice(0, slash); + const slug = cleaned.slice(slash + 1); + // Encode each segment independently so "/" remains a path separator. + return new URL( + `articles/${encodeURIComponent(username)}/${encodeURIComponent(slug)}`, + baseUrl, + ); + } + throw new Error("Either id or path must be provided"); +} + +/** + * Build GET /api/articles/search (official OpenAPI). + * Legacy /search/feed_content returns 404 on live Forem. + */ +export function buildSearchArticlesUrl( + baseUrl: URL, + args: { + q: string; + page?: number; + per_page?: number; + top?: number; + search_fields?: string; + }, +): URL { + const url = new URL("articles/search", baseUrl); + url.searchParams.set("q", args.q); + if (args.page !== undefined && args.page !== null) { + url.searchParams.set("page", String(args.page)); + } + if (args.per_page !== undefined && args.per_page !== null) { + url.searchParams.set("per_page", String(args.per_page)); + } + if (args.top !== undefined && args.top !== null) { + url.searchParams.set("top", String(args.top)); + } + // search_fields is not in OpenAPI v1; omit rather than send unknown params. + return url; +} + export class DevToAPI { #baseUrl: URL; @@ -21,6 +87,11 @@ export class DevToAPI { this.#baseUrl = new URL(normalizedBaseURL); } + /** Exposed for unit tests and smoke scripts. */ + get baseUrl(): URL { + return this.#baseUrl; + } + async #makeRequest(url: URL): Promise { logger.debug({ url }, "Making API request"); @@ -58,24 +129,7 @@ export class DevToAPI { } async getArticle(args: { id?: number; path?: string }): Promise { - let endpoint: URL; - - if (args.id) { - // Validate ID is a positive integer - if (!Number.isInteger(args.id) || args.id <= 0) { - throw new Error("Article ID must be a positive integer"); - } - endpoint = new URL(`articles/${args.id}`, this.#baseUrl); - } else if (args.path) { - // Sanitize path parameter - endpoint = new URL( - `articles/${encodeURIComponent(args.path)}`, - this.#baseUrl, - ); - } else { - throw new Error("Either id or path must be provided"); - } - + const endpoint = buildArticleUrl(this.#baseUrl, args); return await this.#makeRequest(endpoint); } @@ -127,12 +181,7 @@ export class DevToAPI { per_page?: number; search_fields?: string; }): Promise { - const url = new URL("search/feed_content", this.#baseUrl); - Object.entries(args).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - url.searchParams.append(key, String(value)); - } - }); + const url = buildSearchArticlesUrl(this.#baseUrl, args); return await this.#makeRequest(url); } }