Skip to content

Commit df77869

Browse files
feat: exposed response headers to clients (#244)
1 parent 74e41dc commit df77869

8 files changed

Lines changed: 119 additions & 36 deletions

File tree

.changeset/happy-hairs-press.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@ts-rest/react-query': minor
3+
'@ts-rest/solid-query': minor
4+
'@ts-rest/core': minor
5+
---
6+
7+
Response headers are now exposed to clients. Users of custom API fetchers should start returning headers.

apps/docs/docs/core/custom.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,12 @@ const client = initClient(contract, {
108108
headers,
109109
data: body,
110110
});
111-
return { status: result.status, body: result.data };
111+
return { status: result.status, body: result.data, headers: response.headers };
112112
} catch (e: Error | AxiosError | any) {
113113
if (isAxiosError(e)) {
114114
const error = e as AxiosError;
115115
const response = error.response as AxiosResponse;
116-
return { status: response.status, body: response.data };
116+
return { status: response.status, body: response.data, headers: response.headers };
117117
}
118118
throw e;
119119
}
@@ -150,12 +150,12 @@ export class SampleAPI {
150150
headers,
151151
data: body,
152152
});
153-
return { status: result.status, body: result.data };
153+
return { status: result.status, body: result.data, headers: response.headers };
154154
} catch (e: Error | AxiosError | any) {
155155
if (isAxiosError(e)) {
156156
const error = e as AxiosError;
157157
const response = error.response as AxiosResponse;
158-
return { status: response.status, body: response.data };
158+
return { status: response.status, body: response.data, headers: response.headers };
159159
}
160160
throw e;
161161
}
@@ -195,12 +195,12 @@ export class SampleAPI {
195195
},
196196
data: body,
197197
});
198-
return { status: result.status, body: result.data };
198+
return { status: result.status, body: result.data, headers: result.headers };
199199
} catch (e: Error | AxiosError | any) {
200200
if (isAxiosError(e)) {
201201
const error = e as AxiosError;
202202
const response = error.response as AxiosResponse;
203-
return { status: response.status, body: response.data };
203+
return { status: response.status, body: response.data, headers: result.headers };
204204
}
205205
throw e;
206206
}

apps/docs/docs/core/fetch.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,16 @@ The `data` property is typed as follows:
8181
```typescript
8282
const data: {
8383
status: 200;
84-
body: User
84+
body: User;
85+
headers: Headers
8586
} | {
8687
status: 400 | 100 | 101 | 102 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 300 | 301 | 302 | 303 | 304 | 305 | 307 | ... 36 more ... | 511;
8788
body: unknown;
89+
headers: Headers
8890
}
8991
```
9092
93+
In this context, the term 'headers' refers to the response headers retrieved either from the default Fetch client or a custom client implementation.
9194
:::
9295
9396
## Credentials (sending cookies)

apps/example-next/tests/react-query.spec.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,9 @@ describe('react-query', () => {
829829

830830
const data = {
831831
status: 200,
832+
headers: new Headers({
833+
'Content-Type': 'application/json',
834+
}),
832835
body: {
833836
id: '1',
834837
title: 'foo',

libs/ts-rest/core/src/lib/client.spec.ts

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,10 @@ describe('client', () => {
208208

209209
const result = await client.posts.getPosts({});
210210

211-
expect(result).toStrictEqual({ body: value, status: 200 });
211+
expect(result.body).toStrictEqual(value);
212+
expect(result.status).toBe(200);
213+
expect(result.headers.get('Content-Length')).toBe('15');
214+
expect(result.headers.get('Content-Type')).toBe('application/json');
212215
});
213216

214217
it('w/ no query parameters', async () => {
@@ -225,7 +228,10 @@ describe('client', () => {
225228

226229
const result = await client.posts.getPosts({ query: {} });
227230

228-
expect(result).toStrictEqual({ body: value, status: 200 });
231+
expect(result.body).toStrictEqual(value);
232+
expect(result.status).toBe(200);
233+
expect(result.headers.get('Content-Length')).toBe('15');
234+
expect(result.headers.get('Content-Type')).toBe('application/json');
229235
});
230236

231237
it('w/ no parameters (not provided)', async () => {
@@ -242,7 +248,10 @@ describe('client', () => {
242248

243249
const result = await client.posts.getPosts();
244250

245-
expect(result).toStrictEqual({ body: value, status: 200 });
251+
expect(result.body).toStrictEqual(value);
252+
expect(result.status).toBe(200);
253+
expect(result.headers.get('Content-Length')).toBe('15');
254+
expect(result.headers.get('Content-Type')).toBe('application/json');
246255
});
247256

248257
it('w/ query parameters', async () => {
@@ -259,7 +268,10 @@ describe('client', () => {
259268

260269
const result = await client.posts.getPosts({ query: { take: 10 } });
261270

262-
expect(result).toStrictEqual({ body: value, status: 200 });
271+
expect(result.body).toStrictEqual(value);
272+
expect(result.status).toBe(200);
273+
expect(result.headers.get('Content-Length')).toBe('15');
274+
expect(result.headers.get('Content-Type')).toBe('application/json');
263275
});
264276

265277
it('w/ json query parameters', async () => {
@@ -304,7 +316,10 @@ describe('client', () => {
304316
},
305317
});
306318

307-
expect(result).toStrictEqual({ body: value, status: 200 });
319+
expect(result.body).toStrictEqual(value);
320+
expect(result.status).toBe(200);
321+
expect(result.headers.get('Content-Length')).toBe('15');
322+
expect(result.headers.get('Content-Type')).toBe('application/json');
308323
});
309324

310325
it('w/ undefined query parameters', async () => {
@@ -323,7 +338,10 @@ describe('client', () => {
323338
query: { take: 10, skip: undefined },
324339
});
325340

326-
expect(result).toStrictEqual({ body: value, status: 200 });
341+
expect(result.body).toStrictEqual(value);
342+
expect(result.status).toBe(200);
343+
expect(result.headers.get('Content-Length')).toBe('15');
344+
expect(result.headers.get('Content-Type')).toBe('application/json');
327345
});
328346

329347
it('w/ sub path', async () => {
@@ -340,7 +358,10 @@ describe('client', () => {
340358

341359
const result = await client.posts.getPost({ params: { id: '1' } });
342360

343-
expect(result).toStrictEqual({ body: value, status: 200 });
361+
expect(result.body).toStrictEqual(value);
362+
expect(result.status).toBe(200);
363+
expect(result.headers.get('Content-Length')).toBe('15');
364+
expect(result.headers.get('Content-Type')).toBe('application/json');
344365
});
345366

346367
it('w/ a non json response (string)', async () => {
@@ -362,7 +383,10 @@ describe('client', () => {
362383

363384
const result = await client.posts.getPosts({});
364385

365-
expect(result).toStrictEqual({ body: 'string', status: 200 });
386+
expect(result.body).toStrictEqual('string');
387+
expect(result.status).toBe(200);
388+
expect(result.headers.get('Content-Length')).toBe('6');
389+
expect(result.headers.get('Content-Type')).toBe('text/plain');
366390
});
367391
});
368392

@@ -383,7 +407,10 @@ describe('client', () => {
383407
body: { title: 'title', content: 'content', authorId: 'authorId' },
384408
});
385409

386-
expect(result).toStrictEqual({ body: value, status: 200 });
410+
expect(result.body).toStrictEqual(value);
411+
expect(result.status).toBe(200);
412+
expect(result.headers.get('Content-Length')).toBe('15');
413+
expect(result.headers.get('Content-Type')).toBe('application/json');
387414
});
388415

389416
it('w/ query params', async () => {
@@ -403,7 +430,10 @@ describe('client', () => {
403430
body: {},
404431
});
405432

406-
expect(result).toStrictEqual({ body: {}, status: 200 });
433+
expect(result.body).toStrictEqual({});
434+
expect(result.status).toBe(200);
435+
expect(result.headers.get('Content-Length')).toBe('2');
436+
expect(result.headers.get('Content-Type')).toBe('application/json');
407437
});
408438
});
409439

@@ -430,7 +460,10 @@ describe('client', () => {
430460
body: { title: 'title', content: 'content', authorId: 'authorId' },
431461
});
432462

433-
expect(result).toStrictEqual({ body: value, status: 200 });
463+
expect(result.body).toStrictEqual(value);
464+
expect(result.status).toBe(200);
465+
expect(result.headers.get('Content-Length')).toBe('15');
466+
expect(result.headers.get('Content-Type')).toBe('application/json');
434467
});
435468
});
436469

@@ -452,7 +485,10 @@ describe('client', () => {
452485
body: null,
453486
});
454487

455-
expect(result).toStrictEqual({ body: value, status: 200 });
488+
expect(result.body).toStrictEqual(value);
489+
expect(result.status).toBe(200);
490+
expect(result.headers.get('Content-Length')).toBe('15');
491+
expect(result.headers.get('Content-Type')).toBe('application/json');
456492
});
457493
});
458494

@@ -474,7 +510,10 @@ describe('client', () => {
474510
body: null,
475511
});
476512

477-
expect(result).toStrictEqual({ body: value, status: 200 });
513+
expect(result.body).toStrictEqual(value);
514+
expect(result.status).toBe(200);
515+
expect(result.headers.get('Content-Length')).toBe('15');
516+
expect(result.headers.get('Content-Type')).toBe('application/json');
478517
});
479518
});
480519

@@ -494,7 +533,10 @@ describe('client', () => {
494533
body: { file },
495534
});
496535

497-
expect(result).toStrictEqual({ body: value, status: 200 });
536+
expect(result.body).toStrictEqual(value);
537+
expect(result.status).toBe(200);
538+
expect(result.headers.get('Content-Length')).toBe('15');
539+
expect(result.headers.get('Content-Type')).toBe('application/json');
498540

499541
expect(fetchMock).toHaveLastFetched(true, {
500542
matcher: (_, options) => {
@@ -520,7 +562,10 @@ describe('client', () => {
520562
body: formData,
521563
});
522564

523-
expect(result).toStrictEqual({ body: value, status: 200 });
565+
expect(result.body).toStrictEqual(value);
566+
expect(result.status).toBe(200);
567+
expect(result.headers.get('Content-Length')).toBe('15');
568+
expect(result.headers.get('Content-Type')).toBe('application/json');
524569

525570
expect(fetchMock).toHaveLastFetched(true, {
526571
matcher: (_, options) => {
@@ -553,6 +598,7 @@ const customClient = initClient(router, {
553598
return {
554599
status: 200,
555600
body: { message: 'Hello' },
601+
headers: new Headers(),
556602
};
557603
},
558604
});

libs/ts-rest/core/src/lib/client.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export type ApiRouteResponseNoUnknownStatus<T> =
102102
[K in keyof T]: {
103103
status: K;
104104
body: ZodInferOrType<T[K]>;
105+
headers: Headers;
105106
};
106107
}[keyof T];
107108

@@ -110,6 +111,7 @@ export type ApiRouteResponse<T> =
110111
| {
111112
status: Exclude<HTTPStatusCode, keyof T>;
112113
body: unknown;
114+
headers: Headers;
113115
};
114116

115117
/**
@@ -172,9 +174,11 @@ export type ApiFetcherArgs = {
172174
signal?: AbortSignal;
173175
};
174176

175-
export type ApiFetcher = (
176-
args: ApiFetcherArgs
177-
) => Promise<{ status: number; body: unknown }>;
177+
export type ApiFetcher = (args: ApiFetcherArgs) => Promise<{
178+
status: number;
179+
body: unknown;
180+
headers: Headers;
181+
}>;
178182

179183
/**
180184
* Default fetch api implementation:
@@ -201,14 +205,24 @@ export const tsRestFetchApi: ApiFetcher = async ({
201205
const contentType = result.headers.get('content-type');
202206

203207
if (contentType?.includes('application/json')) {
204-
return { status: result.status, body: await result.json() };
205-
}
206-
207-
if (contentType?.includes('text/plain')) {
208-
return { status: result.status, body: await result.text() };
208+
return {
209+
status: result.status,
210+
body: await result.json(),
211+
headers: result.headers,
212+
};
213+
} else if (contentType?.includes('text/plain')) {
214+
return {
215+
status: result.status,
216+
body: await result.text(),
217+
headers: result.headers,
218+
};
219+
} else {
220+
return {
221+
status: result.status,
222+
body: await result.blob(),
223+
headers: result.headers,
224+
};
209225
}
210-
211-
return { status: result.status, body: await result.blob() };
212226
};
213227

214228
const createFormData = (body: unknown) => {

libs/ts-rest/react-query/src/lib/types.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export type DataReturnArgs<
6767
*/
6868
type SuccessResponseMapper<T> = {
6969
[K in keyof T]: K extends SuccessfulHttpStatusCode
70-
? { status: K; body: ZodInferOrType<T[K]> }
70+
? { status: K; body: ZodInferOrType<T[K]>; headers: Headers }
7171
: never;
7272
}[keyof T];
7373

@@ -78,12 +78,17 @@ type ErrorResponseMapper<T> =
7878
| {
7979
[K in keyof T]: K extends SuccessfulHttpStatusCode
8080
? never
81-
: { status: K; body: ZodInferOrType<T[K]> };
81+
: {
82+
status: K;
83+
body: ZodInferOrType<T[K]>;
84+
headers: Headers;
85+
};
8286
}[keyof T]
8387
// If the response isn't one of our typed ones. Return "unknown"
8488
| {
8589
status: Exclude<HTTPStatusCode, keyof T | SuccessfulHttpStatusCode>;
8690
body: unknown;
91+
headers: Headers;
8792
};
8893

8994
// Data response if it's a 2XX

libs/ts-rest/solid-query/src/lib/solid-query.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ type DataReturnArgs<
101101
*/
102102
type SuccessResponseMapper<T> = {
103103
[K in keyof T]: K extends SuccessfulHttpStatusCode
104-
? { status: K; body: ZodInferOrType<T[K]> }
104+
? { status: K; body: ZodInferOrType<T[K]>; headers: Headers }
105105
: never;
106106
}[keyof T];
107107

@@ -112,12 +112,17 @@ type ErrorResponseMapper<T> =
112112
| {
113113
[K in keyof T]: K extends SuccessfulHttpStatusCode
114114
? never
115-
: { status: K; body: ZodInferOrType<T[K]> };
115+
: {
116+
status: K;
117+
body: ZodInferOrType<T[K]>;
118+
headers: Headers;
119+
};
116120
}[keyof T]
117121
// If the response isn't one of our typed ones. Return "unknown"
118122
| {
119123
status: Exclude<HTTPStatusCode, keyof T | SuccessfulHttpStatusCode>;
120124
body: unknown;
125+
headers: Headers;
121126
};
122127

123128
// Data response if it's a 2XX

0 commit comments

Comments
 (0)