Skip to content

Commit 8deaf56

Browse files
committed
✨ file download with json checking #294
1 parent 9914b28 commit 8deaf56

File tree

9 files changed

+370
-3
lines changed

9 files changed

+370
-3
lines changed

layers/common/composables/UseApiRouteFetcher.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,40 @@ export function apiResponseResultHook(includeFalse = true, id = 'responseResult'
116116
});
117117
}
118118

119+
export function apiResponseDownloadHook(id = 'responseDownload'): ApiResponseHook & { id: string } {
120+
return attachId(id, async (context) => {
121+
const responseType = typeof context.options.responseType === 'string'
122+
? context.options.responseType.toLowerCase()
123+
: undefined;
124+
if (responseType !== 'blob') return;
125+
126+
const disposition = context.response.headers.get('content-disposition');
127+
const currentData = context.response._data;
128+
if (isFileResult(currentData)) return;
129+
if (!isBlobLike(currentData)) return;
130+
131+
if (disposition != null && /attachment/i.test(disposition)) {
132+
const name = parseContentDispositionFilename(disposition) ?? 'download.bin';
133+
context.response._data = {
134+
name,
135+
blob: currentData,
136+
} satisfies FileResult;
137+
return;
138+
}
139+
140+
const text = await currentData.text();
141+
if (text) {
142+
const json = JSON.parse(text);
143+
context.response._data = (json != null && typeof json === 'object')
144+
? json
145+
: { success: false, message: text };
146+
}
147+
else {
148+
context.response._data = { success: false };
149+
}
150+
});
151+
}
152+
119153
export function isFetchError(err: unknown): err is FetchError {
120154
return err instanceof FetchError;
121155
}
@@ -124,6 +158,7 @@ export const defaultFetchHooks = {
124158
requestAcceptContent: apiRequestAcceptContentHook(),
125159
responseSession: apiResponseSessionHook(),
126160
responseStatus: apiResponseStatusHook(),
161+
responseDownload: apiResponseDownloadHook(),
127162
responseResult: apiResponseResultHook(),
128163
};
129164

@@ -154,6 +189,7 @@ export const defaultFetchOptions: FetchOptions = {
154189
onResponse: [
155190
defaultFetchHooks.responseSession,
156191
defaultFetchHooks.responseStatus,
192+
defaultFetchHooks.responseDownload,
157193
defaultFetchHooks.responseResult,
158194
],
159195
};

layers/common/tests/UseApiRouteFetcher.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Buffer } from 'node:buffer';
12
import { registerEndpoint } from '@nuxt/test-utils/runtime';
23
import { describe, expect, it, vi } from 'vitest';
34

@@ -19,6 +20,39 @@ registerEndpoint('/api/v1/test-post.json', (event) => {
1920
return { success: true, data: contentType ?? null };
2021
});
2122

23+
registerEndpoint('/api/v1/test-download.json', (event) => {
24+
const url = event.node.req.url != null ? new URL(event.node.req.url, 'http://localhost') : null;
25+
const filename = url?.searchParams.get('f')?.trim();
26+
const useStar = url?.searchParams.get('star') === '1';
27+
const fallback = url?.searchParams.get('fb') ?? filename ?? '';
28+
29+
if (!filename) {
30+
return {
31+
success: false,
32+
errors: [{ message: 'filename required' }],
33+
};
34+
}
35+
36+
const disposition: string[] = ['attachment'];
37+
if (useStar) {
38+
disposition.push(`filename="${fallback || 'download.bin'}"`);
39+
disposition.push(`filename*=UTF-8''${encodeURIComponent(filename)}`);
40+
}
41+
else {
42+
disposition.push(`filename="${filename}"`);
43+
}
44+
45+
event.node.res.setHeader('Content-Disposition', disposition.join('; '));
46+
event.node.res.setHeader('Content-Type', 'application/octet-stream');
47+
48+
return Buffer.from(`download:${filename}`, 'utf-8');
49+
});
50+
51+
registerEndpoint('/api/v1/test-put.json', (event) => {
52+
const contentType = event.node.req.headers['content-type'];
53+
return { success: true, data: contentType ?? null };
54+
});
55+
2256
registerEndpoint('/api/v1/test-403.json', (event) => {
2357
event.node.res.statusCode = 403;
2458
return { error: 'Forbidden' };
@@ -49,6 +83,12 @@ registerEndpoint('/api/v1/test-false.json', () => {
4983
});
5084

5185
describe('useApiRouteFetcher with real $fetch requests', () => {
86+
it('should include download hook by default', () => {
87+
const { opt } = useApiRouteFetcher();
88+
const hooks = (opt().onResponse as SafeAny[] | undefined)?.map((fn: SafeAny) => fn.id ?? 'unknown');
89+
expect(hooks).toContain('responseDownload');
90+
});
91+
5292
it('should send GET request with correct Content-Type (no body)', async () => {
5393
const { get, raw } = useApiRouteFetcher();
5494
const rs1 = await get('/test-get.json');
@@ -70,6 +110,60 @@ describe('useApiRouteFetcher with real $fetch requests', () => {
70110
expect(rs2._data).toEqual({ success: true, data: 'application/json' });
71111
});
72112

113+
it('should send PUT request with JSON Content-Type via req', async () => {
114+
const { req } = useApiRouteFetcher();
115+
const rs = await req<unknown, SafeAny>('/test-put.json', { method: 'put', body: { key: 'value' } });
116+
logger.debug('put JSON', JSON.stringify(rs));
117+
expect(rs.data).toBe('application/json');
118+
});
119+
120+
it('should download file when query filename provided', async () => {
121+
const { raw, req } = useApiRouteFetcher();
122+
const rs = await raw<unknown, SafeAny>('/test-download.json', {
123+
method: 'get',
124+
query: { f: 'report.txt' },
125+
responseType: 'blob',
126+
});
127+
const headers = Object.fromEntries(rs.headers.entries());
128+
const file = rs._data as FileResult;
129+
expect(headers['content-disposition']).toBe('attachment; filename="report.txt"');
130+
expect(headers['content-type']).toBe('application/octet-stream');
131+
expect(file.name).toBe('report.txt');
132+
expect(Object.prototype.toString.call(file.blob)).toBe('[object Blob]');
133+
await expect(file.blob.text()).resolves.toBe('download:report.txt');
134+
135+
const direct = await req<unknown, SafeAny>('/test-download.json', {
136+
method: 'get',
137+
query: { f: 'report.txt' },
138+
responseType: 'blob',
139+
});
140+
expect((direct as FileResult).name).toBe('report.txt');
141+
});
142+
143+
it('should prefer filename* when provided', async () => {
144+
const { raw } = useApiRouteFetcher();
145+
const rs = await raw<unknown, SafeAny>('/test-download.json', {
146+
method: 'get',
147+
query: { f: '报告.txt', star: '1', fb: 'fallback.txt' },
148+
responseType: 'blob',
149+
});
150+
const file = rs._data as FileResult;
151+
expect(file.name).toBe('报告.txt');
152+
expect(Object.prototype.toString.call(file.blob)).toBe('[object Blob]');
153+
await expect(file.blob.text()).resolves.toBe('download:报告.txt');
154+
});
155+
156+
it('should fail download when filename query missing', async () => {
157+
const { req } = useApiRouteFetcher();
158+
await expect(req('/test-download.json', {
159+
responseType: 'blob',
160+
})).rejects.toSatisfy((error: SafeAny) => {
161+
return error instanceof ApiResultError
162+
&& error.errorResult != null
163+
&& error.errorResult.errors?.[0]?.message === 'filename required';
164+
});
165+
});
166+
73167
it('should send POST request with FormData Content-Type', async () => {
74168
const { post } = useApiRouteFetcher();
75169
const formData = new FormData();

layers/common/tests/typed-fetcher.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const mockDataResult: DataResult<string> = { success: true, data: 'test data' };
44
const mockPageResult: PageResult<string> = { success: true, data: ['test data'], page: 1, size: 10, totalPage: 1, totalData: 1 };
55
const mockErrorResult: ErrorResult = { success: false, errors: [{ type: 'Validation', target: 'field', message: 'Invalid input' }] };
6+
const mockFileResult: FileResult = { name: 'file.txt', blob: new Blob(['file']) };
67

78
describe('typed-fetcher', () => {
89
it('should update loading state correctly', async () => {
@@ -66,6 +67,13 @@ describe('typed-fetcher', () => {
6667
expect(result).toEqual({ success: true, data: 'transformed' });
6768
});
6869

70+
it('fetchFileResult should return FileResult', async () => {
71+
const fetching = vi.fn().mockResolvedValue(mockFileResult);
72+
const result = await fetchFileResult(fetching);
73+
expect(fetching).toHaveBeenCalled();
74+
expect(result).toEqual(mockFileResult);
75+
});
76+
6977
it('getDataResult should return DataResult if valid', () => {
7078
expect(isDataResult(mockDataResult)).toBe(true);
7179
expect(isDataResult(mockPageResult)).toBe(true);

layers/common/utils/common-blob.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Save blob to file with filename
3+
*/
4+
export function saveBlobFile(blob: FileResult | Blob, name = 'download.blob'): void {
5+
const result = isFileResult(blob);
6+
const fileName = result ? blob.name : name;
7+
const fileBlob = result ? blob.blob : blob;
8+
const nav = window.navigator as Navigator & { msSaveOrOpenBlob?: (blob: Blob, name?: string) => void };
9+
if (typeof nav?.msSaveOrOpenBlob === 'function') {
10+
nav.msSaveOrOpenBlob(fileBlob, fileName);
11+
return;
12+
}
13+
14+
const link = document.createElement('a');
15+
const blobUrl = URL.createObjectURL(fileBlob);
16+
17+
try {
18+
link.style.display = 'none';
19+
link.href = blobUrl;
20+
link.setAttribute('download', fileName);
21+
link.setAttribute('target', '_blank');
22+
23+
document.body.appendChild(link);
24+
25+
let event: MouseEvent;
26+
try {
27+
event = new MouseEvent('click');
28+
}
29+
catch {
30+
// fallback
31+
event = document.createEvent('MouseEvent');
32+
event.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
33+
}
34+
35+
link.dispatchEvent(event);
36+
}
37+
finally {
38+
setTimeout(() => {
39+
if (link.parentNode) {
40+
link.parentNode.removeChild(link);
41+
}
42+
(window.URL || window.webkitURL || window).revokeObjectURL(blobUrl);
43+
}, 10_000);
44+
}
45+
}
46+
47+
export function parseContentDispositionFilename(disposition: string): string | undefined {
48+
let fallbackName: string | undefined;
49+
for (const part of disposition.split(';')) {
50+
const equalsIndex = part.indexOf('=');
51+
if (equalsIndex === -1) continue;
52+
53+
const key = part.slice(0, equalsIndex).trim().toLowerCase();
54+
let value = part.slice(equalsIndex + 1).trim();
55+
56+
if (value.startsWith('"') && value.endsWith('"')) {
57+
value = value.slice(1, -1);
58+
}
59+
60+
if (key === 'filename*') {
61+
const match = value.match(/^(.*?)'(?:.*)'(.*)$/);
62+
const encoded = match ? match[2] : value;
63+
try {
64+
const decoded = decodeURIComponent(encoded);
65+
if (decoded) return decoded;
66+
}
67+
catch {
68+
if (encoded) return encoded;
69+
}
70+
}
71+
else if (key === 'filename') {
72+
fallbackName = value;
73+
}
74+
}
75+
76+
if (fallbackName != null && fallbackName !== '') {
77+
try {
78+
return decodeURIComponent(fallbackName);
79+
}
80+
catch {
81+
return fallbackName;
82+
}
83+
}
84+
85+
return undefined;
86+
}
87+
88+
export function isBlobLike(data: SafeAny): data is Blob {
89+
return data != null
90+
&& typeof data === 'object'
91+
&& typeof data.arrayBuffer === 'function'
92+
&& typeof data.text === 'function';
93+
}

layers/common/utils/common-result.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ export interface PageResult<T = unknown> extends DataResult<T[]> {
5858

5959
export type ApiResult<T = unknown> = DataResult<T> | PageResult<T> | ErrorResult;
6060

61+
/**
62+
* * name: file name
63+
* * blob: file content
64+
*/
65+
export interface FileResult {
66+
name: string;
67+
blob: Blob;
68+
};
69+
6170
export function isDataResult<T>(result?: ApiResult<T> | null): result is DataResult<T> {
6271
return result != null && !('errors' in result);
6372
}
@@ -87,3 +96,16 @@ export function mustPageResult<T>(result?: ApiResult<T> | null): PageResult<T> {
8796
export function isErrorResult(result?: ApiResult<SafeAny> | null): result is ErrorResult {
8897
return (result != null && 'errors' in result);
8998
}
99+
100+
export function isFileResult(result?: SafeAny | null): result is FileResult {
101+
return (result != null && 'name' in result && 'blob' in result);
102+
}
103+
104+
export function mustFileResult(result?: SafeAny | null): FileResult {
105+
if (isFileResult(result)) {
106+
return result;
107+
}
108+
else {
109+
throw new SystemError('require FileResult', result);
110+
}
111+
}

layers/common/utils/typed-fetcher.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ export async function fetchTypedPage<T>(
5353
const result = await fetchTypedResult(fetching, options);
5454
return mustPageResult(result);
5555
}
56+
57+
/**
58+
* downloading file with loading, result, error handling
59+
*/
60+
export async function fetchFileResult(
61+
fetching: Promise<SafeAny> | (() => Promise<SafeAny>),
62+
options: TypedFetchOptions<SafeAny> | Ref<boolean> = globalLoadingStatus,
63+
): Promise<FileResult> {
64+
const result = await fetchTypedResult(fetching, options);
65+
return mustFileResult(result);
66+
}
67+
5668
/**
5769
* fetching any result with loading, result, error handling
5870
*

plays/1-play-spa/server/api/v1/download.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Readable } from 'stream';
2+
import { getQuery } from 'h3';
23

34
// https://nuxt.com/docs/guide/directory-structure/server
45

@@ -17,15 +18,39 @@ import { Readable } from 'stream';
1718
* ```
1819
*/
1920
export default defineEventHandler(async (event) => {
21+
const query = getQuery(event);
22+
const queryValue = query.f ?? query.filename;
23+
const rawFilename = Array.isArray(queryValue) ? queryValue[0] : queryValue;
24+
const filename = rawFilename?.toString().trim();
25+
26+
if (!filename) {
27+
return {
28+
success: false,
29+
errors: [{ message: 'Filename is required' }],
30+
};
31+
}
32+
33+
const content = typeof query.content === 'string' && query.content !== ''
34+
? query.content
35+
: `download:${filename}`;
36+
2037
const stream = new Readable({
2138
read() {
22-
this.push('download.txt');
39+
this.push(content);
2340
this.push(null);
2441
},
2542
});
2643

27-
event.node.res.setHeader('Content-Disposition', 'attachment; filename="download.txt"');
28-
event.node.res.setHeader('Content-Type', 'text/plain');
44+
const hasNonAscii = /[^\x20-\x7E]/.test(filename);
45+
const safeName = filename.replace(/"/g, '%22');
46+
const fallbackName = hasNonAscii ? 'download.bin' : safeName;
47+
const disposition: string[] = ['attachment', `filename="${fallbackName}"`];
48+
if (hasNonAscii) {
49+
disposition.push(`filename*=UTF-8''${encodeURIComponent(filename)}`);
50+
}
51+
52+
event.node.res.setHeader('Content-Disposition', disposition.join('; '));
53+
event.node.res.setHeader('Content-Type', 'application/octet-stream');
2954

3055
return sendStream(event, stream);
3156
});

0 commit comments

Comments
 (0)