@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
package/src/fortnox.ts
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateful Fortnox API emulator (`/3`), built on the upstream `@emulators/core` custom runtime,
|
|
3
|
+
* with a request ledger, wire faults, and an `/_emulate/*` control plane.
|
|
4
|
+
*
|
|
5
|
+
* It never imports SDK code: its wire shapes and default seed are copied as data from the
|
|
6
|
+
* synthetic Fortnox conformance fixtures, and every route names the conformance cases it follows
|
|
7
|
+
* in `fortnoxEmulatorRoutes`. The observed Fortnox quirks those cases claim (sticky row discounts,
|
|
8
|
+
* empty strings that keep values, payment filters that skip unbooked invoices, `ErrorInformation`
|
|
9
|
+
* rejections, read-only `Country`) are implemented and listed in `fortnoxEmulatorQuirks`.
|
|
10
|
+
*
|
|
11
|
+
* Node-only: `@emulators/core` imports Node builtins, so the core is loaded lazily by
|
|
12
|
+
* `makeFortnoxEmulator` (importing this module has no side effects).
|
|
13
|
+
*
|
|
14
|
+
* @experimental
|
|
15
|
+
*/
|
|
16
|
+
import type { EmulatorSnapshot } from '@emulators/core'
|
|
17
|
+
import { Data, Predicate, Result } from 'effect'
|
|
18
|
+
import * as Schema from 'effect/Schema'
|
|
19
|
+
import {
|
|
20
|
+
EmulatorHeaderRecord,
|
|
21
|
+
EmulatorResponseStatus,
|
|
22
|
+
emulatorJobHeader,
|
|
23
|
+
handlerFailedHeader
|
|
24
|
+
} from './emulator-http.ts'
|
|
25
|
+
import {
|
|
26
|
+
errorInformation,
|
|
27
|
+
fortnoxApiRoutes,
|
|
28
|
+
fortnoxEmulatorErrorCodes,
|
|
29
|
+
matchFortnoxRoute,
|
|
30
|
+
parseJsonText,
|
|
31
|
+
registerFortnoxApi,
|
|
32
|
+
type FortnoxApiEnv,
|
|
33
|
+
type FortnoxEmulatorQuirks
|
|
34
|
+
} from './fortnox/api.ts'
|
|
35
|
+
import {
|
|
36
|
+
buildSeedState,
|
|
37
|
+
decodeState,
|
|
38
|
+
type FortnoxEmulatorSeed,
|
|
39
|
+
type FortnoxEmulatorState
|
|
40
|
+
} from './fortnox/state.ts'
|
|
41
|
+
import {
|
|
42
|
+
emulatorEvidenceHeader,
|
|
43
|
+
type EmulatorEvidence,
|
|
44
|
+
type EmulatorRouteEvidence
|
|
45
|
+
} from './route-evidence.ts'
|
|
46
|
+
|
|
47
|
+
export type { EmulatorEvidence, EmulatorRouteEvidence } from './route-evidence.ts'
|
|
48
|
+
|
|
49
|
+
export { emulatorEvidenceHeader } from './route-evidence.ts'
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
fortnoxEmulatorBasePath,
|
|
53
|
+
fortnoxEmulatorErrorCodes,
|
|
54
|
+
fortnoxEmulatorQuirks,
|
|
55
|
+
type FortnoxEmulatorQuirk,
|
|
56
|
+
type FortnoxEmulatorQuirks
|
|
57
|
+
} from './fortnox/api.ts'
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
FortnoxEmulatorCompany,
|
|
61
|
+
FortnoxEmulatorCustomer,
|
|
62
|
+
FortnoxEmulatorCustomerSeed,
|
|
63
|
+
FortnoxEmulatorInvoice,
|
|
64
|
+
FortnoxEmulatorInvoiceRow,
|
|
65
|
+
FortnoxEmulatorInvoiceRowSeed,
|
|
66
|
+
FortnoxEmulatorInvoiceSeed,
|
|
67
|
+
FortnoxEmulatorOutboxEntry,
|
|
68
|
+
FortnoxEmulatorProfile,
|
|
69
|
+
FortnoxEmulatorSeed,
|
|
70
|
+
FortnoxEmulatorStateSchema,
|
|
71
|
+
type FortnoxEmulatorState
|
|
72
|
+
} from './fortnox/state.ts'
|
|
73
|
+
|
|
74
|
+
/** Origin the connector calls; also the default origin of `@url` links. */
|
|
75
|
+
export const fortnoxEmulatorDefaultOrigin = 'https://api.fortnox.se'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Route evidence manifest: every emulated Fortnox route, whether it writes, and the conformance
|
|
79
|
+
* cases whose (currently synthetic, unverified) wire claims it follows. Kept in sync with the
|
|
80
|
+
* handlers by construction (both come from one route table).
|
|
81
|
+
*/
|
|
82
|
+
export const fortnoxEmulatorRoutes: ReadonlyArray<EmulatorRouteEvidence> = fortnoxApiRoutes.map(
|
|
83
|
+
({ handler: _handler, queryKeys: _queryKeys, ...route }) => route
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const FaultCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))
|
|
87
|
+
|
|
88
|
+
/** Optional fault filter; an omitted field matches every request. `path` ending in `*` is a prefix. */
|
|
89
|
+
export const FortnoxFaultMatch = Schema.Struct({
|
|
90
|
+
method: Schema.optionalKey(Schema.String),
|
|
91
|
+
path: Schema.optionalKey(Schema.String)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export type FortnoxFaultMatch = typeof FortnoxFaultMatch.Type
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A wire fault: answer matching requests with this status, headers, and body (for example 429
|
|
98
|
+
* with `retry-after`) instead of the route's successful answer, so nothing is written. A request
|
|
99
|
+
* the route refuses is answered by the route and uses up no fault. The body defaults to a
|
|
100
|
+
* Fortnox `ErrorInformation`. `count` limits how many requests it answers (omitted: all).
|
|
101
|
+
*
|
|
102
|
+
* The Gateway emulator's rules apply: statuses that cannot carry a body (1xx, 204, 205) and
|
|
103
|
+
* redirects (3xx) are rejected, as are invalid header names or values, a `location` header, and
|
|
104
|
+
* framing headers (`content-length`, `transfer-encoding`, `connection`, `keep-alive`, `upgrade`).
|
|
105
|
+
*/
|
|
106
|
+
export const FortnoxFault = Schema.Struct({
|
|
107
|
+
kind: Schema.Literal('status'),
|
|
108
|
+
status: EmulatorResponseStatus,
|
|
109
|
+
headers: Schema.optionalKey(EmulatorHeaderRecord),
|
|
110
|
+
body: Schema.optionalKey(Schema.Json),
|
|
111
|
+
match: Schema.optionalKey(FortnoxFaultMatch),
|
|
112
|
+
count: Schema.optionalKey(FaultCount)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
export type FortnoxFault = typeof FortnoxFault.Type
|
|
116
|
+
|
|
117
|
+
/** Invalid emulator input from the JS API: a seed, a fault, or an option. A programmer error. */
|
|
118
|
+
export class FortnoxEmulatorInputInvalid extends Data.TaggedError('FortnoxEmulatorInputInvalid')<{
|
|
119
|
+
readonly input: 'seed' | 'fault' | 'option'
|
|
120
|
+
readonly reason: string
|
|
121
|
+
}> {
|
|
122
|
+
override get message(): string {
|
|
123
|
+
return `Invalid Fortnox emulator ${this.input}: ${this.reason}`
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type FortnoxLedgerEntry = {
|
|
128
|
+
/** 1-based arrival order since the last ledger clear or reset. */
|
|
129
|
+
readonly seq: number
|
|
130
|
+
readonly method: string
|
|
131
|
+
/** Concrete request path, for example `/3/invoices/103`. */
|
|
132
|
+
readonly path: string
|
|
133
|
+
/** Path template of the matched route, for example `/3/invoices/{DocumentNumber}`. */
|
|
134
|
+
readonly route?: string
|
|
135
|
+
readonly query: Readonly<Record<string, string>>
|
|
136
|
+
/** Parsed JSON request body, when there was one. */
|
|
137
|
+
readonly body?: Schema.Json
|
|
138
|
+
readonly status: number
|
|
139
|
+
/** Evidence of the matched route; `unknown-route` for requests that failed closed. */
|
|
140
|
+
readonly evidence: EmulatorEvidence | 'unknown-route'
|
|
141
|
+
/** Set when a fault answered the request. */
|
|
142
|
+
readonly fault?: 'status'
|
|
143
|
+
/**
|
|
144
|
+
* Set when the emulator could not build or produce the response, or a route handler threw; the
|
|
145
|
+
* request was answered with a 500 `ErrorInformation` (still evidence-tagged) and no fault was
|
|
146
|
+
* used up.
|
|
147
|
+
*/
|
|
148
|
+
readonly responseError?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type FortnoxFaultState = {
|
|
152
|
+
readonly id: number
|
|
153
|
+
readonly fault: FortnoxFault
|
|
154
|
+
/** Remaining matching requests; `undefined` for an unlimited fault. */
|
|
155
|
+
readonly remaining: number | undefined
|
|
156
|
+
readonly applied: number
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type FortnoxRouteCoverage = EmulatorRouteEvidence & {
|
|
160
|
+
/** Ledger requests answered by this route since the last ledger clear or reset. */
|
|
161
|
+
readonly requests: number
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type FortnoxCoverage = {
|
|
165
|
+
readonly routes: ReadonlyArray<FortnoxRouteCoverage>
|
|
166
|
+
/** Ledger requests to unknown routes (failed closed). */
|
|
167
|
+
readonly unknownRouteRequests: number
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type FortnoxEmulatorOptions = {
|
|
171
|
+
/** Typed seed; defaults to the fixture entities (`profile: 'default'`). */
|
|
172
|
+
readonly seed?: FortnoxEmulatorSeed
|
|
173
|
+
/** Clock in epoch milliseconds. Defaults to `Date.now`. */
|
|
174
|
+
readonly now?: () => number
|
|
175
|
+
/** Drill knobs (tests only): flip one observed quirk to prove the cases catch it. */
|
|
176
|
+
readonly quirks?: FortnoxEmulatorQuirks
|
|
177
|
+
/** Origin of `@url` links in responses. Defaults to `https://api.fortnox.se`. */
|
|
178
|
+
readonly baseUrl?: string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type FortnoxEmulator = {
|
|
182
|
+
/** The fetch handler (the `/3` API plus the `/_emulate/*` control plane). Never rejects. */
|
|
183
|
+
readonly fetch: (request: Request) => Promise<Response>
|
|
184
|
+
/** Origin of `@url` links in responses. */
|
|
185
|
+
readonly baseUrl: string
|
|
186
|
+
readonly ledger: {
|
|
187
|
+
readonly entries: () => ReadonlyArray<FortnoxLedgerEntry>
|
|
188
|
+
readonly clear: () => void
|
|
189
|
+
}
|
|
190
|
+
readonly faults: {
|
|
191
|
+
/** Add a fault; throws `FortnoxEmulatorInputInvalid` for an invalid fault. */
|
|
192
|
+
readonly add: (fault: FortnoxFault) => FortnoxFaultState
|
|
193
|
+
readonly list: () => ReadonlyArray<FortnoxFaultState>
|
|
194
|
+
readonly clear: () => void
|
|
195
|
+
}
|
|
196
|
+
/** Restore the current seed and clear the ledger and faults. */
|
|
197
|
+
readonly reset: () => Promise<void>
|
|
198
|
+
/**
|
|
199
|
+
* Replace the state with a new seed, which becomes what `reset` restores. Rejects with
|
|
200
|
+
* `FortnoxEmulatorInputInvalid` for an invalid seed.
|
|
201
|
+
*/
|
|
202
|
+
readonly seed: (seed: FortnoxEmulatorSeed) => Promise<void>
|
|
203
|
+
/** A deep copy of the current state (entities, outbox, counters). */
|
|
204
|
+
readonly snapshot: () => FortnoxEmulatorState
|
|
205
|
+
readonly coverage: () => FortnoxCoverage
|
|
206
|
+
/** Close the core runtime. Later requests answer 503. Idempotent. */
|
|
207
|
+
readonly close: () => Promise<void>
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const strict = { onExcessProperty: 'error' } as const
|
|
211
|
+
|
|
212
|
+
const decodeFault = Schema.decodeUnknownResult(FortnoxFault, strict)
|
|
213
|
+
|
|
214
|
+
const decodeFaultList = Schema.decodeUnknownResult(
|
|
215
|
+
Schema.Union([FortnoxFault, Schema.Struct({ faults: Schema.Array(FortnoxFault) })]),
|
|
216
|
+
strict
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const issueMessage = (issue: Schema.SchemaError['issue']): string =>
|
|
220
|
+
new Schema.SchemaError(issue).message
|
|
221
|
+
|
|
222
|
+
const jsonResponse = (status: number, body: unknown, headers: HeadersInit = {}): Response => {
|
|
223
|
+
const responseHeaders = new Headers(headers)
|
|
224
|
+
|
|
225
|
+
responseHeaders.set('content-type', 'application/json')
|
|
226
|
+
|
|
227
|
+
return new Response(JSON.stringify(body), { status, headers: responseHeaders })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const controlError = (status: number, message: string, headers: HeadersInit = {}): Response =>
|
|
231
|
+
jsonResponse(status, { error: { message, type: 'emulator_error' } }, headers)
|
|
232
|
+
|
|
233
|
+
const codes = fortnoxEmulatorErrorCodes
|
|
234
|
+
|
|
235
|
+
const defaultFaultBody = (status: number): Schema.Json => ({
|
|
236
|
+
ErrorInformation:
|
|
237
|
+
status === 429
|
|
238
|
+
? { error: 1, message: 'Synthetic: too many requests.', code: codes.rateLimited }
|
|
239
|
+
: { error: 1, message: `Synthetic upstream error (${status}).`, code: codes.upstreamError }
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
// A non-empty bearer credential. The value is never checked, stored, forwarded, or ledgered.
|
|
243
|
+
const bearerPattern = /^bearer\s+\S+/i
|
|
244
|
+
|
|
245
|
+
const pathMatches = (pattern: string, path: string): boolean =>
|
|
246
|
+
pattern.endsWith('*') ? path.startsWith(pattern.slice(0, -1)) : pattern === path
|
|
247
|
+
|
|
248
|
+
const faultMatches = (fault: FortnoxFault, method: string, path: string): boolean =>
|
|
249
|
+
(fault.match?.method === undefined || fault.match.method.toUpperCase() === method) &&
|
|
250
|
+
(fault.match?.path === undefined || pathMatches(fault.match.path, path))
|
|
251
|
+
|
|
252
|
+
const readText = (request: Request): Promise<string | undefined> =>
|
|
253
|
+
request.text().then(
|
|
254
|
+
text => text,
|
|
255
|
+
() => undefined
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
const validOrigin = (input: string): string | undefined => {
|
|
259
|
+
if (!URL.canParse(input)) {
|
|
260
|
+
return undefined
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const url = new URL(input)
|
|
264
|
+
|
|
265
|
+
const bare =
|
|
266
|
+
(url.pathname === '/' || url.pathname === '') &&
|
|
267
|
+
url.search === '' &&
|
|
268
|
+
url.hash === '' &&
|
|
269
|
+
url.username === '' &&
|
|
270
|
+
url.password === ''
|
|
271
|
+
|
|
272
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && bare ? url.origin : undefined
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
type MutableLedgerEntry = {
|
|
276
|
+
seq: number
|
|
277
|
+
method: string
|
|
278
|
+
path: string
|
|
279
|
+
route?: string
|
|
280
|
+
query: Readonly<Record<string, string>>
|
|
281
|
+
body?: Schema.Json
|
|
282
|
+
status: number
|
|
283
|
+
evidence: EmulatorEvidence | 'unknown-route'
|
|
284
|
+
fault?: 'status'
|
|
285
|
+
responseError?: string
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** One core request's fault outcome: the fault's answer, set when a fault answered it. */
|
|
289
|
+
type FaultJob = { faultAnswer?: Response }
|
|
290
|
+
|
|
291
|
+
type MutableFaultState = {
|
|
292
|
+
readonly id: number
|
|
293
|
+
readonly fault: FortnoxFault
|
|
294
|
+
remaining: number | undefined
|
|
295
|
+
applied: number
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const withEvidence = (response: Response, evidence: EmulatorEvidence): Response => {
|
|
299
|
+
const headers = new Headers(response.headers)
|
|
300
|
+
|
|
301
|
+
if (evidence === 'unverified') {
|
|
302
|
+
headers.set(emulatorEvidenceHeader, 'unverified')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return new Response(response.body, {
|
|
306
|
+
status: response.status,
|
|
307
|
+
statusText: response.statusText,
|
|
308
|
+
headers
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const defaultQuirks: Required<FortnoxEmulatorQuirks> = {
|
|
313
|
+
stickyRowDiscount: true,
|
|
314
|
+
emptyStringClears: false,
|
|
315
|
+
paymentFiltersIncludeUnbooked: false
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The evidence-tagged fallback when a matched API route cannot build or produce its response. */
|
|
319
|
+
const responseFailed = (): Response =>
|
|
320
|
+
errorInformation(
|
|
321
|
+
500,
|
|
322
|
+
codes.upstreamError,
|
|
323
|
+
'Synthetic: the emulator could not build the response.'
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
const isControlPath = (path: string): boolean =>
|
|
327
|
+
path === '/_emulate' || path.startsWith('/_emulate/')
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Create a stateful Fortnox emulator on the `@emulators/core` custom runtime. Each call has its
|
|
331
|
+
* own state, ledger, and faults. Rejects with `FortnoxEmulatorInputInvalid` for an invalid seed
|
|
332
|
+
* or `baseUrl`.
|
|
333
|
+
*
|
|
334
|
+
* Requests to `/3/*` need `Authorization: Bearer <non-empty>` (missing: 401 `ErrorInformation`);
|
|
335
|
+
* the token is never checked, stored, forwarded to the core, or ledgered. Precedence per request:
|
|
336
|
+
* route match (unknown routes fail closed with a 404 `ErrorInformation`), authorization, JSON
|
|
337
|
+
* body parsing, then the stateful route's plan (its query allowlist, then its handler on a draft
|
|
338
|
+
* of the state), then the first matching fault, then the commit. A request the route refuses (any
|
|
339
|
+
* answer that is not 2xx, such as a 404 for an unknown id or a 400 for an invalid state) uses up
|
|
340
|
+
* no fault; a faulted request writes nothing. Every response from an unverified route carries
|
|
341
|
+
* `x-emulator-evidence: unverified`.
|
|
342
|
+
*/
|
|
343
|
+
export const makeFortnoxEmulator = async (
|
|
344
|
+
options: FortnoxEmulatorOptions = {}
|
|
345
|
+
): Promise<FortnoxEmulator> => {
|
|
346
|
+
const initial = buildSeedState(options.seed ?? {})
|
|
347
|
+
|
|
348
|
+
if (Predicate.isString(initial)) {
|
|
349
|
+
throw new FortnoxEmulatorInputInvalid({ input: 'seed', reason: initial })
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const linkOrigin = validOrigin(options.baseUrl ?? fortnoxEmulatorDefaultOrigin)
|
|
353
|
+
|
|
354
|
+
if (linkOrigin === undefined) {
|
|
355
|
+
throw new FortnoxEmulatorInputInvalid({
|
|
356
|
+
input: 'option',
|
|
357
|
+
reason: 'baseUrl must be an http(s) origin without path, query, hash, or credentials'
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const env: FortnoxApiEnv = {
|
|
362
|
+
now: options.now ?? (() => Date.now()),
|
|
363
|
+
linkOrigin,
|
|
364
|
+
quirks: { ...defaultQuirks, ...options.quirks }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Loaded lazily: the core imports Node builtins and reads files at import time.
|
|
368
|
+
const core = await import('@emulators/core')
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Fault decisions of the core requests in flight, by job id (asked by the route once it would
|
|
372
|
+
* answer successfully). Job ids never reset, so a ledger clear never makes two jobs share one.
|
|
373
|
+
*/
|
|
374
|
+
const jobs = new Map<string, () => boolean>()
|
|
375
|
+
let nextJobId = 1
|
|
376
|
+
|
|
377
|
+
const definition = core.defineEmulator<FortnoxEmulatorState>({
|
|
378
|
+
name: 'fortnox',
|
|
379
|
+
cors: false,
|
|
380
|
+
state: () => initial,
|
|
381
|
+
validateSeed: value => {
|
|
382
|
+
const decoded = decodeState(value)
|
|
383
|
+
|
|
384
|
+
if (Predicate.isString(decoded)) {
|
|
385
|
+
throw new FortnoxEmulatorInputInvalid({ input: 'seed', reason: decoded })
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return decoded
|
|
389
|
+
},
|
|
390
|
+
setup: ({ app, state }) =>
|
|
391
|
+
registerFortnoxApi(app, state, env, jobId =>
|
|
392
|
+
jobId === undefined ? false : (jobs.get(jobId)?.() ?? false)
|
|
393
|
+
)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
const runtime = await core.createCustomRuntime(definition, { seed: initial })
|
|
397
|
+
|
|
398
|
+
let baseline: FortnoxEmulatorState = initial
|
|
399
|
+
let entries: Array<MutableLedgerEntry> = []
|
|
400
|
+
let faultStates: Array<MutableFaultState> = []
|
|
401
|
+
let nextSeq = 1
|
|
402
|
+
let nextFaultId = 1
|
|
403
|
+
let closed = false
|
|
404
|
+
|
|
405
|
+
const snapshotEntry = (entry: MutableLedgerEntry): FortnoxLedgerEntry => ({
|
|
406
|
+
...entry,
|
|
407
|
+
query: { ...entry.query }
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const snapshotFault = (state: MutableFaultState): FortnoxFaultState => ({ ...state })
|
|
411
|
+
|
|
412
|
+
const clearLedger = () => {
|
|
413
|
+
entries = []
|
|
414
|
+
nextSeq = 1
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const addFault = (input: unknown): FortnoxFaultState | string => {
|
|
418
|
+
const decoded = decodeFault(input)
|
|
419
|
+
|
|
420
|
+
if (Result.isFailure(decoded)) {
|
|
421
|
+
return issueMessage(decoded.failure.issue)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const state: MutableFaultState = {
|
|
425
|
+
id: nextFaultId++,
|
|
426
|
+
fault: decoded.success,
|
|
427
|
+
remaining: decoded.success.count,
|
|
428
|
+
applied: 0
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
faultStates.push(state)
|
|
432
|
+
|
|
433
|
+
return snapshotFault(state)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const takeFault = (method: string, path: string): MutableFaultState | undefined =>
|
|
437
|
+
faultStates.find(
|
|
438
|
+
state =>
|
|
439
|
+
(state.remaining === undefined || state.remaining > 0) &&
|
|
440
|
+
faultMatches(state.fault, method, path)
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
/** Build the fault's response first: a response that cannot be built must not consume it. */
|
|
444
|
+
const applyFault = (state: MutableFaultState): Response => {
|
|
445
|
+
const headers = new Headers(state.fault.headers ?? {})
|
|
446
|
+
const body = state.fault.body ?? defaultFaultBody(state.fault.status)
|
|
447
|
+
|
|
448
|
+
if (!headers.has('content-type')) {
|
|
449
|
+
headers.set('content-type', Predicate.isString(body) ? 'text/plain' : 'application/json')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const response = new Response(Predicate.isString(body) ? body : JSON.stringify(body), {
|
|
453
|
+
status: state.fault.status,
|
|
454
|
+
headers
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
state.applied += 1
|
|
458
|
+
|
|
459
|
+
if (state.remaining !== undefined) {
|
|
460
|
+
state.remaining -= 1
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return response
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const snapshot = (): FortnoxEmulatorState => runtime.snapshot().state
|
|
467
|
+
|
|
468
|
+
const restore = (state: FortnoxEmulatorState): Promise<void> => {
|
|
469
|
+
const current: EmulatorSnapshot<FortnoxEmulatorState> = runtime.snapshot()
|
|
470
|
+
|
|
471
|
+
return runtime.restore({ ...current, state })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const reset = async () => {
|
|
475
|
+
clearLedger()
|
|
476
|
+
faultStates = []
|
|
477
|
+
await restore(baseline)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const reseed = async (input: unknown): Promise<FortnoxEmulatorState | string> => {
|
|
481
|
+
const next = buildSeedState(input)
|
|
482
|
+
|
|
483
|
+
if (Predicate.isString(next)) {
|
|
484
|
+
return next
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
await restore(next)
|
|
488
|
+
baseline = next
|
|
489
|
+
|
|
490
|
+
return next
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const coverage = (): FortnoxCoverage => ({
|
|
494
|
+
routes: fortnoxEmulatorRoutes.map(route => ({
|
|
495
|
+
...route,
|
|
496
|
+
requests: entries.filter(
|
|
497
|
+
entry => entry.route === route.path && entry.method.toUpperCase() === route.method
|
|
498
|
+
).length
|
|
499
|
+
})),
|
|
500
|
+
unknownRouteRequests: entries.filter(entry => entry.evidence === 'unknown-route').length
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Authorization, body parsing, then the core route (without the credential), which asks for the
|
|
505
|
+
* fault only once it would answer successfully.
|
|
506
|
+
*/
|
|
507
|
+
const routed = async (
|
|
508
|
+
request: Request,
|
|
509
|
+
url: URL,
|
|
510
|
+
entry: MutableLedgerEntry
|
|
511
|
+
): Promise<Response> => {
|
|
512
|
+
if (!bearerPattern.test(request.headers.get('authorization') ?? '')) {
|
|
513
|
+
return errorInformation(
|
|
514
|
+
401,
|
|
515
|
+
codes.unauthorized,
|
|
516
|
+
'Synthetic: missing or invalid authorization.'
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const text = await readText(request)
|
|
521
|
+
|
|
522
|
+
if (text === undefined) {
|
|
523
|
+
return errorInformation(400, codes.invalidBody, 'Synthetic: the request body is unreadable.')
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (text !== '') {
|
|
527
|
+
const json = parseJsonText(text)
|
|
528
|
+
|
|
529
|
+
if (json === undefined) {
|
|
530
|
+
return errorInformation(
|
|
531
|
+
400,
|
|
532
|
+
codes.invalidBody,
|
|
533
|
+
'Synthetic: the request body is not valid JSON.'
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
entry.body = json
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const method = request.method.toUpperCase()
|
|
541
|
+
const hasBody = text !== '' && method !== 'GET' && method !== 'HEAD'
|
|
542
|
+
const jobId = String(nextJobId++)
|
|
543
|
+
|
|
544
|
+
const headers = new Headers({ accept: 'application/json', [emulatorJobHeader]: jobId })
|
|
545
|
+
|
|
546
|
+
if (hasBody) headers.set('content-type', 'application/json')
|
|
547
|
+
|
|
548
|
+
const init: RequestInit = { method, headers }
|
|
549
|
+
|
|
550
|
+
if (hasBody) {
|
|
551
|
+
init.body = text
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const job: FaultJob = {}
|
|
555
|
+
|
|
556
|
+
// Asked by the route only for an answer it would send successfully, before it commits.
|
|
557
|
+
jobs.set(jobId, () => {
|
|
558
|
+
const fault = takeFault(method, url.pathname)
|
|
559
|
+
|
|
560
|
+
if (fault === undefined) return false
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
job.faultAnswer = applyFault(fault)
|
|
564
|
+
} catch (error) {
|
|
565
|
+
// The fault's answer could not be built (the route did not fail): the core answers its
|
|
566
|
+
// handler failure, and the fault is not used up.
|
|
567
|
+
entry.responseError = 'the emulator could not build or produce the response'
|
|
568
|
+
|
|
569
|
+
throw error
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
entry.fault = 'status'
|
|
573
|
+
|
|
574
|
+
return true
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
const response = await runtime
|
|
578
|
+
.fetch(new Request(new URL(`${url.pathname}${url.search}`, runtime.baseUrl), init))
|
|
579
|
+
.finally(() => jobs.delete(jobId))
|
|
580
|
+
|
|
581
|
+
// The fault's answer is sent from here, never through the core.
|
|
582
|
+
if (job.faultAnswer !== undefined) return job.faultAnswer
|
|
583
|
+
|
|
584
|
+
if (response.headers.has(handlerFailedHeader)) {
|
|
585
|
+
entry.responseError ??= 'the route handler failed'
|
|
586
|
+
|
|
587
|
+
return responseFailed()
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return response
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const emulatedApi = async (request: Request, url: URL): Promise<Response> => {
|
|
594
|
+
const route = matchFortnoxRoute(request.method, url.pathname)
|
|
595
|
+
|
|
596
|
+
const entry: MutableLedgerEntry = {
|
|
597
|
+
seq: nextSeq++,
|
|
598
|
+
method: request.method,
|
|
599
|
+
path: url.pathname,
|
|
600
|
+
query: Object.fromEntries(url.searchParams),
|
|
601
|
+
status: 0,
|
|
602
|
+
evidence: route?.evidence ?? 'unknown-route'
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
entries.push(entry)
|
|
606
|
+
|
|
607
|
+
if (route === undefined) {
|
|
608
|
+
entry.status = 404
|
|
609
|
+
|
|
610
|
+
return errorInformation(404, codes.unknownRoute, 'Synthetic: no emulated Fortnox route.')
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
entry.route = route.path
|
|
614
|
+
|
|
615
|
+
// Error recovery still answers through the route: the fallback 500 is evidence-tagged and
|
|
616
|
+
// the ledger records the status actually sent.
|
|
617
|
+
const response = await routed(request, url, entry).catch(() => {
|
|
618
|
+
entry.responseError = 'the emulator could not build or produce the response'
|
|
619
|
+
|
|
620
|
+
return responseFailed()
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
const tagged = withEvidence(response, route.evidence)
|
|
624
|
+
|
|
625
|
+
entry.status = tagged.status
|
|
626
|
+
|
|
627
|
+
return tagged
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const controlPlane = async (request: Request, path: string): Promise<Response> => {
|
|
631
|
+
const method = request.method
|
|
632
|
+
const allow = (methods: string) => controlError(405, 'method not allowed', { allow: methods })
|
|
633
|
+
|
|
634
|
+
const jsonBody = async (): Promise<Schema.Json | undefined> => {
|
|
635
|
+
const text = await readText(request)
|
|
636
|
+
|
|
637
|
+
return text === undefined ? undefined : parseJsonText(text)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
switch (path) {
|
|
641
|
+
case '/_emulate/ledger':
|
|
642
|
+
if (method === 'GET') {
|
|
643
|
+
return jsonResponse(200, { entries: entries.map(snapshotEntry) })
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (method === 'DELETE') {
|
|
647
|
+
const cleared = entries.length
|
|
648
|
+
|
|
649
|
+
clearLedger()
|
|
650
|
+
|
|
651
|
+
return jsonResponse(200, { cleared })
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return allow('GET, DELETE')
|
|
655
|
+
|
|
656
|
+
case '/_emulate/faults': {
|
|
657
|
+
if (method === 'GET') {
|
|
658
|
+
return jsonResponse(200, { faults: faultStates.map(snapshotFault) })
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (method === 'DELETE') {
|
|
662
|
+
const cleared = faultStates.length
|
|
663
|
+
|
|
664
|
+
faultStates = []
|
|
665
|
+
|
|
666
|
+
return jsonResponse(200, { cleared })
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (method !== 'POST') {
|
|
670
|
+
return allow('GET, POST, DELETE')
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const decoded = decodeFaultList(await jsonBody())
|
|
674
|
+
|
|
675
|
+
if (Result.isFailure(decoded)) {
|
|
676
|
+
return controlError(400, `invalid fault: ${issueMessage(decoded.failure.issue)}`)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const faults = 'faults' in decoded.success ? decoded.success.faults : [decoded.success]
|
|
680
|
+
|
|
681
|
+
return jsonResponse(201, { faults: faults.map(fault => addFault(fault)) })
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
case '/_emulate/reset':
|
|
685
|
+
if (method !== 'POST') {
|
|
686
|
+
return allow('POST')
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
await reset()
|
|
690
|
+
|
|
691
|
+
return jsonResponse(200, { reset: true })
|
|
692
|
+
|
|
693
|
+
case '/_emulate/state':
|
|
694
|
+
if (method !== 'GET') {
|
|
695
|
+
return allow('GET')
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return jsonResponse(200, {
|
|
699
|
+
state: snapshot(),
|
|
700
|
+
faults: faultStates.map(snapshotFault),
|
|
701
|
+
ledgerEntries: entries.length
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
case '/_emulate/seed': {
|
|
705
|
+
if (method !== 'POST') {
|
|
706
|
+
return allow('POST')
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const next = await reseed((await jsonBody()) ?? null)
|
|
710
|
+
|
|
711
|
+
if (Predicate.isString(next)) {
|
|
712
|
+
return controlError(400, `invalid seed: ${next}`)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return jsonResponse(200, {
|
|
716
|
+
seeded: true,
|
|
717
|
+
customers: next.customers.length,
|
|
718
|
+
invoices: next.invoices.length
|
|
719
|
+
})
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
case '/_emulate/coverage':
|
|
723
|
+
if (method !== 'GET') {
|
|
724
|
+
return allow('GET')
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return jsonResponse(200, coverage())
|
|
728
|
+
|
|
729
|
+
default:
|
|
730
|
+
return controlError(404, 'unknown control-plane route')
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Last resort when handling itself fails (for example an unparseable request URL): API
|
|
736
|
+
* requests answer a Fortnox `ErrorInformation` 500, tagged with the matched route's evidence;
|
|
737
|
+
* control-plane requests answer an emulator error.
|
|
738
|
+
*/
|
|
739
|
+
const lastResort = (request: Request): Response => {
|
|
740
|
+
const path = URL.canParse(request.url) ? new URL(request.url).pathname : undefined
|
|
741
|
+
|
|
742
|
+
if (path !== undefined && isControlPath(path)) {
|
|
743
|
+
return controlError(500, 'emulator failed to handle the request')
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const route = path === undefined ? undefined : matchFortnoxRoute(request.method, path)
|
|
747
|
+
|
|
748
|
+
return route === undefined ? responseFailed() : withEvidence(responseFailed(), route.evidence)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const handle = async (request: Request): Promise<Response> => {
|
|
752
|
+
if (closed) {
|
|
753
|
+
return errorInformation(503, codes.upstreamError, 'Synthetic: the emulator is closed.')
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const url = new URL(request.url)
|
|
757
|
+
|
|
758
|
+
return isControlPath(url.pathname)
|
|
759
|
+
? controlPlane(request, url.pathname)
|
|
760
|
+
: emulatedApi(request, url)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
fetch: request => handle(request).catch(() => lastResort(request)),
|
|
765
|
+
baseUrl: linkOrigin,
|
|
766
|
+
ledger: {
|
|
767
|
+
entries: () => entries.map(snapshotEntry),
|
|
768
|
+
clear: clearLedger
|
|
769
|
+
},
|
|
770
|
+
faults: {
|
|
771
|
+
add: fault => {
|
|
772
|
+
const added = addFault(fault)
|
|
773
|
+
|
|
774
|
+
if (Predicate.isString(added)) {
|
|
775
|
+
throw new FortnoxEmulatorInputInvalid({ input: 'fault', reason: added })
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return added
|
|
779
|
+
},
|
|
780
|
+
list: () => faultStates.map(snapshotFault),
|
|
781
|
+
clear: () => {
|
|
782
|
+
faultStates = []
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
reset,
|
|
786
|
+
seed: async input => {
|
|
787
|
+
const next = await reseed(input)
|
|
788
|
+
|
|
789
|
+
if (Predicate.isString(next)) {
|
|
790
|
+
throw new FortnoxEmulatorInputInvalid({ input: 'seed', reason: next })
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
snapshot,
|
|
794
|
+
coverage,
|
|
795
|
+
close: () => {
|
|
796
|
+
closed = true
|
|
797
|
+
|
|
798
|
+
return runtime.close()
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|