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/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();