@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,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Microsoft Graph emulator wire helpers (internal): the error envelope and codes, JSON
|
|
3
|
+
* responses, `@odata.context`, `$select` projection, `$top`/`$skip` paging with
|
|
4
|
+
* `@odata.nextLink` (folder messages only), single-page `$top` collections (`$top` always required
|
|
5
|
+
* and capped at the largest value a fixture sends to the route), the `Prefer` header, strict nested
|
|
6
|
+
* body objects, and the route handler contract.
|
|
7
|
+
*
|
|
8
|
+
* @experimental
|
|
9
|
+
*/
|
|
10
|
+
import { Predicate, Result } from 'effect'
|
|
11
|
+
import * as Schema from 'effect/Schema'
|
|
12
|
+
import type { MicrosoftEmulatorState, MicrosoftEmulatorUser } from './state.ts'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Error codes the emulator answers with. `ErrorItemNotFound`, `itemNotFound`, and
|
|
16
|
+
* `ErrorIrresolvableConflict` are copied from the fixtures; `InvalidAuthenticationToken` (missing
|
|
17
|
+
* bearer), `ErrorInvalidUser` (a user other than the seeded mailbox), and `TooManyRequests` (the
|
|
18
|
+
* default 429 fault body) are documented Graph codes no fixture records; the `Synthetic*` codes
|
|
19
|
+
* are emulator codes for requests it refuses to emulate (fail closed) or cannot answer.
|
|
20
|
+
*/
|
|
21
|
+
export const microsoftEmulatorErrorCodes = {
|
|
22
|
+
mailItemNotFound: 'ErrorItemNotFound',
|
|
23
|
+
driveItemNotFound: 'itemNotFound',
|
|
24
|
+
conflict: 'ErrorIrresolvableConflict',
|
|
25
|
+
unauthenticated: 'InvalidAuthenticationToken',
|
|
26
|
+
invalidUser: 'ErrorInvalidUser',
|
|
27
|
+
rateLimited: 'TooManyRequests',
|
|
28
|
+
unknownRoute: 'SyntheticRouteNotEmulated',
|
|
29
|
+
unsupportedQuery: 'SyntheticQueryNotEmulated',
|
|
30
|
+
unsupportedValue: 'SyntheticValueNotEmulated',
|
|
31
|
+
invalidRequest: 'SyntheticInvalidRequest',
|
|
32
|
+
invalidBody: 'SyntheticInvalidBody',
|
|
33
|
+
upstreamError: 'SyntheticUpstreamError'
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
export const codes = microsoftEmulatorErrorCodes
|
|
37
|
+
|
|
38
|
+
/** Graph JSON content type, as the fixtures record it. */
|
|
39
|
+
export const graphContentType =
|
|
40
|
+
'application/json; odata.metadata=minimal; odata.streaming=true; IEEE754Compatible=false; charset=utf-8'
|
|
41
|
+
|
|
42
|
+
export const jsonResponse = (
|
|
43
|
+
status: number,
|
|
44
|
+
body: unknown,
|
|
45
|
+
headers: HeadersInit = {}
|
|
46
|
+
): Response => {
|
|
47
|
+
const responseHeaders = new Headers(headers)
|
|
48
|
+
|
|
49
|
+
if (!responseHeaders.has('content-type')) responseHeaders.set('content-type', graphContentType)
|
|
50
|
+
|
|
51
|
+
return new Response(JSON.stringify(body), { status, headers: responseHeaders })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Graph API version prefix of every Graph route. */
|
|
55
|
+
export const microsoftEmulatorBasePath = '/v1.0'
|
|
56
|
+
|
|
57
|
+
/** A percent-decoded path segment, or `undefined` for invalid percent-encoding. */
|
|
58
|
+
export const decodeSegment = (segment: string): string | undefined => {
|
|
59
|
+
try {
|
|
60
|
+
return decodeURIComponent(segment)
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* An OData key segment as the fixtures write it in `@odata.context`: `('ada%40example.test')`,
|
|
68
|
+
* `('b%21synthetic-drive-0001')` (percent-encoded, including `!'()*`).
|
|
69
|
+
*/
|
|
70
|
+
export const odataKey = (value: string): string =>
|
|
71
|
+
`('${encodeURIComponent(value).replace(
|
|
72
|
+
/[!'()*]/g,
|
|
73
|
+
character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`
|
|
74
|
+
)}')`
|
|
75
|
+
|
|
76
|
+
/** `(a,b,c)` for a `$select`, empty without one, as the fixture contexts write it. */
|
|
77
|
+
export const selectSuffix = (fields: ReadonlyArray<string> | undefined): string =>
|
|
78
|
+
fields === undefined ? '' : `(${fields.join(',')})`
|
|
79
|
+
|
|
80
|
+
/** The fixture `@odata.context` URL: `{graphOrigin}/v1.0/$metadata#{path}`. */
|
|
81
|
+
export const metadataContext = (env: MicrosoftApiEnv, path: string): string =>
|
|
82
|
+
`${env.graphOrigin}${microsoftEmulatorBasePath}/$metadata#${path}`
|
|
83
|
+
|
|
84
|
+
/** An entity body with its `@odata.context` first, as the fixtures record it. */
|
|
85
|
+
export const entity = (context: string, body: Schema.JsonObject): Schema.JsonObject => ({
|
|
86
|
+
'@odata.context': context,
|
|
87
|
+
...body
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/** A bodiless success (204 delete, 202 cancel/copy), as the fixtures record them. */
|
|
91
|
+
export const emptyResponse = (status: number, headers: HeadersInit = {}): Response =>
|
|
92
|
+
new Response(null, { status, headers })
|
|
93
|
+
|
|
94
|
+
/** Where a Graph error envelope's `innerError` values come from. */
|
|
95
|
+
export type ErrorContext = {
|
|
96
|
+
/** `innerError.date` (`YYYY-MM-DDTHH:MM:SS`, UTC). */
|
|
97
|
+
readonly date: string
|
|
98
|
+
readonly requestId: string
|
|
99
|
+
readonly clientRequestId: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The Graph error envelope `{ error: { code, message, innerError } }`. */
|
|
103
|
+
export const graphError = (
|
|
104
|
+
context: ErrorContext,
|
|
105
|
+
status: number,
|
|
106
|
+
code: string,
|
|
107
|
+
message: string
|
|
108
|
+
): Response =>
|
|
109
|
+
jsonResponse(status, {
|
|
110
|
+
error: {
|
|
111
|
+
code,
|
|
112
|
+
message,
|
|
113
|
+
innerError: {
|
|
114
|
+
date: context.date,
|
|
115
|
+
'request-id': context.requestId,
|
|
116
|
+
'client-request-id': context.clientRequestId
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
export const padded = (value: number, width: number): string => String(value).padStart(width, '0')
|
|
122
|
+
|
|
123
|
+
/** A synthetic request id for ledger sequence `seq`. */
|
|
124
|
+
export const syntheticRequestId = (seq: number): string =>
|
|
125
|
+
`00000000-0000-4000-8000-${padded(seq % 1_000_000_000_000, 12)}`
|
|
126
|
+
|
|
127
|
+
const clientRequestIdPattern = /^[A-Za-z0-9-]{1,64}$/
|
|
128
|
+
|
|
129
|
+
/** The error context of one request; `client-request-id` is echoed only when it is plain. */
|
|
130
|
+
export const errorContext = (
|
|
131
|
+
now: number,
|
|
132
|
+
seq: number,
|
|
133
|
+
clientRequestId: string | null
|
|
134
|
+
): ErrorContext => {
|
|
135
|
+
const requestId = syntheticRequestId(seq)
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
date: new Date(Math.floor(now / 1000) * 1000).toISOString().slice(0, 19),
|
|
139
|
+
requestId,
|
|
140
|
+
clientRequestId:
|
|
141
|
+
clientRequestId !== null && clientRequestIdPattern.test(clientRequestId)
|
|
142
|
+
? clientRequestId
|
|
143
|
+
: requestId
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The parsed `Prefer` preferences the emulator reads (others are ignored, as Graph does). */
|
|
148
|
+
export type Preferences = {
|
|
149
|
+
/** `IdType="ImmutableId"`: answer message ids in immutable form. */
|
|
150
|
+
readonly immutableId: boolean
|
|
151
|
+
/** `outlook.timezone`, when sent. */
|
|
152
|
+
readonly timezone: string | undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const unquote = (value: string): string =>
|
|
156
|
+
value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value
|
|
157
|
+
|
|
158
|
+
/** Parse `Prefer`: comma-separated `name=value` preferences. */
|
|
159
|
+
export const parsePreferences = (header: string | null): Preferences => {
|
|
160
|
+
let immutableId = false
|
|
161
|
+
let timezone: string | undefined
|
|
162
|
+
|
|
163
|
+
for (const part of (header ?? '').split(',')) {
|
|
164
|
+
const separator = part.indexOf('=')
|
|
165
|
+
const name = (separator === -1 ? part : part.slice(0, separator)).trim().toLowerCase()
|
|
166
|
+
const value = separator === -1 ? '' : unquote(part.slice(separator + 1).trim())
|
|
167
|
+
|
|
168
|
+
if (name === 'idtype' && value.toLowerCase() === 'immutableid') immutableId = true
|
|
169
|
+
|
|
170
|
+
if (name === 'outlook.timezone') timezone = value
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { immutableId, timezone }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One routed API request, as the handlers see it. */
|
|
177
|
+
export type RouteRequest = {
|
|
178
|
+
/** Decoded path parameters. */
|
|
179
|
+
readonly params: Readonly<Record<string, string>>
|
|
180
|
+
/** Raw (still percent-encoded) path, for example `/v1.0/users/ada%40example.test/...`. */
|
|
181
|
+
readonly path: string
|
|
182
|
+
readonly query: URLSearchParams
|
|
183
|
+
/** Parsed JSON body; `undefined` when absent. */
|
|
184
|
+
readonly body: Schema.Json | undefined
|
|
185
|
+
readonly prefer: Preferences
|
|
186
|
+
/** A Graph error envelope response for this request. */
|
|
187
|
+
readonly error: (status: number, code: string, message: string) => Response
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Drill knobs (tests only): each makes the emulator disagree with one conformance claim. */
|
|
191
|
+
export type MicrosoftEmulatorDrills = {
|
|
192
|
+
/** `true`: `calendarView` answers an empty `value` for every range. */
|
|
193
|
+
readonly calendarRangeEmpty?: boolean
|
|
194
|
+
/** `true`: event create answers 201 without the new event's `id` (the event is still created). */
|
|
195
|
+
readonly createOmitsId?: boolean
|
|
196
|
+
/** Fractional digits of event `dateTime` values (Graph uses 7); `3` drills the precision case. */
|
|
197
|
+
readonly timestampPrecisionDigits?: number
|
|
198
|
+
/** `true`: folder message pages never carry `@odata.nextLink`. */
|
|
199
|
+
readonly omitNextLink?: boolean
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A OneDrive copy the monitor reports on (runtime-only, like the ledger; not in the state). */
|
|
203
|
+
export type CopyMonitor = {
|
|
204
|
+
readonly id: string
|
|
205
|
+
readonly sourceId: string
|
|
206
|
+
readonly destinationParentId: string
|
|
207
|
+
/** In-progress answers left before the copy runs and completes. */
|
|
208
|
+
pollsLeft: number
|
|
209
|
+
/** Set once the copy ran and completed. */
|
|
210
|
+
resourceId: string | undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type MicrosoftApiEnv = {
|
|
214
|
+
/** Clock in epoch milliseconds (timestamps of created items). */
|
|
215
|
+
readonly now: () => number
|
|
216
|
+
/** Origin of `@odata.nextLink` values, for example `https://graph.microsoft.com`. */
|
|
217
|
+
readonly graphOrigin: string
|
|
218
|
+
/** Origin of OneDrive `webUrl` values and copy monitor URLs. */
|
|
219
|
+
readonly sharePointOrigin: string
|
|
220
|
+
readonly drills: Required<MicrosoftEmulatorDrills>
|
|
221
|
+
/** In-progress monitor answers before a copy completes (default 0: the first poll completes). */
|
|
222
|
+
readonly copyInProgressPolls: number
|
|
223
|
+
/** How long a message write holds its message; a concurrent write in that time loses (409). */
|
|
224
|
+
readonly conflictWindowMs: number
|
|
225
|
+
/** Immutable ids of messages with a write in flight (runtime-only). */
|
|
226
|
+
readonly messageLocks: Set<string>
|
|
227
|
+
/** Copy monitors by id (runtime-only; cleared by reset and seed). */
|
|
228
|
+
readonly monitors: Map<string, CopyMonitor>
|
|
229
|
+
readonly monitorCounter: { next: number }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A message write's answer and the message it holds once committed: the write holds it for
|
|
234
|
+
* `conflictWindowMs` before answering, so an overlapping write to it gets 409.
|
|
235
|
+
*/
|
|
236
|
+
export type HeldAnswer = {
|
|
237
|
+
readonly response: Response
|
|
238
|
+
/** Immutable id of the message held. */
|
|
239
|
+
readonly holds: string
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A route handler. It runs on a draft of the state and of the copy monitors (see
|
|
244
|
+
* `registerMicrosoftApi`) and decides the whole answer synchronously; it replaces whole state lists
|
|
245
|
+
* and counters, never editing them in place. Any answer that is not 2xx is a refusal.
|
|
246
|
+
*/
|
|
247
|
+
export type RouteHandler = (
|
|
248
|
+
state: MicrosoftEmulatorState,
|
|
249
|
+
request: RouteRequest,
|
|
250
|
+
env: MicrosoftApiEnv
|
|
251
|
+
) => Response | HeldAnswer
|
|
252
|
+
|
|
253
|
+
export const isJsonObject = (value: Schema.Json | undefined): value is Schema.JsonObject =>
|
|
254
|
+
value !== undefined && value !== null && Predicate.isObject(value) && !Array.isArray(value)
|
|
255
|
+
|
|
256
|
+
const decodeJsonText = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Json))
|
|
257
|
+
|
|
258
|
+
/** Parsed JSON, or `undefined` for invalid JSON. */
|
|
259
|
+
export const parseJsonText = (text: string): Schema.Json | undefined => {
|
|
260
|
+
const result = decodeJsonText(text)
|
|
261
|
+
|
|
262
|
+
return Result.isSuccess(result) ? result.success : undefined
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The user a `/users/{userId}` segment names (id, `mail`, or UPN, case-insensitive). */
|
|
266
|
+
export const resolveUser = (
|
|
267
|
+
state: MicrosoftEmulatorState,
|
|
268
|
+
request: RouteRequest
|
|
269
|
+
): MicrosoftEmulatorUser | Response => {
|
|
270
|
+
const userId = (request.params.userId ?? '').toLowerCase()
|
|
271
|
+
const user = state.user
|
|
272
|
+
|
|
273
|
+
return [user.id, user.mail, user.userPrincipalName].some(
|
|
274
|
+
candidate => candidate.toLowerCase() === userId
|
|
275
|
+
)
|
|
276
|
+
? user
|
|
277
|
+
: request.error(404, codes.invalidUser, 'Synthetic: the requested user is not emulated.')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The `$select` fields, `undefined` without `$select`, or a 400 for a field not emulated. */
|
|
281
|
+
export const selectedFields = (
|
|
282
|
+
request: RouteRequest,
|
|
283
|
+
allowed: ReadonlyArray<string>
|
|
284
|
+
): ReadonlyArray<string> | undefined | Response => {
|
|
285
|
+
const raw = request.query.get('$select')
|
|
286
|
+
|
|
287
|
+
if (raw === null) return undefined
|
|
288
|
+
|
|
289
|
+
const fields = raw.split(',').map(field => field.trim())
|
|
290
|
+
const unknown = fields.find(field => !allowed.includes(field))
|
|
291
|
+
|
|
292
|
+
return unknown === undefined
|
|
293
|
+
? fields
|
|
294
|
+
: request.error(
|
|
295
|
+
400,
|
|
296
|
+
codes.unsupportedQuery,
|
|
297
|
+
`Synthetic: $select field '${unknown}' is not emulated on this resource.`
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Annotations and the id are always returned, whatever `$select` names. */
|
|
302
|
+
const alwaysReturned: ReadonlyArray<string> = [
|
|
303
|
+
'@odata.etag',
|
|
304
|
+
'@odata.type',
|
|
305
|
+
'@odata.mediaContentType',
|
|
306
|
+
'id'
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
/** Keep only the selected keys (plus annotations and `id`); everything without `$select`. */
|
|
310
|
+
export const project = (
|
|
311
|
+
full: Schema.JsonObject,
|
|
312
|
+
fields: ReadonlyArray<string> | undefined
|
|
313
|
+
): Schema.JsonObject =>
|
|
314
|
+
fields === undefined
|
|
315
|
+
? full
|
|
316
|
+
: Object.fromEntries(
|
|
317
|
+
Object.entries(full).filter(([key]) => alwaysReturned.includes(key) || fields.includes(key))
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
const integerQuery = (
|
|
321
|
+
request: RouteRequest,
|
|
322
|
+
key: string,
|
|
323
|
+
minimum: number,
|
|
324
|
+
maximum: number
|
|
325
|
+
): number | undefined | Response => {
|
|
326
|
+
const raw = request.query.get(key)
|
|
327
|
+
|
|
328
|
+
if (raw === null) return undefined
|
|
329
|
+
|
|
330
|
+
const value = Number(raw)
|
|
331
|
+
|
|
332
|
+
return /^\d+$/.test(raw) && value >= minimum && value <= maximum
|
|
333
|
+
? value
|
|
334
|
+
: request.error(
|
|
335
|
+
400,
|
|
336
|
+
codes.invalidRequest,
|
|
337
|
+
`Synthetic: ${key} must be an integer from ${minimum} to ${maximum}.`
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The required `$top` of a collection whose fixtures always send it: a positive integer (400
|
|
343
|
+
* invalid otherwise), and 400 not emulated when it is missing or above `maxTop`, the largest
|
|
344
|
+
* `$top` any fixture sends to that route.
|
|
345
|
+
*/
|
|
346
|
+
const requiredTop = (request: RouteRequest, maxTop: number): number | Response => {
|
|
347
|
+
const top = integerQuery(request, '$top', 1, Number.MAX_SAFE_INTEGER)
|
|
348
|
+
|
|
349
|
+
if (top instanceof Response) return top
|
|
350
|
+
|
|
351
|
+
if (top === undefined) {
|
|
352
|
+
return notEmulated(request, 'requests without $top are not emulated on this collection.')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return top > maxTop
|
|
356
|
+
? notEmulated(
|
|
357
|
+
request,
|
|
358
|
+
`$top above ${maxTop} (the largest page size a fixture sends) is not emulated on this collection.`
|
|
359
|
+
)
|
|
360
|
+
: top
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export type Page<A> = {
|
|
364
|
+
readonly items: ReadonlyArray<A>
|
|
365
|
+
readonly top: number
|
|
366
|
+
/** `$skip` of the next page, when there is one. */
|
|
367
|
+
readonly nextSkip: number | undefined
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* One `$top`/`$skip` page of `items`. `$top` is required and at most `maxTop` (see
|
|
372
|
+
* `requiredTop`); no fixture records a default page size.
|
|
373
|
+
*/
|
|
374
|
+
export const pageOf = <A>(
|
|
375
|
+
items: ReadonlyArray<A>,
|
|
376
|
+
request: RouteRequest,
|
|
377
|
+
maxTop: number
|
|
378
|
+
): Page<A> | Response => {
|
|
379
|
+
const top = requiredTop(request, maxTop)
|
|
380
|
+
|
|
381
|
+
if (top instanceof Response) return top
|
|
382
|
+
|
|
383
|
+
const skip = integerQuery(request, '$skip', 0, Number.MAX_SAFE_INTEGER)
|
|
384
|
+
|
|
385
|
+
if (skip instanceof Response) return skip
|
|
386
|
+
|
|
387
|
+
const start = skip ?? 0
|
|
388
|
+
const end = start + top
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
items: items.slice(start, end),
|
|
392
|
+
top,
|
|
393
|
+
nextSkip: end < items.length ? end : undefined
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* A collection whose fixtures send `$top` and never page (calendar views, children listings):
|
|
399
|
+
* the items, or 400 not emulated without `$top`, with `$top` above `maxTop` (see `requiredTop`),
|
|
400
|
+
* or with more items than `$top` (no fixture records a default page size or a second page of
|
|
401
|
+
* these collections).
|
|
402
|
+
*/
|
|
403
|
+
export const singlePage = <A>(
|
|
404
|
+
items: ReadonlyArray<A>,
|
|
405
|
+
request: RouteRequest,
|
|
406
|
+
maxTop: number
|
|
407
|
+
): ReadonlyArray<A> | Response => {
|
|
408
|
+
const top = requiredTop(request, maxTop)
|
|
409
|
+
|
|
410
|
+
if (top instanceof Response) return top
|
|
411
|
+
|
|
412
|
+
return items.length > top
|
|
413
|
+
? notEmulated(
|
|
414
|
+
request,
|
|
415
|
+
`more than $top (${top}) results: paging this collection is not emulated.`
|
|
416
|
+
)
|
|
417
|
+
: items
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The `@odata.nextLink` of a page: the configured Graph origin, the request's raw path (so
|
|
422
|
+
* `/users/ada%40example.test` keeps its `%40`), the carried query keys, then `$top` and `$skip`,
|
|
423
|
+
* encoded like the fixtures (`%24select=id%2Csubject...`). `undefined` on the last page or when
|
|
424
|
+
* the `omitNextLink` drill knob is set. Clients follow it unchanged (opaque).
|
|
425
|
+
*/
|
|
426
|
+
export const nextLinkOf = (
|
|
427
|
+
env: MicrosoftApiEnv,
|
|
428
|
+
request: RouteRequest,
|
|
429
|
+
page: Page<unknown>,
|
|
430
|
+
carried: ReadonlyArray<string>
|
|
431
|
+
): string | undefined => {
|
|
432
|
+
if (page.nextSkip === undefined || env.drills.omitNextLink) return undefined
|
|
433
|
+
|
|
434
|
+
const params = new URLSearchParams()
|
|
435
|
+
|
|
436
|
+
for (const key of carried) {
|
|
437
|
+
const value = request.query.get(key)
|
|
438
|
+
|
|
439
|
+
if (value !== null) params.set(key, value)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
params.set('$top', String(page.top))
|
|
443
|
+
params.set('$skip', String(page.nextSkip))
|
|
444
|
+
|
|
445
|
+
return `${env.graphOrigin}${request.path}?${params.toString()}`
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** `{ @odata.context, value, @odata.nextLink? }`, as the fixture pages record them. */
|
|
449
|
+
export const collection = (
|
|
450
|
+
context: string,
|
|
451
|
+
value: ReadonlyArray<unknown>,
|
|
452
|
+
nextLink: string | undefined
|
|
453
|
+
) =>
|
|
454
|
+
nextLink === undefined
|
|
455
|
+
? { '@odata.context': context, value }
|
|
456
|
+
: { '@odata.context': context, value, '@odata.nextLink': nextLink }
|
|
457
|
+
|
|
458
|
+
/** A write body that is a JSON object with only `allowed` keys, or a 400. */
|
|
459
|
+
export const bodyObject = (
|
|
460
|
+
request: RouteRequest,
|
|
461
|
+
allowed: ReadonlyArray<string>
|
|
462
|
+
): Schema.JsonObject | Response => {
|
|
463
|
+
if (!isJsonObject(request.body)) {
|
|
464
|
+
return request.error(
|
|
465
|
+
400,
|
|
466
|
+
codes.invalidBody,
|
|
467
|
+
'Synthetic: the request body must be a JSON object.'
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const unknown = Object.keys(request.body).find(key => !allowed.includes(key))
|
|
472
|
+
|
|
473
|
+
return unknown === undefined
|
|
474
|
+
? request.body
|
|
475
|
+
: request.error(
|
|
476
|
+
400,
|
|
477
|
+
codes.unsupportedValue,
|
|
478
|
+
`Synthetic: property '${unknown}' is not emulated on this request.`
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** 400 for an invalid value. */
|
|
483
|
+
export const invalidValue = (request: RouteRequest, message: string): Response =>
|
|
484
|
+
request.error(400, codes.invalidRequest, `Synthetic: ${message}`)
|
|
485
|
+
|
|
486
|
+
/** 400 for a valid Graph request the emulator does not emulate (fail closed). */
|
|
487
|
+
export const notEmulated = (request: RouteRequest, message: string): Response =>
|
|
488
|
+
request.error(400, codes.unsupportedValue, `Synthetic: ${message}`)
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* A nested JSON object with only `allowed` keys (fail closed on anything else, before any write),
|
|
492
|
+
* or a 400. `expected` names the expected object in the error message.
|
|
493
|
+
*/
|
|
494
|
+
export const nestedObject = (
|
|
495
|
+
request: RouteRequest,
|
|
496
|
+
value: Schema.Json | undefined,
|
|
497
|
+
allowed: ReadonlyArray<string>,
|
|
498
|
+
expected: string
|
|
499
|
+
): Schema.JsonObject | Response => {
|
|
500
|
+
if (!isJsonObject(value)) return invalidValue(request, `expected ${expected}.`)
|
|
501
|
+
|
|
502
|
+
const unknown = Object.keys(value).find(key => !allowed.includes(key))
|
|
503
|
+
|
|
504
|
+
return unknown === undefined
|
|
505
|
+
? value
|
|
506
|
+
: notEmulated(request, `property '${unknown}' is not emulated in ${expected}.`)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* An `itemBody` write value, `{ contentType, content }` with only those keys. The fixtures write
|
|
511
|
+
* text bodies only (`Text`/`text`); any other content type is not emulated.
|
|
512
|
+
*/
|
|
513
|
+
export const textBody = (
|
|
514
|
+
request: RouteRequest,
|
|
515
|
+
value: Schema.Json | undefined
|
|
516
|
+
): string | Response => {
|
|
517
|
+
const body = nestedObject(
|
|
518
|
+
request,
|
|
519
|
+
value,
|
|
520
|
+
['contentType', 'content'],
|
|
521
|
+
'body { contentType, content }'
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
if (body instanceof Response) return body
|
|
525
|
+
|
|
526
|
+
if (!Predicate.isString(body.contentType) || !Predicate.isString(body.content)) {
|
|
527
|
+
return invalidValue(request, 'body must be { contentType, content } with string values.')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return body.contentType.toLowerCase() === 'text'
|
|
531
|
+
? body.content
|
|
532
|
+
: notEmulated(request, `body contentType ${body.contentType} is not emulated (text only).`)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** A Graph timestamp (`2026-09-29T10:00:00Z`) from the emulator clock. */
|
|
536
|
+
export const nowTimestamp = (env: MicrosoftApiEnv): string =>
|
|
537
|
+
new Date(Math.floor(env.now() / 1000) * 1000).toISOString().replace('.000Z', 'Z')
|
|
538
|
+
|
|
539
|
+
/** The next change key (bumps the counter). */
|
|
540
|
+
export const nextChangeKey = (state: MicrosoftEmulatorState, prefix: string): string => {
|
|
541
|
+
const number = state.counters.nextChangeKeyNumber
|
|
542
|
+
|
|
543
|
+
state.counters = { ...state.counters, nextChangeKeyNumber: number + 1 }
|
|
544
|
+
|
|
545
|
+
return `${prefix}${padded(number, 4)}`
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** The `personal/{site}` segment of the mailbox owner (`ada@example.test` → `ada_example_test`). */
|
|
549
|
+
export const personalSite = (user: MicrosoftEmulatorUser): string =>
|
|
550
|
+
user.mail.toLowerCase().replace(/[^a-z0-9]/g, '_')
|