@usagekit/providers 0.4.0

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.
Files changed (43) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/dist/budget.d.ts +15 -0
  4. package/dist/budget.js +50 -0
  5. package/dist/catalog.d.ts +59 -0
  6. package/dist/catalog.js +149 -0
  7. package/dist/dataforseo/descriptor.d.ts +10 -0
  8. package/dist/dataforseo/descriptor.js +106 -0
  9. package/dist/dataforseo/extractors.d.ts +8 -0
  10. package/dist/dataforseo/extractors.js +145 -0
  11. package/dist/dataforseo/index.d.ts +4 -0
  12. package/dist/dataforseo/index.js +5 -0
  13. package/dist/eslint.d.ts +8 -0
  14. package/dist/eslint.js +70 -0
  15. package/dist/execute.d.ts +36 -0
  16. package/dist/execute.js +81 -0
  17. package/dist/fixtures.d.ts +45 -0
  18. package/dist/fixtures.js +96 -0
  19. package/dist/index.d.ts +19 -0
  20. package/dist/index.js +10 -0
  21. package/dist/match.d.ts +7 -0
  22. package/dist/match.js +55 -0
  23. package/dist/pointer.d.ts +2 -0
  24. package/dist/pointer.js +13 -0
  25. package/dist/prices.d.ts +11 -0
  26. package/dist/prices.js +25 -0
  27. package/dist/receipts.d.ts +10 -0
  28. package/dist/receipts.js +32 -0
  29. package/dist/serpapi/descriptor.d.ts +8 -0
  30. package/dist/serpapi/descriptor.js +97 -0
  31. package/dist/serpapi/extractors.d.ts +2 -0
  32. package/dist/serpapi/extractors.js +46 -0
  33. package/dist/serpapi/index.d.ts +3 -0
  34. package/dist/serpapi/index.js +4 -0
  35. package/dist/testing/conformance.d.ts +11 -0
  36. package/dist/testing/conformance.js +33 -0
  37. package/dist/testing/example.d.ts +10 -0
  38. package/dist/testing/example.js +146 -0
  39. package/dist/testing/index.d.ts +3 -0
  40. package/dist/testing/index.js +3 -0
  41. package/dist/types.d.ts +48 -0
  42. package/dist/types.js +1 -0
  43. package/package.json +53 -0
@@ -0,0 +1,145 @@
1
+ import { formatQuantity } from "@usagekit/core";
2
+ import { listEstimate } from "../prices.js";
3
+ import { pointer } from "../pointer.js";
4
+ import { moneyFromDollars, requestsMeasured } from "../receipts.js";
5
+ const tasksOf = (body) => {
6
+ const tasks = pointer(body, "/tasks");
7
+ return Array.isArray(tasks) ? tasks : [];
8
+ };
9
+ const isCost = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
10
+ const centsQuantity = (dollars) => ({
11
+ value: moneyFromDollars(dollars).units,
12
+ scale: 4,
13
+ unit: "cents",
14
+ });
15
+ const priceListSource = "https://docs.dataforseo.com/v3/appendix/user_data/";
16
+ /**
17
+ * Price object keys `api.func_type.func_name` to operation ids. The key layout follows the
18
+ * documented field schema; a recorded user_data fixture confirms it. Unmapped keys, per-result
19
+ * entries and free functions are skipped.
20
+ */
21
+ export const priceKeys = {
22
+ "serp.live.advanced": "serp.google.organic.live.advanced",
23
+ "serp.task_post.organic": "serp.google.organic.task_post",
24
+ ...Object.fromEntries([
25
+ "keyword_ideas",
26
+ "keyword_suggestions",
27
+ "related_keywords",
28
+ "keyword_overview",
29
+ "ranked_keywords",
30
+ "domain_rank_overview",
31
+ "historical_rank_overview",
32
+ "relevant_pages",
33
+ ].map((n) => [`dataforseo_labs.live.${n}`, `dataforseo_labs.google.${n}.live`])),
34
+ ...Object.fromEntries(["summary", "history", "backlinks"].map((n) => [`backlinks.live.${n}`, `backlinks.${n}.live`])),
35
+ };
36
+ const withPriority = new Set(["serp.google.organic.task_post"]);
37
+ function timestamp(text) {
38
+ if (typeof text !== "string")
39
+ return undefined;
40
+ const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) \+00:00$/.exec(text);
41
+ return m ? new Date(`${m[1]}T${m[2]}Z`).toISOString() : undefined;
42
+ }
43
+ export const extractors = {
44
+ estimate: listEstimate,
45
+ /** priority 1 is normal (the documented default when absent), 2 is high; anything else is unreadable. */
46
+ options: (operation, request) => {
47
+ if (!operation.optionKeys?.includes("priority"))
48
+ return {};
49
+ const first = Array.isArray(request.body) ? request.body[0] : null;
50
+ if (!first || typeof first !== "object")
51
+ return {};
52
+ const priority = first.priority ?? 1;
53
+ return priority === 1 ? { priority: "normal" } : priority === 2 ? { priority: "high" } : {};
54
+ },
55
+ /**
56
+ * Cost from the envelope `cost` (USD, every task of the call). A task status of 40000 or
57
+ * more, or an HTTP error, marks the receipt failed with whatever the provider charged.
58
+ * Deferred operations keep cost unknown and carry the task id for resolution.
59
+ */
60
+ extract: (operation, _request, response, body) => {
61
+ const tasks = tasksOf(body), id = extractors.requestId(body), cost = pointer(body, "/cost"), status = pointer(body, "/status_code");
62
+ const failed = response.status >= 400 ||
63
+ (typeof status === "number" && status >= 40000) ||
64
+ tasks.some((t) => typeof t.status_code === "number" && t.status_code >= 40000);
65
+ const known = operation.costEvidence !== "deferred" && isCost(cost);
66
+ const cents = known
67
+ ? { unit: "cents", quantity: centsQuantity(cost), certainty: "measured" }
68
+ : { unit: "cents", quantity: null, certainty: "unknown" };
69
+ return {
70
+ measurements: [cents, requestsMeasured()],
71
+ cost: known
72
+ ? { certainty: "measured", money: moneyFromDollars(cost) }
73
+ : { certainty: "unknown", money: null },
74
+ ...(id ? { providerRequestId: id } : {}),
75
+ cached: false,
76
+ failed,
77
+ };
78
+ },
79
+ requestId: (body) => {
80
+ const id = tasksOf(body)[0]?.id;
81
+ return typeof id === "string" && id ? id : undefined;
82
+ },
83
+ balance: (body) => {
84
+ const result = pointer(body, "/tasks/0/result/0");
85
+ const snapshot = { plan: "prepaid" };
86
+ const balance = pointer(result, "/money/balance"), total = pointer(result, "/money/total"), login = pointer(result, "/login"), perMinute = pointer(result, "/rates/limits/minute/total");
87
+ if (isCost(balance))
88
+ snapshot.remaining = centsQuantity(balance);
89
+ if (isCost(balance) && isCost(total) && total >= balance)
90
+ snapshot.used = {
91
+ ...centsQuantity(total),
92
+ value: centsQuantity(total).value - centsQuantity(balance).value,
93
+ };
94
+ if (typeof login === "string" && login)
95
+ snapshot.accountIdentity = login;
96
+ if (typeof perMinute === "number" && Number.isSafeInteger(perMinute))
97
+ snapshot.rateLimitPerHour = perMinute * 60;
98
+ return snapshot;
99
+ },
100
+ /** Maps price.api.func_type.func_name.priority to operation and priority option. */
101
+ priceList: (body, checkedAt) => {
102
+ const price = pointer(body, "/tasks/0/result/0/price");
103
+ const rows = [];
104
+ if (!price || typeof price !== "object")
105
+ return rows;
106
+ for (const [api, types] of Object.entries(price))
107
+ for (const [type, names] of Object.entries((types ?? {})))
108
+ for (const [name, priorities] of Object.entries((names ?? {}))) {
109
+ const operation = priceKeys[`${api}.${type}.${name}`];
110
+ if (!operation)
111
+ continue;
112
+ for (const [priority, entry] of Object.entries((priorities ?? {}))) {
113
+ const level = priority.replace(/^priority_/, "");
114
+ if (entry?.cost_type !== "per_request" || !isCost(entry.cost))
115
+ continue;
116
+ if (!withPriority.has(operation) && level !== "normal")
117
+ continue;
118
+ rows.push({
119
+ validFrom: checkedAt,
120
+ operation,
121
+ ...(withPriority.has(operation) ? { option: { priority: level } } : {}),
122
+ unit: "cents",
123
+ perUnit: formatQuantity(centsQuantity(entry.cost)),
124
+ source: priceListSource,
125
+ checkedAt,
126
+ });
127
+ }
128
+ }
129
+ return rows;
130
+ },
131
+ /** Task history lines: the charge of each posted task, matched by task id. */
132
+ billingExport: (input) => tasksOf(input).flatMap((t) => {
133
+ const occurredAt = timestamp(pointer(t, "/result/0/datetime"));
134
+ if (typeof t.id !== "string" || !isCost(t.cost) || !occurredAt)
135
+ return [];
136
+ return [
137
+ {
138
+ providerRequestId: t.id,
139
+ operation: "serp.google.organic.task_post",
140
+ occurredAt,
141
+ cost: moneyFromDollars(t.cost),
142
+ },
143
+ ];
144
+ }),
145
+ };
@@ -0,0 +1,4 @@
1
+ import type { ProviderModule } from "../types.js";
2
+ /** Provider A: per-call cost, prepaid balance, basic auth. See docs/PROVIDERS.md section 5. */
3
+ export declare const dataforseo: ProviderModule;
4
+ export { priceKeys } from "./extractors.js";
@@ -0,0 +1,5 @@
1
+ import { descriptor } from "./descriptor.js";
2
+ import { extractors } from "./extractors.js";
3
+ /** Provider A: per-call cost, prepaid balance, basic auth. See docs/PROVIDERS.md section 5. */
4
+ export const dataforseo = { descriptor, extractors };
5
+ export { priceKeys } from "./extractors.js";
@@ -0,0 +1,8 @@
1
+ import type { Rule } from "eslint";
2
+ export declare const noDirectProviderCall: Rule.RuleModule;
3
+ declare const plugin: {
4
+ rules: {
5
+ "no-direct-provider-call": Rule.RuleModule;
6
+ };
7
+ };
8
+ export default plugin;
package/dist/eslint.js ADDED
@@ -0,0 +1,70 @@
1
+ /** A small glob dialect: * matches one path segment; ** matches any depth. */
2
+ function matches(pattern, value) {
3
+ let source = "";
4
+ for (let i = 0; i < pattern.length; i++) {
5
+ const c = pattern[i];
6
+ if (c === "*" && pattern[i + 1] === "*") {
7
+ i++;
8
+ if (pattern[i + 1] === "/") {
9
+ source += "(?:.*/)?";
10
+ i++;
11
+ }
12
+ else
13
+ source += ".*";
14
+ }
15
+ else if (c === "*")
16
+ source += "[^/]*";
17
+ else
18
+ source += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
+ }
20
+ return new RegExp(`^${source}$`).test(value);
21
+ }
22
+ export const noDirectProviderCall = {
23
+ meta: {
24
+ type: "problem",
25
+ docs: { description: "Keep provider clients inside reviewed metering wrappers." },
26
+ schema: [
27
+ {
28
+ type: "object",
29
+ properties: {
30
+ modules: { type: "array", minItems: 1, items: { type: "string", minLength: 1 } },
31
+ wrappers: { type: "array", items: { type: "string", minLength: 1 } },
32
+ },
33
+ required: ["modules", "wrappers"],
34
+ additionalProperties: false,
35
+ },
36
+ ],
37
+ messages: { direct: "Import {{module}} only from an approved metering wrapper." },
38
+ },
39
+ create(context) {
40
+ const options = context.options[0];
41
+ if (!options)
42
+ return {};
43
+ const cwd = context.cwd.replace(/\\/g, "/").replace(/\/$/, "");
44
+ const filename = context.filename.replace(/\\/g, "/");
45
+ const relative = filename.startsWith(cwd + "/") ? filename.slice(cwd.length + 1) : filename;
46
+ if (options.wrappers.some((pattern) => matches(pattern, relative)))
47
+ return {};
48
+ const check = (node, value) => {
49
+ if (typeof value === "string" && options.modules.some((pattern) => matches(pattern, value)))
50
+ context.report({ node, messageId: "direct", data: { module: value } });
51
+ };
52
+ return {
53
+ ImportDeclaration: (node) => check(node, node.source.value),
54
+ ExportNamedDeclaration: (node) => check(node, node.source?.value),
55
+ ExportAllDeclaration: (node) => check(node, node.source.value),
56
+ ImportExpression: (node) => {
57
+ if (node.source.type === "Literal")
58
+ check(node, node.source.value);
59
+ },
60
+ CallExpression: (node) => {
61
+ if (node.callee.type === "Identifier" &&
62
+ node.callee.name === "require" &&
63
+ node.arguments[0]?.type === "Literal")
64
+ check(node, node.arguments[0].value);
65
+ },
66
+ };
67
+ },
68
+ };
69
+ const plugin = { rules: { "no-direct-provider-call": noDirectProviderCall } };
70
+ export default plugin;
@@ -0,0 +1,36 @@
1
+ import type { DispatchGrant, Meter, MeterReserveInput, Operation, ReserveResult, SettleResult } from "@usagekit/core";
2
+ import type { Catalog } from "./catalog.js";
3
+ import type { ProviderRequest, ProviderResponse } from "./types.js";
4
+ export type ProviderExecution = {
5
+ outcome: "completed";
6
+ response: ProviderResponse;
7
+ body: unknown;
8
+ operation: Operation | null;
9
+ accounting: "metered" | "passthrough";
10
+ } | {
11
+ outcome: "not_dispatched";
12
+ result: ReserveResult | DispatchGrant;
13
+ } | {
14
+ outcome: "transport_failed";
15
+ operation: Operation | null;
16
+ } | {
17
+ outcome: "accounting_failed";
18
+ result: SettleResult;
19
+ };
20
+ export type ProviderExecutionInput = {
21
+ meter: Meter;
22
+ catalog: Catalog;
23
+ input: Omit<MeterReserveInput, "operation" | "options">;
24
+ request: ProviderRequest;
25
+ /** Reads the entire response before resolving. Inject credentials here, never in accounting. */
26
+ dispatch(request: ProviderRequest): Promise<{
27
+ response: ProviderResponse;
28
+ body: unknown;
29
+ }>;
30
+ now?: () => Date;
31
+ holder?: string;
32
+ leaseTtlMs?: number;
33
+ };
34
+ /** Execute one provider request. A replay, rejected reserve or lost grant never calls dispatch.
35
+ * Long transports must finish within leaseTtlMs; streaming/recovery workers are host-owned. */
36
+ export declare function executeProviderRequest({ meter, catalog, input, request, dispatch, now, holder, leaseTtlMs, }: ProviderExecutionInput): Promise<ProviderExecution>;
@@ -0,0 +1,81 @@
1
+ /** Execute one provider request. A replay, rejected reserve or lost grant never calls dispatch.
2
+ * Long transports must finish within leaseTtlMs; streaming/recovery workers are host-owned. */
3
+ export async function executeProviderRequest({ meter, catalog, input, request, dispatch, now = () => new Date(), holder = "provider-wrapper", leaseTtlMs = 60000, }) {
4
+ const matched = catalog.match(input.provider, request);
5
+ const { estimate, ...base } = input;
6
+ const reserved = await meter.reserve({
7
+ ...base,
8
+ operation: matched.operation === "unknown" ? "unknown" : matched.operation.id,
9
+ ...(matched.operation === "unknown"
10
+ ? {}
11
+ : { ...(estimate === undefined ? {} : { estimate }), options: matched.options }),
12
+ });
13
+ if (matched.operation === "unknown")
14
+ return { outcome: "not_dispatched", result: reserved };
15
+ const ref = {
16
+ namespace: input.scope.namespace,
17
+ principal: input.scope.principal,
18
+ operationId: input.operationId,
19
+ };
20
+ let grant;
21
+ if (reserved.outcome === "reserved" && !reserved.replayed) {
22
+ const intent = await meter.markDispatchIntent({
23
+ ...ref,
24
+ commandId: input.operationId + ":intent",
25
+ expectedVersion: reserved.operation.version,
26
+ holder,
27
+ leaseTtlMs,
28
+ });
29
+ if (!("granted" in intent) || !intent.granted)
30
+ return { outcome: "not_dispatched", result: intent };
31
+ grant = { version: intent.operation.version, leaseId: intent.lease.leaseId };
32
+ }
33
+ else if (reserved.outcome !== "passthrough" || reserved.replayed)
34
+ return { outcome: "not_dispatched", result: reserved };
35
+ const occurredAt = now().toISOString();
36
+ const settle = (receipt) => grant
37
+ ? meter.settle({
38
+ ...ref,
39
+ commandId: input.operationId + ":settle",
40
+ expectedVersion: grant.version,
41
+ authority: { kind: "lease", leaseId: grant.leaseId },
42
+ receipt: {
43
+ ...receipt,
44
+ id: input.operationId + ":receipt",
45
+ occurredAt,
46
+ recordedAt: now().toISOString(),
47
+ },
48
+ })
49
+ : undefined;
50
+ let returned, receipt;
51
+ try {
52
+ returned = await dispatch(request);
53
+ receipt = catalog.extract(input.provider, matched.operation.id, request, returned.response, returned.body);
54
+ }
55
+ catch {
56
+ const settled = await settle({
57
+ measurements: [
58
+ {
59
+ unit: "requests",
60
+ quantity: { unit: "requests", value: 1n, scale: 0 },
61
+ certainty: "measured",
62
+ },
63
+ ],
64
+ cost: { certainty: "unknown", money: null },
65
+ cached: false,
66
+ failed: true,
67
+ });
68
+ if (settled && settled.outcome !== "settled")
69
+ return { outcome: "accounting_failed", result: settled };
70
+ return { outcome: "transport_failed", operation: settled?.operation ?? null };
71
+ }
72
+ const settled = await settle(receipt);
73
+ if (settled && settled.outcome !== "settled")
74
+ return { outcome: "accounting_failed", result: settled };
75
+ return {
76
+ outcome: "completed",
77
+ ...returned,
78
+ operation: settled?.operation ?? null,
79
+ accounting: grant ? "metered" : "passthrough",
80
+ };
81
+ }
@@ -0,0 +1,45 @@
1
+ import type { BalanceSnapshot, BillingLine, PriceRow, ProviderDescriptor } from "@usagekit/core";
2
+ import type { ProviderRequest, ProviderResponse, ReceiptDraft } from "./types.js";
3
+ import type { Catalog } from "./catalog.js";
4
+ /**
5
+ * One fixture file: a redacted request and response pair plus the expected parsed values.
6
+ * documentation fixtures are authored from public API documentation; recorded ones come from
7
+ * the local server recorder; synthetic ones exercise the catalog itself.
8
+ */
9
+ export type Fixture = {
10
+ path: string;
11
+ origin: "documentation" | "recorded" | "synthetic";
12
+ provider: string;
13
+ operation: string;
14
+ source?: string;
15
+ description?: string;
16
+ recordedAt?: string;
17
+ request: ProviderRequest;
18
+ response: ProviderResponse & {
19
+ body: unknown;
20
+ };
21
+ expect: FixtureExpectation;
22
+ };
23
+ export type FixtureExpectation = {
24
+ options?: Record<string, string>;
25
+ receipt?: ReceiptDraft;
26
+ /** null asserts that the body carries no provider request id. */
27
+ requestId?: string | null;
28
+ balance?: BalanceSnapshot;
29
+ priceList?: {
30
+ checkedAt: string;
31
+ rows: PriceRow[];
32
+ };
33
+ billingLines?: BillingLine[];
34
+ };
35
+ /** Parsed fixture files keyed by path, for example the result of import.meta.glob(..., { eager: true }). */
36
+ export type FixtureDirectory = Readonly<Record<string, unknown>>;
37
+ /**
38
+ * Validate fixture files. Paths end in <operation>/<case>.json and the folder must name the
39
+ * fixture's operation. Quantities and money in expectations become bigint.
40
+ */
41
+ export declare function loadFixtures(files: FixtureDirectory): Fixture[];
42
+ /** Advertised operations without any fixture. A descriptor may not advertise them. */
43
+ export declare function missingFixtures(descriptor: ProviderDescriptor, fixtures: readonly Fixture[]): string[];
44
+ /** Problems of one fixture against the catalog: match, options and every declared expectation. */
45
+ export declare function verifyFixture(catalog: Catalog, fixture: Fixture): string[];
@@ -0,0 +1,96 @@
1
+ import { decodeMeterJson } from "@usagekit/core";
2
+ const origins = ["documentation", "recorded", "synthetic"];
3
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
4
+ /**
5
+ * Validate fixture files. Paths end in <operation>/<case>.json and the folder must name the
6
+ * fixture's operation. Quantities and money in expectations become bigint.
7
+ */
8
+ export function loadFixtures(files) {
9
+ return Object.entries(files)
10
+ .sort(([a], [b]) => a.localeCompare(b))
11
+ .map(([path, raw]) => {
12
+ const fail = (what) => new Error(`InvalidFixture: ${path}: ${what}`);
13
+ const segments = path.split("/");
14
+ if (segments.length < 2 || !path.endsWith(".json"))
15
+ throw fail("path");
16
+ if (!isObject(raw))
17
+ throw fail("object");
18
+ const data = decodeMeterJson(JSON.stringify(raw));
19
+ if (!origins.includes(String(data.origin)))
20
+ throw fail("origin");
21
+ if (typeof data.provider !== "string" || typeof data.operation !== "string")
22
+ throw fail("provider and operation");
23
+ if (segments.at(-2) !== data.operation)
24
+ throw fail("operation folder");
25
+ const request = data.request, response = data.response;
26
+ if (!isObject(request) ||
27
+ typeof request.method !== "string" ||
28
+ typeof request.url !== "string")
29
+ throw fail("request");
30
+ if (!isObject(response) || typeof response.status !== "number" || !("body" in response))
31
+ throw fail("response");
32
+ return {
33
+ ...data,
34
+ expect: (data.expect ?? {}),
35
+ path,
36
+ };
37
+ });
38
+ }
39
+ /** Advertised operations without any fixture. A descriptor may not advertise them. */
40
+ export function missingFixtures(descriptor, fixtures) {
41
+ return descriptor.operations
42
+ .map((o) => o.id)
43
+ .filter((id) => !fixtures.some((f) => f.provider === descriptor.id && f.operation === id));
44
+ }
45
+ /** Key-order-insensitive deep equality with exact bigints. */
46
+ function canonical(value) {
47
+ if (typeof value === "bigint")
48
+ return `${value}n`;
49
+ if (Array.isArray(value))
50
+ return `[${value.map(canonical).join(",")}]`;
51
+ if (value && typeof value === "object")
52
+ return `{${Object.entries(value)
53
+ .filter(([, v]) => v !== undefined)
54
+ .sort(([a], [b]) => (a < b ? -1 : 1))
55
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
56
+ .join(",")}}`;
57
+ return JSON.stringify(value) ?? "undefined";
58
+ }
59
+ const same = (a, b) => canonical(a) === canonical(b);
60
+ /** Problems of one fixture against the catalog: match, options and every declared expectation. */
61
+ export function verifyFixture(catalog, fixture) {
62
+ const problems = [], e = fixture.expect, matched = catalog.match(fixture.provider, fixture.request);
63
+ const id = matched.operation === "unknown" ? "unknown" : matched.operation.id;
64
+ if (id !== fixture.operation)
65
+ return [`matched ${id}, expected ${fixture.operation}`];
66
+ const module = catalog.providers().find((d) => d.id === fixture.provider);
67
+ if (e.options && "options" in matched && !same(matched.options, e.options))
68
+ problems.push("options differ");
69
+ const declared = ["options", "receipt", "requestId", "balance", "priceList", "billingLines"].filter((k) => k in e);
70
+ if (!declared.length)
71
+ problems.push("fixture declares no expectation");
72
+ if (e.receipt) {
73
+ const receipt = catalog.extract(fixture.provider, fixture.operation, fixture.request, fixture.response, fixture.response.body);
74
+ if (!same(receipt, e.receipt))
75
+ problems.push("receipt differs");
76
+ }
77
+ if ("requestId" in e &&
78
+ catalog.requestId(fixture.provider, fixture.response.body) !== (e.requestId ?? undefined))
79
+ problems.push("request id differs");
80
+ if (e.balance && !same(catalog.probe(fixture.provider, fixture.response.body), e.balance))
81
+ problems.push("balance differs");
82
+ const parsers = catalog.parsers(module.id);
83
+ if (e.priceList) {
84
+ if (!parsers.priceList)
85
+ problems.push("no price list parser");
86
+ else if (!same(parsers.priceList(fixture.response.body, e.priceList.checkedAt), e.priceList.rows))
87
+ problems.push("price list differs");
88
+ }
89
+ if (e.billingLines) {
90
+ if (!parsers.billingExport)
91
+ problems.push("no billing export parser");
92
+ else if (!same(parsers.billingExport(fixture.response.body), e.billingLines))
93
+ problems.push("billing lines differ");
94
+ }
95
+ return problems;
96
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Provider catalog: descriptors, match, estimate, extract, probe and plan budgets. Web runtime,
3
+ * no storage: connection data comes from the host. See docs/PROVIDERS.md.
4
+ */
5
+ export type { Extractors, EstimateInput, ProviderModule, ProviderRequest, ProviderResponse, ReceiptDraft, } from "./types.js";
6
+ export { createCatalog, probeByPointers } from "./catalog.js";
7
+ export type { Catalog, CatalogConnection, CatalogEstimate, CatalogMatch, EstimateSource, } from "./catalog.js";
8
+ export { proposePlanBudget } from "./budget.js";
9
+ export type { BudgetTarget } from "./budget.js";
10
+ export { listEstimate, selectPriceRow } from "./prices.js";
11
+ export { matchOperation } from "./match.js";
12
+ export { pointer } from "./pointer.js";
13
+ export { decimalQuantity, numberText, moneyFromCents, moneyFromDollars, requestsMeasured, } from "./receipts.js";
14
+ export { loadFixtures, missingFixtures, verifyFixture } from "./fixtures.js";
15
+ export type { Fixture, FixtureDirectory, FixtureExpectation } from "./fixtures.js";
16
+ export { dataforseo } from "./dataforseo/index.js";
17
+ export { serpapi } from "./serpapi/index.js";
18
+ export { executeProviderRequest } from "./execute.js";
19
+ export type { ProviderExecution, ProviderExecutionInput } from "./execute.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { createCatalog, probeByPointers } from "./catalog.js";
2
+ export { proposePlanBudget } from "./budget.js";
3
+ export { listEstimate, selectPriceRow } from "./prices.js";
4
+ export { matchOperation } from "./match.js";
5
+ export { pointer } from "./pointer.js";
6
+ export { decimalQuantity, numberText, moneyFromCents, moneyFromDollars, requestsMeasured, } from "./receipts.js";
7
+ export { loadFixtures, missingFixtures, verifyFixture } from "./fixtures.js";
8
+ export { dataforseo } from "./dataforseo/index.js";
9
+ export { serpapi } from "./serpapi/index.js";
10
+ export { executeProviderRequest } from "./execute.js";
@@ -0,0 +1,7 @@
1
+ import type { ProviderDescriptor, ProviderOperation } from "@usagekit/core";
2
+ import type { ProviderRequest } from "./types.js";
3
+ /**
4
+ * The operation for a request: the longest literal path wins, then most query constraints, then
5
+ * an exact method over ANY. The request must target the upstream origin and base path.
6
+ */
7
+ export declare function matchOperation(descriptor: ProviderDescriptor, request: ProviderRequest): ProviderOperation | undefined;
package/dist/match.js ADDED
@@ -0,0 +1,55 @@
1
+ function pathScore(pattern, path) {
2
+ const want = pattern.split("/").filter(Boolean), have = path.split("/").filter(Boolean);
3
+ let literal = 0;
4
+ for (let i = 0; i < want.length; i++) {
5
+ const segment = want[i];
6
+ if (segment === "*" && i === want.length - 1)
7
+ return literal;
8
+ if (i >= have.length)
9
+ return null;
10
+ if (segment.startsWith(":"))
11
+ continue;
12
+ if (segment !== have[i])
13
+ return null;
14
+ literal += segment.length + 1;
15
+ }
16
+ return want.length === have.length ? literal : null;
17
+ }
18
+ function score(m, method, path, query) {
19
+ if (m.method !== "ANY" && m.method !== method)
20
+ return null;
21
+ const literal = pathScore(m.path, path);
22
+ if (literal === null)
23
+ return null;
24
+ const constraints = Object.entries(m.query ?? {});
25
+ for (const [key, expected] of constraints) {
26
+ const actual = query.get(key);
27
+ if (actual === null)
28
+ return null;
29
+ if (typeof expected === "string" ? actual !== expected : !expected.test(actual))
30
+ return null;
31
+ }
32
+ return [literal, constraints.length, m.method === "ANY" ? 0 : 1];
33
+ }
34
+ const better = (a, b) => a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2];
35
+ /**
36
+ * The operation for a request: the longest literal path wins, then most query constraints, then
37
+ * an exact method over ANY. The request must target the upstream origin and base path.
38
+ */
39
+ export function matchOperation(descriptor, request) {
40
+ const upstream = new URL(descriptor.upstream), url = new URL(request.url, upstream);
41
+ if (url.origin !== upstream.origin)
42
+ return undefined;
43
+ const base = upstream.pathname.replace(/\/$/, "");
44
+ if (base && url.pathname !== base && !url.pathname.startsWith(base + "/"))
45
+ return undefined;
46
+ const path = url.pathname.slice(base.length) || "/", method = request.method.toUpperCase();
47
+ let best;
48
+ for (const op of descriptor.operations)
49
+ for (const m of op.match) {
50
+ const s = score(m, method, path, url.searchParams);
51
+ if (s && (!best || better(s, best.score)))
52
+ best = { op, score: s };
53
+ }
54
+ return best?.op;
55
+ }
@@ -0,0 +1,2 @@
1
+ /** RFC 6901 JSON pointer lookup; undefined when any step is missing. */
2
+ export declare function pointer(value: unknown, path: string): unknown;
@@ -0,0 +1,13 @@
1
+ /** RFC 6901 JSON pointer lookup; undefined when any step is missing. */
2
+ export function pointer(value, path) {
3
+ if (path === "")
4
+ return value;
5
+ let current = value;
6
+ for (const raw of path.replace(/^\//, "").split("/")) {
7
+ const key = raw.replaceAll("~1", "/").replaceAll("~0", "~");
8
+ if (current === null || typeof current !== "object" || !Object.hasOwn(current, key))
9
+ return undefined;
10
+ current = current[key];
11
+ }
12
+ return current;
13
+ }
@@ -0,0 +1,11 @@
1
+ import type { PriceRow, Quantity } from "@usagekit/core";
2
+ import type { EstimateInput } from "./types.js";
3
+ /**
4
+ * The price row behind a list estimate. Rows must name the operation, match the connection plan
5
+ * (rows without plan apply to any plan) and the overage state, and every option they name must
6
+ * equal the request option. Plan-specific rows beat any-plan rows, then rows naming more options
7
+ * win, then later validFrom. An operation priced per plan resolves nothing without a plan.
8
+ */
9
+ export declare function selectPriceRow(input: EstimateInput): PriceRow | undefined;
10
+ /** Default estimate: one call at the selected row's per-unit price; empty without a row. */
11
+ export declare function listEstimate(input: EstimateInput): Quantity[];
package/dist/prices.js ADDED
@@ -0,0 +1,25 @@
1
+ import { decimalQuantity } from "./receipts.js";
2
+ /**
3
+ * The price row behind a list estimate. Rows must name the operation, match the connection plan
4
+ * (rows without plan apply to any plan) and the overage state, and every option they name must
5
+ * equal the request option. Plan-specific rows beat any-plan rows, then rows naming more options
6
+ * win, then later validFrom. An operation priced per plan resolves nothing without a plan.
7
+ */
8
+ export function selectPriceRow(input) {
9
+ const rows = input.prices.filter((r) => r.operation === input.operation.id);
10
+ if (input.plan === undefined && rows.some((r) => r.plan !== undefined))
11
+ return undefined;
12
+ const candidates = rows.filter((r) => (r.plan === undefined || r.plan === input.plan) &&
13
+ Boolean(r.overage) === Boolean(input.overage) &&
14
+ Object.entries(r.option ?? {}).every(([k, v]) => input.options[k] === v));
15
+ const rank = (r) => [r.plan ? 1 : 0, Object.keys(r.option ?? {}).length];
16
+ return candidates.sort((a, b) => {
17
+ const [ap, ao] = rank(a), [bp, bo] = rank(b);
18
+ return bp - ap || bo - ao || b.validFrom.localeCompare(a.validFrom);
19
+ })[0];
20
+ }
21
+ /** Default estimate: one call at the selected row's per-unit price; empty without a row. */
22
+ export function listEstimate(input) {
23
+ const row = selectPriceRow(input);
24
+ return row ? [decimalQuantity(row.perUnit, row.unit)] : [];
25
+ }