google-drive-mock 1.0.12 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +12 -4
- package/dist/batch.js +123 -88
- package/dist/index.js +1 -1
- package/dist/routes/v3.js +54 -4
- package/dist/store.js +1 -1
- package/package.json +1 -1
- package/src/batch.ts +146 -101
- package/src/index.ts +1 -1
- package/src/routes/v3.ts +57 -3
- package/src/store.ts +1 -1
- package/test/batch_insert_download.test.ts +150 -0
- package/test/concurrent_fetch.test.ts +255 -0
- package/test/dates_and_sorting.test.ts +0 -2
- package/test/iterate_changes.test.ts +408 -0
- package/test/parallel_update.test.ts +138 -0
- package/test/url_parameters.test.ts +76 -0
|
@@ -4,8 +4,8 @@ on:
|
|
|
4
4
|
workflow_dispatch:
|
|
5
5
|
inputs:
|
|
6
6
|
version:
|
|
7
|
-
description: 'New Version (e.g. 1.0.0)'
|
|
8
|
-
required:
|
|
7
|
+
description: 'New Version (e.g. 1.0.0, or leave empty for minor)'
|
|
8
|
+
required: false
|
|
9
9
|
type: string
|
|
10
10
|
|
|
11
11
|
jobs:
|
|
@@ -25,7 +25,15 @@ jobs:
|
|
|
25
25
|
cache: 'npm'
|
|
26
26
|
|
|
27
27
|
- run: npm install
|
|
28
|
-
-
|
|
28
|
+
- name: Bump Version
|
|
29
|
+
id: bump
|
|
30
|
+
run: |
|
|
31
|
+
if [ -z "${{ inputs.version }}" ]; then
|
|
32
|
+
VERSION=$(npm version minor --no-git-tag-version)
|
|
33
|
+
else
|
|
34
|
+
VERSION=$(npm version ${{ inputs.version }} --no-git-tag-version)
|
|
35
|
+
fi
|
|
36
|
+
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
|
29
37
|
- run: npm run build
|
|
30
38
|
- run: npm run lint
|
|
31
39
|
- run: npm test
|
|
@@ -38,5 +46,5 @@ jobs:
|
|
|
38
46
|
git config --global user.name 'github-actions[bot]'
|
|
39
47
|
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
|
40
48
|
git add package.json package-lock.json
|
|
41
|
-
git commit -m "release: ${{
|
|
49
|
+
git commit -m "release: ${{ steps.bump.outputs.version }}"
|
|
42
50
|
git push
|
package/dist/batch.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.handleBatchRequest = void 0;
|
|
|
4
4
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
5
5
|
const store_1 = require("./store");
|
|
6
6
|
const handleBatchRequest = (req, res) => {
|
|
7
|
+
// ... (unchanged)
|
|
7
8
|
const contentType = req.headers['content-type'];
|
|
8
9
|
if (!contentType || !contentType.includes('multipart/mixed')) {
|
|
9
10
|
return res.status(400).send('Content-Type must be multipart/mixed');
|
|
@@ -13,7 +14,6 @@ const handleBatchRequest = (req, res) => {
|
|
|
13
14
|
return res.status(400).send('Multipart boundary missing');
|
|
14
15
|
}
|
|
15
16
|
let boundary = boundaryMatch[1];
|
|
16
|
-
// Boundaries in header can be quoted
|
|
17
17
|
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
|
18
18
|
boundary = boundary.substring(1, boundary.length - 1);
|
|
19
19
|
}
|
|
@@ -24,7 +24,7 @@ const handleBatchRequest = (req, res) => {
|
|
|
24
24
|
const parts = parseMultipart(rawBody, boundary);
|
|
25
25
|
const responses = [];
|
|
26
26
|
for (const part of parts) {
|
|
27
|
-
const response = processPart(part);
|
|
27
|
+
const response = processPart(part, req);
|
|
28
28
|
responses.push(response);
|
|
29
29
|
}
|
|
30
30
|
const responseBoundary = `batch_${Math.random().toString(36).substring(2)}`;
|
|
@@ -33,6 +33,127 @@ const handleBatchRequest = (req, res) => {
|
|
|
33
33
|
res.end(responseBody);
|
|
34
34
|
};
|
|
35
35
|
exports.handleBatchRequest = handleBatchRequest;
|
|
36
|
+
// ... parseMultipart unchanged ...
|
|
37
|
+
function processPart(part, req) {
|
|
38
|
+
const fileIdMatch = part.url.match(/\/drive\/v3\/files\/([^/?]+)/);
|
|
39
|
+
const filesListMatch = part.url.match(/\/drive\/v3\/files/);
|
|
40
|
+
const aboutMatch = part.url.match(/\/drive\/v3\/about/);
|
|
41
|
+
// Simple query parser
|
|
42
|
+
const queryIdx = part.url.indexOf('?');
|
|
43
|
+
const query = {};
|
|
44
|
+
if (queryIdx !== -1) {
|
|
45
|
+
const queryStr = part.url.substring(queryIdx + 1);
|
|
46
|
+
queryStr.split('&').forEach(pair => {
|
|
47
|
+
const [key, val] = pair.split('=');
|
|
48
|
+
if (key)
|
|
49
|
+
query[key] = val ? decodeURIComponent(val) : '';
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
// GET File
|
|
54
|
+
if (part.method === 'GET' && fileIdMatch) {
|
|
55
|
+
const fileId = fileIdMatch[1];
|
|
56
|
+
const file = store_1.driveStore.getFile(fileId);
|
|
57
|
+
if (!file)
|
|
58
|
+
return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
59
|
+
if (query['alt'] === 'media') {
|
|
60
|
+
// Return 302 Redirect to download URL
|
|
61
|
+
// We construct a fully qualified URL if possible, or relative.
|
|
62
|
+
// The Mock server is running on some port. We can try relative to the batch endpoint?
|
|
63
|
+
// Or better, we just use the path since most clients handle it.
|
|
64
|
+
// Real API returns absolute URL.
|
|
65
|
+
// We'll mimic Real API structure roughly: /drive/v3/files/{id}?alt=media
|
|
66
|
+
// But we need to serve the content on that GET route.
|
|
67
|
+
// `src/routes/v3.ts` already handles `GET /drive/v3/files/:id?alt=media`.
|
|
68
|
+
// We need the host. `req.headers.host` might work if passed through.
|
|
69
|
+
const host = req.headers.host || 'localhost';
|
|
70
|
+
const protocol = req.protocol || 'http';
|
|
71
|
+
const location = `${protocol}://${host}/drive/v3/files/${fileId}?alt=media`;
|
|
72
|
+
return {
|
|
73
|
+
contentId: part.contentId,
|
|
74
|
+
statusCode: 302,
|
|
75
|
+
headers: { 'Location': location },
|
|
76
|
+
body: null // No body for 302 usually, or empty
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { contentId: part.contentId, statusCode: 200, body: file };
|
|
80
|
+
}
|
|
81
|
+
// GET Files List
|
|
82
|
+
if (part.method === 'GET' && filesListMatch && !fileIdMatch) {
|
|
83
|
+
const files = store_1.driveStore.listFiles();
|
|
84
|
+
return {
|
|
85
|
+
contentId: part.contentId,
|
|
86
|
+
statusCode: 200,
|
|
87
|
+
body: {
|
|
88
|
+
kind: "drive#fileList",
|
|
89
|
+
incompleteSearch: false,
|
|
90
|
+
files: files
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// GET About
|
|
95
|
+
if (part.method === 'GET' && aboutMatch) {
|
|
96
|
+
const about = store_1.driveStore.getAbout();
|
|
97
|
+
return {
|
|
98
|
+
contentId: part.contentId,
|
|
99
|
+
statusCode: 200,
|
|
100
|
+
body: Object.assign({ kind: "drive#about" }, about)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// POST Create File (Standard)
|
|
104
|
+
if (part.method === 'POST' && filesListMatch) {
|
|
105
|
+
if (!part.body || !part.body.name) {
|
|
106
|
+
return { contentId: part.contentId, statusCode: 400, body: { error: { code: 400, message: 'Name required' } } };
|
|
107
|
+
}
|
|
108
|
+
const newFile = store_1.driveStore.createFile({
|
|
109
|
+
name: part.body.name,
|
|
110
|
+
mimeType: part.body.mimeType,
|
|
111
|
+
parents: part.body.parents
|
|
112
|
+
});
|
|
113
|
+
return { contentId: part.contentId, statusCode: 200, body: newFile };
|
|
114
|
+
}
|
|
115
|
+
if (part.method === 'PATCH' && fileIdMatch) {
|
|
116
|
+
const fileId = fileIdMatch[1];
|
|
117
|
+
const updated = store_1.driveStore.updateFile(fileId, part.body);
|
|
118
|
+
if (!updated)
|
|
119
|
+
return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
120
|
+
return { contentId: part.contentId, statusCode: 200, body: updated };
|
|
121
|
+
}
|
|
122
|
+
if (part.method === 'DELETE' && fileIdMatch) {
|
|
123
|
+
const fileId = fileIdMatch[1];
|
|
124
|
+
const deleted = store_1.driveStore.deleteFile(fileId);
|
|
125
|
+
if (!deleted)
|
|
126
|
+
return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
127
|
+
return { contentId: part.contentId, statusCode: 204 }; // No body
|
|
128
|
+
}
|
|
129
|
+
return { contentId: part.contentId, statusCode: 404, body: { error: { message: "Not handler found for batch request url " + part.url } } };
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return { contentId: part.contentId, statusCode: 500, body: { error: { message: e.message } } };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function buildMultipartResponse(responses, boundary) {
|
|
136
|
+
let output = '';
|
|
137
|
+
for (const response of responses) {
|
|
138
|
+
output += `--${boundary}\r\n`;
|
|
139
|
+
output += `Content-Type: application/http\r\n`;
|
|
140
|
+
output += `Content-ID: ${response.contentId}\r\n\r\n`;
|
|
141
|
+
output += `HTTP/1.1 ${response.statusCode} ${(response.statusCode === 200 ? 'OK' : (response.statusCode === 302 ? 'Found' : ''))}\r\n`;
|
|
142
|
+
output += `Content-Type: application/json; charset=UTF-8\r\n`; // Always adding this might be weird for 302/204 but ok for now
|
|
143
|
+
if (response.headers) {
|
|
144
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
145
|
+
output += `${key}: ${value}\r\n`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
output += `\r\n`; // End headers
|
|
149
|
+
if (response.body) {
|
|
150
|
+
output += JSON.stringify(response.body) + '\r\n';
|
|
151
|
+
}
|
|
152
|
+
output += '\r\n';
|
|
153
|
+
}
|
|
154
|
+
output += `--${boundary}--`;
|
|
155
|
+
return output;
|
|
156
|
+
}
|
|
36
157
|
function parseMultipart(body, boundary) {
|
|
37
158
|
const parts = [];
|
|
38
159
|
// Split by --boundary
|
|
@@ -148,89 +269,3 @@ function parseMultipart(body, boundary) {
|
|
|
148
269
|
}
|
|
149
270
|
return parts;
|
|
150
271
|
}
|
|
151
|
-
function processPart(part) {
|
|
152
|
-
// Simple logic dispatch
|
|
153
|
-
// We only support /drive/v3/files operations basically
|
|
154
|
-
// Helper to match URL (Simplified for mock)
|
|
155
|
-
const fileIdMatch = part.url.match(/\/drive\/v3\/files\/([^/?]+)/);
|
|
156
|
-
const filesListMatch = part.url.match(/\/drive\/v3\/files/); // Matches /drive/v3/files?q=... or just .../files
|
|
157
|
-
const aboutMatch = part.url.match(/\/drive\/v3\/about/);
|
|
158
|
-
try {
|
|
159
|
-
// GET File
|
|
160
|
-
if (part.method === 'GET' && fileIdMatch) {
|
|
161
|
-
const fileId = fileIdMatch[1];
|
|
162
|
-
const file = store_1.driveStore.getFile(fileId);
|
|
163
|
-
if (!file)
|
|
164
|
-
return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
165
|
-
return { contentId: part.contentId, statusCode: 200, body: file };
|
|
166
|
-
}
|
|
167
|
-
// GET Files List
|
|
168
|
-
if (part.method === 'GET' && filesListMatch && !fileIdMatch) {
|
|
169
|
-
const files = store_1.driveStore.listFiles();
|
|
170
|
-
return {
|
|
171
|
-
contentId: part.contentId,
|
|
172
|
-
statusCode: 200,
|
|
173
|
-
body: {
|
|
174
|
-
kind: "drive#fileList",
|
|
175
|
-
incompleteSearch: false,
|
|
176
|
-
files: files
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
// GET About
|
|
181
|
-
if (part.method === 'GET' && aboutMatch) {
|
|
182
|
-
const about = store_1.driveStore.getAbout();
|
|
183
|
-
return {
|
|
184
|
-
contentId: part.contentId,
|
|
185
|
-
statusCode: 200,
|
|
186
|
-
body: Object.assign({ kind: "drive#about" }, about)
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
// POST Create File
|
|
190
|
-
if (part.method === 'POST' && filesListMatch) {
|
|
191
|
-
if (!part.body || !part.body.name) {
|
|
192
|
-
return { contentId: part.contentId, statusCode: 400, body: { error: { code: 400, message: 'Name required' } } };
|
|
193
|
-
}
|
|
194
|
-
const newFile = store_1.driveStore.createFile({
|
|
195
|
-
name: part.body.name,
|
|
196
|
-
mimeType: part.body.mimeType,
|
|
197
|
-
parents: part.body.parents
|
|
198
|
-
});
|
|
199
|
-
return { contentId: part.contentId, statusCode: 200, body: newFile };
|
|
200
|
-
}
|
|
201
|
-
if (part.method === 'PATCH' && fileIdMatch) {
|
|
202
|
-
const fileId = fileIdMatch[1];
|
|
203
|
-
const updated = store_1.driveStore.updateFile(fileId, part.body);
|
|
204
|
-
if (!updated)
|
|
205
|
-
return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
206
|
-
return { contentId: part.contentId, statusCode: 200, body: updated };
|
|
207
|
-
}
|
|
208
|
-
if (part.method === 'DELETE' && fileIdMatch) {
|
|
209
|
-
const fileId = fileIdMatch[1];
|
|
210
|
-
const deleted = store_1.driveStore.deleteFile(fileId);
|
|
211
|
-
if (!deleted)
|
|
212
|
-
return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
213
|
-
return { contentId: part.contentId, statusCode: 204 }; // No body
|
|
214
|
-
}
|
|
215
|
-
return { contentId: part.contentId, statusCode: 404, body: { error: { message: "Not handler found for batch request url " + part.url } } };
|
|
216
|
-
}
|
|
217
|
-
catch (e) {
|
|
218
|
-
return { contentId: part.contentId, statusCode: 500, body: { error: { message: e.message } } };
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
function buildMultipartResponse(responses, boundary) {
|
|
222
|
-
let output = '';
|
|
223
|
-
for (const response of responses) {
|
|
224
|
-
output += `--${boundary}\r\n`;
|
|
225
|
-
output += `Content-Type: application/http\r\n`;
|
|
226
|
-
output += `Content-ID: ${response.contentId}\r\n\r\n`;
|
|
227
|
-
output += `HTTP/1.1 ${response.statusCode} OK\r\n`; // Simplified status text
|
|
228
|
-
output += `Content-Type: application/json; charset=UTF-8\r\n\r\n`;
|
|
229
|
-
if (response.body) {
|
|
230
|
-
output += JSON.stringify(response.body) + '\r\n';
|
|
231
|
-
}
|
|
232
|
-
output += '\r\n';
|
|
233
|
-
}
|
|
234
|
-
output += `--${boundary}--`;
|
|
235
|
-
return output;
|
|
236
|
-
}
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ const createApp = (config = {}) => {
|
|
|
26
26
|
}
|
|
27
27
|
const app = (0, express_1.default)();
|
|
28
28
|
app.use((0, cors_1.default)({
|
|
29
|
-
exposedHeaders: ['ETag']
|
|
29
|
+
exposedHeaders: ['ETag', 'Date', 'Content-Length']
|
|
30
30
|
}));
|
|
31
31
|
app.set('etag', false); // Disable default ETag generation to match Real API behavior
|
|
32
32
|
app.use((req, res, next) => __awaiter(void 0, void 0, void 0, function* () {
|
package/dist/routes/v3.js
CHANGED
|
@@ -22,7 +22,7 @@ const createV3Router = () => {
|
|
|
22
22
|
// Enhanced query parser for Mock
|
|
23
23
|
// Recursive function to handle nested OR/AND logic with parens
|
|
24
24
|
const evaluateQuery = (queryStr, file) => {
|
|
25
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
|
25
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
|
|
26
26
|
const str = queryStr.trim();
|
|
27
27
|
if (!str)
|
|
28
28
|
return true;
|
|
@@ -136,6 +136,10 @@ const createV3Router = () => {
|
|
|
136
136
|
const timeStr = (_k = part.match(/modifiedTime < '(.*)'/)) === null || _k === void 0 ? void 0 : _k[1];
|
|
137
137
|
return !!(timeStr && new Date(file.modifiedTime) < new Date(timeStr));
|
|
138
138
|
}
|
|
139
|
+
if (part.startsWith("modifiedTime = '")) {
|
|
140
|
+
const timeStr = (_l = part.match(/modifiedTime = '(.*)'/)) === null || _l === void 0 ? void 0 : _l[1];
|
|
141
|
+
return !!(timeStr && new Date(file.modifiedTime).toISOString() === new Date(timeStr).toISOString());
|
|
142
|
+
}
|
|
139
143
|
// Fallback / Unknown
|
|
140
144
|
return true;
|
|
141
145
|
};
|
|
@@ -172,10 +176,33 @@ const createV3Router = () => {
|
|
|
172
176
|
return 0;
|
|
173
177
|
});
|
|
174
178
|
}
|
|
179
|
+
// Pagination
|
|
180
|
+
const pageSize = req.query.pageSize ? parseInt(req.query.pageSize, 10) : 100; // Default 100
|
|
181
|
+
let skip = 0;
|
|
182
|
+
if (req.query.pageToken) {
|
|
183
|
+
try {
|
|
184
|
+
const tokenJson = Buffer.from(req.query.pageToken, 'base64').toString('utf-8');
|
|
185
|
+
const tokenData = JSON.parse(tokenJson);
|
|
186
|
+
if (typeof tokenData.skip === 'number') {
|
|
187
|
+
skip = tokenData.skip;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (_a) {
|
|
191
|
+
// Ignore invalid token, start from 0
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const totalFiles = files.length;
|
|
195
|
+
const resultFiles = files.slice(skip, skip + pageSize);
|
|
196
|
+
let nextPageToken;
|
|
197
|
+
if (skip + pageSize < totalFiles) {
|
|
198
|
+
const nextSkip = skip + pageSize;
|
|
199
|
+
nextPageToken = Buffer.from(JSON.stringify({ skip: nextSkip })).toString('base64');
|
|
200
|
+
}
|
|
175
201
|
res.json({
|
|
176
202
|
kind: "drive#fileList",
|
|
177
203
|
incompleteSearch: false,
|
|
178
|
-
files:
|
|
204
|
+
files: resultFiles,
|
|
205
|
+
nextPageToken
|
|
179
206
|
});
|
|
180
207
|
});
|
|
181
208
|
// Changes: Get Start Page Token
|
|
@@ -419,8 +446,10 @@ const createV3Router = () => {
|
|
|
419
446
|
res.status(400).send("Invalid file ID");
|
|
420
447
|
return;
|
|
421
448
|
}
|
|
422
|
-
const updates = req.body;
|
|
423
|
-
|
|
449
|
+
const updates = req.body || {};
|
|
450
|
+
const hasBody = Object.keys(updates).length > 0;
|
|
451
|
+
const hasQueryParams = req.query.addParents || req.query.removeParents;
|
|
452
|
+
if (!hasBody && !hasQueryParams) {
|
|
424
453
|
res.status(400).json({ error: { code: 400, message: "Bad Request: No updates provided" } });
|
|
425
454
|
return;
|
|
426
455
|
}
|
|
@@ -429,6 +458,27 @@ const createV3Router = () => {
|
|
|
429
458
|
res.status(404).json({ error: { code: 404, message: "File not found" } });
|
|
430
459
|
return;
|
|
431
460
|
}
|
|
461
|
+
const addParents = req.query.addParents;
|
|
462
|
+
if (addParents) {
|
|
463
|
+
const parentsToAdd = addParents.split(',');
|
|
464
|
+
const currentParents = updatedFile.parents || [];
|
|
465
|
+
const newParents = [...new Set([...currentParents, ...parentsToAdd])]; // Union
|
|
466
|
+
// Update the file with new parents
|
|
467
|
+
const result = store_1.driveStore.updateFile(fileId, { parents: newParents });
|
|
468
|
+
if (result) {
|
|
469
|
+
Object.assign(updatedFile, result);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const removeParents = req.query.removeParents;
|
|
473
|
+
if (removeParents) {
|
|
474
|
+
const parentsToRemove = removeParents.split(',');
|
|
475
|
+
const currentParents = updatedFile.parents || [];
|
|
476
|
+
const newParents = currentParents.filter(p => !parentsToRemove.includes(p));
|
|
477
|
+
const result = store_1.driveStore.updateFile(fileId, { parents: newParents });
|
|
478
|
+
if (result) {
|
|
479
|
+
Object.assign(updatedFile, result);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
432
482
|
res.json(updatedFile);
|
|
433
483
|
});
|
|
434
484
|
// Files: Delete
|
package/dist/store.js
CHANGED
|
@@ -81,7 +81,7 @@ class DriveStore {
|
|
|
81
81
|
}
|
|
82
82
|
// Merge updates and increment version
|
|
83
83
|
const newVersion = file.version + 1;
|
|
84
|
-
const updatedFile = Object.assign(Object.assign(Object.assign(Object.assign({}, file), updates), statsUpdates), { version: newVersion, etag: String(newVersion), modifiedTime: new Date().toISOString() });
|
|
84
|
+
const updatedFile = Object.assign(Object.assign(Object.assign(Object.assign({}, file), updates), statsUpdates), { version: newVersion, etag: String(newVersion), modifiedTime: updates.modifiedTime || new Date().toISOString() });
|
|
85
85
|
this.files.set(id, updatedFile);
|
|
86
86
|
this.addChange(updatedFile);
|
|
87
87
|
return updatedFile;
|
package/package.json
CHANGED
package/src/batch.ts
CHANGED
|
@@ -10,13 +10,16 @@ interface BatchPart {
|
|
|
10
10
|
body?: any;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
interface BatchResponse {
|
|
14
15
|
contentId: string;
|
|
15
16
|
statusCode: number;
|
|
17
|
+
headers?: Record<string, string>;
|
|
16
18
|
body?: any;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export const handleBatchRequest = (req: Request, res: Response) => {
|
|
22
|
+
// ... (unchanged)
|
|
20
23
|
const contentType = req.headers['content-type'];
|
|
21
24
|
if (!contentType || !contentType.includes('multipart/mixed')) {
|
|
22
25
|
return res.status(400).send('Content-Type must be multipart/mixed');
|
|
@@ -27,7 +30,6 @@ export const handleBatchRequest = (req: Request, res: Response) => {
|
|
|
27
30
|
return res.status(400).send('Multipart boundary missing');
|
|
28
31
|
}
|
|
29
32
|
let boundary = boundaryMatch[1];
|
|
30
|
-
// Boundaries in header can be quoted
|
|
31
33
|
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
|
32
34
|
boundary = boundary.substring(1, boundary.length - 1);
|
|
33
35
|
}
|
|
@@ -41,7 +43,7 @@ export const handleBatchRequest = (req: Request, res: Response) => {
|
|
|
41
43
|
const responses: BatchResponse[] = [];
|
|
42
44
|
|
|
43
45
|
for (const part of parts) {
|
|
44
|
-
const response = processPart(part);
|
|
46
|
+
const response = processPart(part, req);
|
|
45
47
|
responses.push(response);
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -52,6 +54,148 @@ export const handleBatchRequest = (req: Request, res: Response) => {
|
|
|
52
54
|
res.end(responseBody);
|
|
53
55
|
};
|
|
54
56
|
|
|
57
|
+
// ... parseMultipart unchanged ...
|
|
58
|
+
|
|
59
|
+
function processPart(part: BatchPart, req: Request): BatchResponse {
|
|
60
|
+
const fileIdMatch = part.url.match(/\/drive\/v3\/files\/([^/?]+)/);
|
|
61
|
+
const filesListMatch = part.url.match(/\/drive\/v3\/files/);
|
|
62
|
+
const aboutMatch = part.url.match(/\/drive\/v3\/about/);
|
|
63
|
+
|
|
64
|
+
// Simple query parser
|
|
65
|
+
const queryIdx = part.url.indexOf('?');
|
|
66
|
+
const query: Record<string, string> = {};
|
|
67
|
+
if (queryIdx !== -1) {
|
|
68
|
+
const queryStr = part.url.substring(queryIdx + 1);
|
|
69
|
+
queryStr.split('&').forEach(pair => {
|
|
70
|
+
const [key, val] = pair.split('=');
|
|
71
|
+
if (key) query[key] = val ? decodeURIComponent(val) : '';
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
// GET File
|
|
77
|
+
if (part.method === 'GET' && fileIdMatch) {
|
|
78
|
+
const fileId = fileIdMatch[1];
|
|
79
|
+
const file = driveStore.getFile(fileId);
|
|
80
|
+
|
|
81
|
+
if (!file) return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
82
|
+
|
|
83
|
+
if (query['alt'] === 'media') {
|
|
84
|
+
// Return 302 Redirect to download URL
|
|
85
|
+
// We construct a fully qualified URL if possible, or relative.
|
|
86
|
+
// The Mock server is running on some port. We can try relative to the batch endpoint?
|
|
87
|
+
// Or better, we just use the path since most clients handle it.
|
|
88
|
+
// Real API returns absolute URL.
|
|
89
|
+
// We'll mimic Real API structure roughly: /drive/v3/files/{id}?alt=media
|
|
90
|
+
// But we need to serve the content on that GET route.
|
|
91
|
+
// `src/routes/v3.ts` already handles `GET /drive/v3/files/:id?alt=media`.
|
|
92
|
+
|
|
93
|
+
// We need the host. `req.headers.host` might work if passed through.
|
|
94
|
+
const host = req.headers.host || 'localhost';
|
|
95
|
+
const protocol = req.protocol || 'http';
|
|
96
|
+
const location = `${protocol}://${host}/drive/v3/files/${fileId}?alt=media`;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
contentId: part.contentId,
|
|
100
|
+
statusCode: 302,
|
|
101
|
+
headers: { 'Location': location },
|
|
102
|
+
body: null // No body for 302 usually, or empty
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { contentId: part.contentId, statusCode: 200, body: file };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// GET Files List
|
|
110
|
+
if (part.method === 'GET' && filesListMatch && !fileIdMatch) {
|
|
111
|
+
const files = driveStore.listFiles();
|
|
112
|
+
return {
|
|
113
|
+
contentId: part.contentId,
|
|
114
|
+
statusCode: 200,
|
|
115
|
+
body: {
|
|
116
|
+
kind: "drive#fileList",
|
|
117
|
+
incompleteSearch: false,
|
|
118
|
+
files: files
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// GET About
|
|
124
|
+
if (part.method === 'GET' && aboutMatch) {
|
|
125
|
+
const about = driveStore.getAbout();
|
|
126
|
+
return {
|
|
127
|
+
contentId: part.contentId,
|
|
128
|
+
statusCode: 200,
|
|
129
|
+
body: {
|
|
130
|
+
kind: "drive#about",
|
|
131
|
+
...about
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
// POST Create File (Standard)
|
|
138
|
+
if (part.method === 'POST' && filesListMatch) {
|
|
139
|
+
if (!part.body || !part.body.name) {
|
|
140
|
+
return { contentId: part.contentId, statusCode: 400, body: { error: { code: 400, message: 'Name required' } } };
|
|
141
|
+
}
|
|
142
|
+
const newFile = driveStore.createFile({
|
|
143
|
+
name: part.body.name,
|
|
144
|
+
mimeType: part.body.mimeType,
|
|
145
|
+
parents: part.body.parents
|
|
146
|
+
});
|
|
147
|
+
return { contentId: part.contentId, statusCode: 200, body: newFile };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (part.method === 'PATCH' && fileIdMatch) {
|
|
151
|
+
const fileId = fileIdMatch[1];
|
|
152
|
+
const updated = driveStore.updateFile(fileId, part.body);
|
|
153
|
+
if (!updated) return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
154
|
+
return { contentId: part.contentId, statusCode: 200, body: updated };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (part.method === 'DELETE' && fileIdMatch) {
|
|
158
|
+
const fileId = fileIdMatch[1];
|
|
159
|
+
const deleted = driveStore.deleteFile(fileId);
|
|
160
|
+
if (!deleted) return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
161
|
+
return { contentId: part.contentId, statusCode: 204 }; // No body
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { contentId: part.contentId, statusCode: 404, body: { error: { message: "Not handler found for batch request url " + part.url } } };
|
|
165
|
+
|
|
166
|
+
} catch (e: any) {
|
|
167
|
+
return { contentId: part.contentId, statusCode: 500, body: { error: { message: e.message } } };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildMultipartResponse(responses: BatchResponse[], boundary: string): string {
|
|
172
|
+
let output = '';
|
|
173
|
+
|
|
174
|
+
for (const response of responses) {
|
|
175
|
+
output += `--${boundary}\r\n`;
|
|
176
|
+
output += `Content-Type: application/http\r\n`;
|
|
177
|
+
output += `Content-ID: ${response.contentId}\r\n\r\n`;
|
|
178
|
+
|
|
179
|
+
output += `HTTP/1.1 ${response.statusCode} ${(response.statusCode === 200 ? 'OK' : (response.statusCode === 302 ? 'Found' : ''))}\r\n`;
|
|
180
|
+
output += `Content-Type: application/json; charset=UTF-8\r\n`; // Always adding this might be weird for 302/204 but ok for now
|
|
181
|
+
|
|
182
|
+
if (response.headers) {
|
|
183
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
184
|
+
output += `${key}: ${value}\r\n`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
output += `\r\n`; // End headers
|
|
188
|
+
|
|
189
|
+
if (response.body) {
|
|
190
|
+
output += JSON.stringify(response.body) + '\r\n';
|
|
191
|
+
}
|
|
192
|
+
output += '\r\n';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
output += `--${boundary}--`;
|
|
196
|
+
return output;
|
|
197
|
+
}
|
|
198
|
+
|
|
55
199
|
function parseMultipart(body: string, boundary: string): BatchPart[] {
|
|
56
200
|
const parts: BatchPart[] = [];
|
|
57
201
|
// Split by --boundary
|
|
@@ -185,102 +329,3 @@ function parseMultipart(body: string, boundary: string): BatchPart[] {
|
|
|
185
329
|
return parts;
|
|
186
330
|
}
|
|
187
331
|
|
|
188
|
-
function processPart(part: BatchPart): BatchResponse {
|
|
189
|
-
// Simple logic dispatch
|
|
190
|
-
// We only support /drive/v3/files operations basically
|
|
191
|
-
|
|
192
|
-
// Helper to match URL (Simplified for mock)
|
|
193
|
-
const fileIdMatch = part.url.match(/\/drive\/v3\/files\/([^/?]+)/);
|
|
194
|
-
const filesListMatch = part.url.match(/\/drive\/v3\/files/); // Matches /drive/v3/files?q=... or just .../files
|
|
195
|
-
const aboutMatch = part.url.match(/\/drive\/v3\/about/);
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
// GET File
|
|
199
|
-
if (part.method === 'GET' && fileIdMatch) {
|
|
200
|
-
const fileId = fileIdMatch[1];
|
|
201
|
-
const file = driveStore.getFile(fileId);
|
|
202
|
-
if (!file) return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
203
|
-
return { contentId: part.contentId, statusCode: 200, body: file };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// GET Files List
|
|
207
|
-
if (part.method === 'GET' && filesListMatch && !fileIdMatch) {
|
|
208
|
-
const files = driveStore.listFiles();
|
|
209
|
-
return {
|
|
210
|
-
contentId: part.contentId,
|
|
211
|
-
statusCode: 200,
|
|
212
|
-
body: {
|
|
213
|
-
kind: "drive#fileList",
|
|
214
|
-
incompleteSearch: false,
|
|
215
|
-
files: files
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// GET About
|
|
221
|
-
if (part.method === 'GET' && aboutMatch) {
|
|
222
|
-
const about = driveStore.getAbout();
|
|
223
|
-
return {
|
|
224
|
-
contentId: part.contentId,
|
|
225
|
-
statusCode: 200,
|
|
226
|
-
body: {
|
|
227
|
-
kind: "drive#about",
|
|
228
|
-
...about
|
|
229
|
-
}
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// POST Create File
|
|
234
|
-
if (part.method === 'POST' && filesListMatch) {
|
|
235
|
-
if (!part.body || !part.body.name) {
|
|
236
|
-
return { contentId: part.contentId, statusCode: 400, body: { error: { code: 400, message: 'Name required' } } };
|
|
237
|
-
}
|
|
238
|
-
const newFile = driveStore.createFile({
|
|
239
|
-
name: part.body.name,
|
|
240
|
-
mimeType: part.body.mimeType,
|
|
241
|
-
parents: part.body.parents
|
|
242
|
-
});
|
|
243
|
-
return { contentId: part.contentId, statusCode: 200, body: newFile };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (part.method === 'PATCH' && fileIdMatch) {
|
|
247
|
-
const fileId = fileIdMatch[1];
|
|
248
|
-
const updated = driveStore.updateFile(fileId, part.body);
|
|
249
|
-
if (!updated) return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
250
|
-
return { contentId: part.contentId, statusCode: 200, body: updated };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (part.method === 'DELETE' && fileIdMatch) {
|
|
254
|
-
const fileId = fileIdMatch[1];
|
|
255
|
-
const deleted = driveStore.deleteFile(fileId);
|
|
256
|
-
if (!deleted) return { contentId: part.contentId, statusCode: 404, body: { error: { code: 404, message: 'File not found' } } };
|
|
257
|
-
return { contentId: part.contentId, statusCode: 204 }; // No body
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
return { contentId: part.contentId, statusCode: 404, body: { error: { message: "Not handler found for batch request url " + part.url } } };
|
|
261
|
-
|
|
262
|
-
} catch (e: any) {
|
|
263
|
-
return { contentId: part.contentId, statusCode: 500, body: { error: { message: e.message } } };
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function buildMultipartResponse(responses: BatchResponse[], boundary: string): string {
|
|
268
|
-
let output = '';
|
|
269
|
-
|
|
270
|
-
for (const response of responses) {
|
|
271
|
-
output += `--${boundary}\r\n`;
|
|
272
|
-
output += `Content-Type: application/http\r\n`;
|
|
273
|
-
output += `Content-ID: ${response.contentId}\r\n\r\n`;
|
|
274
|
-
|
|
275
|
-
output += `HTTP/1.1 ${response.statusCode} OK\r\n`; // Simplified status text
|
|
276
|
-
output += `Content-Type: application/json; charset=UTF-8\r\n\r\n`;
|
|
277
|
-
|
|
278
|
-
if (response.body) {
|
|
279
|
-
output += JSON.stringify(response.body) + '\r\n';
|
|
280
|
-
}
|
|
281
|
-
output += '\r\n';
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
output += `--${boundary}--`;
|
|
285
|
-
return output;
|
|
286
|
-
}
|