Skip to content
Merged
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
1 change: 1 addition & 0 deletions .changepacks/changepack_log_bjyLfJVI9V96nCCsK8t6v.json
Original file line number Diff line number Diff line change
@@ -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. `serializeApiResponse` (used by generated Server Actions) no longer copies `Set-Cookie` or `Set-Cookie2` headers.","date":"2026-10-09T14:51:45.266807400Z"}
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<T, E, SerializedResponse>`. 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<T, E, SerializedResponse>`. 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.

Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, E, SerializedResponse>`.
- Generated actions return `DevupApiResponse<T, E, SerializedResponse>`; 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.
Expand Down
4 changes: 3 additions & 1 deletion packages/fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand Down
38 changes: 38 additions & 0 deletions packages/fetch/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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<typeof mock>

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<typeof mock>

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(
Expand Down
5 changes: 5 additions & 0 deletions packages/fetch/src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 86 additions & 0 deletions packages/fetch/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,99 @@ 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) => {
expect(getApiEndpoint(baseUrl, path, params)).toBe(expected)
},
)

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'],
['', ''],
Expand Down
8 changes: 4 additions & 4 deletions packages/fetch/src/server-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ export type SerializedDevupApiResponse<T, E = unknown> = 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,
Expand Down
11 changes: 10 additions & 1 deletion packages/fetch/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading