Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/devto-api.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
97 changes: 73 additions & 24 deletions src/devto-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines +30 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject paths containing more than two segments.

user/slug/extra passes validation and becomes user/slug%2Fextra, despite the documented two-segment contract. Split first and require exactly two non-empty segments.

Proposed fix
-    const slash = cleaned.indexOf("/");
-    if (slash <= 0 || slash === cleaned.length - 1) {
+    const segments = cleaned.split("/");
+    if (segments.length !== 2 || segments.some((segment) => !segment)) {
       throw new Error(
         'Article path must be "username/slug" (two path segments)',
       );
     }
-    const username = cleaned.slice(0, slash);
-    const slug = cleaned.slice(slash + 1);
+    const [username, slug] = segments;

Also add a rejection test for "user/slug/extra".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
if (args.path) {
const cleaned = args.path.replace(/^\/+/, "").replace(/\/+$/, "");
if (!cleaned || cleaned.includes("..") || cleaned.includes("://")) {
throw new Error("Invalid article path");
}
const segments = cleaned.split("/");
if (segments.length !== 2 || segments.some((segment) => !segment)) {
throw new Error(
'Article path must be "username/slug" (two path segments)',
);
}
const [username, slug] = segments;
// Encode each segment independently so "/" remains a path separator.
return new URL(
`articles/${encodeURIComponent(username)}/${encodeURIComponent(slug)}`,
baseUrl,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/devto-api.ts` around lines 30 - 46, Update the article path validation
around the cleaned path construction to split on "/" and require exactly two
non-empty segments, rejecting values such as "user/slug/extra" before URL
encoding. Preserve independent encoding of the username and slug, and add a test
covering the rejected three-segment path.

);
}
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;

Expand All @@ -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;
}
Comment on lines +90 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Return a copy instead of exposing mutable request state.

URL is mutable, so api.baseUrl.hostname = ... silently redirects all subsequent API requests despite #baseUrl being private.

Proposed fix
   get baseUrl(): URL {
-    return this.#baseUrl;
+    return new URL(this.#baseUrl);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Exposed for unit tests and smoke scripts. */
get baseUrl(): URL {
return this.#baseUrl;
}
/** Exposed for unit tests and smoke scripts. */
get baseUrl(): URL {
return new URL(this.#baseUrl);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/devto-api.ts` around lines 90 - 93, Update the DevTo API class’s baseUrl
getter to return a new URL copy derived from `#baseUrl` rather than the private
URL instance, while preserving the existing URL value and getter interface.


async #makeRequest(url: URL): Promise<unknown> {
logger.debug({ url }, "Making API request");

Expand Down Expand Up @@ -58,24 +129,7 @@ export class DevToAPI {
}

async getArticle(args: { id?: number; path?: string }): Promise<unknown> {
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);
}

Expand Down Expand Up @@ -127,12 +181,7 @@ export class DevToAPI {
per_page?: number;
search_fields?: string;
}): Promise<unknown> {
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);
}
}