-
-
Notifications
You must be signed in to change notification settings - Fork 18
fix: per-segment article path encoding + /articles/search #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+90
to
+93
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Proposed fix get baseUrl(): URL {
- return this.#baseUrl;
+ return new URL(this.#baseUrl);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| async #makeRequest(url: URL): Promise<unknown> { | ||||||||||||||||||
| logger.debug({ url }, "Making API request"); | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -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); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -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); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
There was a problem hiding this comment.
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/extrapasses validation and becomesuser/slug%2Fextra, despite the documented two-segment contract. Split first and require exactly two non-empty segments.Proposed fix
Also add a rejection test for
"user/slug/extra".📝 Committable suggestion
🤖 Prompt for AI Agents