google-drive-mock 0.0.1
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/.ENV_EXAMPLE +15 -0
- package/.aiexclude +6 -0
- package/.github/workflows/ci.yml +41 -0
- package/.github/workflows/release.yml +24 -0
- package/AGENTS.md +9 -0
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/dist/batch.d.ts +2 -0
- package/dist/batch.js +236 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +186 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.js +58 -0
- package/eslint.config.mjs +15 -0
- package/examples/google-login.html +196 -0
- package/examples/serve-login.ts +11 -0
- package/google-drive-mock.png +0 -0
- package/package.json +64 -0
- package/specs/googleapiscom-drive.json +1471 -0
- package/specs/openapi.json +7106 -0
- package/specs/openapi.yaml +4748 -0
- package/src/batch.ts +286 -0
- package/src/index.ts +219 -0
- package/src/store.ts +85 -0
- package/test/basics.test.ts +201 -0
- package/test/config.ts +193 -0
- package/test/latency.test.ts +65 -0
- package/test/routines.test.ts +224 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +17 -0
package/src/batch.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { driveStore } from './store';
|
|
3
|
+
import { Request, Response } from 'express';
|
|
4
|
+
|
|
5
|
+
interface BatchPart {
|
|
6
|
+
contentId: string;
|
|
7
|
+
method: string;
|
|
8
|
+
url: string;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body?: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface BatchResponse {
|
|
14
|
+
contentId: string;
|
|
15
|
+
statusCode: number;
|
|
16
|
+
body?: any;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const handleBatchRequest = (req: Request, res: Response) => {
|
|
20
|
+
const contentType = req.headers['content-type'];
|
|
21
|
+
if (!contentType || !contentType.includes('multipart/mixed')) {
|
|
22
|
+
return res.status(400).send('Content-Type must be multipart/mixed');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const boundaryMatch = contentType.match(/boundary=(.+)/);
|
|
26
|
+
if (!boundaryMatch) {
|
|
27
|
+
return res.status(400).send('Multipart boundary missing');
|
|
28
|
+
}
|
|
29
|
+
let boundary = boundaryMatch[1];
|
|
30
|
+
// Boundaries in header can be quoted
|
|
31
|
+
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
|
32
|
+
boundary = boundary.substring(1, boundary.length - 1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rawBody = req.body;
|
|
36
|
+
if (typeof rawBody !== 'string') {
|
|
37
|
+
return res.status(400).send('Body parsing failed');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parts = parseMultipart(rawBody, boundary);
|
|
41
|
+
const responses: BatchResponse[] = [];
|
|
42
|
+
|
|
43
|
+
for (const part of parts) {
|
|
44
|
+
const response = processPart(part);
|
|
45
|
+
responses.push(response);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const responseBoundary = `batch_${Math.random().toString(36).substring(2)}`;
|
|
49
|
+
const responseBody = buildMultipartResponse(responses, responseBoundary);
|
|
50
|
+
|
|
51
|
+
res.set('Content-Type', `multipart/mixed; boundary=${responseBoundary}`);
|
|
52
|
+
res.end(responseBody);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function parseMultipart(body: string, boundary: string): BatchPart[] {
|
|
56
|
+
const parts: BatchPart[] = [];
|
|
57
|
+
// Split by --boundary
|
|
58
|
+
// Note: The last part ends with --boundary--
|
|
59
|
+
const rawParts = body.split(`--${boundary}`);
|
|
60
|
+
|
|
61
|
+
for (const rawPart of rawParts) {
|
|
62
|
+
// Skip empty or end parts
|
|
63
|
+
if (rawPart.trim() === '' || rawPart.trim() === '--') continue;
|
|
64
|
+
|
|
65
|
+
// Parse outer headers
|
|
66
|
+
const sections = rawPart.trim().split(/\r?\n\r?\n/);
|
|
67
|
+
const headersSection = sections[0];
|
|
68
|
+
const rest = sections.slice(1);
|
|
69
|
+
|
|
70
|
+
let contentId = '';
|
|
71
|
+
const headerLines = headersSection.split(/\r?\n/);
|
|
72
|
+
for (const line of headerLines) {
|
|
73
|
+
if (line.toLowerCase().startsWith('content-id:')) {
|
|
74
|
+
contentId = line.split(':')[1].trim();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const httpContent = rest.join('\r\n\r\n'); // Reconstruct body if multiple parts?
|
|
79
|
+
|
|
80
|
+
if (!httpContent && sections.length < 2) continue; // No body?
|
|
81
|
+
|
|
82
|
+
// Ideally, httpContent is the rest.
|
|
83
|
+
// But if we split by double newline, we might have split the inner body too.
|
|
84
|
+
// Better: Find first double newline index manually.
|
|
85
|
+
|
|
86
|
+
// ... (Rewriting loop to be safer)
|
|
87
|
+
|
|
88
|
+
const firstDoubleNewline = rawPart.indexOf('\r\n\r\n');
|
|
89
|
+
const firstDoubleNewlineLF = rawPart.indexOf('\n\n');
|
|
90
|
+
|
|
91
|
+
let splitIndex = -1;
|
|
92
|
+
let splitLen = 0;
|
|
93
|
+
|
|
94
|
+
if (firstDoubleNewline !== -1) {
|
|
95
|
+
splitIndex = firstDoubleNewline;
|
|
96
|
+
splitLen = 4;
|
|
97
|
+
} else if (firstDoubleNewlineLF !== -1) {
|
|
98
|
+
splitIndex = firstDoubleNewlineLF;
|
|
99
|
+
splitLen = 2;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (splitIndex === -1) continue;
|
|
103
|
+
|
|
104
|
+
const headersStr = rawPart.substring(0, splitIndex).trim();
|
|
105
|
+
const bodyStr = rawPart.substring(splitIndex + splitLen); // No trim on body?
|
|
106
|
+
|
|
107
|
+
// Parse outer headers
|
|
108
|
+
const hLines = headersStr.split(/\r?\n/);
|
|
109
|
+
for (const line of hLines) {
|
|
110
|
+
if (line.toLowerCase().startsWith('content-id:')) {
|
|
111
|
+
contentId = line.split(':')[1].trim();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!bodyStr) continue;
|
|
116
|
+
|
|
117
|
+
// Inner HTTP part
|
|
118
|
+
// Same logic for inner split
|
|
119
|
+
const innerSplitIndexCRLF = bodyStr.indexOf('\r\n\r\n');
|
|
120
|
+
const innerSplitIndexLF = bodyStr.indexOf('\n\n');
|
|
121
|
+
|
|
122
|
+
let innerSplitIndex = -1;
|
|
123
|
+
let innerSplitLen = 0;
|
|
124
|
+
|
|
125
|
+
if (innerSplitIndexCRLF !== -1) {
|
|
126
|
+
innerSplitIndex = innerSplitIndexCRLF;
|
|
127
|
+
innerSplitLen = 4;
|
|
128
|
+
} else if (innerSplitIndexLF !== -1 && (innerSplitIndexCRLF === -1 || innerSplitIndexLF < innerSplitIndexCRLF)) {
|
|
129
|
+
innerSplitIndex = innerSplitIndexLF;
|
|
130
|
+
innerSplitLen = 2;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// If NO header terminator found in inner body, maybe no headers? (But request line exists)
|
|
134
|
+
// Request line is mandatory.
|
|
135
|
+
|
|
136
|
+
let requestLine = '';
|
|
137
|
+
let innerHeadersStr = '';
|
|
138
|
+
let httpBody = '';
|
|
139
|
+
|
|
140
|
+
if (innerSplitIndex !== -1) {
|
|
141
|
+
const head = bodyStr.substring(0, innerSplitIndex);
|
|
142
|
+
httpBody = bodyStr.substring(innerSplitIndex + innerSplitLen);
|
|
143
|
+
const lines = head.split(/\r?\n/);
|
|
144
|
+
requestLine = lines[0];
|
|
145
|
+
innerHeadersStr = lines.slice(1).join('\n');
|
|
146
|
+
} else {
|
|
147
|
+
// Maybe no body? Just headers?
|
|
148
|
+
const lines = bodyStr.trim().split(/\r?\n/);
|
|
149
|
+
requestLine = lines[0];
|
|
150
|
+
innerHeadersStr = lines.slice(1).join('\n');
|
|
151
|
+
httpBody = '';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const [method, url] = requestLine.split(' ');
|
|
155
|
+
|
|
156
|
+
// Parse inner headers
|
|
157
|
+
const headers: Record<string, string> = {};
|
|
158
|
+
const innerHLines = innerHeadersStr.split(/\r?\n/);
|
|
159
|
+
for (const line of innerHLines) {
|
|
160
|
+
const [key, ...value] = line.split(':');
|
|
161
|
+
if (key) headers[key.toLowerCase()] = value.join(':').trim();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let parsedBody;
|
|
165
|
+
if (httpBody && httpBody.trim()) {
|
|
166
|
+
try {
|
|
167
|
+
parsedBody = JSON.parse(httpBody);
|
|
168
|
+
} catch {
|
|
169
|
+
parsedBody = httpBody;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Clean URL (remove prefix if present, though clients usually send relative path)
|
|
174
|
+
// Ensure /drive/v3/files...
|
|
175
|
+
|
|
176
|
+
parts.push({
|
|
177
|
+
contentId,
|
|
178
|
+
method,
|
|
179
|
+
url,
|
|
180
|
+
headers,
|
|
181
|
+
body: parsedBody
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return parts;
|
|
186
|
+
}
|
|
187
|
+
|
|
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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import express, { Request, Response } from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import { driveStore } from './store';
|
|
4
|
+
import { handleBatchRequest } from './batch';
|
|
5
|
+
|
|
6
|
+
interface AppConfig {
|
|
7
|
+
serverLagBefore?: number;
|
|
8
|
+
serverLagAfter?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const createApp = (config: AppConfig = {}) => {
|
|
12
|
+
const app = express();
|
|
13
|
+
app.use(cors());
|
|
14
|
+
|
|
15
|
+
app.use(async (req, res, next) => {
|
|
16
|
+
if (config.serverLagBefore && config.serverLagBefore > 0) {
|
|
17
|
+
await new Promise(resolve => setTimeout(resolve, config.serverLagBefore));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (config.serverLagAfter && config.serverLagAfter > 0) {
|
|
21
|
+
const originalSend = res.send;
|
|
22
|
+
res.send = function (...args) {
|
|
23
|
+
setTimeout(() => {
|
|
24
|
+
originalSend.apply(res, args);
|
|
25
|
+
}, config.serverLagAfter);
|
|
26
|
+
return res;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
next();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
app.use(express.json());
|
|
33
|
+
app.use(express.text({ type: 'multipart/mixed' }));
|
|
34
|
+
|
|
35
|
+
// Batch Route
|
|
36
|
+
app.post('/batch', handleBatchRequest);
|
|
37
|
+
|
|
38
|
+
// Debug Route (for testing)
|
|
39
|
+
app.post('/debug/clear', (req, res) => {
|
|
40
|
+
driveStore.clear();
|
|
41
|
+
res.status(200).send('Cleared');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Health Check
|
|
45
|
+
app.get('/', (req, res) => {
|
|
46
|
+
res.status(200).send('OK');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Auth Middleware
|
|
50
|
+
const validTokens = ['valid-token', 'another-valid-token'];
|
|
51
|
+
app.use((req, res, next) => {
|
|
52
|
+
const authHeader = req.headers.authorization;
|
|
53
|
+
if (!authHeader) {
|
|
54
|
+
res.status(401).json({ error: { code: 401, message: "Unauthorized: No token provided" } });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const token = authHeader.split(' ')[1];
|
|
59
|
+
if (!validTokens.includes(token)) {
|
|
60
|
+
res.status(401).json({ error: { code: 401, message: "Unauthorized: Invalid token" } });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
next();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Middleware to simulate some Google API behaviors (optional, can be expanded)
|
|
67
|
+
|
|
68
|
+
// About
|
|
69
|
+
app.get('/drive/v3/about', (req: Request, res: Response) => {
|
|
70
|
+
const about = driveStore.getAbout();
|
|
71
|
+
res.json({
|
|
72
|
+
kind: "drive#about",
|
|
73
|
+
...about
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Files: List
|
|
78
|
+
app.get('/drive/v3/files', (req: Request, res: Response) => {
|
|
79
|
+
const files = driveStore.listFiles();
|
|
80
|
+
res.json({
|
|
81
|
+
kind: "drive#fileList",
|
|
82
|
+
incompleteSearch: false,
|
|
83
|
+
files: files
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Files: Create
|
|
88
|
+
app.post('/drive/v3/files', (req: Request, res: Response) => {
|
|
89
|
+
const body = req.body;
|
|
90
|
+
if (!body || !body.name) {
|
|
91
|
+
res.status(400).json({ error: { code: 400, message: "Bad Request: Name is required" } });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Enforce Unique Name Constraint (Mock Behavior customization)
|
|
96
|
+
const existing = driveStore.listFiles().find(f => f.name === body.name);
|
|
97
|
+
if (existing) {
|
|
98
|
+
res.status(409).json({ error: { code: 409, message: "Conflict: File with same name already exists" } });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const newFile = driveStore.createFile({
|
|
103
|
+
name: body.name,
|
|
104
|
+
mimeType: body.mimeType || "application/octet-stream",
|
|
105
|
+
parents: body.parents || []
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
res.status(200).json(newFile);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Files: Get
|
|
112
|
+
app.get('/drive/v3/files/:fileId', (req: Request, res: Response) => {
|
|
113
|
+
const fileId = req.params.fileId;
|
|
114
|
+
if (typeof fileId !== 'string') {
|
|
115
|
+
res.status(400).send("Invalid file ID");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const file = driveStore.getFile(fileId);
|
|
119
|
+
|
|
120
|
+
if (!file) {
|
|
121
|
+
res.status(404).json({ error: { code: 404, message: "File not found" } });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const etag = `"${file.version}"`;
|
|
126
|
+
res.setHeader('ETag', etag);
|
|
127
|
+
|
|
128
|
+
if (req.headers['if-none-match'] === etag) {
|
|
129
|
+
res.status(304).end();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
res.json(file);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Files: Update
|
|
137
|
+
app.patch('/drive/v3/files/:fileId', (req: Request, res: Response) => {
|
|
138
|
+
const fileId = req.params.fileId;
|
|
139
|
+
if (typeof fileId !== 'string') {
|
|
140
|
+
res.status(400).send("Invalid file ID");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const updates = req.body;
|
|
144
|
+
|
|
145
|
+
if (!updates) {
|
|
146
|
+
res.status(400).json({ error: { code: 400, message: "Bad Request: No updates provided" } });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check for Precondition (If-Match)
|
|
151
|
+
// Note: Real Google Drive API V3 was observed to allow overwrites (status 200)
|
|
152
|
+
// on PATCH even with mismatching If-Match headers (likely due to ETag generation nuances).
|
|
153
|
+
// Relaxing Mock to match Real API behavior (Last Write Wins).
|
|
154
|
+
/*
|
|
155
|
+
const existingFile = driveStore.getFile(fileId);
|
|
156
|
+
if (existingFile) {
|
|
157
|
+
const ifMatch = req.headers['if-match'];
|
|
158
|
+
if (ifMatch && ifMatch !== '*' && ifMatch !== `"${existingFile.version}"`) {
|
|
159
|
+
res.status(412).json({ error: { code: 412, message: "Precondition Failed" } });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
const updatedFile = driveStore.updateFile(fileId, updates);
|
|
166
|
+
|
|
167
|
+
if (!updatedFile) {
|
|
168
|
+
res.status(404).json({ error: { code: 404, message: "File not found" } });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
res.json(updatedFile);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Files: Delete
|
|
176
|
+
app.delete('/drive/v3/files/:fileId', (req: Request, res: Response) => {
|
|
177
|
+
const fileId = req.params.fileId;
|
|
178
|
+
if (typeof fileId !== 'string') {
|
|
179
|
+
res.status(400).send("Invalid file ID");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Check for Precondition (If-Match)
|
|
183
|
+
const existingFile = driveStore.getFile(fileId);
|
|
184
|
+
if (existingFile) {
|
|
185
|
+
const ifMatch = req.headers['if-match'];
|
|
186
|
+
if (ifMatch && ifMatch !== '*' && ifMatch !== `"${existingFile.version}"`) {
|
|
187
|
+
res.status(412).json({ error: { code: 412, message: "Precondition Failed" } });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const deleted = driveStore.deleteFile(fileId);
|
|
193
|
+
|
|
194
|
+
if (!deleted) {
|
|
195
|
+
// According to Google API, delete might return 404 if not found, or 204 if successful (or 200).
|
|
196
|
+
// Docs says "If successful, this method returns an empty response body." usually 204.
|
|
197
|
+
// But if not found:
|
|
198
|
+
res.status(404).json({ error: { code: 404, message: "File not found" } });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
res.status(204).send();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return app;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const startServer = (port: number, host: string = 'localhost', config: AppConfig = {}) => {
|
|
209
|
+
const app = createApp(config);
|
|
210
|
+
return app.listen(port, host, () => {
|
|
211
|
+
console.log(`Server is running on http://${host}:${port}`);
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (require.main === module) {
|
|
216
|
+
startServer(3000);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export { createApp, startServer };
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export interface DriveFile {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
mimeType: string;
|
|
5
|
+
kind: string;
|
|
6
|
+
parents?: string[];
|
|
7
|
+
version: number;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class DriveStore {
|
|
12
|
+
private files: Map<string, DriveFile>;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
this.files = new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
createFile(file: Partial<DriveFile> & { name: string }): DriveFile {
|
|
19
|
+
if (!file.name) {
|
|
20
|
+
throw new Error("File name is required");
|
|
21
|
+
}
|
|
22
|
+
const id = file.id || Math.random().toString(36).substring(7);
|
|
23
|
+
const newFile: DriveFile = {
|
|
24
|
+
kind: "drive#file",
|
|
25
|
+
mimeType: "application/octet-stream",
|
|
26
|
+
...file,
|
|
27
|
+
id,
|
|
28
|
+
version: 1, // Initialize version
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
this.files.set(id, newFile);
|
|
32
|
+
return newFile;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
updateFile(id: string, updates: Partial<DriveFile>): DriveFile | null {
|
|
36
|
+
const file = this.files.get(id);
|
|
37
|
+
if (!file) return null;
|
|
38
|
+
|
|
39
|
+
// Merge updates and increment version
|
|
40
|
+
const updatedFile = {
|
|
41
|
+
...file,
|
|
42
|
+
...updates,
|
|
43
|
+
version: file.version + 1
|
|
44
|
+
};
|
|
45
|
+
this.files.set(id, updatedFile);
|
|
46
|
+
return updatedFile;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getFile(id: string): DriveFile | null {
|
|
50
|
+
return this.files.get(id) || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
deleteFile(id: string): boolean {
|
|
54
|
+
return this.files.delete(id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
listFiles(): DriveFile[] {
|
|
58
|
+
// Basic implementation, ignores query for now
|
|
59
|
+
return Array.from(this.files.values());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
clear(): void {
|
|
63
|
+
this.files.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getAbout(): object {
|
|
67
|
+
return {
|
|
68
|
+
user: {
|
|
69
|
+
displayName: "Mock User",
|
|
70
|
+
emailAddress: "mock@example.com",
|
|
71
|
+
kind: "drive#user",
|
|
72
|
+
me: true,
|
|
73
|
+
permissionId: "mock-permission-id"
|
|
74
|
+
},
|
|
75
|
+
storageQuota: {
|
|
76
|
+
limit: "10000000000",
|
|
77
|
+
usage: "0",
|
|
78
|
+
usageInDrive: "0",
|
|
79
|
+
usageInDriveTrash: "0"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const driveStore = new DriveStore();
|