@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/google.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateful Google emulator (the Gmail, Calendar, and Drive routes the Google conformance cases
|
|
3
|
+
* send), built on the upstream `@emulators/core` custom runtime, with a request ledger, status
|
|
4
|
+
* faults, and an `/_emulate/*` control plane.
|
|
5
|
+
*
|
|
6
|
+
* It never imports SDK code: its wire shapes, error envelopes, and default seed are copied as data
|
|
7
|
+
* from the synthetic Google conformance fixtures, and every route names the conformance cases it
|
|
8
|
+
* follows in `googleEmulatorRoutes`. Only the routes those thirteen cases (with their cleanup)
|
|
9
|
+
* send are emulated: Gmail on `https://gmail.googleapis.com` (`/gmail/v1/users/me/...` and the
|
|
10
|
+
* multipart send upload), Calendar and Drive on `https://www.googleapis.com` (`/calendar/v3` and
|
|
11
|
+
* `/drive/v3`). Each route answers only on its recorded origin (`fetchOn(origin)` serves one
|
|
12
|
+
* origin behind a loopback rewrite). Response behaviour comes only from the fixtures: anything
|
|
13
|
+
* they do not show answers one ledgered 400 not-emulated, writes nothing, and uses up no fault.
|
|
14
|
+
* That includes an empty listing and every read of the leftover lookup
|
|
15
|
+
* (`findGoogleConformanceLeftovers`, whose label listing, draft search, and free-text event query
|
|
16
|
+
* no fixture records): the lookup fails, and runners print their lookup-failed WARN.
|
|
17
|
+
*
|
|
18
|
+
* The practice send (`google.gmail.send-practice-address`) is irreversible on Gmail. Here it is
|
|
19
|
+
* only recorded in the state (the sent message reads back; nothing is delivered anywhere), and only
|
|
20
|
+
* the recorded 7-bit message whose sole recipient header is `To: <the seeded practice address>`
|
|
21
|
+
* is accepted, and only while that address is the recorded `practice@example.test` (the recorded
|
|
22
|
+
* `sizeEstimate` covers it, as it covers the draft and send subjects, which therefore take only a
|
|
23
|
+
* run id of the fixtures' 13-character length).
|
|
24
|
+
*
|
|
25
|
+
* Fail closed: Google follows the shared fail-closed rule of `src/stateful-emulator.ts` (its opt-in
|
|
26
|
+
* `failClosed` mode, the same rule as `/github`). Every route parameter has a raw pattern matched
|
|
27
|
+
* in full, so a request is recognised only when its raw path is exactly an emulated route shape
|
|
28
|
+
* under that route's method and any `Authorization` header is exactly `Bearer <token>` with a
|
|
29
|
+
* recognisable bearer (below); every other request is ledgered and answered with constant text only
|
|
30
|
+
* (`/<unrecognised>`, a standard method or `<other>`, an empty query, no body, a constant reason).
|
|
31
|
+
* The bearer value is never compared against anything, stored, forwarded, or ledgered. A recognised
|
|
32
|
+
* request that repeats it in its raw path, any path segment, the query or any query key or value,
|
|
33
|
+
* the recorded `content-type` header, or its raw body (the multipart send body included), or, on
|
|
34
|
+
* the draft compose and update routes, in the base64url-decoded MIME of `message.raw` (Gmail's own
|
|
35
|
+
* wire format wraps the draft this way; the routes give it to the wrapper as a decoded view), is
|
|
36
|
+
* ledgered as the constant credential-repeat entry, checked through the wrapper's fixpoint closure
|
|
37
|
+
* of a tolerant percent-decode and a tolerant JSON-unescape; any other recognised request has the
|
|
38
|
+
* bearer scrubbed from its ledgered fields and every not-emulated reason. A `message.raw` the route
|
|
39
|
+
* would refuse (anything but canonical unpadded base64url of exactly the recorded draft MIME, the
|
|
40
|
+
* run id aside) makes the view throw a `DecodedViewRefusal` with one of the route's declared
|
|
41
|
+
* constant reasons (`viewRefusalReasons`: the canonical-base64url reason, the
|
|
42
|
+
* other-than-the-recorded-run-draft reason, the 13-character run-id reason, the extra-key reason),
|
|
43
|
+
* which the wrapper ledgers in the constant entry before anything is recorded (or
|
|
44
|
+
* `the request body repeats the credential` when the raw decodes cleanly to text holding the
|
|
45
|
+
* bearer), so a refused `message.raw` never reaches the ledger. Google refusals use the wrapper's
|
|
46
|
+
* constant-reason `exactQuery` and `exactBodyKeys`, so they never echo a request's own query or
|
|
47
|
+
* body keys.
|
|
48
|
+
*
|
|
49
|
+
* Request-shape latitude (`/google`, the only accepted deviations): any bearer value in the RFC
|
|
50
|
+
* 6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with a
|
|
51
|
+
* character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, with at least one outside
|
|
52
|
+
* `[0-9.eE+-]` (Google's `ya29.…` access tokens qualify), that occurs nowhere else in the request
|
|
53
|
+
* (never compared against anything, stored, or ledgered); extra request headers (except
|
|
54
|
+
* `X-Goog-Drive-Resource-Keys`, which no fixture sends); JSON key order; `content-type` media-type
|
|
55
|
+
* parameters on JSON requests; query parameters in any order; any `run-` run id (at most 40
|
|
56
|
+
* characters) in a run-scoped label name, event summary, or folder name; in a draft subject
|
|
57
|
+
* (compose and update) and the sent subject, only a run id of exactly 13 characters, the length of
|
|
58
|
+
* the fixtures' `run-synthetic`, because the recorded `sizeEstimate` of the draft and sent messages
|
|
59
|
+
* (answered by their message reads and the draft thread) covers the subject; on the practice send,
|
|
60
|
+
* a `content-type` of exactly `multipart/related; boundary=<b>` with any one unquoted boundary of 1
|
|
61
|
+
* to 70 `[A-Za-z0-9_]` characters and no other parameter; a `gmail.list` `maxResults` from 1 to
|
|
62
|
+
* 500, a `calendar.list_events` `maxResults` from 1 to 2500, and a `drive.list_files` `pageSize`
|
|
63
|
+
* from 1 to 1000; any `timeMin` before `timeMax` (RFC 3339 instants with a real calendar date, hour
|
|
64
|
+
* 0 to 23, minute and second 0 to 59, and a `Z` or in-range numeric offset); any id of an item the
|
|
65
|
+
* state holds where a fixture has an id (writes: only items created here, plus label changes,
|
|
66
|
+
* trash, and untrash of a stored non-draft message); and, for an id the state does not hold, only
|
|
67
|
+
* the recorded not-found answers (a `format=minimal` read of a 16-hex-digit message id, a read of a
|
|
68
|
+
* `Label_<1 to 999999999>` label, a delete of an `r-<digits>` draft, and a Drive file read). A seed
|
|
69
|
+
* may set another `practiceAddress`, but then every send is refused, since the recorded
|
|
70
|
+
* `sizeEstimate` of the sent message also covers the address: the send answers only while the
|
|
71
|
+
* seeded address is the recorded `practice@example.test`. On the draft compose and update routes,
|
|
72
|
+
* `message.raw` must be canonical unpadded base64url of exactly the recorded draft MIME of that
|
|
73
|
+
* route, the run id aside; any other `message.raw` (line-wrapped, the standard alphabet, padded,
|
|
74
|
+
* with a stray character, or with MIME-level encodings such as RFC 2047 encoded-words,
|
|
75
|
+
* quoted-printable, or UTF-16) is refused before anything is recorded or a fault is decided, as a
|
|
76
|
+
* constant entry with the route's own declared reason
|
|
77
|
+
* (`message.raw must be canonical base64url UTF-8 MIME`,
|
|
78
|
+
* `a draft compose other than the recorded run draft is not emulated` or its `update` form, the
|
|
79
|
+
* 13-character run-id reason, or `message has a key this route does not take`), or
|
|
80
|
+
* `the request body repeats the credential` when that raw decodes cleanly to text holding the
|
|
81
|
+
* bearer, so a refused `message.raw` never reaches the ledger and an admitted one is the recorded
|
|
82
|
+
* text. `Authorization` must be exactly `Bearer <token>` (that spelling, one space). Everything
|
|
83
|
+
* else (other keys, values, formats, query parameters, empty query components such as a bare `?` or
|
|
84
|
+
* a stray `&`, recorded headers such as Drive's `accept: application/json` missing, another origin,
|
|
85
|
+
* repeated query parameters, any recipient but the seeded practice address, a draft or send run id
|
|
86
|
+
* of another length, a bearer repeated anywhere in the request, including base64url-encoded inside
|
|
87
|
+
* a draft's `message.raw`, and page tokens not issued for the same list since the last reset or
|
|
88
|
+
* seed, or whose list changed) is not emulated.
|
|
89
|
+
*
|
|
90
|
+
* Node-only: `@emulators/core` imports Node builtins, so the core is loaded lazily by
|
|
91
|
+
* `makeGoogleEmulator` (importing this module has no side effects).
|
|
92
|
+
*
|
|
93
|
+
* @experimental
|
|
94
|
+
*/
|
|
95
|
+
import { Data, Predicate } from 'effect'
|
|
96
|
+
import { calendarRoutes } from './google/calendar.ts'
|
|
97
|
+
import { driveRoutes } from './google/drive.ts'
|
|
98
|
+
import { gmailRoutes } from './google/gmail.ts'
|
|
99
|
+
import {
|
|
100
|
+
googleEmulatorDrillKnobs,
|
|
101
|
+
type GoogleApiEnv,
|
|
102
|
+
type GoogleEmulatorDrills,
|
|
103
|
+
type GoogleRoute
|
|
104
|
+
} from './google/shared.ts'
|
|
105
|
+
import {
|
|
106
|
+
buildSeedState,
|
|
107
|
+
decodeState,
|
|
108
|
+
type GoogleEmulatorSeed,
|
|
109
|
+
type GoogleEmulatorState
|
|
110
|
+
} from './google/state.ts'
|
|
111
|
+
import type { EmulatorRouteEvidence } from './route-evidence.ts'
|
|
112
|
+
import {
|
|
113
|
+
StatefulFault,
|
|
114
|
+
StatefulFaultMatch,
|
|
115
|
+
checkBooleanDrills,
|
|
116
|
+
makeStatefulEmulator,
|
|
117
|
+
routeEvidence,
|
|
118
|
+
type StatefulCoverage,
|
|
119
|
+
type StatefulEmulatorApi,
|
|
120
|
+
type StatefulFaultState,
|
|
121
|
+
type StatefulInputKind,
|
|
122
|
+
type StatefulLedgerEntry
|
|
123
|
+
} from './stateful-emulator.ts'
|
|
124
|
+
import { statefulCoreRuntime } from './stateful-core.ts'
|
|
125
|
+
|
|
126
|
+
export type { EmulatorEvidence, EmulatorRouteEvidence } from './route-evidence.ts'
|
|
127
|
+
|
|
128
|
+
export { emulatorEvidenceHeader } from './route-evidence.ts'
|
|
129
|
+
|
|
130
|
+
export {
|
|
131
|
+
googleEmulatorApisOrigin,
|
|
132
|
+
googleEmulatorGmailOrigin,
|
|
133
|
+
type GoogleEmulatorDrills
|
|
134
|
+
} from './google/shared.ts'
|
|
135
|
+
|
|
136
|
+
export {
|
|
137
|
+
GoogleEmulatorAttachment,
|
|
138
|
+
GoogleEmulatorCalendar,
|
|
139
|
+
GoogleEmulatorCalendarEvent,
|
|
140
|
+
GoogleEmulatorDraft,
|
|
141
|
+
GoogleEmulatorDriveFile,
|
|
142
|
+
GoogleEmulatorEventBoundary,
|
|
143
|
+
GoogleEmulatorGmailLabel,
|
|
144
|
+
GoogleEmulatorGmailMessage,
|
|
145
|
+
GoogleEmulatorImpliedMessage,
|
|
146
|
+
GoogleEmulatorPracticeAddress,
|
|
147
|
+
GoogleEmulatorProfile,
|
|
148
|
+
GoogleEmulatorSeed,
|
|
149
|
+
GoogleEmulatorStateSchema,
|
|
150
|
+
type GoogleEmulatorState
|
|
151
|
+
} from './google/state.ts'
|
|
152
|
+
|
|
153
|
+
const googleApiRoutes: ReadonlyArray<GoogleRoute> = [
|
|
154
|
+
...gmailRoutes,
|
|
155
|
+
...calendarRoutes,
|
|
156
|
+
...driveRoutes
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Route evidence manifest: every emulated route, whether it writes, and the conformance cases
|
|
161
|
+
* whose (currently synthetic, unverified) wire claims it follows. Kept in sync with the handlers
|
|
162
|
+
* by construction (both come from one route table). Every route cites at least one case.
|
|
163
|
+
*/
|
|
164
|
+
export const googleEmulatorRoutes: ReadonlyArray<EmulatorRouteEvidence> =
|
|
165
|
+
googleApiRoutes.map(routeEvidence)
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Optional fault filter; an omitted field matches every request. `method` is the HTTP method;
|
|
169
|
+
* `path` is the raw request path (ending in `*`, a prefix); `route` is a route template of the
|
|
170
|
+
* manifest (another value is rejected when the fault is added).
|
|
171
|
+
*/
|
|
172
|
+
export const GoogleFaultMatch = StatefulFaultMatch
|
|
173
|
+
|
|
174
|
+
export type GoogleFaultMatch = typeof StatefulFaultMatch.Type
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A status fault (400-599): answer matching requests with this status, headers, and body instead
|
|
178
|
+
* of the route's write, so nothing is written. The body defaults to
|
|
179
|
+
* `{ error: { type: 'emulator_fault', message } }`; `count` limits how many requests it answers.
|
|
180
|
+
* Only a request the emulator would answer reaches a fault: a request that is not emulated, by
|
|
181
|
+
* its shape or by the state, never uses one up.
|
|
182
|
+
*/
|
|
183
|
+
export const GoogleFault = StatefulFault
|
|
184
|
+
|
|
185
|
+
export type GoogleFault = StatefulFault
|
|
186
|
+
|
|
187
|
+
export type GoogleFaultState = StatefulFaultState
|
|
188
|
+
|
|
189
|
+
export type GoogleLedgerEntry = StatefulLedgerEntry
|
|
190
|
+
|
|
191
|
+
export type GoogleCoverage = StatefulCoverage
|
|
192
|
+
|
|
193
|
+
/** Invalid emulator input from the JS API: a seed, a fault, or an option. A programmer error. */
|
|
194
|
+
export class GoogleEmulatorInputInvalid extends Data.TaggedError('GoogleEmulatorInputInvalid')<{
|
|
195
|
+
readonly input: StatefulInputKind
|
|
196
|
+
readonly reason: string
|
|
197
|
+
}> {
|
|
198
|
+
override get message(): string {
|
|
199
|
+
return `Invalid Google emulator ${this.input}: ${this.reason}`
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export type GoogleEmulatorOptions = {
|
|
204
|
+
/** Typed seed; defaults to the fixture entities (`profile: 'default'`). */
|
|
205
|
+
readonly seed?: GoogleEmulatorSeed
|
|
206
|
+
/**
|
|
207
|
+
* Clock in epoch milliseconds (created and updated event times, Drive folder times, and the
|
|
208
|
+
* sent message's `Date` header). Defaults to `Date.now`.
|
|
209
|
+
*/
|
|
210
|
+
readonly now?: () => number
|
|
211
|
+
/** Drill knobs (tests only): make the emulator disagree with one conformance case. */
|
|
212
|
+
readonly drills?: GoogleEmulatorDrills
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type GoogleEmulator = StatefulEmulatorApi<GoogleEmulatorState, GoogleEmulatorSeed>
|
|
216
|
+
|
|
217
|
+
/** The constant reason of a request on no emulated route shape (nothing of it is ledgered). */
|
|
218
|
+
const googleUnrecognisedReason = 'no emulated Google route for this method and path'
|
|
219
|
+
|
|
220
|
+
/** The constant reason of a request whose `Authorization` header is not one recognisable bearer. */
|
|
221
|
+
const googleUnrecognisedAuthorizationReason =
|
|
222
|
+
'an unrecognisable Authorization header is not emulated'
|
|
223
|
+
|
|
224
|
+
const inputInvalid = (input: StatefulInputKind, reason: string) =>
|
|
225
|
+
new GoogleEmulatorInputInvalid({ input, reason })
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create a stateful Google emulator on the `@emulators/core` custom runtime. Each call has its
|
|
229
|
+
* own state, ledger, faults, and page tokens. Rejects with `GoogleEmulatorInputInvalid` for an
|
|
230
|
+
* invalid seed or option. See `src/stateful-emulator.ts` for the request precedence.
|
|
231
|
+
*/
|
|
232
|
+
export const makeGoogleEmulator = async (
|
|
233
|
+
options: GoogleEmulatorOptions = {}
|
|
234
|
+
): Promise<GoogleEmulator> => {
|
|
235
|
+
const initial = buildSeedState(options.seed ?? {})
|
|
236
|
+
|
|
237
|
+
if (Predicate.isString(initial)) {
|
|
238
|
+
throw inputInvalid('seed', initial)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
checkBooleanDrills(options.drills, googleEmulatorDrillKnobs, inputInvalid)
|
|
242
|
+
|
|
243
|
+
const drills = options.drills ?? {}
|
|
244
|
+
|
|
245
|
+
const env: GoogleApiEnv = {
|
|
246
|
+
now: options.now ?? (() => Date.now()),
|
|
247
|
+
drills: {
|
|
248
|
+
gmailPageRepeats: drills.gmailPageRepeats === true,
|
|
249
|
+
attachmentStandardBase64: drills.attachmentStandardBase64 === true,
|
|
250
|
+
notFoundWithoutMessage: drills.notFoundWithoutMessage === true,
|
|
251
|
+
labelDeleteKeepsOnMessages: drills.labelDeleteKeepsOnMessages === true,
|
|
252
|
+
draftUpdateKeepsContent: drills.draftUpdateKeepsContent === true,
|
|
253
|
+
trashAnswerOmitsTrash: drills.trashAnswerOmitsTrash === true,
|
|
254
|
+
sentMessageWithoutTo: drills.sentMessageWithoutTo === true,
|
|
255
|
+
calendarPageRepeats: drills.calendarPageRepeats === true,
|
|
256
|
+
eventPatchKeepsSummary: drills.eventPatchKeepsSummary === true,
|
|
257
|
+
repeatedEventDeleteConflict: drills.repeatedEventDeleteConflict === true,
|
|
258
|
+
drivePageRepeats: drills.drivePageRepeats === true,
|
|
259
|
+
getFileWithoutParents: drills.getFileWithoutParents === true,
|
|
260
|
+
listIncludesTrashed: drills.listIncludesTrashed === true
|
|
261
|
+
},
|
|
262
|
+
pageTokens: new Map(),
|
|
263
|
+
generation: { current: 0 },
|
|
264
|
+
firstIssued: new Map()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return makeStatefulEmulator<GoogleEmulatorState, GoogleApiEnv, GoogleEmulatorSeed>(
|
|
268
|
+
{
|
|
269
|
+
routes: googleApiRoutes,
|
|
270
|
+
env,
|
|
271
|
+
initial,
|
|
272
|
+
buildSeed: buildSeedState,
|
|
273
|
+
requestProblem: header =>
|
|
274
|
+
header('x-goog-drive-resource-keys') === undefined
|
|
275
|
+
? undefined
|
|
276
|
+
: 'X-Goog-Drive-Resource-Keys is not emulated (no fixture sends it)',
|
|
277
|
+
// `content-type` only (never a credential header); the bearer is checked in it too.
|
|
278
|
+
recordHeaders: [{ name: 'content-type', json: false }],
|
|
279
|
+
// Page token values never cross a reset or seed: the generation makes later tokens distinct.
|
|
280
|
+
clearRuntime: () => {
|
|
281
|
+
env.pageTokens.clear()
|
|
282
|
+
env.generation.current += 1
|
|
283
|
+
},
|
|
284
|
+
runtimeState: () => ({ pageTokens: env.pageTokens.size }),
|
|
285
|
+
seedSummary: state => ({
|
|
286
|
+
messages: state.messages.length,
|
|
287
|
+
events: state.events.length,
|
|
288
|
+
files: state.files.length
|
|
289
|
+
}),
|
|
290
|
+
inputInvalid,
|
|
291
|
+
failClosed: {
|
|
292
|
+
unrecognised: googleUnrecognisedReason,
|
|
293
|
+
unrecognisedAuthorization: googleUnrecognisedAuthorizationReason
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
statefulCoreRuntime({ name: 'google', initial, decodeState, inputInvalid })
|
|
297
|
+
)
|
|
298
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn search emulator API: the route table (evidence, request-shape checks, and state-reading
|
|
3
|
+
* plans), the fixture error bodies, and answer rendering (internal; re-exported by
|
|
4
|
+
* `src/linkedin-search.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Only the routes the seven LinkedIn search conformance cases send are emulated, each on its
|
|
7
|
+
* recorded origin: the Exa people search (`POST /search` on `https://api.exa.ai`) and the Enrich
|
|
8
|
+
* Layer profile and email lookups (`GET /api/v2/profile` and `GET /api/v2/profile/email` on
|
|
9
|
+
* `https://enrichlayer.com`). Every route is a read: a plan reads the state and answers a fixture's
|
|
10
|
+
* response or refuses (400 not-emulated, no fault used up); no commit writes.
|
|
11
|
+
*
|
|
12
|
+
* @experimental
|
|
13
|
+
*/
|
|
14
|
+
import { Predicate } from 'effect'
|
|
15
|
+
import type * as Schema from 'effect/Schema'
|
|
16
|
+
import {
|
|
17
|
+
exactBodyKeys,
|
|
18
|
+
exactQuery,
|
|
19
|
+
integerIn,
|
|
20
|
+
isNotEmulated,
|
|
21
|
+
notEmulated,
|
|
22
|
+
statefulRoute,
|
|
23
|
+
type Commit,
|
|
24
|
+
type EmulatedRequest,
|
|
25
|
+
type NotEmulated,
|
|
26
|
+
type StatefulRoute
|
|
27
|
+
} from '../stateful-emulator.ts'
|
|
28
|
+
import {
|
|
29
|
+
linkedInSearchEmulatorEnrichLayerOrigin,
|
|
30
|
+
linkedInSearchEmulatorExaOrigin,
|
|
31
|
+
linkedInSearchMaxNumResults,
|
|
32
|
+
linkedInSearchProfileUrlPattern,
|
|
33
|
+
linkedInSearchQueryPattern,
|
|
34
|
+
type LinkedInSearchEmulatorProfile,
|
|
35
|
+
type LinkedInSearchEmulatorResult,
|
|
36
|
+
type LinkedInSearchEmulatorSearch,
|
|
37
|
+
type LinkedInSearchEmulatorState
|
|
38
|
+
} from './state.ts'
|
|
39
|
+
|
|
40
|
+
/** Content type of every answer, as the fixtures record it. */
|
|
41
|
+
const contentType = 'application/json'
|
|
42
|
+
|
|
43
|
+
/** Drill knobs (tests only): each makes the emulator disagree with one conformance claim. */
|
|
44
|
+
export type LinkedInSearchEmulatorDrills = {
|
|
45
|
+
/** A search with the default `numResults` (10) answers its results without `text`. */
|
|
46
|
+
readonly defaultSearchWithoutText?: boolean
|
|
47
|
+
/** A search answers the query's seeded search with the most results, whatever `numResults`. */
|
|
48
|
+
readonly numResultsIgnored?: boolean
|
|
49
|
+
/** A profile lookup answers an empty JSON object. */
|
|
50
|
+
readonly profileAnswersEmptyObject?: boolean
|
|
51
|
+
/** An email lookup answers a JSON object without `email`. */
|
|
52
|
+
readonly emailAnswerOmitsEmail?: boolean
|
|
53
|
+
/** A rejected Exa key answers status 500 (with the recorded 401 body). */
|
|
54
|
+
readonly exaUnauthorizedAs5xx?: boolean
|
|
55
|
+
/** A rejected Enrich Layer key answers status 200 (with the recorded 401 body). */
|
|
56
|
+
readonly enrichLayerUnauthorizedAs2xx?: boolean
|
|
57
|
+
/** An absent profile answers status 200 (with the recorded 404 body). */
|
|
58
|
+
readonly absentProfileAs2xx?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const linkedInSearchEmulatorDrillKnobs: ReadonlyArray<keyof LinkedInSearchEmulatorDrills> = [
|
|
62
|
+
'defaultSearchWithoutText',
|
|
63
|
+
'numResultsIgnored',
|
|
64
|
+
'profileAnswersEmptyObject',
|
|
65
|
+
'emailAnswerOmitsEmail',
|
|
66
|
+
'exaUnauthorizedAs5xx',
|
|
67
|
+
'enrichLayerUnauthorizedAs2xx',
|
|
68
|
+
'absentProfileAs2xx'
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
export type LinkedInSearchApiEnv = {
|
|
72
|
+
readonly drills: Readonly<Record<keyof LinkedInSearchEmulatorDrills, boolean>>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The fixtures' error bodies, byte for byte (`JSON.stringify` keeps this key order): Exa's 401,
|
|
77
|
+
* Enrich Layer's 401 (the profile and the email lookup answer the same body), and Enrich Layer's
|
|
78
|
+
* not-found 404.
|
|
79
|
+
*/
|
|
80
|
+
export const linkedInSearchEmulatorErrorBodies = {
|
|
81
|
+
exaUnauthorized: { requestId: 'synthetic-request-0003', error: 'Invalid API key' },
|
|
82
|
+
enrichLayerUnauthorized: { code: 401, description: 'Invalid API Key', name: 'Unauthorized' },
|
|
83
|
+
profileNotFound: { code: 404, description: 'Person profile does not exist', name: 'Not Found' }
|
|
84
|
+
} as const satisfies Readonly<Record<string, Schema.Json>>
|
|
85
|
+
|
|
86
|
+
type Route = StatefulRoute<LinkedInSearchEmulatorState, LinkedInSearchApiEnv>
|
|
87
|
+
|
|
88
|
+
const peopleResultsCase = 'linkedin-search.search.people-results'
|
|
89
|
+
|
|
90
|
+
const numResultsLimitCase = 'linkedin-search.search.num-results-limit'
|
|
91
|
+
|
|
92
|
+
const profileCase = 'linkedin-search.profile.get-profile'
|
|
93
|
+
|
|
94
|
+
const emailCase = 'linkedin-search.email.lookup-answer'
|
|
95
|
+
|
|
96
|
+
const exaUnauthorizedCase = 'linkedin-search.errors.exa-unauthorized'
|
|
97
|
+
|
|
98
|
+
const enrichLayerUnauthorizedCase = 'linkedin-search.errors.enrich-layer-unauthorized'
|
|
99
|
+
|
|
100
|
+
const profileNotFoundCase = 'linkedin-search.errors.profile-not-found'
|
|
101
|
+
|
|
102
|
+
const evidence = (
|
|
103
|
+
method: string,
|
|
104
|
+
path: string,
|
|
105
|
+
origin: string,
|
|
106
|
+
caseIds: ReadonlyArray<string>
|
|
107
|
+
) => ({
|
|
108
|
+
method,
|
|
109
|
+
path,
|
|
110
|
+
kind: 'connector' as const,
|
|
111
|
+
write: false,
|
|
112
|
+
caseIds,
|
|
113
|
+
evidence: 'unverified' as const,
|
|
114
|
+
origin,
|
|
115
|
+
// No route has a path parameter: every raw path is matched exactly.
|
|
116
|
+
params: {}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const json = (status: number, body: Schema.Json): Response =>
|
|
120
|
+
new Response(JSON.stringify(body), { status, headers: { 'content-type': contentType } })
|
|
121
|
+
|
|
122
|
+
/** A read-only commit. */
|
|
123
|
+
const answer =
|
|
124
|
+
(response: () => Response): Commit =>
|
|
125
|
+
() =>
|
|
126
|
+
response()
|
|
127
|
+
|
|
128
|
+
/** Whether the request's per-origin bearer digest is one a seed marks as rejected. */
|
|
129
|
+
const rejected = (digests: ReadonlyArray<string>, digest: string | undefined): boolean =>
|
|
130
|
+
digest !== undefined && digests.includes(digest)
|
|
131
|
+
|
|
132
|
+
// Exa people search.
|
|
133
|
+
|
|
134
|
+
type SearchInput = {
|
|
135
|
+
readonly query: string
|
|
136
|
+
readonly numResults: number
|
|
137
|
+
readonly bearerDigest: string | undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The connector's default `numResults` (the people-results fixture's). */
|
|
141
|
+
const defaultNumResults = 10
|
|
142
|
+
|
|
143
|
+
/** The result fields, in the fixtures' key order. */
|
|
144
|
+
const resultKeys = ['title', 'url', 'author', 'publishedDate', 'text'] as const
|
|
145
|
+
|
|
146
|
+
const renderResult = (result: LinkedInSearchEmulatorResult): Schema.JsonObject => {
|
|
147
|
+
const rendered: Record<string, string> = {}
|
|
148
|
+
|
|
149
|
+
for (const key of resultKeys) {
|
|
150
|
+
const value = result[key]
|
|
151
|
+
|
|
152
|
+
if (value !== undefined) rendered[key] = value
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return rendered
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The search the state answers for the input, or `undefined` (drills aside: the exact one). */
|
|
159
|
+
const searchFor = (
|
|
160
|
+
state: LinkedInSearchEmulatorState,
|
|
161
|
+
input: SearchInput,
|
|
162
|
+
env: LinkedInSearchApiEnv
|
|
163
|
+
): LinkedInSearchEmulatorSearch | undefined => {
|
|
164
|
+
const exact = state.searches.find(
|
|
165
|
+
search => search.query === input.query && search.numResults === input.numResults
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if (exact === undefined || !env.drills.numResultsIgnored) return exact
|
|
169
|
+
|
|
170
|
+
// The drill: the query's widest seeded answer (the first of the widest), whatever was asked.
|
|
171
|
+
return state.searches
|
|
172
|
+
.filter(search => search.query === input.query)
|
|
173
|
+
.reduce((widest, search) => (search.results.length > widest.results.length ? search : widest))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const search: Route = statefulRoute(
|
|
177
|
+
{
|
|
178
|
+
...evidence('POST', '/search', linkedInSearchEmulatorExaOrigin, [
|
|
179
|
+
peopleResultsCase,
|
|
180
|
+
numResultsLimitCase,
|
|
181
|
+
exaUnauthorizedCase
|
|
182
|
+
])
|
|
183
|
+
},
|
|
184
|
+
'json',
|
|
185
|
+
(request): SearchInput | NotEmulated => {
|
|
186
|
+
const query = exactQuery(request, [], [], { rawNames: true })
|
|
187
|
+
|
|
188
|
+
if (isNotEmulated(query)) return query
|
|
189
|
+
|
|
190
|
+
const body = exactBodyKeys(request.json, 'the search body', [
|
|
191
|
+
'query',
|
|
192
|
+
'category',
|
|
193
|
+
'numResults',
|
|
194
|
+
'type',
|
|
195
|
+
'contents'
|
|
196
|
+
])
|
|
197
|
+
|
|
198
|
+
if (isNotEmulated(body)) return body
|
|
199
|
+
|
|
200
|
+
const text = body.query
|
|
201
|
+
|
|
202
|
+
if (!Predicate.isString(text) || !linkedInSearchQueryPattern.test(text) || text.length > 500) {
|
|
203
|
+
return notEmulated('query must be one trimmed, non-empty line of at most 500 characters')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (body.category !== 'people') return notEmulated('category other than people is not emulated')
|
|
207
|
+
|
|
208
|
+
if (body.type !== 'auto') return notEmulated('type other than auto is not emulated')
|
|
209
|
+
|
|
210
|
+
const contents = exactBodyKeys(body.contents, 'contents', ['text'])
|
|
211
|
+
|
|
212
|
+
if (isNotEmulated(contents)) return contents
|
|
213
|
+
|
|
214
|
+
if (contents.text !== true)
|
|
215
|
+
return notEmulated('contents other than { text: true } is not emulated')
|
|
216
|
+
|
|
217
|
+
const numResults = integerIn(body.numResults, 'numResults', 1, linkedInSearchMaxNumResults)
|
|
218
|
+
|
|
219
|
+
if (isNotEmulated(numResults)) return numResults
|
|
220
|
+
|
|
221
|
+
return { query: text, numResults, bearerDigest: request.bearerDigest }
|
|
222
|
+
},
|
|
223
|
+
(state, input, { env }) => {
|
|
224
|
+
if (rejected(state.exaRejectedKeyDigests, input.bearerDigest)) {
|
|
225
|
+
// The Exa unauthorized fixture's 401, whatever the (well-formed) search.
|
|
226
|
+
return answer(() =>
|
|
227
|
+
json(
|
|
228
|
+
env.drills.exaUnauthorizedAs5xx ? 500 : 401,
|
|
229
|
+
linkedInSearchEmulatorErrorBodies.exaUnauthorized
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const found = searchFor(state, input, env)
|
|
235
|
+
|
|
236
|
+
if (found === undefined) {
|
|
237
|
+
return notEmulated(
|
|
238
|
+
'a search the state holds no answer for (this query and numResults) is not emulated'
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return answer(() =>
|
|
243
|
+
json(200, {
|
|
244
|
+
results: found.results.map(result => {
|
|
245
|
+
const rendered = renderResult(result)
|
|
246
|
+
|
|
247
|
+
if (!env.drills.defaultSearchWithoutText || input.numResults !== defaultNumResults) {
|
|
248
|
+
return rendered
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const { text: _text, ...withoutText } = rendered
|
|
252
|
+
|
|
253
|
+
return withoutText
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
// Enrich Layer lookups.
|
|
261
|
+
|
|
262
|
+
type LookupInput = { readonly url: string; readonly bearerDigest: string | undefined }
|
|
263
|
+
|
|
264
|
+
/** The one `linkedin_profile_url` query parameter: a profile URL (decoded once). */
|
|
265
|
+
const lookupInput = (request: EmulatedRequest): LookupInput | NotEmulated => {
|
|
266
|
+
const query = exactQuery(request, ['linkedin_profile_url'], [], { rawNames: true })
|
|
267
|
+
|
|
268
|
+
if (isNotEmulated(query)) return query
|
|
269
|
+
|
|
270
|
+
const url = query.linkedin_profile_url ?? ''
|
|
271
|
+
|
|
272
|
+
if (!linkedInSearchProfileUrlPattern.test(url) || url.length > 2048) {
|
|
273
|
+
return notEmulated('linkedin_profile_url must be a profile URL https://<host>/in/<slug>')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { url, bearerDigest: request.bearerDigest }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const profileHeld = (
|
|
280
|
+
state: LinkedInSearchEmulatorState,
|
|
281
|
+
url: string
|
|
282
|
+
): LinkedInSearchEmulatorProfile | undefined => state.profiles.find(profile => profile.url === url)
|
|
283
|
+
|
|
284
|
+
/** The Enrich Layer unauthorized fixture's 401 (both lookups answer it). */
|
|
285
|
+
const enrichLayerUnauthorized = (env: LinkedInSearchApiEnv): Commit =>
|
|
286
|
+
answer(() =>
|
|
287
|
+
json(
|
|
288
|
+
env.drills.enrichLayerUnauthorizedAs2xx ? 200 : 401,
|
|
289
|
+
linkedInSearchEmulatorErrorBodies.enrichLayerUnauthorized
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
const profile: Route = statefulRoute(
|
|
294
|
+
evidence('GET', '/api/v2/profile', linkedInSearchEmulatorEnrichLayerOrigin, [
|
|
295
|
+
profileCase,
|
|
296
|
+
enrichLayerUnauthorizedCase,
|
|
297
|
+
profileNotFoundCase
|
|
298
|
+
]),
|
|
299
|
+
'none',
|
|
300
|
+
lookupInput,
|
|
301
|
+
(state, input, { env }) => {
|
|
302
|
+
if (rejected(state.enrichLayerRejectedKeyDigests, input.bearerDigest)) {
|
|
303
|
+
return enrichLayerUnauthorized(env)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const held = profileHeld(state, input.url)
|
|
307
|
+
|
|
308
|
+
if (held !== undefined) {
|
|
309
|
+
return answer(() =>
|
|
310
|
+
json(
|
|
311
|
+
200,
|
|
312
|
+
env.drills.profileAnswersEmptyObject
|
|
313
|
+
? {}
|
|
314
|
+
: {
|
|
315
|
+
public_identifier: held.publicIdentifier,
|
|
316
|
+
full_name: held.fullName,
|
|
317
|
+
headline: held.headline
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (state.absentProfileUrls.includes(input.url)) {
|
|
324
|
+
return answer(() =>
|
|
325
|
+
json(
|
|
326
|
+
env.drills.absentProfileAs2xx ? 200 : 404,
|
|
327
|
+
linkedInSearchEmulatorErrorBodies.profileNotFound
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return notEmulated(
|
|
333
|
+
'a profile URL the state holds neither as a profile nor as absent is not emulated'
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
const email: Route = statefulRoute(
|
|
339
|
+
evidence('GET', '/api/v2/profile/email', linkedInSearchEmulatorEnrichLayerOrigin, [
|
|
340
|
+
emailCase,
|
|
341
|
+
enrichLayerUnauthorizedCase
|
|
342
|
+
]),
|
|
343
|
+
'none',
|
|
344
|
+
lookupInput,
|
|
345
|
+
(state, input, { env }) => {
|
|
346
|
+
if (rejected(state.enrichLayerRejectedKeyDigests, input.bearerDigest)) {
|
|
347
|
+
return enrichLayerUnauthorized(env)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const address = profileHeld(state, input.url)?.email
|
|
351
|
+
|
|
352
|
+
if (address === undefined) {
|
|
353
|
+
return notEmulated(
|
|
354
|
+
'an email lookup of a profile the state holds no email answer for is not emulated'
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return answer(() => json(200, env.drills.emailAnswerOmitsEmail ? {} : { email: address }))
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
/** Every emulated route, in manifest order. */
|
|
363
|
+
export const linkedInSearchApiRoutes: ReadonlyArray<Route> = [search, profile, email]
|