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/.ENV_EXAMPLE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# To run tests against the real Google Drive API
|
|
2
|
+
# Copy this file to .ENV and fill in the values
|
|
3
|
+
|
|
4
|
+
# 'mock' (default) or 'real'
|
|
5
|
+
TEST_TARGET=real
|
|
6
|
+
|
|
7
|
+
# OAuth2 Access Token for your Google Account
|
|
8
|
+
# Get one via 'npm run example:login'
|
|
9
|
+
GDRIVE_TOKEN=ya29.a0...
|
|
10
|
+
|
|
11
|
+
# Optional: Simulate latency in ms (only works with TEST_TARGET=mock)
|
|
12
|
+
# LATENCY=50
|
|
13
|
+
|
|
14
|
+
# Client ID for the Google Login Example
|
|
15
|
+
# GDRIVE_CLIENT_ID=...
|
package/.aiexclude
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build-and-test:
|
|
11
|
+
runs-on: ubuntu-24.04
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Use Node.js
|
|
17
|
+
uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '24'
|
|
20
|
+
cache: 'npm'
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: npm install
|
|
24
|
+
|
|
25
|
+
- name: Lint
|
|
26
|
+
run: npm run lint
|
|
27
|
+
|
|
28
|
+
- name: Build
|
|
29
|
+
run: npm run build
|
|
30
|
+
|
|
31
|
+
- name: Test (Node)
|
|
32
|
+
run: npm test
|
|
33
|
+
|
|
34
|
+
- name: Install Playwright Browsers
|
|
35
|
+
run: npx playwright@1.58.0 install chromium
|
|
36
|
+
|
|
37
|
+
- name: Test (Browser)
|
|
38
|
+
run: npm run test:browser
|
|
39
|
+
|
|
40
|
+
- name: Test Slow
|
|
41
|
+
run: npm run test:slow
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [created]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-24.04
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-node@v4
|
|
13
|
+
with:
|
|
14
|
+
node-version: '24'
|
|
15
|
+
registry-url: 'https://registry.npmjs.org'
|
|
16
|
+
cache: 'npm'
|
|
17
|
+
|
|
18
|
+
- run: npm install
|
|
19
|
+
- run: npm run build
|
|
20
|
+
- run: npm run lint
|
|
21
|
+
- run: npm test
|
|
22
|
+
- run: npm publish
|
|
23
|
+
env:
|
|
24
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/AGENTS.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Meyer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# google-drive-mock
|
|
2
|
+

|
|
3
|
+
|
|
4
|
+
<br />
|
|
5
|
+
|
|
6
|
+
<p style="text-align: center;">
|
|
7
|
+
Mock-Server that simulates being google-drive.<br />
|
|
8
|
+
Used for testing the <a href="https://rxdb.info/" target="_blank">RxDB Google-Drive-Sync</a>.<br />
|
|
9
|
+
Mostly Vibe-Coded.<br />
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install google-drive-mock
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { startServer } from 'google-drive-mock';
|
|
23
|
+
|
|
24
|
+
// start the server
|
|
25
|
+
const port = 3000;
|
|
26
|
+
const server = startServer(port);
|
|
27
|
+
|
|
28
|
+
// Store a file
|
|
29
|
+
const createResponse = await fetch('http://localhost:3000/drive/v3/files', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Authorization': 'Bearer valid-token',
|
|
33
|
+
'Content-Type': 'application/json'
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
name: 'test-file.txt',
|
|
37
|
+
mimeType: 'text/plain'
|
|
38
|
+
})
|
|
39
|
+
});
|
|
40
|
+
const file = await createResponse.json();
|
|
41
|
+
console.log('Created File:', file);
|
|
42
|
+
|
|
43
|
+
// Read the file
|
|
44
|
+
const readResponse = await fetch(`http://localhost:3000/drive/v3/files/${file.id}`, {
|
|
45
|
+
method: 'GET',
|
|
46
|
+
headers: {
|
|
47
|
+
'Authorization': 'Bearer valid-token'
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
const fileContent = await readResponse.json();
|
|
51
|
+
console.log('Read File:', fileContent);
|
|
52
|
+
|
|
53
|
+
// Stop the server
|
|
54
|
+
server.close();
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Tech
|
|
59
|
+
|
|
60
|
+
- TypeScript
|
|
61
|
+
- Express
|
|
62
|
+
- Vitest
|
|
63
|
+
|
|
64
|
+
## Browser Testing
|
|
65
|
+
|
|
66
|
+
To run tests inside a headless browser (Chromium):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm run test:browser
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Real Google Drive API Testing
|
|
73
|
+
|
|
74
|
+
To run tests against the real Google Drive API instead of the mock:
|
|
75
|
+
|
|
76
|
+
1. Create a `.ENV` file (see `.ENV_EXAMPLE`):
|
|
77
|
+
```
|
|
78
|
+
TEST_TARGET=real
|
|
79
|
+
GDRIVE_TOKEN=your-access-token
|
|
80
|
+
```
|
|
81
|
+
2. Run tests:
|
|
82
|
+
```bash
|
|
83
|
+
npm test:real
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Contributing
|
|
87
|
+
|
|
88
|
+
GitHub issues for this project are closed. If you find a bug, please create a Pull Request with a test case reproducing the issue.
|
|
89
|
+
|
package/dist/batch.d.ts
ADDED
package/dist/batch.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleBatchRequest = void 0;
|
|
4
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
5
|
+
const store_1 = require("./store");
|
|
6
|
+
const handleBatchRequest = (req, res) => {
|
|
7
|
+
const contentType = req.headers['content-type'];
|
|
8
|
+
if (!contentType || !contentType.includes('multipart/mixed')) {
|
|
9
|
+
return res.status(400).send('Content-Type must be multipart/mixed');
|
|
10
|
+
}
|
|
11
|
+
const boundaryMatch = contentType.match(/boundary=(.+)/);
|
|
12
|
+
if (!boundaryMatch) {
|
|
13
|
+
return res.status(400).send('Multipart boundary missing');
|
|
14
|
+
}
|
|
15
|
+
let boundary = boundaryMatch[1];
|
|
16
|
+
// Boundaries in header can be quoted
|
|
17
|
+
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
|
18
|
+
boundary = boundary.substring(1, boundary.length - 1);
|
|
19
|
+
}
|
|
20
|
+
const rawBody = req.body;
|
|
21
|
+
if (typeof rawBody !== 'string') {
|
|
22
|
+
return res.status(400).send('Body parsing failed');
|
|
23
|
+
}
|
|
24
|
+
const parts = parseMultipart(rawBody, boundary);
|
|
25
|
+
const responses = [];
|
|
26
|
+
for (const part of parts) {
|
|
27
|
+
const response = processPart(part);
|
|
28
|
+
responses.push(response);
|
|
29
|
+
}
|
|
30
|
+
const responseBoundary = `batch_${Math.random().toString(36).substring(2)}`;
|
|
31
|
+
const responseBody = buildMultipartResponse(responses, responseBoundary);
|
|
32
|
+
res.set('Content-Type', `multipart/mixed; boundary=${responseBoundary}`);
|
|
33
|
+
res.end(responseBody);
|
|
34
|
+
};
|
|
35
|
+
exports.handleBatchRequest = handleBatchRequest;
|
|
36
|
+
function parseMultipart(body, boundary) {
|
|
37
|
+
const parts = [];
|
|
38
|
+
// Split by --boundary
|
|
39
|
+
// Note: The last part ends with --boundary--
|
|
40
|
+
const rawParts = body.split(`--${boundary}`);
|
|
41
|
+
for (const rawPart of rawParts) {
|
|
42
|
+
// Skip empty or end parts
|
|
43
|
+
if (rawPart.trim() === '' || rawPart.trim() === '--')
|
|
44
|
+
continue;
|
|
45
|
+
// Parse outer headers
|
|
46
|
+
const sections = rawPart.trim().split(/\r?\n\r?\n/);
|
|
47
|
+
const headersSection = sections[0];
|
|
48
|
+
const rest = sections.slice(1);
|
|
49
|
+
let contentId = '';
|
|
50
|
+
const headerLines = headersSection.split(/\r?\n/);
|
|
51
|
+
for (const line of headerLines) {
|
|
52
|
+
if (line.toLowerCase().startsWith('content-id:')) {
|
|
53
|
+
contentId = line.split(':')[1].trim();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const httpContent = rest.join('\r\n\r\n'); // Reconstruct body if multiple parts?
|
|
57
|
+
if (!httpContent && sections.length < 2)
|
|
58
|
+
continue; // No body?
|
|
59
|
+
// Ideally, httpContent is the rest.
|
|
60
|
+
// But if we split by double newline, we might have split the inner body too.
|
|
61
|
+
// Better: Find first double newline index manually.
|
|
62
|
+
// ... (Rewriting loop to be safer)
|
|
63
|
+
const firstDoubleNewline = rawPart.indexOf('\r\n\r\n');
|
|
64
|
+
const firstDoubleNewlineLF = rawPart.indexOf('\n\n');
|
|
65
|
+
let splitIndex = -1;
|
|
66
|
+
let splitLen = 0;
|
|
67
|
+
if (firstDoubleNewline !== -1) {
|
|
68
|
+
splitIndex = firstDoubleNewline;
|
|
69
|
+
splitLen = 4;
|
|
70
|
+
}
|
|
71
|
+
else if (firstDoubleNewlineLF !== -1) {
|
|
72
|
+
splitIndex = firstDoubleNewlineLF;
|
|
73
|
+
splitLen = 2;
|
|
74
|
+
}
|
|
75
|
+
if (splitIndex === -1)
|
|
76
|
+
continue;
|
|
77
|
+
const headersStr = rawPart.substring(0, splitIndex).trim();
|
|
78
|
+
const bodyStr = rawPart.substring(splitIndex + splitLen); // No trim on body?
|
|
79
|
+
// Parse outer headers
|
|
80
|
+
const hLines = headersStr.split(/\r?\n/);
|
|
81
|
+
for (const line of hLines) {
|
|
82
|
+
if (line.toLowerCase().startsWith('content-id:')) {
|
|
83
|
+
contentId = line.split(':')[1].trim();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!bodyStr)
|
|
87
|
+
continue;
|
|
88
|
+
// Inner HTTP part
|
|
89
|
+
// Same logic for inner split
|
|
90
|
+
const innerSplitIndexCRLF = bodyStr.indexOf('\r\n\r\n');
|
|
91
|
+
const innerSplitIndexLF = bodyStr.indexOf('\n\n');
|
|
92
|
+
let innerSplitIndex = -1;
|
|
93
|
+
let innerSplitLen = 0;
|
|
94
|
+
if (innerSplitIndexCRLF !== -1) {
|
|
95
|
+
innerSplitIndex = innerSplitIndexCRLF;
|
|
96
|
+
innerSplitLen = 4;
|
|
97
|
+
}
|
|
98
|
+
else if (innerSplitIndexLF !== -1 && (innerSplitIndexCRLF === -1 || innerSplitIndexLF < innerSplitIndexCRLF)) {
|
|
99
|
+
innerSplitIndex = innerSplitIndexLF;
|
|
100
|
+
innerSplitLen = 2;
|
|
101
|
+
}
|
|
102
|
+
// If NO header terminator found in inner body, maybe no headers? (But request line exists)
|
|
103
|
+
// Request line is mandatory.
|
|
104
|
+
let requestLine = '';
|
|
105
|
+
let innerHeadersStr = '';
|
|
106
|
+
let httpBody = '';
|
|
107
|
+
if (innerSplitIndex !== -1) {
|
|
108
|
+
const head = bodyStr.substring(0, innerSplitIndex);
|
|
109
|
+
httpBody = bodyStr.substring(innerSplitIndex + innerSplitLen);
|
|
110
|
+
const lines = head.split(/\r?\n/);
|
|
111
|
+
requestLine = lines[0];
|
|
112
|
+
innerHeadersStr = lines.slice(1).join('\n');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// Maybe no body? Just headers?
|
|
116
|
+
const lines = bodyStr.trim().split(/\r?\n/);
|
|
117
|
+
requestLine = lines[0];
|
|
118
|
+
innerHeadersStr = lines.slice(1).join('\n');
|
|
119
|
+
httpBody = '';
|
|
120
|
+
}
|
|
121
|
+
const [method, url] = requestLine.split(' ');
|
|
122
|
+
// Parse inner headers
|
|
123
|
+
const headers = {};
|
|
124
|
+
const innerHLines = innerHeadersStr.split(/\r?\n/);
|
|
125
|
+
for (const line of innerHLines) {
|
|
126
|
+
const [key, ...value] = line.split(':');
|
|
127
|
+
if (key)
|
|
128
|
+
headers[key.toLowerCase()] = value.join(':').trim();
|
|
129
|
+
}
|
|
130
|
+
let parsedBody;
|
|
131
|
+
if (httpBody && httpBody.trim()) {
|
|
132
|
+
try {
|
|
133
|
+
parsedBody = JSON.parse(httpBody);
|
|
134
|
+
}
|
|
135
|
+
catch (_a) {
|
|
136
|
+
parsedBody = httpBody;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Clean URL (remove prefix if present, though clients usually send relative path)
|
|
140
|
+
// Ensure /drive/v3/files...
|
|
141
|
+
parts.push({
|
|
142
|
+
contentId,
|
|
143
|
+
method,
|
|
144
|
+
url,
|
|
145
|
+
headers,
|
|
146
|
+
body: parsedBody
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return parts;
|
|
150
|
+
}
|
|
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.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface AppConfig {
|
|
2
|
+
serverLagBefore?: number;
|
|
3
|
+
serverLagAfter?: number;
|
|
4
|
+
}
|
|
5
|
+
declare const createApp: (config?: AppConfig) => import("express-serve-static-core").Express;
|
|
6
|
+
declare const startServer: (port: number, host?: string, config?: AppConfig) => import("node:http").Server<typeof import("node:http").IncomingMessage, typeof import("node:http").ServerResponse>;
|
|
7
|
+
export { createApp, startServer };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.startServer = exports.createApp = void 0;
|
|
16
|
+
const express_1 = __importDefault(require("express"));
|
|
17
|
+
const store_1 = require("./store");
|
|
18
|
+
const batch_1 = require("./batch");
|
|
19
|
+
const createApp = (config = {}) => {
|
|
20
|
+
const app = (0, express_1.default)();
|
|
21
|
+
app.use((req, res, next) => __awaiter(void 0, void 0, void 0, function* () {
|
|
22
|
+
if (config.serverLagBefore && config.serverLagBefore > 0) {
|
|
23
|
+
yield new Promise(resolve => setTimeout(resolve, config.serverLagBefore));
|
|
24
|
+
}
|
|
25
|
+
if (config.serverLagAfter && config.serverLagAfter > 0) {
|
|
26
|
+
const originalSend = res.send;
|
|
27
|
+
res.send = function (...args) {
|
|
28
|
+
setTimeout(() => {
|
|
29
|
+
originalSend.apply(res, args);
|
|
30
|
+
}, config.serverLagAfter);
|
|
31
|
+
return res;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
next();
|
|
35
|
+
}));
|
|
36
|
+
app.use(express_1.default.json());
|
|
37
|
+
app.use(express_1.default.text({ type: 'multipart/mixed' }));
|
|
38
|
+
// Batch Route
|
|
39
|
+
app.post('/batch', batch_1.handleBatchRequest);
|
|
40
|
+
// Debug Route (for testing)
|
|
41
|
+
app.post('/debug/clear', (req, res) => {
|
|
42
|
+
store_1.driveStore.clear();
|
|
43
|
+
res.status(200).send('Cleared');
|
|
44
|
+
});
|
|
45
|
+
// Auth Middleware
|
|
46
|
+
const validTokens = ['valid-token', 'another-valid-token'];
|
|
47
|
+
app.use((req, res, next) => {
|
|
48
|
+
const authHeader = req.headers.authorization;
|
|
49
|
+
if (!authHeader) {
|
|
50
|
+
res.status(401).json({ error: { code: 401, message: "Unauthorized: No token provided" } });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const token = authHeader.split(' ')[1];
|
|
54
|
+
if (!validTokens.includes(token)) {
|
|
55
|
+
res.status(401).json({ error: { code: 401, message: "Unauthorized: Invalid token" } });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
next();
|
|
59
|
+
});
|
|
60
|
+
// Middleware to simulate some Google API behaviors (optional, can be expanded)
|
|
61
|
+
// About
|
|
62
|
+
app.get('/drive/v3/about', (req, res) => {
|
|
63
|
+
const about = store_1.driveStore.getAbout();
|
|
64
|
+
res.json(Object.assign({ kind: "drive#about" }, about));
|
|
65
|
+
});
|
|
66
|
+
// Files: List
|
|
67
|
+
app.get('/drive/v3/files', (req, res) => {
|
|
68
|
+
const files = store_1.driveStore.listFiles();
|
|
69
|
+
res.json({
|
|
70
|
+
kind: "drive#fileList",
|
|
71
|
+
incompleteSearch: false,
|
|
72
|
+
files: files
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
// Files: Create
|
|
76
|
+
app.post('/drive/v3/files', (req, res) => {
|
|
77
|
+
const body = req.body;
|
|
78
|
+
if (!body || !body.name) {
|
|
79
|
+
res.status(400).json({ error: { code: 400, message: "Bad Request: Name is required" } });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Enforce Unique Name Constraint (Mock Behavior customization)
|
|
83
|
+
const existing = store_1.driveStore.listFiles().find(f => f.name === body.name);
|
|
84
|
+
if (existing) {
|
|
85
|
+
res.status(409).json({ error: { code: 409, message: "Conflict: File with same name already exists" } });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const newFile = store_1.driveStore.createFile({
|
|
89
|
+
name: body.name,
|
|
90
|
+
mimeType: body.mimeType || "application/octet-stream",
|
|
91
|
+
parents: body.parents || []
|
|
92
|
+
});
|
|
93
|
+
res.status(200).json(newFile);
|
|
94
|
+
});
|
|
95
|
+
// Files: Get
|
|
96
|
+
app.get('/drive/v3/files/:fileId', (req, res) => {
|
|
97
|
+
const fileId = req.params.fileId;
|
|
98
|
+
if (typeof fileId !== 'string') {
|
|
99
|
+
res.status(400).send("Invalid file ID");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const file = store_1.driveStore.getFile(fileId);
|
|
103
|
+
if (!file) {
|
|
104
|
+
res.status(404).json({ error: { code: 404, message: "File not found" } });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const etag = `"${file.version}"`;
|
|
108
|
+
res.setHeader('ETag', etag);
|
|
109
|
+
if (req.headers['if-none-match'] === etag) {
|
|
110
|
+
res.status(304).end();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
res.json(file);
|
|
114
|
+
});
|
|
115
|
+
// Files: Update
|
|
116
|
+
app.patch('/drive/v3/files/:fileId', (req, res) => {
|
|
117
|
+
const fileId = req.params.fileId;
|
|
118
|
+
if (typeof fileId !== 'string') {
|
|
119
|
+
res.status(400).send("Invalid file ID");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const updates = req.body;
|
|
123
|
+
if (!updates) {
|
|
124
|
+
res.status(400).json({ error: { code: 400, message: "Bad Request: No updates provided" } });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Check for Precondition (If-Match)
|
|
128
|
+
// Note: Real Google Drive API V3 was observed to allow overwrites (status 200)
|
|
129
|
+
// on PATCH even with mismatching If-Match headers (likely due to ETag generation nuances).
|
|
130
|
+
// Relaxing Mock to match Real API behavior (Last Write Wins).
|
|
131
|
+
/*
|
|
132
|
+
const existingFile = driveStore.getFile(fileId);
|
|
133
|
+
if (existingFile) {
|
|
134
|
+
const ifMatch = req.headers['if-match'];
|
|
135
|
+
if (ifMatch && ifMatch !== '*' && ifMatch !== `"${existingFile.version}"`) {
|
|
136
|
+
res.status(412).json({ error: { code: 412, message: "Precondition Failed" } });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
*/
|
|
141
|
+
const updatedFile = store_1.driveStore.updateFile(fileId, updates);
|
|
142
|
+
if (!updatedFile) {
|
|
143
|
+
res.status(404).json({ error: { code: 404, message: "File not found" } });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
res.json(updatedFile);
|
|
147
|
+
});
|
|
148
|
+
// Files: Delete
|
|
149
|
+
app.delete('/drive/v3/files/:fileId', (req, res) => {
|
|
150
|
+
const fileId = req.params.fileId;
|
|
151
|
+
if (typeof fileId !== 'string') {
|
|
152
|
+
res.status(400).send("Invalid file ID");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Check for Precondition (If-Match)
|
|
156
|
+
const existingFile = store_1.driveStore.getFile(fileId);
|
|
157
|
+
if (existingFile) {
|
|
158
|
+
const ifMatch = req.headers['if-match'];
|
|
159
|
+
if (ifMatch && ifMatch !== '*' && ifMatch !== `"${existingFile.version}"`) {
|
|
160
|
+
res.status(412).json({ error: { code: 412, message: "Precondition Failed" } });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const deleted = store_1.driveStore.deleteFile(fileId);
|
|
165
|
+
if (!deleted) {
|
|
166
|
+
// According to Google API, delete might return 404 if not found, or 204 if successful (or 200).
|
|
167
|
+
// Docs says "If successful, this method returns an empty response body." usually 204.
|
|
168
|
+
// But if not found:
|
|
169
|
+
res.status(404).json({ error: { code: 404, message: "File not found" } });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
res.status(204).send();
|
|
173
|
+
});
|
|
174
|
+
return app;
|
|
175
|
+
};
|
|
176
|
+
exports.createApp = createApp;
|
|
177
|
+
const startServer = (port, host = 'localhost', config = {}) => {
|
|
178
|
+
const app = createApp(config);
|
|
179
|
+
return app.listen(port, host, () => {
|
|
180
|
+
console.log(`Server is running on http://${host}:${port}`);
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
exports.startServer = startServer;
|
|
184
|
+
if (require.main === module) {
|
|
185
|
+
startServer(3000);
|
|
186
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
export declare class DriveStore {
|
|
11
|
+
private files;
|
|
12
|
+
constructor();
|
|
13
|
+
createFile(file: Partial<DriveFile> & {
|
|
14
|
+
name: string;
|
|
15
|
+
}): DriveFile;
|
|
16
|
+
updateFile(id: string, updates: Partial<DriveFile>): DriveFile | null;
|
|
17
|
+
getFile(id: string): DriveFile | null;
|
|
18
|
+
deleteFile(id: string): boolean;
|
|
19
|
+
listFiles(): DriveFile[];
|
|
20
|
+
clear(): void;
|
|
21
|
+
getAbout(): object;
|
|
22
|
+
}
|
|
23
|
+
export declare const driveStore: DriveStore;
|