@benclmnt/postmock 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/dist/seeds/conformance/00-core.d.ts +4 -0
- package/dist/seeds/conformance/00-core.js +12 -0
- package/dist/seeds/conformance/20-bounces.d.ts +3 -0
- package/dist/seeds/conformance/20-bounces.js +108 -0
- package/dist/seeds/conformance/40-messages.d.ts +3 -0
- package/dist/seeds/conformance/40-messages.js +144 -0
- package/dist/seeds/conformance/50-server-color.d.ts +8 -0
- package/dist/seeds/conformance/50-server-color.js +13 -0
- package/dist/seeds/conformance/70-account.d.ts +9 -0
- package/dist/seeds/conformance/70-account.js +29 -0
- package/dist/seeds/conformance/80-templates.d.ts +4 -0
- package/dist/seeds/conformance/80-templates.js +23 -0
- package/dist/seeds/conformance.d.ts +2 -0
- package/dist/seeds/conformance.js +3 -0
- package/dist/seeds/empty.d.ts +4 -0
- package/dist/seeds/empty.js +3 -0
- package/dist/seeds/lib/conformance.d.ts +16 -0
- package/dist/seeds/lib/conformance.js +18 -0
- package/dist/seeds/lib/history.d.ts +27 -0
- package/dist/seeds/lib/history.js +129 -0
- package/dist/seeds/lib/read-server.d.ts +10 -0
- package/dist/seeds/lib/read-server.js +10 -0
- package/dist/seeds/lib/template-server.d.ts +13 -0
- package/dist/seeds/lib/template-server.js +13 -0
- package/dist/src/api/account/authentication.d.ts +39 -0
- package/dist/src/api/account/authentication.js +97 -0
- package/dist/src/api/account/domains.d.ts +59 -0
- package/dist/src/api/account/domains.js +78 -0
- package/dist/src/api/account/paging.d.ts +12 -0
- package/dist/src/api/account/paging.js +35 -0
- package/dist/src/api/account/push.d.ts +14 -0
- package/dist/src/api/account/push.js +84 -0
- package/dist/src/api/account/routes.d.ts +1 -0
- package/dist/src/api/account/routes.js +288 -0
- package/dist/src/api/account/senders.d.ts +54 -0
- package/dist/src/api/account/senders.js +87 -0
- package/dist/src/api/account/servers.d.ts +40 -0
- package/dist/src/api/account/servers.js +148 -0
- package/dist/src/api/bounces/json.d.ts +25 -0
- package/dist/src/api/bounces/json.js +27 -0
- package/dist/src/api/bounces/routes.d.ts +1 -0
- package/dist/src/api/bounces/routes.js +127 -0
- package/dist/src/api/bulk/bulk.d.ts +43 -0
- package/dist/src/api/bulk/bulk.js +81 -0
- package/dist/src/api/bulk/routes.d.ts +1 -0
- package/dist/src/api/bulk/routes.js +252 -0
- package/dist/src/api/data-removals/routes.d.ts +1 -0
- package/dist/src/api/data-removals/routes.js +59 -0
- package/dist/src/api/email/json.d.ts +11 -0
- package/dist/src/api/email/json.js +33 -0
- package/dist/src/api/email/routes.d.ts +1 -0
- package/dist/src/api/email/routes.js +64 -0
- package/dist/src/api/inbound/routes.d.ts +1 -0
- package/dist/src/api/inbound/routes.js +40 -0
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +3 -0
- package/dist/src/api/message-streams/routes.d.ts +1 -0
- package/dist/src/api/message-streams/routes.js +211 -0
- package/dist/src/api/message-streams/streams.d.ts +28 -0
- package/dist/src/api/message-streams/streams.js +50 -0
- package/dist/src/api/messages/json.d.ts +149 -0
- package/dist/src/api/messages/json.js +114 -0
- package/dist/src/api/messages/query.d.ts +19 -0
- package/dist/src/api/messages/query.js +64 -0
- package/dist/src/api/messages/routes.d.ts +1 -0
- package/dist/src/api/messages/routes.js +203 -0
- package/dist/src/api/server/json.d.ts +26 -0
- package/dist/src/api/server/json.js +25 -0
- package/dist/src/api/server/routes.d.ts +1 -0
- package/dist/src/api/server/routes.js +17 -0
- package/dist/src/api/stats/aggregate.d.ts +58 -0
- package/dist/src/api/stats/aggregate.js +66 -0
- package/dist/src/api/stats/routes.d.ts +1 -0
- package/dist/src/api/stats/routes.js +72 -0
- package/dist/src/api/suppressions/routes.d.ts +1 -0
- package/dist/src/api/suppressions/routes.js +98 -0
- package/dist/src/api/templates/content.d.ts +21 -0
- package/dist/src/api/templates/content.js +39 -0
- package/dist/src/api/templates/routes.d.ts +1 -0
- package/dist/src/api/templates/routes.js +164 -0
- package/dist/src/api/templates/send.d.ts +13 -0
- package/dist/src/api/templates/send.js +105 -0
- package/dist/src/api/templates/templates.d.ts +100 -0
- package/dist/src/api/templates/templates.js +218 -0
- package/dist/src/api/templates/validate.d.ts +15 -0
- package/dist/src/api/templates/validate.js +77 -0
- package/dist/src/api/triggers/routes.d.ts +1 -0
- package/dist/src/api/triggers/routes.js +62 -0
- package/dist/src/api/webhooks/routes.d.ts +14 -0
- package/dist/src/api/webhooks/routes.js +192 -0
- package/dist/src/config.d.ts +17 -0
- package/dist/src/config.js +95 -0
- package/dist/src/control/app.d.ts +5 -0
- package/dist/src/control/app.js +42 -0
- package/dist/src/control/endpoints/account.d.ts +1 -0
- package/dist/src/control/endpoints/account.js +68 -0
- package/dist/src/control/endpoints/bulk.d.ts +1 -0
- package/dist/src/control/endpoints/bulk.js +29 -0
- package/dist/src/control/endpoints/core.d.ts +1 -0
- package/dist/src/control/endpoints/core.js +104 -0
- package/dist/src/control/endpoints/inbound.d.ts +1 -0
- package/dist/src/control/endpoints/inbound.js +61 -0
- package/dist/src/control/endpoints/recipients.d.ts +1 -0
- package/dist/src/control/endpoints/recipients.js +136 -0
- package/dist/src/control/endpoints/smtp.d.ts +1 -0
- package/dist/src/control/endpoints/smtp.js +74 -0
- package/dist/src/control/endpoints/tracking.d.ts +1 -0
- package/dist/src/control/endpoints/tracking.js +95 -0
- package/dist/src/control/endpoints/webhooks.d.ts +1 -0
- package/dist/src/control/endpoints/webhooks.js +36 -0
- package/dist/src/control/index.d.ts +1 -0
- package/dist/src/control/index.js +3 -0
- package/dist/src/control/registry.d.ts +25 -0
- package/dist/src/control/registry.js +19 -0
- package/dist/src/control/seed.d.ts +11 -0
- package/dist/src/control/seed.js +32 -0
- package/dist/src/control/seeding.d.ts +10 -0
- package/dist/src/control/seeding.js +27 -0
- package/dist/src/discover.d.ts +16 -0
- package/dist/src/discover.js +49 -0
- package/dist/src/errors.d.ts +49 -0
- package/dist/src/errors.js +701 -0
- package/dist/src/events.d.ts +49 -0
- package/dist/src/events.js +19 -0
- package/dist/src/http/app.d.ts +8 -0
- package/dist/src/http/app.js +51 -0
- package/dist/src/http/auth.d.ts +7 -0
- package/dist/src/http/auth.js +32 -0
- package/dist/src/http/faults.d.ts +8 -0
- package/dist/src/http/faults.js +33 -0
- package/dist/src/http/normalize.d.ts +57 -0
- package/dist/src/http/normalize.js +225 -0
- package/dist/src/http/respond.d.ts +23 -0
- package/dist/src/http/respond.js +37 -0
- package/dist/src/http/routes.d.ts +60 -0
- package/dist/src/http/routes.js +64 -0
- package/dist/src/inbound/deliver.d.ts +9 -0
- package/dist/src/inbound/deliver.js +34 -0
- package/dist/src/inbound/parse.d.ts +21 -0
- package/dist/src/inbound/parse.js +71 -0
- package/dist/src/inbound/receive.d.ts +17 -0
- package/dist/src/inbound/receive.js +89 -0
- package/dist/src/main.d.ts +2 -0
- package/dist/src/main.js +14 -0
- package/dist/src/mime/compose.d.ts +33 -0
- package/dist/src/mime/compose.js +58 -0
- package/dist/src/pipeline/addresses.d.ts +6 -0
- package/dist/src/pipeline/addresses.js +38 -0
- package/dist/src/pipeline/inactive.d.ts +8 -0
- package/dist/src/pipeline/inactive.js +20 -0
- package/dist/src/pipeline/submit.d.ts +138 -0
- package/dist/src/pipeline/submit.js +415 -0
- package/dist/src/plugins/message-events.d.ts +7 -0
- package/dist/src/plugins/message-events.js +76 -0
- package/dist/src/plugins/smtp.d.ts +15 -0
- package/dist/src/plugins/smtp.js +42 -0
- package/dist/src/plugins/stats.d.ts +7 -0
- package/dist/src/plugins/stats.js +78 -0
- package/dist/src/plugins/webhooks.d.ts +4 -0
- package/dist/src/plugins/webhooks.js +80 -0
- package/dist/src/plugins.d.ts +20 -0
- package/dist/src/plugins.js +9 -0
- package/dist/src/recipients/test-bounces.d.ts +15 -0
- package/dist/src/recipients/test-bounces.js +31 -0
- package/dist/src/recipients/transitions.d.ts +57 -0
- package/dist/src/recipients/transitions.js +279 -0
- package/dist/src/render/mustachio.d.ts +60 -0
- package/dist/src/render/mustachio.js +265 -0
- package/dist/src/runtime.d.ts +12 -0
- package/dist/src/runtime.js +11 -0
- package/dist/src/server.d.ts +25 -0
- package/dist/src/server.js +49 -0
- package/dist/src/smtp/auth.d.ts +35 -0
- package/dist/src/smtp/auth.js +66 -0
- package/dist/src/smtp/listener.d.ts +16 -0
- package/dist/src/smtp/listener.js +121 -0
- package/dist/src/smtp/mime.d.ts +36 -0
- package/dist/src/smtp/mime.js +181 -0
- package/dist/src/smtp/receive.d.ts +21 -0
- package/dist/src/smtp/receive.js +89 -0
- package/dist/src/smtp/reply.d.ts +14 -0
- package/dist/src/smtp/reply.js +24 -0
- package/dist/src/state/clock.d.ts +35 -0
- package/dist/src/state/clock.js +107 -0
- package/dist/src/state/ids.d.ts +6 -0
- package/dist/src/state/ids.js +7 -0
- package/dist/src/state/servers.d.ts +27 -0
- package/dist/src/state/servers.js +101 -0
- package/dist/src/state/store.d.ts +57 -0
- package/dist/src/state/store.js +98 -0
- package/dist/src/state/suppressions.d.ts +9 -0
- package/dist/src/state/suppressions.js +8 -0
- package/dist/src/state/types.d.ts +530 -0
- package/dist/src/state/types.js +41 -0
- package/dist/src/time.d.ts +11 -0
- package/dist/src/time.js +48 -0
- package/dist/src/tracking.d.ts +53 -0
- package/dist/src/tracking.js +150 -0
- package/dist/src/webhooks/deliver.d.ts +60 -0
- package/dist/src/webhooks/deliver.js +172 -0
- package/dist/src/webhooks/egress.d.ts +19 -0
- package/dist/src/webhooks/egress.js +40 -0
- package/dist/src/webhooks/outbound.d.ts +26 -0
- package/dist/src/webhooks/outbound.js +47 -0
- package/dist/src/webhooks/payloads.d.ts +111 -0
- package/dist/src/webhooks/payloads.js +128 -0
- package/package.json +55 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Bounce, ClickEvent, InboundMessage, OpenEvent, OutboundMessage, SubscriptionChange } from "./state/types.ts";
|
|
2
|
+
/** Domain events (docs/11 §2). Webhooks, stats and message events subscribe to them. */
|
|
3
|
+
export interface EventMap {
|
|
4
|
+
/** A message was accepted for delivery. */
|
|
5
|
+
sent: {
|
|
6
|
+
message: OutboundMessage;
|
|
7
|
+
};
|
|
8
|
+
delivered: {
|
|
9
|
+
message: OutboundMessage;
|
|
10
|
+
recipient: string;
|
|
11
|
+
deliveredAt: Date;
|
|
12
|
+
details: string;
|
|
13
|
+
};
|
|
14
|
+
bounced: {
|
|
15
|
+
bounce: Bounce;
|
|
16
|
+
};
|
|
17
|
+
opened: {
|
|
18
|
+
open: OpenEvent;
|
|
19
|
+
};
|
|
20
|
+
clicked: {
|
|
21
|
+
click: ClickEvent;
|
|
22
|
+
};
|
|
23
|
+
spamComplaint: {
|
|
24
|
+
bounce: Bounce;
|
|
25
|
+
};
|
|
26
|
+
subscriptionChange: {
|
|
27
|
+
change: SubscriptionChange;
|
|
28
|
+
};
|
|
29
|
+
inboundReceived: {
|
|
30
|
+
message: InboundMessage;
|
|
31
|
+
};
|
|
32
|
+
/** An SMTP message failed validation after DATA (docs/07 §1.4). */
|
|
33
|
+
smtpApiError: {
|
|
34
|
+
bounce: Bounce;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export type EventName = keyof EventMap;
|
|
38
|
+
type Listener<K extends EventName> = (payload: EventMap[K]) => void | Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* `emit` awaits each listener in subscription order, so a request or control call that emits returns
|
|
41
|
+
* after every listener finished. A listener that needs later work (a webhook retry) schedules it on
|
|
42
|
+
* the clock. A listener that throws fails the emitter (AGENTS.md rule 5).
|
|
43
|
+
*/
|
|
44
|
+
export declare class EventBus {
|
|
45
|
+
private listeners;
|
|
46
|
+
on<K extends EventName>(name: K, listener: Listener<K>): () => void;
|
|
47
|
+
emit<K extends EventName>(name: K, payload: EventMap[K]): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `emit` awaits each listener in subscription order, so a request or control call that emits returns
|
|
3
|
+
* after every listener finished. A listener that needs later work (a webhook retry) schedules it on
|
|
4
|
+
* the clock. A listener that throws fails the emitter (AGENTS.md rule 5).
|
|
5
|
+
*/
|
|
6
|
+
export class EventBus {
|
|
7
|
+
listeners = {};
|
|
8
|
+
on(name, listener) {
|
|
9
|
+
const set = this.listeners[name] ?? new Set();
|
|
10
|
+
this.listeners[name] = set;
|
|
11
|
+
set.add(listener);
|
|
12
|
+
return () => set.delete(listener);
|
|
13
|
+
}
|
|
14
|
+
async emit(name, payload) {
|
|
15
|
+
for (const listener of [...(this.listeners[name] ?? [])]) {
|
|
16
|
+
await listener(payload);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HttpBindings } from "@hono/node-server";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import "../api/index.ts";
|
|
4
|
+
import type { Runtime } from "../runtime.ts";
|
|
5
|
+
/** The Postmark REST API on any host and port, at the root path (docs/08 R1). */
|
|
6
|
+
export declare function createApiApp(runtime: Runtime): Hono<{
|
|
7
|
+
Bindings: HttpBindings;
|
|
8
|
+
}>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import "../api/index.js";
|
|
3
|
+
import { ApiError } from "../errors.js";
|
|
4
|
+
import { authenticate } from "./auth.js";
|
|
5
|
+
import { applyFault } from "./faults.js";
|
|
6
|
+
import { decodeJsonBody, Query } from "./normalize.js";
|
|
7
|
+
import { errorResponse, jsonResponse, textResponse, Unsupported } from "./respond.js";
|
|
8
|
+
import { apiRoutes } from "./routes.js";
|
|
9
|
+
/** The Postmark REST API on any host and port, at the root path (docs/08 R1). */
|
|
10
|
+
export function createApiApp(runtime) {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
app.all("*", async (c) => {
|
|
13
|
+
const request = c.req.raw;
|
|
14
|
+
const url = new URL(request.url);
|
|
15
|
+
const fault = await applyFault(runtime.store.state, request.method, url.pathname, c.env);
|
|
16
|
+
if (fault)
|
|
17
|
+
return fault;
|
|
18
|
+
const matched = apiRoutes.match(request.method, url.pathname);
|
|
19
|
+
// The body of an unknown route is not captured (docs/02 §9 Q9).
|
|
20
|
+
if (!matched)
|
|
21
|
+
return textResponse(404, `postmock: no route for ${request.method} ${url.pathname}`);
|
|
22
|
+
const { route, params } = matched;
|
|
23
|
+
try {
|
|
24
|
+
const auth = authenticate(route.auth, request.headers, runtime.store.state, `${route.method} ${route.path}`, runtime.clock.now());
|
|
25
|
+
const body = decodeJsonBody(await request.arrayBuffer());
|
|
26
|
+
const result = await route.handler({
|
|
27
|
+
...runtime,
|
|
28
|
+
method: route.method,
|
|
29
|
+
params,
|
|
30
|
+
query: new Query(url.searchParams),
|
|
31
|
+
body,
|
|
32
|
+
headers: request.headers,
|
|
33
|
+
auth,
|
|
34
|
+
});
|
|
35
|
+
return jsonResponse(result);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof ApiError)
|
|
39
|
+
return errorResponse(error);
|
|
40
|
+
if (error instanceof Unsupported)
|
|
41
|
+
return textResponse(501, `postmock: ${error.message}`);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
// A bug in postmock: a loud 500 that no Postmark client mistakes for a Postmark envelope.
|
|
46
|
+
app.onError((error) => {
|
|
47
|
+
console.error(error);
|
|
48
|
+
return textResponse(500, `postmock crashed: ${error.stack ?? error.message}`);
|
|
49
|
+
});
|
|
50
|
+
return app;
|
|
51
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { State } from "../state/store.ts";
|
|
2
|
+
import type { AuthRequirement, AuthResult } from "./routes.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Checks the token header the route needs. A missing, unknown or wrong-type token is HTTP 401,
|
|
5
|
+
* ErrorCode 10 (docs/02 §3.4). The other token header is ignored.
|
|
6
|
+
*/
|
|
7
|
+
export declare function authenticate<A extends AuthRequirement>(requirement: A, headers: Headers, state: State, routeName: string, now: Date): AuthResult[A];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { apiError } from "../errors.js";
|
|
2
|
+
import { TEST_TOKEN, testTokenContext } from "../state/servers.js";
|
|
3
|
+
import { Unsupported } from "./respond.js";
|
|
4
|
+
// "The header name and value are case insensitive." refs/api_overview.md:21 (docs/02 §3.1).
|
|
5
|
+
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
6
|
+
/**
|
|
7
|
+
* Checks the token header the route needs. A missing, unknown or wrong-type token is HTTP 401,
|
|
8
|
+
* ErrorCode 10 (docs/02 §3.4). The other token header is ignored.
|
|
9
|
+
*/
|
|
10
|
+
export function authenticate(requirement, headers, state, routeName, now) {
|
|
11
|
+
if (requirement === "account") {
|
|
12
|
+
const token = headers.get("X-Postmark-Account-Token");
|
|
13
|
+
if (token !== null && state.account.tokens.some((t) => same(t, token))) {
|
|
14
|
+
return { kind: "account" };
|
|
15
|
+
}
|
|
16
|
+
throw apiError(10);
|
|
17
|
+
}
|
|
18
|
+
const token = headers.get("X-Postmark-Server-Token");
|
|
19
|
+
if (token === null)
|
|
20
|
+
throw apiError(10);
|
|
21
|
+
if (same(token, TEST_TOKEN)) {
|
|
22
|
+
if (requirement === "serverOrTest")
|
|
23
|
+
return testTokenContext(now);
|
|
24
|
+
throw new Unsupported(`${TEST_TOKEN} on ${routeName}: behavior not captured (docs/02 §9 Q8)`);
|
|
25
|
+
}
|
|
26
|
+
for (const server of state.servers.values()) {
|
|
27
|
+
if (server.ApiTokens.some((t) => same(t, token))) {
|
|
28
|
+
return { kind: "server", server };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw apiError(10);
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HttpBindings } from "@hono/node-server";
|
|
2
|
+
import type { State } from "../state/store.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Applies the first fault that matches the request and uses one of its `times`
|
|
5
|
+
* (docs/09 §5 `POST /control/faults`). `timeout` holds the request open until the client gives up;
|
|
6
|
+
* `reset` destroys the socket.
|
|
7
|
+
*/
|
|
8
|
+
export declare function applyFault(state: State, method: string, pathname: string, env: HttpBindings): Promise<Response | undefined>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ApiError } from "../errors.js";
|
|
2
|
+
import { errorResponse } from "./respond.js";
|
|
3
|
+
import { RouteTable } from "./routes.js";
|
|
4
|
+
/**
|
|
5
|
+
* Applies the first fault that matches the request and uses one of its `times`
|
|
6
|
+
* (docs/09 §5 `POST /control/faults`). `timeout` holds the request open until the client gives up;
|
|
7
|
+
* `reset` destroys the socket.
|
|
8
|
+
*/
|
|
9
|
+
export async function applyFault(state, method, pathname, env) {
|
|
10
|
+
const fault = state.faults.find((f) => {
|
|
11
|
+
if (f.remaining <= 0)
|
|
12
|
+
return false;
|
|
13
|
+
const table = new RouteTable();
|
|
14
|
+
table.add({ method: f.method.toUpperCase(), path: f.path });
|
|
15
|
+
return table.match(method, pathname) !== undefined;
|
|
16
|
+
});
|
|
17
|
+
if (!fault)
|
|
18
|
+
return undefined;
|
|
19
|
+
fault.remaining -= 1;
|
|
20
|
+
return reply(fault, env);
|
|
21
|
+
}
|
|
22
|
+
async function reply(fault, env) {
|
|
23
|
+
const { reply } = fault;
|
|
24
|
+
if (reply === "reset") {
|
|
25
|
+
env.incoming.socket.destroy();
|
|
26
|
+
return new Response(null, { status: 499 });
|
|
27
|
+
}
|
|
28
|
+
if (reply === "timeout") {
|
|
29
|
+
await new Promise((resolve) => env.incoming.socket.once("close", resolve));
|
|
30
|
+
return new Response(null, { status: 499 });
|
|
31
|
+
}
|
|
32
|
+
return errorResponse(new ApiError(reply.status, reply.body));
|
|
33
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare class Query {
|
|
3
|
+
private readonly values;
|
|
4
|
+
private readonly raw;
|
|
5
|
+
constructor(params: URLSearchParams);
|
|
6
|
+
/** The last value for `name`. */
|
|
7
|
+
get(name: string): string | undefined;
|
|
8
|
+
/** Every value for `name`: postmark.js repeats a key for an array (docs/02 §2.6). */
|
|
9
|
+
all(name: string): string[];
|
|
10
|
+
/** `metadata_<key>=value` pairs, prefix matched without case, key case kept (docs/08 R3). */
|
|
11
|
+
prefixed(prefix: string): Record<string, string>;
|
|
12
|
+
/** Parses the schema's keys (each read with `get`) with the schema. */
|
|
13
|
+
pick<S extends z.ZodObject>(schema: S): z.ZodSafeParseResult<z.output<S>>;
|
|
14
|
+
}
|
|
15
|
+
/** `true`/`True`/`1` and the false forms (docs/08 R4). */
|
|
16
|
+
export declare const queryBool: z.ZodPipe<z.ZodString, z.ZodTransform<boolean, string>>;
|
|
17
|
+
export declare const queryInt: z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>;
|
|
18
|
+
export interface QueryDate {
|
|
19
|
+
instant: Date;
|
|
20
|
+
/** True for `YYYY-MM-DD`; an inclusive `todate` then covers the whole day. */
|
|
21
|
+
dateOnly: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM:SS`, and `...SS.fffffff` (docs/08 R5). Month and day may have one
|
|
25
|
+
* digit: the postmark.js live stats test sends `2026-9-7`. A zoneless value is US
|
|
26
|
+
* Eastern time (docs/02 §7.1). postmark.js sends `Date#toISOString()`, which carries `Z`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseQueryDate(value: string): QueryDate | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The exclusive end of an inclusive `todate`: the next Eastern midnight for a date-only value,
|
|
31
|
+
* else the instant plus 1 ms.
|
|
32
|
+
*/
|
|
33
|
+
export declare function inclusiveUpperBound({ instant, dateOnly }: QueryDate): Date;
|
|
34
|
+
export declare const queryDate: z.ZodPipe<z.ZodString, z.ZodTransform<QueryDate, string>>;
|
|
35
|
+
/**
|
|
36
|
+
* The request body as JSON. Bytes decode as UTF-8 whatever the charset (docs/08 R13).
|
|
37
|
+
* Empty, whitespace or the literal `null` is absent, with or without Content-Type (docs/08 R7).
|
|
38
|
+
* Malformed JSON is ErrorCode 402 (docs/02 §7).
|
|
39
|
+
*/
|
|
40
|
+
export declare function decodeJsonBody(bytes: ArrayBuffer): unknown;
|
|
41
|
+
/**
|
|
42
|
+
* Renames body keys to the schema's spelling, matched without case, at every object level the
|
|
43
|
+
* schema describes: `HTMLBody`, `htmlBody` → `HtmlBody`; `ReturnPathDOmain` → `ReturnPathDomain`
|
|
44
|
+
* (docs/08 R8). Record keys (Metadata) keep their case. Unknown keys pass through; zod strips them
|
|
45
|
+
* (docs/08 R12).
|
|
46
|
+
*/
|
|
47
|
+
export declare function canonicalizeKeys(schema: unknown, value: unknown): unknown;
|
|
48
|
+
/** Parses a JSON body with key case folding (docs/08 R8). The route maps issues to ErrorCodes. */
|
|
49
|
+
export declare function parseBody<S extends z.ZodType>(schema: S, body: unknown): z.ZodSafeParseResult<z.output<S>>;
|
|
50
|
+
/** An optional scalar where `null` and `""` mean absent (docs/08 R9). */
|
|
51
|
+
export declare const absent: <S extends z.ZodType>(schema: S) => z.ZodPreprocess<z.ZodOptional<S>, unknown>;
|
|
52
|
+
/** An integer sent as a number or a numeric string (docs/08 R10: cli `TemplateId`, py server IDs). */
|
|
53
|
+
export declare const intLike: z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>;
|
|
54
|
+
/** An object that PHP serializes as `[]` when empty (docs/08 R10: `TemplateModel: []`). */
|
|
55
|
+
export declare const objectOrEmptyArray: <S extends z.ZodType>(schema: S) => z.ZodPreprocess<S, unknown>;
|
|
56
|
+
/** Base64 content; line breaks inside are allowed (docs/08 R11: the gem wraps every 60 chars). */
|
|
57
|
+
export declare const base64: z.ZodPipe<z.ZodString, z.ZodTransform<Buffer<ArrayBuffer>, string>>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiError } from "../errors.js";
|
|
3
|
+
import { easternWallTime, formatEasternDate } from "../time.js";
|
|
4
|
+
import { Unsupported } from "./respond.js";
|
|
5
|
+
// Request normalization: the accept rules of docs/08 §4.1. SDKs disagree on key case,
|
|
6
|
+
// boolean text and date shapes, so the server accepts every form an official SDK sends.
|
|
7
|
+
// Query names match without case and without underscores: SDKs send `fromEmail`, `fromemail`,
|
|
8
|
+
// `clientName` for the doc's `client_name` (docs/08 R3; docs/06 §1.1, INFERRED).
|
|
9
|
+
const foldQueryKey = (key) => key.toLowerCase().replaceAll("_", "");
|
|
10
|
+
export class Query {
|
|
11
|
+
values = new Map();
|
|
12
|
+
raw;
|
|
13
|
+
constructor(params) {
|
|
14
|
+
this.raw = params;
|
|
15
|
+
for (const [key, value] of params) {
|
|
16
|
+
// An empty `key=` is absent (docs/08 R4; gem and dotnet send them).
|
|
17
|
+
if (value === "")
|
|
18
|
+
continue;
|
|
19
|
+
const folded = foldQueryKey(key);
|
|
20
|
+
this.values.set(folded, [...(this.values.get(folded) ?? []), value]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The last value for `name`. */
|
|
24
|
+
get(name) {
|
|
25
|
+
return this.values.get(foldQueryKey(name))?.at(-1);
|
|
26
|
+
}
|
|
27
|
+
/** Every value for `name`: postmark.js repeats a key for an array (docs/02 §2.6). */
|
|
28
|
+
all(name) {
|
|
29
|
+
return this.values.get(foldQueryKey(name)) ?? [];
|
|
30
|
+
}
|
|
31
|
+
/** `metadata_<key>=value` pairs, prefix matched without case, key case kept (docs/08 R3). */
|
|
32
|
+
prefixed(prefix) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [key, value] of this.raw) {
|
|
35
|
+
if (value !== "" && key.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
36
|
+
out[key.slice(prefix.length)] = value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** Parses the schema's keys (each read with `get`) with the schema. */
|
|
42
|
+
pick(schema) {
|
|
43
|
+
const input = {};
|
|
44
|
+
for (const key of Object.keys(schema.shape)) {
|
|
45
|
+
const value = this.get(key);
|
|
46
|
+
if (value !== undefined)
|
|
47
|
+
input[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return schema.safeParse(input);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** `true`/`True`/`1` and the false forms (docs/08 R4). */
|
|
53
|
+
export const queryBool = z.string().transform((value, ctx) => {
|
|
54
|
+
const lower = value.toLowerCase();
|
|
55
|
+
if (lower === "true" || lower === "1")
|
|
56
|
+
return true;
|
|
57
|
+
if (lower === "false" || lower === "0")
|
|
58
|
+
return false;
|
|
59
|
+
ctx.issues.push({ code: "custom", message: `not a boolean: ${value}`, input: value });
|
|
60
|
+
return z.NEVER;
|
|
61
|
+
});
|
|
62
|
+
export const queryInt = z
|
|
63
|
+
.string()
|
|
64
|
+
.regex(/^-?\d+$/)
|
|
65
|
+
.transform(Number);
|
|
66
|
+
const DATE_RE = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,7}))?)? ?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
67
|
+
/**
|
|
68
|
+
* `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM:SS`, and `...SS.fffffff` (docs/08 R5). Month and day may have one
|
|
69
|
+
* digit: the postmark.js live stats test sends `2026-9-7`. A zoneless value is US
|
|
70
|
+
* Eastern time (docs/02 §7.1). postmark.js sends `Date#toISOString()`, which carries `Z`.
|
|
71
|
+
*/
|
|
72
|
+
export function parseQueryDate(value) {
|
|
73
|
+
const m = DATE_RE.exec(value);
|
|
74
|
+
if (!m)
|
|
75
|
+
return undefined;
|
|
76
|
+
const [, y, mo, d, h, mi, s, frac, zone] = m;
|
|
77
|
+
const n = (x) => Number(x ?? 0);
|
|
78
|
+
// Date.UTC rolls 2024-13-45 over into 2025; a field out of range is no date.
|
|
79
|
+
const probe = new Date(Date.UTC(n(y), n(mo) - 1, n(d), n(h), n(mi), n(s)));
|
|
80
|
+
if (probe.getUTCFullYear() !== n(y) ||
|
|
81
|
+
probe.getUTCMonth() !== n(mo) - 1 ||
|
|
82
|
+
probe.getUTCDate() !== n(d) ||
|
|
83
|
+
probe.getUTCHours() !== n(h) ||
|
|
84
|
+
probe.getUTCMinutes() !== n(mi) ||
|
|
85
|
+
probe.getUTCSeconds() !== n(s)) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const ms = Math.floor(Number(`0.${frac ?? 0}`) * 1000);
|
|
89
|
+
if (h === undefined)
|
|
90
|
+
return { instant: easternWallTime(n(y), n(mo), n(d)), dateOnly: true };
|
|
91
|
+
if (zone === undefined) {
|
|
92
|
+
return { instant: easternWallTime(n(y), n(mo), n(d), n(h), n(mi), n(s), ms), dateOnly: false };
|
|
93
|
+
}
|
|
94
|
+
const utc = Date.UTC(n(y), n(mo) - 1, n(d), n(h), n(mi), n(s), ms);
|
|
95
|
+
const [zoneHours, zoneMinutes] = [Number(zone.slice(1, 3)), Number(zone.slice(-2))];
|
|
96
|
+
if (zone !== "Z" && (zoneHours > 14 || zoneMinutes > 59))
|
|
97
|
+
return undefined;
|
|
98
|
+
const offset = zone === "Z" ? 0 : (zone.startsWith("-") ? -1 : 1) * (zoneHours * 60 + zoneMinutes);
|
|
99
|
+
return { instant: new Date(utc - offset * 60000), dateOnly: false };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The exclusive end of an inclusive `todate`: the next Eastern midnight for a date-only value,
|
|
103
|
+
* else the instant plus 1 ms.
|
|
104
|
+
*/
|
|
105
|
+
export function inclusiveUpperBound({ instant, dateOnly }) {
|
|
106
|
+
if (!dateOnly)
|
|
107
|
+
return new Date(instant.getTime() + 1);
|
|
108
|
+
// 30 h past a midnight is inside the next day on 23 h and 25 h DST days.
|
|
109
|
+
const [y, m, d] = formatEasternDate(new Date(instant.getTime() + 30 * 3600_000))
|
|
110
|
+
.split("-")
|
|
111
|
+
.map(Number);
|
|
112
|
+
return easternWallTime(y, m, d);
|
|
113
|
+
}
|
|
114
|
+
export const queryDate = z.string().transform((value, ctx) => {
|
|
115
|
+
const parsed = parseQueryDate(value);
|
|
116
|
+
if (parsed)
|
|
117
|
+
return parsed;
|
|
118
|
+
ctx.issues.push({ code: "custom", message: `not a date: ${value}`, input: value });
|
|
119
|
+
return z.NEVER;
|
|
120
|
+
});
|
|
121
|
+
/**
|
|
122
|
+
* The request body as JSON. Bytes decode as UTF-8 whatever the charset (docs/08 R13).
|
|
123
|
+
* Empty, whitespace or the literal `null` is absent, with or without Content-Type (docs/08 R7).
|
|
124
|
+
* Malformed JSON is ErrorCode 402 (docs/02 §7).
|
|
125
|
+
*/
|
|
126
|
+
export function decodeJsonBody(bytes) {
|
|
127
|
+
const text = new TextDecoder("utf-8").decode(bytes).trim();
|
|
128
|
+
if (text === "")
|
|
129
|
+
return undefined;
|
|
130
|
+
let value;
|
|
131
|
+
try {
|
|
132
|
+
value = JSON.parse(text);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw apiError(402);
|
|
136
|
+
}
|
|
137
|
+
return value === null ? undefined : value;
|
|
138
|
+
}
|
|
139
|
+
const defOf = (schema) => schema._zod.def;
|
|
140
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
141
|
+
/**
|
|
142
|
+
* Renames body keys to the schema's spelling, matched without case, at every object level the
|
|
143
|
+
* schema describes: `HTMLBody`, `htmlBody` → `HtmlBody`; `ReturnPathDOmain` → `ReturnPathDomain`
|
|
144
|
+
* (docs/08 R8). Record keys (Metadata) keep their case. Unknown keys pass through; zod strips them
|
|
145
|
+
* (docs/08 R12).
|
|
146
|
+
*/
|
|
147
|
+
export function canonicalizeKeys(schema, value) {
|
|
148
|
+
const def = defOf(schema);
|
|
149
|
+
switch (def.type) {
|
|
150
|
+
case "object": {
|
|
151
|
+
if (!isPlainObject(value))
|
|
152
|
+
return value;
|
|
153
|
+
const shape = def.shape;
|
|
154
|
+
const byFolded = new Map(Object.keys(shape).map((k) => [k.toLowerCase(), k]));
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const [key, item] of Object.entries(value)) {
|
|
157
|
+
const canonical = byFolded.get(key.toLowerCase());
|
|
158
|
+
const target = canonical ?? key;
|
|
159
|
+
// Postmark's answer is not captured; answer 501, not a guess (AGENTS.md rule 5).
|
|
160
|
+
if (target in out)
|
|
161
|
+
throw new Unsupported(`body has two spellings of key '${target}'`);
|
|
162
|
+
out[target] = canonical === undefined ? item : canonicalizeKeys(shape[canonical], item);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
case "array":
|
|
167
|
+
return Array.isArray(value) ? value.map((v) => canonicalizeKeys(def.element, v)) : value;
|
|
168
|
+
case "record":
|
|
169
|
+
return isPlainObject(value)
|
|
170
|
+
? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, canonicalizeKeys(def.valueType, v)]))
|
|
171
|
+
: value;
|
|
172
|
+
case "optional":
|
|
173
|
+
case "nullable":
|
|
174
|
+
case "default":
|
|
175
|
+
case "prefault":
|
|
176
|
+
case "readonly":
|
|
177
|
+
case "catch":
|
|
178
|
+
case "nonoptional":
|
|
179
|
+
return canonicalizeKeys(def.innerType, value);
|
|
180
|
+
case "pipe":
|
|
181
|
+
return canonicalizeKeys(defOf(def.in).type === "transform" ? def.out : def.in, value);
|
|
182
|
+
case "union":
|
|
183
|
+
return def.options.reduce((v, option) => canonicalizeKeys(option, v), value);
|
|
184
|
+
case "intersection":
|
|
185
|
+
return canonicalizeKeys(def.right, canonicalizeKeys(def.left, value));
|
|
186
|
+
case "tuple": {
|
|
187
|
+
if (!Array.isArray(value))
|
|
188
|
+
return value;
|
|
189
|
+
const items = def.items;
|
|
190
|
+
return value.map((v, i) => {
|
|
191
|
+
const item = i < items.length ? items[i] : def.rest;
|
|
192
|
+
return item === undefined || item === null ? v : canonicalizeKeys(item, v);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
case "lazy":
|
|
196
|
+
return canonicalizeKeys(def.getter(), value);
|
|
197
|
+
default:
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Parses a JSON body with key case folding (docs/08 R8). The route maps issues to ErrorCodes. */
|
|
202
|
+
export function parseBody(schema, body) {
|
|
203
|
+
return schema.safeParse(canonicalizeKeys(schema, body));
|
|
204
|
+
}
|
|
205
|
+
/** An optional scalar where `null` and `""` mean absent (docs/08 R9). */
|
|
206
|
+
export const absent = (schema) => z.preprocess((v) => (v === null || v === "" ? undefined : v), schema.optional());
|
|
207
|
+
/** An integer sent as a number or a numeric string (docs/08 R10: cli `TemplateId`, py server IDs). */
|
|
208
|
+
export const intLike = z.union([
|
|
209
|
+
z.number().int(),
|
|
210
|
+
z
|
|
211
|
+
.string()
|
|
212
|
+
.regex(/^-?\d+$/)
|
|
213
|
+
.transform(Number),
|
|
214
|
+
]);
|
|
215
|
+
/** An object that PHP serializes as `[]` when empty (docs/08 R10: `TemplateModel: []`). */
|
|
216
|
+
export const objectOrEmptyArray = (schema) => z.preprocess((v) => (Array.isArray(v) && v.length === 0 ? {} : v), schema);
|
|
217
|
+
/** Base64 content; line breaks inside are allowed (docs/08 R11: the gem wraps every 60 chars). */
|
|
218
|
+
export const base64 = z.string().transform((value, ctx) => {
|
|
219
|
+
const compact = value.replace(/\s+/g, "");
|
|
220
|
+
if (/^[A-Za-z0-9+/]*={0,2}$/.test(compact) && compact.length % 4 === 0) {
|
|
221
|
+
return Buffer.from(compact, "base64");
|
|
222
|
+
}
|
|
223
|
+
ctx.issues.push({ code: "custom", message: "not base64", input: value });
|
|
224
|
+
return z.NEVER;
|
|
225
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ApiError } from "../errors.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Behavior postmock does not know yet (an uncaptured response, an unbuilt route).
|
|
4
|
+
* Answers 501 with a plain-text reason, so a test fails loudly instead of learning a guess
|
|
5
|
+
* (AGENTS.md rule 5).
|
|
6
|
+
*/
|
|
7
|
+
export declare class Unsupported extends Error {
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Serializes JSON. Refuses `undefined` (no JSON body: an empty 200 breaks php, gem, java and dotnet,
|
|
11
|
+
* docs/08 §1.5) and a raw Date (each surface formats its own dates, docs/02 §7.1).
|
|
12
|
+
*/
|
|
13
|
+
export declare function toJson(body: unknown): string;
|
|
14
|
+
/** Every success is HTTP 200 with a JSON body (docs/08 E1, E2). */
|
|
15
|
+
export declare const jsonResponse: (body: unknown) => Response;
|
|
16
|
+
/** `{ErrorCode, Message}` with `X-PM-ApiErrorCode` (docs/02 §4.1; docs/08 E3). */
|
|
17
|
+
export declare const errorResponse: (error: ApiError) => Response;
|
|
18
|
+
export declare const textResponse: (status: number, text: string) => Response;
|
|
19
|
+
/**
|
|
20
|
+
* A list page with its wrapper key and `TotalCount`, both present when empty. `TotalCount` counts
|
|
21
|
+
* every match, not the page (docs/08 E9).
|
|
22
|
+
*/
|
|
23
|
+
export declare function paged<T>(key: string, items: readonly T[], count: number, offset: number): Record<string, unknown>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavior postmock does not know yet (an uncaptured response, an unbuilt route).
|
|
3
|
+
* Answers 501 with a plain-text reason, so a test fails loudly instead of learning a guess
|
|
4
|
+
* (AGENTS.md rule 5).
|
|
5
|
+
*/
|
|
6
|
+
export class Unsupported extends Error {
|
|
7
|
+
}
|
|
8
|
+
// docs/02 §4.1 example: `Content-Type: application/json`; charset pending capture Q4.
|
|
9
|
+
const JSON_TYPE = "application/json";
|
|
10
|
+
/**
|
|
11
|
+
* Serializes JSON. Refuses `undefined` (no JSON body: an empty 200 breaks php, gem, java and dotnet,
|
|
12
|
+
* docs/08 §1.5) and a raw Date (each surface formats its own dates, docs/02 §7.1).
|
|
13
|
+
*/
|
|
14
|
+
export function toJson(body) {
|
|
15
|
+
if (body === undefined)
|
|
16
|
+
throw new Error("response body is undefined");
|
|
17
|
+
return JSON.stringify(body, function (key, value) {
|
|
18
|
+
if (this[key] instanceof Date)
|
|
19
|
+
throw new Error(`unformatted Date at key '${key}'`);
|
|
20
|
+
return value;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** Every success is HTTP 200 with a JSON body (docs/08 E1, E2). */
|
|
24
|
+
export const jsonResponse = (body) => new Response(toJson(body), { status: 200, headers: { "Content-Type": JSON_TYPE } });
|
|
25
|
+
/** `{ErrorCode, Message}` with `X-PM-ApiErrorCode` (docs/02 §4.1; docs/08 E3). */
|
|
26
|
+
export const errorResponse = (error) => new Response(toJson(error.body), {
|
|
27
|
+
status: error.status,
|
|
28
|
+
headers: { "Content-Type": JSON_TYPE, "X-PM-ApiErrorCode": String(error.body.ErrorCode) },
|
|
29
|
+
});
|
|
30
|
+
export const textResponse = (status, text) => new Response(text, { status, headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
|
31
|
+
/**
|
|
32
|
+
* A list page with its wrapper key and `TotalCount`, both present when empty. `TotalCount` counts
|
|
33
|
+
* every match, not the page (docs/08 E9).
|
|
34
|
+
*/
|
|
35
|
+
export function paged(key, items, count, offset) {
|
|
36
|
+
return { TotalCount: items.length, [key]: items.slice(offset, offset + count) };
|
|
37
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Runtime } from "../runtime.ts";
|
|
2
|
+
import type { TestTokenContext } from "../state/servers.ts";
|
|
3
|
+
import type { Server } from "../state/types.ts";
|
|
4
|
+
import type { Query } from "./normalize.ts";
|
|
5
|
+
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
6
|
+
/**
|
|
7
|
+
* Method + path patterns (`/templates/:idOrAlias`). Literal segments match without case and a
|
|
8
|
+
* trailing slash is ignored (docs/08 R2). A literal beats a param at the same position, so
|
|
9
|
+
* `PUT /templates/push` wins over `PUT /templates/:idOrAlias` (docs/08 §2.5).
|
|
10
|
+
*/
|
|
11
|
+
export declare class RouteTable<R extends {
|
|
12
|
+
method: Method;
|
|
13
|
+
path: string;
|
|
14
|
+
}> {
|
|
15
|
+
private readonly entries;
|
|
16
|
+
add(route: R): void;
|
|
17
|
+
match(method: string, pathname: string): {
|
|
18
|
+
route: R;
|
|
19
|
+
params: Record<string, string>;
|
|
20
|
+
} | undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* - `server`: a server token; `POSTMARK_API_TEST` answers 501 until its behavior there is known.
|
|
24
|
+
* - `serverOrTest`: a server token or `POSTMARK_API_TEST` (docs/02 §3.3), which gets a
|
|
25
|
+
* `TestTokenContext`.
|
|
26
|
+
* - `account`: an account token.
|
|
27
|
+
*/
|
|
28
|
+
export type AuthRequirement = "server" | "serverOrTest" | "account";
|
|
29
|
+
export type ServerAuth = {
|
|
30
|
+
kind: "server";
|
|
31
|
+
server: Server;
|
|
32
|
+
};
|
|
33
|
+
export type AuthResult = {
|
|
34
|
+
server: ServerAuth;
|
|
35
|
+
serverOrTest: ServerAuth | TestTokenContext;
|
|
36
|
+
account: {
|
|
37
|
+
kind: "account";
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export interface RequestContext extends Runtime {
|
|
41
|
+
method: Method;
|
|
42
|
+
/** Raw path params, URL-decoded, case kept. */
|
|
43
|
+
params: Record<string, string>;
|
|
44
|
+
query: Query;
|
|
45
|
+
/** Parsed JSON body; `undefined` when empty or the literal `null` (docs/08 R7). */
|
|
46
|
+
body: unknown;
|
|
47
|
+
headers: Headers;
|
|
48
|
+
}
|
|
49
|
+
export interface ApiRoute<A extends AuthRequirement = AuthRequirement> {
|
|
50
|
+
method: Method;
|
|
51
|
+
path: string;
|
|
52
|
+
auth: A;
|
|
53
|
+
/** Returns the JSON body of a 200 response (docs/08 E1). Throws `ApiError` for an error. */
|
|
54
|
+
handler: (ctx: RequestContext & {
|
|
55
|
+
auth: AuthResult[A];
|
|
56
|
+
}) => unknown;
|
|
57
|
+
}
|
|
58
|
+
export declare const apiRoutes: RouteTable<ApiRoute<AuthRequirement>>;
|
|
59
|
+
/** Registers a Postmark API route. Each `src/api/<group>/routes.ts` calls it at import. */
|
|
60
|
+
export declare function defineRoute<A extends AuthRequirement>(route: ApiRoute<A>): void;
|