@yolk-sdk/emulators 0.1.0-canary.96
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/LICENSE +21 -0
- package/README.md +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- package/src/xai.ts +341 -0
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dropbox emulator API: the route table (evidence, request-shape checks, and stateful handlers),
|
|
3
|
+
* the fixture error envelopes, metadata rendering, and list and search cursors (internal;
|
|
4
|
+
* re-exported by `src/dropbox.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Only the RPC and upload routes the eight Dropbox conformance cases (and their cleanup) send are
|
|
7
|
+
* emulated, with the wire shapes of their synthetic fixtures. Every route is a `POST` under `/2`: the RPC routes on `api.dropboxapi.com` with a JSON body, the
|
|
8
|
+
* upload on `content.dropboxapi.com` with an `application/octet-stream` body and a
|
|
9
|
+
* `Dropbox-API-Arg` header, each only on the origin its fixtures record. Route errors are the
|
|
10
|
+
* fixtures' HTTP 409 `error_summary` envelopes, copied byte for byte. Every route plans first
|
|
11
|
+
* (reading the state, writing nothing) and commits only an eligible request: anything no fixture
|
|
12
|
+
* shows is not emulated (400, nothing written, no fault used up).
|
|
13
|
+
*
|
|
14
|
+
* @experimental
|
|
15
|
+
*/
|
|
16
|
+
import { Predicate } from 'effect'
|
|
17
|
+
import type * as Schema from 'effect/Schema'
|
|
18
|
+
import {
|
|
19
|
+
exactObject,
|
|
20
|
+
integerIn,
|
|
21
|
+
isNotEmulated,
|
|
22
|
+
jsonResponse,
|
|
23
|
+
mediaType,
|
|
24
|
+
notEmulated,
|
|
25
|
+
parseJsonText,
|
|
26
|
+
statefulRoute,
|
|
27
|
+
type Commit,
|
|
28
|
+
type EmulatedRequest,
|
|
29
|
+
type NotEmulated,
|
|
30
|
+
type StatefulRoute
|
|
31
|
+
} from '../stateful-emulator.ts'
|
|
32
|
+
import {
|
|
33
|
+
syntheticContentHash,
|
|
34
|
+
type DropboxEmulatorDeleted,
|
|
35
|
+
type DropboxEmulatorEntry,
|
|
36
|
+
type DropboxEmulatorFile,
|
|
37
|
+
type DropboxEmulatorState
|
|
38
|
+
} from './state.ts'
|
|
39
|
+
|
|
40
|
+
/** API version prefix of every Dropbox route. */
|
|
41
|
+
export const dropboxEmulatorBasePath = '/2'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The fixture error envelopes, byte for byte (`not-found-envelope.ts`, `create-folder-conflict.ts`,
|
|
45
|
+
* and `upload-rev-precondition.ts`), each answered with HTTP 409.
|
|
46
|
+
*/
|
|
47
|
+
export const dropboxEmulatorErrorBodies = {
|
|
48
|
+
notFound:
|
|
49
|
+
'{"error_summary": "path/not_found/.", "error": {".tag": "path", "path": {".tag": "not_found"}}}',
|
|
50
|
+
folderConflict:
|
|
51
|
+
'{"error_summary": "path/conflict/folder/..", "error": {".tag": "path", "path": {".tag": "conflict", "conflict": {".tag": "folder"}}}}',
|
|
52
|
+
uploadConflict:
|
|
53
|
+
'{"error_summary": "path/conflict/file/..", "error": {".tag": "path", "path": {"reason": {".tag": "conflict", "conflict": {".tag": "file"}}}}}'
|
|
54
|
+
} as const
|
|
55
|
+
|
|
56
|
+
// Drill-only envelopes (tests only): each disagrees with one claim.
|
|
57
|
+
const drillErrorBodies = {
|
|
58
|
+
notFoundAsPathLookup:
|
|
59
|
+
'{"error_summary": "path_lookup/not_found/.", "error": {".tag": "path_lookup", "path_lookup": {".tag": "not_found"}}}',
|
|
60
|
+
folderConflictAsFile:
|
|
61
|
+
'{"error_summary": "path/conflict/file/..", "error": {".tag": "path", "path": {".tag": "conflict", "conflict": {".tag": "file"}}}}'
|
|
62
|
+
} as const
|
|
63
|
+
|
|
64
|
+
/** Drill knobs (tests only): each makes the emulator disagree with one conformance claim. */
|
|
65
|
+
export type DropboxEmulatorDrills = {
|
|
66
|
+
/** `list_folder` ignores `limit`: one page with every entry and `has_more: false`. */
|
|
67
|
+
readonly listFolderSinglePage?: boolean
|
|
68
|
+
/** `get_metadata` matches path components case-sensitively. */
|
|
69
|
+
readonly getMetadataCaseSensitive?: boolean
|
|
70
|
+
/** `search/continue_v2` answers the earlier pages' matches again before the new ones. */
|
|
71
|
+
readonly searchRepeatsMatches?: boolean
|
|
72
|
+
/** A missing path answers a `path_lookup/not_found` envelope (still 409). */
|
|
73
|
+
readonly notFoundAsPathLookup?: boolean
|
|
74
|
+
/** A folder create conflict answers `path/conflict/file` instead of `path/conflict/folder`. */
|
|
75
|
+
readonly folderConflictAsFile?: boolean
|
|
76
|
+
/** `delete_v2` keeps no deleted-entry record, so `include_deleted` is not emulated. */
|
|
77
|
+
readonly deleteLeavesNoTombstone?: boolean
|
|
78
|
+
/** `move_v2` gives the moved file a new id. */
|
|
79
|
+
readonly moveMintsNewId?: boolean
|
|
80
|
+
/** An `update` upload naming a stale rev overwrites instead of conflicting. */
|
|
81
|
+
readonly uploadIgnoresRev?: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const dropboxEmulatorDrillKnobs: ReadonlyArray<keyof DropboxEmulatorDrills> = [
|
|
85
|
+
'listFolderSinglePage',
|
|
86
|
+
'getMetadataCaseSensitive',
|
|
87
|
+
'searchRepeatsMatches',
|
|
88
|
+
'notFoundAsPathLookup',
|
|
89
|
+
'folderConflictAsFile',
|
|
90
|
+
'deleteLeavesNoTombstone',
|
|
91
|
+
'moveMintsNewId',
|
|
92
|
+
'uploadIgnoresRev'
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
/** A `list_folder` cursor (runtime data, not state). */
|
|
96
|
+
export type DropboxListCursor = {
|
|
97
|
+
readonly kind: 'list'
|
|
98
|
+
readonly folderId: string
|
|
99
|
+
/** The whole listing as rendered when the cursor was issued; any change is not emulated. */
|
|
100
|
+
readonly fingerprint: string
|
|
101
|
+
readonly offset: number
|
|
102
|
+
readonly limit: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A `search_v2` cursor (runtime data, not state). */
|
|
106
|
+
export type DropboxSearchCursor = {
|
|
107
|
+
readonly kind: 'search'
|
|
108
|
+
readonly query: string
|
|
109
|
+
/** Every match as rendered when the cursor was issued; any change is not emulated. */
|
|
110
|
+
readonly fingerprint: string
|
|
111
|
+
readonly offset: number
|
|
112
|
+
readonly maxResults: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type DropboxCursor = DropboxListCursor | DropboxSearchCursor
|
|
116
|
+
|
|
117
|
+
export type DropboxApiEnv = {
|
|
118
|
+
/** Clock in epoch milliseconds (upload timestamps only). */
|
|
119
|
+
readonly now: () => number
|
|
120
|
+
readonly drills: Readonly<Record<keyof DropboxEmulatorDrills, boolean>>
|
|
121
|
+
/** Issued cursors by value (runtime-only; cleared by reset and seed). */
|
|
122
|
+
readonly cursors: Map<string, DropboxCursor>
|
|
123
|
+
/** Cursor numbers; never reset, so a cursor issued before a reset is never issued again. */
|
|
124
|
+
readonly cursorCounters: { list: number; search: number }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type Route = StatefulRoute<DropboxEmulatorState, DropboxApiEnv>
|
|
128
|
+
|
|
129
|
+
/** Origin of the RPC routes. */
|
|
130
|
+
export const dropboxEmulatorApiOrigin = 'https://api.dropboxapi.com'
|
|
131
|
+
|
|
132
|
+
/** Origin of the upload route. */
|
|
133
|
+
export const dropboxEmulatorContentOrigin = 'https://content.dropboxapi.com'
|
|
134
|
+
|
|
135
|
+
const pagingCase = 'dropbox.files.list-folder-cursor-paging'
|
|
136
|
+
|
|
137
|
+
const pathLowerCase = 'dropbox.files.path-lower-lookup'
|
|
138
|
+
|
|
139
|
+
const searchCase = 'dropbox.files.search-continue'
|
|
140
|
+
|
|
141
|
+
const notFoundCase = 'dropbox.errors.not-found-409-envelope'
|
|
142
|
+
|
|
143
|
+
const conflictCase = 'dropbox.files.create-folder-conflict'
|
|
144
|
+
|
|
145
|
+
const deleteCase = 'dropbox.files.delete-then-not-found'
|
|
146
|
+
|
|
147
|
+
const copyCase = 'dropbox.files.copy-move-metadata'
|
|
148
|
+
|
|
149
|
+
const uploadCase = 'dropbox.files.upload-rev-precondition'
|
|
150
|
+
|
|
151
|
+
const evidence = (
|
|
152
|
+
path: string,
|
|
153
|
+
write: boolean,
|
|
154
|
+
caseIds: ReadonlyArray<string>,
|
|
155
|
+
origin: string = dropboxEmulatorApiOrigin
|
|
156
|
+
) => ({
|
|
157
|
+
method: 'POST',
|
|
158
|
+
path: `${dropboxEmulatorBasePath}${path}`,
|
|
159
|
+
kind: 'connector' as const,
|
|
160
|
+
write,
|
|
161
|
+
caseIds,
|
|
162
|
+
evidence: 'unverified' as const,
|
|
163
|
+
origin
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// Responses.
|
|
167
|
+
|
|
168
|
+
const json = (status: number, text: string): Response =>
|
|
169
|
+
new Response(text, { status, headers: { 'content-type': 'application/json' } })
|
|
170
|
+
|
|
171
|
+
const ok = (body: Schema.Json): Response => jsonResponse(200, body)
|
|
172
|
+
|
|
173
|
+
const notFound = (env: DropboxApiEnv): Response =>
|
|
174
|
+
json(
|
|
175
|
+
409,
|
|
176
|
+
env.drills.notFoundAsPathLookup
|
|
177
|
+
? drillErrorBodies.notFoundAsPathLookup
|
|
178
|
+
: dropboxEmulatorErrorBodies.notFound
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// Paths.
|
|
182
|
+
|
|
183
|
+
/** A parsed absolute path: its components as sent. */
|
|
184
|
+
type DropboxPath = { readonly components: ReadonlyArray<string> }
|
|
185
|
+
|
|
186
|
+
const pathPattern = /^(?:\/[^/]+)+$/
|
|
187
|
+
|
|
188
|
+
const parsePath = (value: Schema.Json | undefined, label: string): DropboxPath | NotEmulated => {
|
|
189
|
+
if (!Predicate.isString(value) || !pathPattern.test(value)) {
|
|
190
|
+
return notEmulated(
|
|
191
|
+
`${label} must be an absolute /path (the root, ids, and revs are not emulated)`
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const components = value.split('/').slice(1)
|
|
196
|
+
|
|
197
|
+
return components.some(component => component === '.' || component === '..')
|
|
198
|
+
? notEmulated(`${label} with . or .. components is not emulated`)
|
|
199
|
+
: { components }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const idPattern = /^id:\S+$/
|
|
203
|
+
|
|
204
|
+
const childrenOf = (state: DropboxEmulatorState, folderId: string | null) =>
|
|
205
|
+
state.entries.filter(entry => entry.parentId === folderId)
|
|
206
|
+
|
|
207
|
+
const sameName = (left: string, right: string, caseSensitive: boolean): boolean =>
|
|
208
|
+
caseSensitive ? left === right : left.toLowerCase() === right.toLowerCase()
|
|
209
|
+
|
|
210
|
+
/** The entry at `components` (case-insensitive, like Dropbox, unless `caseSensitive`). */
|
|
211
|
+
const lookup = (
|
|
212
|
+
state: DropboxEmulatorState,
|
|
213
|
+
components: ReadonlyArray<string>,
|
|
214
|
+
caseSensitive = false
|
|
215
|
+
): DropboxEmulatorEntry | undefined => {
|
|
216
|
+
let current: DropboxEmulatorEntry | undefined
|
|
217
|
+
let parentId: string | null = null
|
|
218
|
+
|
|
219
|
+
for (const component of components) {
|
|
220
|
+
current = childrenOf(state, parentId).find(entry =>
|
|
221
|
+
sameName(entry.name, component, caseSensitive)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if (current === undefined) return undefined
|
|
225
|
+
|
|
226
|
+
parentId = current.id
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return current
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The folder holding `components`: `null` for the root, the folder, or not emulated when it is
|
|
234
|
+
* missing or a file (every fixture names a path whose parent folder exists).
|
|
235
|
+
*/
|
|
236
|
+
const parentFolder = (
|
|
237
|
+
state: DropboxEmulatorState,
|
|
238
|
+
components: ReadonlyArray<string>,
|
|
239
|
+
label: string
|
|
240
|
+
): DropboxEmulatorEntry | null | NotEmulated => {
|
|
241
|
+
if (components.length === 1) return null
|
|
242
|
+
|
|
243
|
+
const parent = lookup(state, components.slice(0, -1))
|
|
244
|
+
|
|
245
|
+
return parent !== undefined && parent.file === null
|
|
246
|
+
? parent
|
|
247
|
+
: notEmulated(`${label} under a missing parent folder (or a file) is not emulated`)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Stored names from the root down to the entry. */
|
|
251
|
+
const storedNames = (
|
|
252
|
+
state: DropboxEmulatorState,
|
|
253
|
+
entry: DropboxEmulatorEntry
|
|
254
|
+
): ReadonlyArray<string> => {
|
|
255
|
+
const names: Array<string> = []
|
|
256
|
+
let current: DropboxEmulatorEntry | undefined = entry
|
|
257
|
+
|
|
258
|
+
while (current !== undefined) {
|
|
259
|
+
names.unshift(current.name)
|
|
260
|
+
|
|
261
|
+
const parentId: string | null = current.parentId
|
|
262
|
+
|
|
263
|
+
current =
|
|
264
|
+
parentId === null ? undefined : state.entries.find(candidate => candidate.id === parentId)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return names
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const pathOf = (names: ReadonlyArray<string>): string => names.map(name => `/${name}`).join('')
|
|
271
|
+
|
|
272
|
+
// Metadata, in the fixture key order.
|
|
273
|
+
|
|
274
|
+
const fileMetadata = (
|
|
275
|
+
entry: DropboxEmulatorEntry,
|
|
276
|
+
file: DropboxEmulatorFile,
|
|
277
|
+
display: string
|
|
278
|
+
): Schema.JsonObject => ({
|
|
279
|
+
'.tag': 'file',
|
|
280
|
+
name: entry.name,
|
|
281
|
+
path_lower: display.toLowerCase(),
|
|
282
|
+
path_display: display,
|
|
283
|
+
id: entry.id,
|
|
284
|
+
client_modified: file.clientModified,
|
|
285
|
+
server_modified: file.serverModified,
|
|
286
|
+
rev: file.rev,
|
|
287
|
+
size: file.size,
|
|
288
|
+
is_downloadable: true,
|
|
289
|
+
content_hash: file.contentHash
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
/** Folder metadata; `create_folder_v2` answers it without `.tag`, as its fixtures record. */
|
|
293
|
+
const folderMetadata = (
|
|
294
|
+
entry: DropboxEmulatorEntry,
|
|
295
|
+
display: string,
|
|
296
|
+
tagged: boolean
|
|
297
|
+
): Schema.JsonObject => {
|
|
298
|
+
const body = {
|
|
299
|
+
name: entry.name,
|
|
300
|
+
path_lower: display.toLowerCase(),
|
|
301
|
+
path_display: display,
|
|
302
|
+
id: entry.id
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return tagged ? { '.tag': 'folder', ...body } : body
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const impliedRefusal = (): NotEmulated =>
|
|
309
|
+
notEmulated(
|
|
310
|
+
'an answer showing a folder no fixture shows (an implied seed folder) is not emulated'
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
/** Metadata at the stored display path, or not emulated for an implied entry. */
|
|
314
|
+
const metadataOf = (
|
|
315
|
+
state: DropboxEmulatorState,
|
|
316
|
+
entry: DropboxEmulatorEntry
|
|
317
|
+
): Schema.JsonObject | NotEmulated => {
|
|
318
|
+
if (entry.implied) return impliedRefusal()
|
|
319
|
+
|
|
320
|
+
const display = pathOf(storedNames(state, entry))
|
|
321
|
+
|
|
322
|
+
return entry.file === null
|
|
323
|
+
? folderMetadata(entry, display, true)
|
|
324
|
+
: fileMetadata(entry, entry.file, display)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Metadata of every entry, or not emulated when any is implied. */
|
|
328
|
+
const metadataOfAll = (
|
|
329
|
+
state: DropboxEmulatorState,
|
|
330
|
+
entries: ReadonlyArray<DropboxEmulatorEntry>
|
|
331
|
+
): ReadonlyArray<Schema.JsonObject> | NotEmulated => {
|
|
332
|
+
const rendered: Array<Schema.JsonObject> = []
|
|
333
|
+
|
|
334
|
+
for (const entry of entries) {
|
|
335
|
+
const metadata = metadataOf(state, entry)
|
|
336
|
+
|
|
337
|
+
if (isNotEmulated(metadata)) return metadata
|
|
338
|
+
|
|
339
|
+
rendered.push(metadata)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return rendered
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const deletedMetadata = (deleted: DropboxEmulatorDeleted): Schema.JsonObject => ({
|
|
346
|
+
'.tag': 'deleted',
|
|
347
|
+
name: deleted.name,
|
|
348
|
+
path_lower: deleted.pathLower,
|
|
349
|
+
path_display: deleted.pathDisplay
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// Counters and timestamps (minted values).
|
|
353
|
+
|
|
354
|
+
const pad = (value: number, width: number): string => String(value).padStart(width, '0')
|
|
355
|
+
|
|
356
|
+
const nextId = (state: DropboxEmulatorState): string => {
|
|
357
|
+
const number = state.counters.nextIdNumber
|
|
358
|
+
|
|
359
|
+
state.counters = { ...state.counters, nextIdNumber: number + 1 }
|
|
360
|
+
|
|
361
|
+
return `id:SyntheticEntry${pad(number, 8)}`
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const nextRev = (state: DropboxEmulatorState): string => {
|
|
365
|
+
const number = state.counters.nextRevNumber
|
|
366
|
+
|
|
367
|
+
state.counters = { ...state.counters, nextRevNumber: number + 1 }
|
|
368
|
+
|
|
369
|
+
return `a1b2c3d4e5f6${pad(number, 4)}`
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** A Dropbox timestamp (`2026-09-29T14:00:00Z`) from the emulator clock. */
|
|
373
|
+
const nowTimestamp = (env: DropboxApiEnv): string =>
|
|
374
|
+
new Date(Math.floor(env.now() / 1000) * 1000).toISOString().replace('.000Z', 'Z')
|
|
375
|
+
|
|
376
|
+
/** Issue a cursor (in a commit): a value never issued before, registered until reset or seed. */
|
|
377
|
+
const issueCursor = (env: DropboxApiEnv, cursor: DropboxCursor): string => {
|
|
378
|
+
const number = env.cursorCounters[cursor.kind]
|
|
379
|
+
|
|
380
|
+
env.cursorCounters[cursor.kind] = number + 1
|
|
381
|
+
|
|
382
|
+
const value = `AAHsynthetic${cursor.kind === 'list' ? 'List' : 'Search'}Cursor${pad(number, 4)}`
|
|
383
|
+
|
|
384
|
+
env.cursors.set(value, cursor)
|
|
385
|
+
|
|
386
|
+
return value
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Request-shape checks shared by the routes.
|
|
390
|
+
|
|
391
|
+
const withoutQuery = (request: EmulatedRequest): NotEmulated | undefined =>
|
|
392
|
+
request.query.size > 0
|
|
393
|
+
? notEmulated('query parameters are not emulated on Dropbox routes')
|
|
394
|
+
: undefined
|
|
395
|
+
|
|
396
|
+
const rpcBody = (
|
|
397
|
+
request: EmulatedRequest,
|
|
398
|
+
route: string,
|
|
399
|
+
required: ReadonlyArray<string>,
|
|
400
|
+
optional: ReadonlyArray<string> = []
|
|
401
|
+
): Schema.JsonObject | NotEmulated =>
|
|
402
|
+
withoutQuery(request) ?? exactObject(request.json, `the ${route} body`, required, optional)
|
|
403
|
+
|
|
404
|
+
const falseFlag = (value: Schema.Json | undefined, label: string): NotEmulated | undefined =>
|
|
405
|
+
value === false ? undefined : notEmulated(`${label} other than false is not emulated`)
|
|
406
|
+
|
|
407
|
+
const cursorValue = (request: EmulatedRequest, route: string): string | NotEmulated => {
|
|
408
|
+
const body = rpcBody(request, route, ['cursor'])
|
|
409
|
+
|
|
410
|
+
if (isNotEmulated(body)) return body
|
|
411
|
+
|
|
412
|
+
return Predicate.isString(body.cursor) && body.cursor.length > 0
|
|
413
|
+
? body.cursor
|
|
414
|
+
: notEmulated('cursor must be a non-empty string')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** A read-only commit. */
|
|
418
|
+
const answer =
|
|
419
|
+
(response: () => Response): Commit =>
|
|
420
|
+
() =>
|
|
421
|
+
response()
|
|
422
|
+
|
|
423
|
+
// Routes.
|
|
424
|
+
|
|
425
|
+
type GetMetadataInput = { readonly path: DropboxPath; readonly includeDeleted: boolean }
|
|
426
|
+
|
|
427
|
+
const getMetadata: Route = statefulRoute(
|
|
428
|
+
evidence('/files/get_metadata', false, [
|
|
429
|
+
pathLowerCase,
|
|
430
|
+
notFoundCase,
|
|
431
|
+
conflictCase,
|
|
432
|
+
deleteCase,
|
|
433
|
+
copyCase,
|
|
434
|
+
uploadCase
|
|
435
|
+
]),
|
|
436
|
+
'json',
|
|
437
|
+
(request): GetMetadataInput | NotEmulated => {
|
|
438
|
+
const body = rpcBody(request, 'get_metadata', ['path'], ['include_deleted'])
|
|
439
|
+
|
|
440
|
+
if (isNotEmulated(body)) return body
|
|
441
|
+
|
|
442
|
+
if (body.include_deleted !== undefined && body.include_deleted !== true) {
|
|
443
|
+
return notEmulated('include_deleted other than true is not emulated')
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const path = parsePath(body.path, 'path')
|
|
447
|
+
|
|
448
|
+
return isNotEmulated(path) ? path : { path, includeDeleted: body.include_deleted === true }
|
|
449
|
+
},
|
|
450
|
+
(state, input, { env }) => {
|
|
451
|
+
const entry = lookup(state, input.path.components, env.drills.getMetadataCaseSensitive)
|
|
452
|
+
|
|
453
|
+
if (entry === undefined) {
|
|
454
|
+
const parent = parentFolder(state, input.path.components, 'get_metadata of a path')
|
|
455
|
+
|
|
456
|
+
if (isNotEmulated(parent)) return parent
|
|
457
|
+
|
|
458
|
+
if (!input.includeDeleted) return answer(() => notFound(env))
|
|
459
|
+
|
|
460
|
+
const lower = pathOf(input.path.components).toLowerCase()
|
|
461
|
+
const deleted = state.deleted.find(candidate => candidate.pathLower === lower)
|
|
462
|
+
|
|
463
|
+
return deleted === undefined
|
|
464
|
+
? notEmulated('include_deleted for a path without a recorded delete is not emulated')
|
|
465
|
+
: answer(() => ok(deletedMetadata(deleted)))
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (input.includeDeleted) {
|
|
469
|
+
return notEmulated('include_deleted for an existing entry is not emulated')
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (entry.file === null) {
|
|
473
|
+
return notEmulated('get_metadata of a folder is not emulated (no fixture records it)')
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (entry.implied) return impliedRefusal()
|
|
477
|
+
|
|
478
|
+
// Only the last component keeps the stored casing; the rest echo the request.
|
|
479
|
+
const display = pathOf([...input.path.components.slice(0, -1), entry.name])
|
|
480
|
+
const file = entry.file
|
|
481
|
+
|
|
482
|
+
return answer(() => ok(fileMetadata(entry, file, display)))
|
|
483
|
+
}
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
type ListInput = { readonly path: DropboxPath; readonly limit: number }
|
|
487
|
+
|
|
488
|
+
/** The whole listing of a folder, rendered, or not emulated (an implied child). */
|
|
489
|
+
const listingOf = (
|
|
490
|
+
state: DropboxEmulatorState,
|
|
491
|
+
folderId: string
|
|
492
|
+
): ReadonlyArray<Schema.JsonObject> | NotEmulated =>
|
|
493
|
+
metadataOfAll(state, childrenOf(state, folderId))
|
|
494
|
+
|
|
495
|
+
/** A commit answering one listing page and issuing its cursor. */
|
|
496
|
+
const listPage =
|
|
497
|
+
(
|
|
498
|
+
env: DropboxApiEnv,
|
|
499
|
+
folderId: string,
|
|
500
|
+
listing: ReadonlyArray<Schema.JsonObject>,
|
|
501
|
+
offset: number,
|
|
502
|
+
limit: number
|
|
503
|
+
): Commit =>
|
|
504
|
+
() => {
|
|
505
|
+
const page = listing.slice(offset, offset + limit)
|
|
506
|
+
const end = offset + page.length
|
|
507
|
+
|
|
508
|
+
const cursor = issueCursor(env, {
|
|
509
|
+
kind: 'list',
|
|
510
|
+
folderId,
|
|
511
|
+
fingerprint: JSON.stringify(listing),
|
|
512
|
+
offset: end,
|
|
513
|
+
limit
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
return ok({ entries: [...page], cursor, has_more: end < listing.length })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const listFolder: Route = statefulRoute(
|
|
520
|
+
evidence('/files/list_folder', false, [pagingCase]),
|
|
521
|
+
'json',
|
|
522
|
+
(request): ListInput | NotEmulated => {
|
|
523
|
+
const body = rpcBody(request, 'list_folder', ['path', 'limit'])
|
|
524
|
+
|
|
525
|
+
if (isNotEmulated(body)) return body
|
|
526
|
+
|
|
527
|
+
const path = parsePath(body.path, 'path')
|
|
528
|
+
|
|
529
|
+
if (isNotEmulated(path)) return path
|
|
530
|
+
|
|
531
|
+
const limit = integerIn(body.limit, 'limit', 1, 2000)
|
|
532
|
+
|
|
533
|
+
return isNotEmulated(limit) ? limit : { path, limit }
|
|
534
|
+
},
|
|
535
|
+
(state, input, { env }) => {
|
|
536
|
+
const folder = lookup(state, input.path.components)
|
|
537
|
+
|
|
538
|
+
if (folder === undefined || folder.file !== null) {
|
|
539
|
+
return notEmulated('list_folder of a missing path or a file is not emulated')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const listing = listingOf(state, folder.id)
|
|
543
|
+
|
|
544
|
+
if (isNotEmulated(listing)) return listing
|
|
545
|
+
|
|
546
|
+
// Every recorded listing has entries; an empty one (for example the leftover lookup's empty
|
|
547
|
+
// work folder) is no fixture's answer.
|
|
548
|
+
if (listing.length === 0) {
|
|
549
|
+
return notEmulated('a listing of an empty folder is not emulated (no fixture records one)')
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const limit = env.drills.listFolderSinglePage ? Math.max(listing.length, 1) : input.limit
|
|
553
|
+
|
|
554
|
+
return listPage(env, folder.id, listing, 0, limit)
|
|
555
|
+
}
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
const listFolderContinue: Route = statefulRoute(
|
|
559
|
+
evidence('/files/list_folder/continue', false, [pagingCase]),
|
|
560
|
+
'json',
|
|
561
|
+
request => cursorValue(request, 'list_folder/continue'),
|
|
562
|
+
(state, value, { env }) => {
|
|
563
|
+
const cursor = env.cursors.get(value)
|
|
564
|
+
|
|
565
|
+
if (cursor === undefined || cursor.kind !== 'list') {
|
|
566
|
+
return notEmulated(
|
|
567
|
+
'a list cursor this emulator did not issue since the last reset is not emulated'
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const folder = state.entries.find(entry => entry.id === cursor.folderId)
|
|
572
|
+
const listing = folder === undefined ? undefined : listingOf(state, folder.id)
|
|
573
|
+
|
|
574
|
+
if (
|
|
575
|
+
folder === undefined ||
|
|
576
|
+
listing === undefined ||
|
|
577
|
+
isNotEmulated(listing) ||
|
|
578
|
+
JSON.stringify(listing) !== cursor.fingerprint
|
|
579
|
+
) {
|
|
580
|
+
return notEmulated(
|
|
581
|
+
'continuing a listing whose folder changed since its cursor is not emulated'
|
|
582
|
+
)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (cursor.offset >= listing.length) {
|
|
586
|
+
return notEmulated('continuing a finished listing (changes since it) is not emulated')
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return listPage(env, folder.id, listing, cursor.offset, cursor.limit)
|
|
590
|
+
}
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
type SearchInput = { readonly query: string; readonly maxResults: number }
|
|
594
|
+
|
|
595
|
+
/** Every match of `query`, rendered as the fixture's match objects, or not emulated. */
|
|
596
|
+
const searchMatches = (
|
|
597
|
+
state: DropboxEmulatorState,
|
|
598
|
+
query: string
|
|
599
|
+
): ReadonlyArray<Schema.JsonObject> | NotEmulated => {
|
|
600
|
+
const lower = query.toLowerCase()
|
|
601
|
+
const matches = state.entries.filter(entry => entry.name.toLowerCase().includes(lower))
|
|
602
|
+
|
|
603
|
+
if (matches.length === 0) return notEmulated('a search without matches is not emulated')
|
|
604
|
+
|
|
605
|
+
if (matches.some(entry => entry.file === null)) {
|
|
606
|
+
return notEmulated('a search matching a folder is not emulated (the fixture matches files)')
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const rendered = metadataOfAll(state, matches)
|
|
610
|
+
|
|
611
|
+
return isNotEmulated(rendered)
|
|
612
|
+
? rendered
|
|
613
|
+
: rendered.map(metadata => ({
|
|
614
|
+
match_type: { '.tag': 'filename' },
|
|
615
|
+
metadata: { '.tag': 'metadata', metadata }
|
|
616
|
+
}))
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** A commit answering one search page (the fixtures' last page carries no cursor). */
|
|
620
|
+
const searchPage =
|
|
621
|
+
(
|
|
622
|
+
env: DropboxApiEnv,
|
|
623
|
+
query: string,
|
|
624
|
+
matches: ReadonlyArray<Schema.JsonObject>,
|
|
625
|
+
start: number,
|
|
626
|
+
offset: number,
|
|
627
|
+
maxResults: number
|
|
628
|
+
): Commit =>
|
|
629
|
+
() => {
|
|
630
|
+
const end = Math.min(offset + maxResults, matches.length)
|
|
631
|
+
const hasMore = end < matches.length
|
|
632
|
+
const body = { matches: matches.slice(start, end), has_more: hasMore }
|
|
633
|
+
|
|
634
|
+
if (!hasMore) return ok(body)
|
|
635
|
+
|
|
636
|
+
const cursor = issueCursor(env, {
|
|
637
|
+
kind: 'search',
|
|
638
|
+
query,
|
|
639
|
+
fingerprint: JSON.stringify(matches),
|
|
640
|
+
offset: end,
|
|
641
|
+
maxResults
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
return ok({ ...body, cursor })
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const search: Route = statefulRoute(
|
|
648
|
+
evidence('/files/search_v2', false, [searchCase]),
|
|
649
|
+
'json',
|
|
650
|
+
(request): SearchInput | NotEmulated => {
|
|
651
|
+
const body = rpcBody(request, 'search_v2', ['query', 'options'])
|
|
652
|
+
|
|
653
|
+
if (isNotEmulated(body)) return body
|
|
654
|
+
|
|
655
|
+
if (!Predicate.isString(body.query) || body.query.trim().length === 0) {
|
|
656
|
+
return notEmulated('query must be a non-empty string')
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const options = exactObject(body.options, 'options', ['max_results', 'filename_only'])
|
|
660
|
+
|
|
661
|
+
if (isNotEmulated(options)) return options
|
|
662
|
+
|
|
663
|
+
if (options.filename_only !== true) {
|
|
664
|
+
return notEmulated('options.filename_only other than true is not emulated')
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const maxResults = integerIn(options.max_results, 'options.max_results', 1, 1000)
|
|
668
|
+
|
|
669
|
+
return isNotEmulated(maxResults) ? maxResults : { query: body.query, maxResults }
|
|
670
|
+
},
|
|
671
|
+
(state, input, { env }) => {
|
|
672
|
+
const matches = searchMatches(state, input.query)
|
|
673
|
+
|
|
674
|
+
return isNotEmulated(matches)
|
|
675
|
+
? matches
|
|
676
|
+
: searchPage(env, input.query, matches, 0, 0, input.maxResults)
|
|
677
|
+
}
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
const searchContinue: Route = statefulRoute(
|
|
681
|
+
evidence('/files/search/continue_v2', false, [searchCase]),
|
|
682
|
+
'json',
|
|
683
|
+
request => cursorValue(request, 'search/continue_v2'),
|
|
684
|
+
(state, value, { env }) => {
|
|
685
|
+
const cursor = env.cursors.get(value)
|
|
686
|
+
|
|
687
|
+
if (cursor === undefined || cursor.kind !== 'search') {
|
|
688
|
+
return notEmulated(
|
|
689
|
+
'a search cursor this emulator did not issue since the last reset is not emulated'
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const matches = searchMatches(state, cursor.query)
|
|
694
|
+
|
|
695
|
+
if (isNotEmulated(matches) || JSON.stringify(matches) !== cursor.fingerprint) {
|
|
696
|
+
return notEmulated(
|
|
697
|
+
'continuing a search whose matches changed since its cursor is not emulated'
|
|
698
|
+
)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const start = env.drills.searchRepeatsMatches ? 0 : cursor.offset
|
|
702
|
+
|
|
703
|
+
return searchPage(env, cursor.query, matches, start, cursor.offset, cursor.maxResults)
|
|
704
|
+
}
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
const createFolder: Route = statefulRoute(
|
|
708
|
+
evidence('/files/create_folder_v2', true, [conflictCase, deleteCase, copyCase, uploadCase]),
|
|
709
|
+
'json',
|
|
710
|
+
(request): DropboxPath | NotEmulated => {
|
|
711
|
+
const body = rpcBody(request, 'create_folder_v2', ['path', 'autorename'])
|
|
712
|
+
|
|
713
|
+
if (isNotEmulated(body)) return body
|
|
714
|
+
|
|
715
|
+
return falseFlag(body.autorename, 'autorename') ?? parsePath(body.path, 'path')
|
|
716
|
+
},
|
|
717
|
+
(state, path, { env }) => {
|
|
718
|
+
const parent = parentFolder(state, path.components, 'create_folder_v2')
|
|
719
|
+
|
|
720
|
+
if (isNotEmulated(parent)) return parent
|
|
721
|
+
|
|
722
|
+
const existing = lookup(state, path.components)
|
|
723
|
+
|
|
724
|
+
if (existing !== undefined) {
|
|
725
|
+
if (existing.file !== null) {
|
|
726
|
+
return notEmulated('create_folder_v2 over a file is not emulated')
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return answer(() =>
|
|
730
|
+
json(
|
|
731
|
+
409,
|
|
732
|
+
env.drills.folderConflictAsFile
|
|
733
|
+
? drillErrorBodies.folderConflictAsFile
|
|
734
|
+
: dropboxEmulatorErrorBodies.folderConflict
|
|
735
|
+
)
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return () => {
|
|
740
|
+
const created: DropboxEmulatorEntry = {
|
|
741
|
+
id: nextId(state),
|
|
742
|
+
parentId: parent?.id ?? null,
|
|
743
|
+
name: path.components.at(-1) ?? '',
|
|
744
|
+
file: null,
|
|
745
|
+
implied: false
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
state.entries = [...state.entries, created]
|
|
749
|
+
|
|
750
|
+
return ok({ metadata: folderMetadata(created, pathOf(storedNames(state, created)), false) })
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
/** The entry and everything under it. */
|
|
756
|
+
const subtreeOf = (
|
|
757
|
+
state: DropboxEmulatorState,
|
|
758
|
+
root: DropboxEmulatorEntry
|
|
759
|
+
): ReadonlyArray<DropboxEmulatorEntry> => [
|
|
760
|
+
root,
|
|
761
|
+
...childrenOf(state, root.id).flatMap(child => subtreeOf(state, child))
|
|
762
|
+
]
|
|
763
|
+
|
|
764
|
+
type Target = { readonly id: string } | { readonly path: DropboxPath }
|
|
765
|
+
|
|
766
|
+
const deleteEntry: Route = statefulRoute(
|
|
767
|
+
evidence('/files/delete_v2', true, [conflictCase, deleteCase, copyCase, uploadCase]),
|
|
768
|
+
'json',
|
|
769
|
+
(request): Target | NotEmulated => {
|
|
770
|
+
const body = rpcBody(request, 'delete_v2', ['path'])
|
|
771
|
+
|
|
772
|
+
if (isNotEmulated(body)) return body
|
|
773
|
+
|
|
774
|
+
if (Predicate.isString(body.path) && idPattern.test(body.path)) return { id: body.path }
|
|
775
|
+
|
|
776
|
+
const path = parsePath(body.path, 'path')
|
|
777
|
+
|
|
778
|
+
return isNotEmulated(path) ? path : { path }
|
|
779
|
+
},
|
|
780
|
+
(state, target, { env }) => {
|
|
781
|
+
const entry =
|
|
782
|
+
'id' in target
|
|
783
|
+
? state.entries.find(candidate => candidate.id === target.id)
|
|
784
|
+
: lookup(state, target.path.components)
|
|
785
|
+
|
|
786
|
+
if (entry === undefined) {
|
|
787
|
+
return notEmulated('delete_v2 of a missing entry is not emulated (no fixture records it)')
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (entry.file !== null) {
|
|
791
|
+
return notEmulated('delete_v2 of a file is not emulated (the fixtures delete folders)')
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const removed = subtreeOf(state, entry)
|
|
795
|
+
|
|
796
|
+
if (removed.some(candidate => candidate.implied)) return impliedRefusal()
|
|
797
|
+
|
|
798
|
+
const metadata = metadataOf(state, entry)
|
|
799
|
+
|
|
800
|
+
if (isNotEmulated(metadata)) return metadata
|
|
801
|
+
|
|
802
|
+
return () => {
|
|
803
|
+
const removedIds = new Set(removed.map(candidate => candidate.id))
|
|
804
|
+
|
|
805
|
+
// Only an empty folder's delete has a recorded `include_deleted` answer.
|
|
806
|
+
if (removed.length === 1 && !env.drills.deleteLeavesNoTombstone) {
|
|
807
|
+
const display = pathOf(storedNames(state, entry))
|
|
808
|
+
const lower = display.toLowerCase()
|
|
809
|
+
|
|
810
|
+
state.deleted = [
|
|
811
|
+
...state.deleted.filter(record => record.pathLower !== lower),
|
|
812
|
+
{ name: entry.name, pathLower: lower, pathDisplay: display }
|
|
813
|
+
]
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
state.entries = state.entries.filter(candidate => !removedIds.has(candidate.id))
|
|
817
|
+
|
|
818
|
+
return ok({ metadata })
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
type RelocationInput = { readonly from: DropboxPath; readonly to: DropboxPath }
|
|
824
|
+
|
|
825
|
+
const relocationInput = (
|
|
826
|
+
request: EmulatedRequest,
|
|
827
|
+
route: string
|
|
828
|
+
): RelocationInput | NotEmulated => {
|
|
829
|
+
const body = rpcBody(request, route, ['from_path', 'to_path', 'autorename'])
|
|
830
|
+
|
|
831
|
+
if (isNotEmulated(body)) return body
|
|
832
|
+
|
|
833
|
+
const autorename = falseFlag(body.autorename, 'autorename')
|
|
834
|
+
|
|
835
|
+
if (autorename !== undefined) return autorename
|
|
836
|
+
|
|
837
|
+
const from = parsePath(body.from_path, 'from_path')
|
|
838
|
+
|
|
839
|
+
if (isNotEmulated(from)) return from
|
|
840
|
+
|
|
841
|
+
const to = parsePath(body.to_path, 'to_path')
|
|
842
|
+
|
|
843
|
+
return isNotEmulated(to) ? to : { from, to }
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
type Relocation = {
|
|
847
|
+
readonly source: DropboxEmulatorEntry
|
|
848
|
+
readonly file: DropboxEmulatorFile
|
|
849
|
+
readonly parent: DropboxEmulatorEntry | null
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** The source file and destination folder of a copy or move, or not emulated. */
|
|
853
|
+
const relocation = (
|
|
854
|
+
state: DropboxEmulatorState,
|
|
855
|
+
input: RelocationInput,
|
|
856
|
+
route: string
|
|
857
|
+
): Relocation | NotEmulated => {
|
|
858
|
+
const source = lookup(state, input.from.components)
|
|
859
|
+
|
|
860
|
+
if (source === undefined || source.file === null || source.implied) {
|
|
861
|
+
return notEmulated(`${route} of a missing entry or a folder is not emulated (files only)`)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const parent = parentFolder(state, input.to.components, route)
|
|
865
|
+
|
|
866
|
+
if (isNotEmulated(parent)) return parent
|
|
867
|
+
|
|
868
|
+
return lookup(state, input.to.components) === undefined
|
|
869
|
+
? { source, file: source.file, parent }
|
|
870
|
+
: notEmulated(`${route} onto an existing entry is not emulated`)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const copy: Route = statefulRoute(
|
|
874
|
+
evidence('/files/copy_v2', true, [copyCase]),
|
|
875
|
+
'json',
|
|
876
|
+
request => relocationInput(request, 'copy_v2'),
|
|
877
|
+
(state, input) => {
|
|
878
|
+
const found = relocation(state, input, 'copy_v2')
|
|
879
|
+
|
|
880
|
+
if (isNotEmulated(found)) return found
|
|
881
|
+
|
|
882
|
+
// The copy keeps the source's size, content hash, and timestamps, as the fixture records.
|
|
883
|
+
return () => {
|
|
884
|
+
const file = { ...found.file, rev: nextRev(state) }
|
|
885
|
+
|
|
886
|
+
const created: DropboxEmulatorEntry = {
|
|
887
|
+
id: nextId(state),
|
|
888
|
+
parentId: found.parent?.id ?? null,
|
|
889
|
+
name: input.to.components.at(-1) ?? '',
|
|
890
|
+
file,
|
|
891
|
+
implied: false
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
state.entries = [...state.entries, created]
|
|
895
|
+
|
|
896
|
+
return ok({ metadata: fileMetadata(created, file, pathOf(storedNames(state, created))) })
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
const move: Route = statefulRoute(
|
|
902
|
+
evidence('/files/move_v2', true, [copyCase]),
|
|
903
|
+
'json',
|
|
904
|
+
request => relocationInput(request, 'move_v2'),
|
|
905
|
+
(state, input, { env }) => {
|
|
906
|
+
const found = relocation(state, input, 'move_v2')
|
|
907
|
+
|
|
908
|
+
if (isNotEmulated(found)) return found
|
|
909
|
+
|
|
910
|
+
// The moved file keeps its id and timestamps and gets a new rev, as the fixture records.
|
|
911
|
+
return () => {
|
|
912
|
+
const file = { ...found.file, rev: nextRev(state) }
|
|
913
|
+
|
|
914
|
+
const moved: DropboxEmulatorEntry = {
|
|
915
|
+
id: env.drills.moveMintsNewId ? nextId(state) : found.source.id,
|
|
916
|
+
parentId: found.parent?.id ?? null,
|
|
917
|
+
name: input.to.components.at(-1) ?? '',
|
|
918
|
+
file,
|
|
919
|
+
implied: false
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
state.entries = state.entries.map(entry => (entry.id === found.source.id ? moved : entry))
|
|
923
|
+
|
|
924
|
+
return ok({ metadata: fileMetadata(moved, file, pathOf(storedNames(state, moved))) })
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
type UploadInput =
|
|
930
|
+
| { readonly mode: 'add'; readonly path: DropboxPath; readonly bytes: Uint8Array }
|
|
931
|
+
| {
|
|
932
|
+
readonly mode: 'update'
|
|
933
|
+
readonly id: string
|
|
934
|
+
readonly rev: string
|
|
935
|
+
readonly bytes: Uint8Array
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const revPattern = /^[0-9a-f]{9,}$/
|
|
939
|
+
|
|
940
|
+
/** A file written by an upload (in a commit): a new rev, its size, hash, and clock timestamps. */
|
|
941
|
+
const uploadedFile = (
|
|
942
|
+
state: DropboxEmulatorState,
|
|
943
|
+
at: string,
|
|
944
|
+
bytes: Uint8Array
|
|
945
|
+
): DropboxEmulatorFile => {
|
|
946
|
+
const rev = nextRev(state)
|
|
947
|
+
|
|
948
|
+
return {
|
|
949
|
+
rev,
|
|
950
|
+
size: bytes.byteLength,
|
|
951
|
+
contentHash: syntheticContentHash(rev.slice(-2)),
|
|
952
|
+
clientModified: at,
|
|
953
|
+
serverModified: at
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const upload: Route = statefulRoute(
|
|
958
|
+
evidence('/files/upload', true, [uploadCase], dropboxEmulatorContentOrigin),
|
|
959
|
+
'bytes',
|
|
960
|
+
(request): UploadInput | NotEmulated => {
|
|
961
|
+
const noQuery = withoutQuery(request)
|
|
962
|
+
|
|
963
|
+
if (noQuery !== undefined) return noQuery
|
|
964
|
+
|
|
965
|
+
if (mediaType(request.header('content-type')) !== 'application/octet-stream') {
|
|
966
|
+
return notEmulated('the upload takes a content-type: application/octet-stream body')
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const header = request.header('dropbox-api-arg')
|
|
970
|
+
const arg = header === undefined ? undefined : parseJsonText(header)
|
|
971
|
+
|
|
972
|
+
if (arg === undefined) return notEmulated('the upload needs a JSON Dropbox-API-Arg header')
|
|
973
|
+
|
|
974
|
+
const fields = exactObject(arg, 'Dropbox-API-Arg', [
|
|
975
|
+
'path',
|
|
976
|
+
'mode',
|
|
977
|
+
'autorename',
|
|
978
|
+
'strict_conflict'
|
|
979
|
+
])
|
|
980
|
+
|
|
981
|
+
if (isNotEmulated(fields)) return fields
|
|
982
|
+
|
|
983
|
+
const autorename = falseFlag(fields.autorename, 'autorename')
|
|
984
|
+
|
|
985
|
+
if (autorename !== undefined) return autorename
|
|
986
|
+
|
|
987
|
+
if (fields.strict_conflict !== true) {
|
|
988
|
+
return notEmulated('strict_conflict other than true is not emulated')
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const bytes = request.bytes ?? new Uint8Array()
|
|
992
|
+
|
|
993
|
+
if (fields.mode === 'add') {
|
|
994
|
+
const path = parsePath(fields.path, 'path')
|
|
995
|
+
|
|
996
|
+
return isNotEmulated(path) ? path : { mode: 'add', path, bytes }
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const mode = exactObject(fields.mode, 'mode', ['.tag', 'update'])
|
|
1000
|
+
|
|
1001
|
+
if (isNotEmulated(mode)) return mode
|
|
1002
|
+
|
|
1003
|
+
if (
|
|
1004
|
+
mode['.tag'] !== 'update' ||
|
|
1005
|
+
!Predicate.isString(mode.update) ||
|
|
1006
|
+
!revPattern.test(mode.update)
|
|
1007
|
+
) {
|
|
1008
|
+
return notEmulated(
|
|
1009
|
+
'mode other than add or { ".tag": "update", update: <rev> } is not emulated'
|
|
1010
|
+
)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return Predicate.isString(fields.path) && idPattern.test(fields.path)
|
|
1014
|
+
? { mode: 'update', id: fields.path, rev: mode.update, bytes }
|
|
1015
|
+
: notEmulated('an update upload addresses the file by its id: form only')
|
|
1016
|
+
},
|
|
1017
|
+
(state, input, { env }) => {
|
|
1018
|
+
if (input.mode === 'add') {
|
|
1019
|
+
const parent = parentFolder(state, input.path.components, 'the upload')
|
|
1020
|
+
|
|
1021
|
+
if (isNotEmulated(parent)) return parent
|
|
1022
|
+
|
|
1023
|
+
const existing = lookup(state, input.path.components)
|
|
1024
|
+
|
|
1025
|
+
if (existing !== undefined) {
|
|
1026
|
+
return existing.file === null
|
|
1027
|
+
? notEmulated('an add upload onto a folder is not emulated')
|
|
1028
|
+
: answer(() => json(409, dropboxEmulatorErrorBodies.uploadConflict))
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
return () => {
|
|
1032
|
+
// Read the clock before any write: a failing clock writes nothing.
|
|
1033
|
+
const at = nowTimestamp(env)
|
|
1034
|
+
const file = uploadedFile(state, at, input.bytes)
|
|
1035
|
+
|
|
1036
|
+
const created: DropboxEmulatorEntry = {
|
|
1037
|
+
id: nextId(state),
|
|
1038
|
+
parentId: parent?.id ?? null,
|
|
1039
|
+
name: input.path.components.at(-1) ?? '',
|
|
1040
|
+
file,
|
|
1041
|
+
implied: false
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
state.entries = [...state.entries, created]
|
|
1045
|
+
|
|
1046
|
+
return ok(fileMetadata(created, file, pathOf(storedNames(state, created))))
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const existing = state.entries.find(entry => entry.id === input.id)
|
|
1051
|
+
|
|
1052
|
+
if (existing === undefined || existing.file === null || existing.implied) {
|
|
1053
|
+
return notEmulated('an update upload of a missing file or a folder is not emulated')
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (existing.file.rev !== input.rev && !env.drills.uploadIgnoresRev) {
|
|
1057
|
+
return answer(() => json(409, dropboxEmulatorErrorBodies.uploadConflict))
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return () => {
|
|
1061
|
+
const at = nowTimestamp(env)
|
|
1062
|
+
const file = uploadedFile(state, at, input.bytes)
|
|
1063
|
+
const updated: DropboxEmulatorEntry = { ...existing, file }
|
|
1064
|
+
|
|
1065
|
+
state.entries = state.entries.map(entry => (entry.id === existing.id ? updated : entry))
|
|
1066
|
+
|
|
1067
|
+
return ok(fileMetadata(updated, file, pathOf(storedNames(state, updated))))
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
/** The route table: evidence plus handlers. `dropboxEmulatorRoutes` is its evidence part. */
|
|
1073
|
+
export const dropboxApiRoutes: ReadonlyArray<Route> = [
|
|
1074
|
+
listFolder,
|
|
1075
|
+
listFolderContinue,
|
|
1076
|
+
getMetadata,
|
|
1077
|
+
search,
|
|
1078
|
+
searchContinue,
|
|
1079
|
+
createFolder,
|
|
1080
|
+
deleteEntry,
|
|
1081
|
+
copy,
|
|
1082
|
+
move,
|
|
1083
|
+
upload
|
|
1084
|
+
]
|