From dcd7d2da01a51e9f32b205c2b51a5e8e9b70cc5f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 9 Oct 2026 23:53:28 +0900 Subject: [PATCH 1/2] Encode path parameters --- .../changepack_log_bjyLfJVI9V96nCCsK8t6v.json | 1 + README.md | 2 +- packages/fetch/README.md | 4 +- packages/fetch/src/__tests__/api.test.ts | 38 ++++++++ packages/fetch/src/__tests__/utils.test.ts | 86 +++++++++++++++++++ packages/fetch/src/utils.ts | 11 ++- 6 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 .changepacks/changepack_log_bjyLfJVI9V96nCCsK8t6v.json diff --git a/.changepacks/changepack_log_bjyLfJVI9V96nCCsK8t6v.json b/.changepacks/changepack_log_bjyLfJVI9V96nCCsK8t6v.json new file mode 100644 index 0000000..62ee4cb --- /dev/null +++ b/.changepacks/changepack_log_bjyLfJVI9V96nCCsK8t6v.json @@ -0,0 +1 @@ +{"changes":{"packages/fetch/package.json":"Patch"},"note":"Encode path parameters with `encodeURIComponent`. Code that put `/` in a path parameter to build a multi-segment path now sends one encoded segment (`a/b` becomes `a%2Fb`), and `.` or `..` now throws instead of changing the request path.","date":"2026-10-09T14:51:45.266807400Z"} \ No newline at end of file diff --git a/README.md b/README.md index 0aff138..18f792b 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Just write API calls — the types are already there. ### **🪝 Fetch-compatible design** devup-api feels like using `fetch`, but with superpowers: -- Path params automatically replaced +- Path params automatically replaced and URL-encoded (`.` and `..` are rejected) - Query/body/header types enforced - Typed success & error responses - Optional runtime schema validation diff --git a/packages/fetch/README.md b/packages/fetch/README.md index 3a6e782..006e63f 100644 --- a/packages/fetch/README.md +++ b/packages/fetch/README.md @@ -114,7 +114,7 @@ console.log(result.response.status) ### Using Path Parameters ```ts -// Path parameters are automatically replaced +// Path parameters are automatically replaced and URL-encoded const result = await api.get('/users/{userId}/posts/{postId}', { params: { userId: '123', @@ -124,6 +124,8 @@ const result = await api.get('/users/{userId}/posts/{postId}', { // URL becomes: /users/123/posts/456 ``` +Each value is encoded with `encodeURIComponent`, so `a/b` is sent as `a%2Fb` and stays one path segment. `.` and `..` would still be resolved as dot segments, so they throw instead of sending the request. + ### Using Query Parameters ```ts diff --git a/packages/fetch/src/__tests__/api.test.ts b/packages/fetch/src/__tests__/api.test.ts index 24a0b28..6c6ad86 100644 --- a/packages/fetch/src/__tests__/api.test.ts +++ b/packages/fetch/src/__tests__/api.test.ts @@ -1,6 +1,7 @@ /** biome-ignore-all lint/suspicious/noExplicitAny: any is used to allow for flexibility in the type */ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { DevupApi } from '../api' +import { createApi } from '../create-api' const originalFetch = globalThis.fetch @@ -221,6 +222,43 @@ test('request uses params to replace path parameters', async () => { } }) +test.each([ + ['../members', 'https://api.example.com/admin/notices/..%2Fmembers'], + ['a/b', 'https://api.example.com/admin/notices/a%2Fb'], + ['x?y=1', 'https://api.example.com/admin/notices/x%3Fy%3D1'], + ['x#y', 'https://api.example.com/admin/notices/x%23y'], +] as const)( + 'request sends path param %s as one encoded segment', + async (id, expected) => { + const api = createApi('https://api.example.com') + const mockFetch = globalThis.fetch as unknown as ReturnType + + await api.delete( + '/admin/notices/{id}' as never, + { params: { id } } as never, + ) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [request] = mockFetch.mock.calls[0] as [Request] + expect(request.url).toBe(expected) + }, +) + +test.each(['.', '..'] as const)( + 'request rejects path param %s without fetching', + async (id) => { + const api = createApi('https://api.example.com') + const mockFetch = globalThis.fetch as unknown as ReturnType + + await expect( + api.delete('/admin/notices/{id}' as never, { params: { id } } as never), + ).rejects.toThrow( + `Path parameter "id" cannot be "${id}": it would change the request path`, + ) + expect(mockFetch).not.toHaveBeenCalled() + }, +) + test('request returns response with data on success', async () => { globalThis.fetch = mock(() => Promise.resolve( diff --git a/packages/fetch/src/__tests__/utils.test.ts b/packages/fetch/src/__tests__/utils.test.ts index e6c19ce..c51f773 100644 --- a/packages/fetch/src/__tests__/utils.test.ts +++ b/packages/fetch/src/__tests__/utils.test.ts @@ -109,6 +109,84 @@ test.each([ { id: '123' }, 'https://api.example.com/users/123/profile', ], + [ + 'https://api.example.com', + '/users/{id}', + { id: '../members' }, + 'https://api.example.com/users/..%2Fmembers', + ], + [ + 'https://api.example.com', + '/users/{id}', + { id: 'a/b' }, + 'https://api.example.com/users/a%2Fb', + ], + [ + 'https://api.example.com', + '/users/{id}', + { id: 'x?y=1' }, + 'https://api.example.com/users/x%3Fy%3D1', + ], + [ + 'https://api.example.com', + '/users/{id}', + { id: 'x#y' }, + 'https://api.example.com/users/x%23y', + ], + [ + 'https://api.example.com', + '/users/{id}', + { id: '%' }, + 'https://api.example.com/users/%25', + ], + [ + 'https://api.example.com', + '/users/{id}', + { id: 'a b' }, + 'https://api.example.com/users/a%20b', + ], + [ + 'https://api.example.com', + '/users/{id}', + { id: '공지' }, + 'https://api.example.com/users/%EA%B3%B5%EC%A7%80', + ], + [ + 'https://api.example.com', + '/users/{id}', + { id: '$&' }, + 'https://api.example.com/users/%24%26', + ], + [ + 'https://api.example.com', + '/users/{id}', + { id: '$`' }, + 'https://api.example.com/users/%24%60', + ], + [ + 'https://api.example.com', + '/users/{id}', + { id: 123 }, + 'https://api.example.com/users/123', + ], + [ + 'https://api.example.com', + '/users/{id}/friends/{id}', + { id: 'a/b' }, + 'https://api.example.com/users/a%2Fb/friends/a%2Fb', + ], + [ + 'https://api.example.com', + '/users/{id}', + { id: '' }, + 'https://api.example.com/users/', + ], + [ + 'https://api.example.com', + '/users', + { id: '..' }, + 'https://api.example.com/users', + ], ])( 'getApiEndpoint: baseUrl=%s, path=%s, params=%s -> %s', (baseUrl, path, params, expected) => { @@ -116,6 +194,14 @@ test.each([ }, ) +test.each(['.', '..'])('getApiEndpoint rejects path param %s', (id) => { + expect(() => + getApiEndpoint('https://api.example.com', '/users/{id}', { id }), + ).toThrow( + `Path parameter "id" cannot be "${id}": it would change the request path`, + ) +}) + test.each([ ['a=1&b=2', 'a=1&b=2'], ['', ''], diff --git a/packages/fetch/src/utils.ts b/packages/fetch/src/utils.ts index a8d39dc..0d3bb46 100644 --- a/packages/fetch/src/utils.ts +++ b/packages/fetch/src/utils.ts @@ -14,7 +14,16 @@ export function getApiEndpoint( ): string { let ret = `${baseUrl}${path}` for (const [key, value] of Object.entries(params ?? {})) { - ret = ret.replace(`{${key}}`, value) + const placeholder = `{${key}}` + if (!ret.includes(placeholder)) continue + const encoded = encodeURIComponent(String(value)) + // encodeURIComponent keeps "." and "..", and URL parsers resolve them as dot segments. + if (encoded === '.' || encoded === '..') { + throw new Error( + `Path parameter "${key}" cannot be "${encoded}": it would change the request path`, + ) + } + ret = ret.replaceAll(placeholder, () => encoded) } return ret } From 08bf1d24e127f9c8770f9585e6bcf485a9578784 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 9 Oct 2026 23:54:13 +0900 Subject: [PATCH 2/2] Keep Set-Cookie out of serialized responses --- .changepacks/changepack_log_bjyLfJVI9V96nCCsK8t6v.json | 2 +- README.md | 2 +- SKILL.md | 2 +- packages/fetch/src/__tests__/server.test.ts | 5 +++++ packages/fetch/src/server-utils.ts | 8 ++++---- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.changepacks/changepack_log_bjyLfJVI9V96nCCsK8t6v.json b/.changepacks/changepack_log_bjyLfJVI9V96nCCsK8t6v.json index 62ee4cb..449df4c 100644 --- a/.changepacks/changepack_log_bjyLfJVI9V96nCCsK8t6v.json +++ b/.changepacks/changepack_log_bjyLfJVI9V96nCCsK8t6v.json @@ -1 +1 @@ -{"changes":{"packages/fetch/package.json":"Patch"},"note":"Encode path parameters with `encodeURIComponent`. Code that put `/` in a path parameter to build a multi-segment path now sends one encoded segment (`a/b` becomes `a%2Fb`), and `.` or `..` now throws instead of changing the request path.","date":"2026-10-09T14:51:45.266807400Z"} \ No newline at end of file +{"changes":{"packages/fetch/package.json":"Patch"},"note":"Encode path parameters with `encodeURIComponent`. Code that put `/` in a path parameter to build a multi-segment path now sends one encoded segment (`a/b` becomes `a%2Fb`), and `.` or `..` now throws instead of changing the request path. `serializeApiResponse` (used by generated Server Actions) no longer copies `Set-Cookie` or `Set-Cookie2` headers.","date":"2026-10-09T14:51:45.266807400Z"} \ No newline at end of file diff --git a/README.md b/README.md index 18f792b..acf2cb6 100644 --- a/README.md +++ b/README.md @@ -764,7 +764,7 @@ export function UserButton() { The generated `df/server.ts` file contains `'use server'` and exports one named async function for every operationId in your OpenAPI schemas. You should import from `@devup-api/fetch/server`, not from `df/server.ts` directly; the build plugin aliases that module to the generated file. -Generated actions return `DevupApiResponse`. This keeps the same `data` / `error` / `isOk` / `isError` shape as normal `api.get()` calls, while replacing the native `Response` instance with a plain serializable response object that can cross the Server Action boundary. +Generated actions return `DevupApiResponse`. This keeps the same `data` / `error` / `isOk` / `isError` shape as normal `api.get()` calls, while replacing the native `Response` instance with a plain serializable response object that can cross the Server Action boundary. Its headers leave out `Set-Cookie` and `Set-Cookie2`, matching what browser `fetch` exposes to JavaScript. During cold typing, `@devup-api/fetch/server` is still importable before `df` exists. The fallback keeps initial setup from failing, and the generated module replaces it with strict operation-specific types after `dev` or `build` runs. diff --git a/SKILL.md b/SKILL.md index af89d7e..58b48f2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -179,7 +179,7 @@ const result = await getUser({ params: { id: '123' } }) Notes: - Generated `df/server.ts` contains `'use server'` and top-level named async exports. -- Generated actions return `DevupApiResponse`. +- Generated actions return `DevupApiResponse`; the serialized headers omit `Set-Cookie` and `Set-Cookie2`. - `@devup-api/fetch/server` has a cold typing fallback before `df` exists. - The plugin aliases `@devup-api/fetch/server` to generated `df/server.ts` during dev/build. - When enabled, every operationId is generated as a named Server Action export. diff --git a/packages/fetch/src/__tests__/server.test.ts b/packages/fetch/src/__tests__/server.test.ts index 5f5ca04..dd9c49d 100644 --- a/packages/fetch/src/__tests__/server.test.ts +++ b/packages/fetch/src/__tests__/server.test.ts @@ -9,6 +9,9 @@ describe('serializeApiResponse', () => { statusText: 'Created', headers: { 'X-Test': 'yes' }, }) + // The test DOM's Response constructor strips Set-Cookie, so append it here. + response.headers.append('Set-Cookie', 'session=SECRET; HttpOnly') + response.headers.append('Set-Cookie2', 'legacy=1') const result: DevupApiResponse<{ id: number }, { message: string }> = { data: { id: 1 }, isOk: true, @@ -36,6 +39,8 @@ describe('serializeApiResponse', () => { test('converts error responses to Server Action-safe plain objects', () => { const response = new Response('missing', { status: 404 }) + response.headers.append('Set-Cookie', 'session=SECRET; HttpOnly') + response.headers.append('Set-Cookie2', 'legacy=1') const result: DevupApiResponse<{ id: number }, { message: string }> = { error: { message: 'Not found' }, isOk: false, diff --git a/packages/fetch/src/server-utils.ts b/packages/fetch/src/server-utils.ts index 1f380be..b3b8ef2 100644 --- a/packages/fetch/src/server-utils.ts +++ b/packages/fetch/src/server-utils.ts @@ -18,10 +18,10 @@ export type SerializedDevupApiResponse = DevupApiResponse< function serializeResponse(response: Response): SerializedResponse { return { headers: Object.fromEntries( - [...response.headers.entries()].map(([key, value]) => [ - key.toLowerCase(), - value, - ]), + [...response.headers.entries()] + .map(([key, value]) => [key.toLowerCase(), value]) + // Browsers never expose these to JS; the result is handed to client code. + .filter(([key]) => key !== 'set-cookie' && key !== 'set-cookie2'), ), redirected: response.redirected, status: response.status,