fareql 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +186 -2
- package/dist/apollo.d.ts +19 -0
- package/dist/apollo.js +100 -0
- package/dist/core.d.ts +119 -0
- package/dist/core.js +211 -0
- package/dist/fetch.d.ts +81 -0
- package/dist/fetch.js +291 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/price.d.ts +103 -0
- package/dist/price.js +485 -0
- package/dist/yoga.d.ts +26 -0
- package/dist/yoga.js +205 -0
- package/package.json +83 -4
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { GraphQLSchema } from 'graphql';
|
|
2
|
+
import { type DocumentLimits, type FareOptions } from './core.js';
|
|
3
|
+
import { type OperationPrice } from './price.js';
|
|
4
|
+
export type GraphQLParams = {
|
|
5
|
+
query?: string | null;
|
|
6
|
+
operationName?: string | null;
|
|
7
|
+
variables?: Record<string, unknown> | null;
|
|
8
|
+
extensions?: Record<string, unknown> | null;
|
|
9
|
+
/** GraphQL-over-HTTP persisted document identifier. */
|
|
10
|
+
documentId?: string | null;
|
|
11
|
+
};
|
|
12
|
+
/** Request bodies the gate reads besides `application/json`. Enable only what your handler accepts. */
|
|
13
|
+
export type Encoding = 'application/graphql+json' | 'application/graphql' | 'application/x-www-form-urlencoded' | 'multipart/form-data';
|
|
14
|
+
export type ReadOptions = {
|
|
15
|
+
/** Extra body encodings to accept. Default: none (GET and `application/json` only). */
|
|
16
|
+
encodings?: readonly Encoding[];
|
|
17
|
+
/** Largest request body read, in bytes. Default: 1 MB. */
|
|
18
|
+
maxBodyBytes?: number;
|
|
19
|
+
};
|
|
20
|
+
export declare class RequestError extends Error {
|
|
21
|
+
readonly status: number;
|
|
22
|
+
readonly code: string;
|
|
23
|
+
name: string;
|
|
24
|
+
constructor(message: string, status: number, code: string);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Reads GraphQL-over-HTTP params without consuming the request body. It refuses anything that
|
|
28
|
+
* a handler could read differently (URL fragments, duplicate or unknown parameters, GraphQL
|
|
29
|
+
* parameters in the URL of a POST, bodies on other methods), so what is priced is what runs.
|
|
30
|
+
* Returns undefined when the request carries no GraphQL operation (e.g. a GraphiQL page).
|
|
31
|
+
* Throws {@link RequestError} for anything it will not price.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readGraphQLParams(request: Request, options?: ReadOptions): Promise<GraphQLParams | GraphQLParams[] | undefined>;
|
|
34
|
+
export type GateOptions = FareOptions & ReadOptions & DocumentLimits & {
|
|
35
|
+
schema: GraphQLSchema;
|
|
36
|
+
/** Accept JSON arrays of operations and charge their total in one payment. Default: false. */
|
|
37
|
+
batching?: boolean;
|
|
38
|
+
/** Most operations in one batch. Default: 10. */
|
|
39
|
+
maxBatchSize?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Looks up persisted documents (trusted documents, `documentId`, or APQ hashes your server
|
|
42
|
+
* stores itself). Without it, APQ still works: FareQL keeps its own hash → query cache.
|
|
43
|
+
*/
|
|
44
|
+
resolveDocument?: (params: GraphQLParams) => string | undefined | Promise<string | undefined>;
|
|
45
|
+
};
|
|
46
|
+
export type GateResult =
|
|
47
|
+
/** Nothing to charge (price 0, or not a GraphQL request). Run the handler. */
|
|
48
|
+
{
|
|
49
|
+
status: 'free';
|
|
50
|
+
price?: OperationPrice;
|
|
51
|
+
}
|
|
52
|
+
/** Return `response` (402 with the MPP Challenge). */
|
|
53
|
+
| {
|
|
54
|
+
status: 'payment-required';
|
|
55
|
+
response: Response;
|
|
56
|
+
price: OperationPrice;
|
|
57
|
+
}
|
|
58
|
+
/** Paid. Run the handler and pass its response through `withReceipt`. */
|
|
59
|
+
| {
|
|
60
|
+
status: 'paid';
|
|
61
|
+
price: OperationPrice;
|
|
62
|
+
withReceipt: (response: Response) => Response;
|
|
63
|
+
}
|
|
64
|
+
/** Must not run (unreadable, invalid, unpriceable or over the price limit). Return `response`. */
|
|
65
|
+
| {
|
|
66
|
+
status: 'rejected';
|
|
67
|
+
response: Response;
|
|
68
|
+
error: Error;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Creates a framework-agnostic payment gate. Call it with each request before executing
|
|
72
|
+
* GraphQL. It fails closed: anything it cannot read or price is answered, never executed.
|
|
73
|
+
*/
|
|
74
|
+
export declare function createPaymentGate(options: GateOptions): (request: Request) => Promise<GateResult>;
|
|
75
|
+
/**
|
|
76
|
+
* Wraps any Fetch-API GraphQL handler (graphql-http, Apollo's fetch integrations, a Hono or
|
|
77
|
+
* Next.js route). Free operations pass through; priced ones get a 402 Challenge, or run and
|
|
78
|
+
* receive a Payment-Receipt. For GraphQL Yoga and Apollo Server, prefer their plugins: they
|
|
79
|
+
* price after the server's own validation.
|
|
80
|
+
*/
|
|
81
|
+
export declare function withPayment(handler: (request: Request) => Response | Promise<Response>, options: GateOptions): (request: Request) => Promise<Response>;
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { chargeOperations, createDocumentCache, graphqlChallenge, jsonResponse, pricingErrorBody, sha256Hex, } from './core.js';
|
|
2
|
+
import { PricingError, selectOperation } from './price.js';
|
|
3
|
+
export class RequestError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
name = 'RequestError';
|
|
7
|
+
constructor(message, status, code) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const PARAM_KEYS = ['query', 'operationName', 'variables', 'extensions', 'documentId'];
|
|
14
|
+
const bad = (message, status = 400, code = 'BAD_REQUEST') => new RequestError(message, status, code);
|
|
15
|
+
/** Content-type match as GraphQL Yoga does it: first entry of a list, case-sensitive, parameters allowed. */
|
|
16
|
+
function contentTypeIs(request, expected) {
|
|
17
|
+
const t = (request.headers.get('content-type') ?? '').split(',')[0]?.trim() ?? '';
|
|
18
|
+
return t === expected || t.startsWith(`${expected};`);
|
|
19
|
+
}
|
|
20
|
+
function parseJson(text, what) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw bad(`${what} is not valid JSON`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(typeof Blob !== 'undefined' && v instanceof Blob);
|
|
29
|
+
function hasGraphQLParams(p) {
|
|
30
|
+
return PARAM_KEYS.some((k) => p.has(k));
|
|
31
|
+
}
|
|
32
|
+
function fromSearchParams(p) {
|
|
33
|
+
if (!hasGraphQLParams(p))
|
|
34
|
+
return undefined;
|
|
35
|
+
for (const k of PARAM_KEYS)
|
|
36
|
+
if (p.getAll(k).length > 1)
|
|
37
|
+
throw bad(`Parameter "${k}" is given more than once`);
|
|
38
|
+
const variables = p.get('variables');
|
|
39
|
+
const extensions = p.get('extensions');
|
|
40
|
+
// Empty values count as absent, as in GraphQL Yoga.
|
|
41
|
+
return checkParams({
|
|
42
|
+
query: p.get('query') || null,
|
|
43
|
+
operationName: p.get('operationName') || null,
|
|
44
|
+
variables: variables ? parseJson(variables, 'variables') : null,
|
|
45
|
+
extensions: extensions ? parseJson(extensions, 'extensions') : null,
|
|
46
|
+
documentId: p.get('documentId') || null,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function setPath(target, path, value) {
|
|
50
|
+
const keys = path.split('.');
|
|
51
|
+
let cur = target;
|
|
52
|
+
for (const [i, key] of keys.entries()) {
|
|
53
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype')
|
|
54
|
+
return;
|
|
55
|
+
if (i === keys.length - 1)
|
|
56
|
+
cur[key] = value;
|
|
57
|
+
else {
|
|
58
|
+
if (typeof cur[key] !== 'object' || cur[key] === null)
|
|
59
|
+
cur[key] = {};
|
|
60
|
+
cur = cur[key];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Checks one set of params the way GraphQL Yoga does, so the gate never prices what the server would refuse. */
|
|
65
|
+
function checkParams(value) {
|
|
66
|
+
if (!isPlainObject(value))
|
|
67
|
+
throw bad('GraphQL params must be an object');
|
|
68
|
+
for (const key of Object.keys(value)) {
|
|
69
|
+
if (!PARAM_KEYS.includes(key))
|
|
70
|
+
throw bad(`Unexpected parameter "${key}"`);
|
|
71
|
+
}
|
|
72
|
+
const { query, operationName, variables, extensions, documentId } = value;
|
|
73
|
+
if (query != null && typeof query !== 'string')
|
|
74
|
+
throw bad('"query" must be a string');
|
|
75
|
+
if (operationName != null && (typeof operationName !== 'string' || operationName === '')) {
|
|
76
|
+
throw bad('"operationName" must be a non-empty string');
|
|
77
|
+
}
|
|
78
|
+
if (variables != null && !isPlainObject(variables))
|
|
79
|
+
throw bad('"variables" must be an object');
|
|
80
|
+
if (extensions != null && !isPlainObject(extensions))
|
|
81
|
+
throw bad('"extensions" must be an object');
|
|
82
|
+
if (documentId != null && typeof documentId !== 'string')
|
|
83
|
+
throw bad('"documentId" must be a string');
|
|
84
|
+
if (documentId != null && query != null)
|
|
85
|
+
throw bad('Send either "query" or "documentId", not both');
|
|
86
|
+
if (documentId != null && extensions?.persistedQuery !== undefined) {
|
|
87
|
+
throw bad('Send either "documentId" or extensions.persistedQuery, not both');
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function normalize(value) {
|
|
92
|
+
return Array.isArray(value) ? value.map(checkParams) : checkParams(value);
|
|
93
|
+
}
|
|
94
|
+
async function readBody(request, maxBytes) {
|
|
95
|
+
const declared = Number(request.headers.get('content-length') ?? 'NaN');
|
|
96
|
+
if (declared > maxBytes)
|
|
97
|
+
throw bad(`Request body is larger than ${maxBytes} bytes`, 413, 'PAYLOAD_TOO_LARGE');
|
|
98
|
+
const text = await request.clone().text();
|
|
99
|
+
if (text.length > maxBytes)
|
|
100
|
+
throw bad(`Request body is larger than ${maxBytes} bytes`, 413, 'PAYLOAD_TOO_LARGE');
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reads GraphQL-over-HTTP params without consuming the request body. It refuses anything that
|
|
105
|
+
* a handler could read differently (URL fragments, duplicate or unknown parameters, GraphQL
|
|
106
|
+
* parameters in the URL of a POST, bodies on other methods), so what is priced is what runs.
|
|
107
|
+
* Returns undefined when the request carries no GraphQL operation (e.g. a GraphiQL page).
|
|
108
|
+
* Throws {@link RequestError} for anything it will not price.
|
|
109
|
+
*/
|
|
110
|
+
export async function readGraphQLParams(request, options = {}) {
|
|
111
|
+
const encodings = options.encodings ?? [];
|
|
112
|
+
const maxBytes = options.maxBodyBytes ?? 1_000_000;
|
|
113
|
+
// URL parsers disagree about "#": URL drops it, GraphQL Yoga keeps what follows "?".
|
|
114
|
+
if (request.url.includes('#'))
|
|
115
|
+
throw bad('URL must not contain "#"');
|
|
116
|
+
const search = new URL(request.url).searchParams;
|
|
117
|
+
if (request.method === 'GET')
|
|
118
|
+
return fromSearchParams(search);
|
|
119
|
+
if (request.method === 'HEAD') {
|
|
120
|
+
if (hasGraphQLParams(search))
|
|
121
|
+
throw bad('Use GET or POST for GraphQL operations', 405, 'METHOD_NOT_ALLOWED');
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
if (request.method !== 'POST') {
|
|
125
|
+
if (request.body !== null || hasGraphQLParams(search))
|
|
126
|
+
throw bad(`Method ${request.method} is not allowed`, 405, 'METHOD_NOT_ALLOWED');
|
|
127
|
+
return undefined; // e.g. CORS preflight
|
|
128
|
+
}
|
|
129
|
+
if (hasGraphQLParams(search))
|
|
130
|
+
throw bad('A POST request must carry its GraphQL parameters in the body');
|
|
131
|
+
if (request.body === null)
|
|
132
|
+
throw bad('Missing request body');
|
|
133
|
+
const accepts = (type) => encodings.includes(type) && contentTypeIs(request, type);
|
|
134
|
+
if (contentTypeIs(request, 'application/json') || accepts('application/graphql+json')) {
|
|
135
|
+
return normalize(parseJson(await readBody(request, maxBytes), 'Request body'));
|
|
136
|
+
}
|
|
137
|
+
if (accepts('application/graphql'))
|
|
138
|
+
return { query: await readBody(request, maxBytes) };
|
|
139
|
+
if (accepts('application/x-www-form-urlencoded')) {
|
|
140
|
+
return fromSearchParams(new URLSearchParams(await readBody(request, maxBytes))) ?? checkParams({});
|
|
141
|
+
}
|
|
142
|
+
if (accepts('multipart/form-data')) {
|
|
143
|
+
const declared = Number(request.headers.get('content-length') ?? 'NaN');
|
|
144
|
+
if (declared > maxBytes)
|
|
145
|
+
throw bad(`Request body is larger than ${maxBytes} bytes`, 413, 'PAYLOAD_TOO_LARGE');
|
|
146
|
+
let form;
|
|
147
|
+
try {
|
|
148
|
+
form = await request.clone().formData();
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
throw bad(`Invalid multipart body: ${e.message}`);
|
|
152
|
+
}
|
|
153
|
+
if (form.getAll('operations').length !== 1 || form.getAll('map').length > 1) {
|
|
154
|
+
throw bad('Multipart body needs exactly one "operations" field and at most one "map" field');
|
|
155
|
+
}
|
|
156
|
+
const ops = form.get('operations');
|
|
157
|
+
if (typeof ops !== 'string')
|
|
158
|
+
throw bad('Multipart field "operations" must be a string');
|
|
159
|
+
const operations = parseJson(ops, 'Multipart field "operations"');
|
|
160
|
+
const map = form.get('map');
|
|
161
|
+
if (typeof map === 'string') {
|
|
162
|
+
for (const [file, paths] of Object.entries(parseJson(map, 'Multipart field "map"'))) {
|
|
163
|
+
for (const path of Array.isArray(paths) ? paths : [])
|
|
164
|
+
if (typeof path === 'string')
|
|
165
|
+
setPath(operations, path, form.get(file));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return normalize(operations);
|
|
169
|
+
}
|
|
170
|
+
throw bad('Unsupported content type for this endpoint', 415, 'UNSUPPORTED_MEDIA_TYPE');
|
|
171
|
+
}
|
|
172
|
+
const reject = (status, errors, error, headers) => ({
|
|
173
|
+
status: 'rejected',
|
|
174
|
+
response: jsonResponse(status, errors, headers),
|
|
175
|
+
error,
|
|
176
|
+
});
|
|
177
|
+
const rejectWith = (status, code, message, headers) => reject(status, [{ message, extensions: { code } }], new RequestError(message, status, code), headers);
|
|
178
|
+
/** Hash of an Automatic Persisted Query, if the params carry one. */
|
|
179
|
+
function apqHash(params) {
|
|
180
|
+
const pq = params.extensions?.persistedQuery;
|
|
181
|
+
return typeof pq?.sha256Hash === 'string' ? pq.sha256Hash : undefined;
|
|
182
|
+
}
|
|
183
|
+
const APQ_CACHEABLE_LENGTH = 32_768;
|
|
184
|
+
/**
|
|
185
|
+
* Creates a framework-agnostic payment gate. Call it with each request before executing
|
|
186
|
+
* GraphQL. It fails closed: anything it cannot read or price is answered, never executed.
|
|
187
|
+
*/
|
|
188
|
+
export function createPaymentGate(options) {
|
|
189
|
+
const documents = createDocumentCache(options);
|
|
190
|
+
const apq = new Map();
|
|
191
|
+
const exposePrice = options.exposePrice ?? true;
|
|
192
|
+
const maxBatchSize = options.maxBatchSize ?? 10;
|
|
193
|
+
const queryOf = async (params) => {
|
|
194
|
+
const hash = apqHash(params);
|
|
195
|
+
if (typeof params.query === 'string' && params.query.length > 0) {
|
|
196
|
+
if (hash !== undefined) {
|
|
197
|
+
// Exact comparison, as Yoga and Apollo do: the hash names exactly this text.
|
|
198
|
+
if ((await sha256Hex(params.query)) !== hash) {
|
|
199
|
+
return rejectWith(400, 'PERSISTED_QUERY_HASH_MISMATCH', 'persistedQuery.sha256Hash does not match the query');
|
|
200
|
+
}
|
|
201
|
+
if (params.query.length <= APQ_CACHEABLE_LENGTH) {
|
|
202
|
+
apq.set(hash, params.query);
|
|
203
|
+
if (apq.size > 1000)
|
|
204
|
+
apq.delete(apq.keys().next().value);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return params.query;
|
|
208
|
+
}
|
|
209
|
+
const resolved = (await options.resolveDocument?.(params)) ?? (hash !== undefined ? apq.get(hash) : undefined);
|
|
210
|
+
if (resolved)
|
|
211
|
+
return resolved;
|
|
212
|
+
if (hash !== undefined) {
|
|
213
|
+
// Same reply as Apollo/Yoga: APQ clients resend the full query, which is then priced.
|
|
214
|
+
return reject(200, [{ message: 'PersistedQueryNotFound', extensions: { code: 'PERSISTED_QUERY_NOT_FOUND' } }], new RequestError('PersistedQueryNotFound', 200, 'PERSISTED_QUERY_NOT_FOUND'));
|
|
215
|
+
}
|
|
216
|
+
if (params.documentId) {
|
|
217
|
+
return rejectWith(400, 'PERSISTED_DOCUMENT_NOT_FOUND', 'Unknown persisted document; pass resolveDocument to price persisted documents');
|
|
218
|
+
}
|
|
219
|
+
return rejectWith(400, 'BAD_REQUEST', 'Must provide a query string');
|
|
220
|
+
};
|
|
221
|
+
return async (request) => {
|
|
222
|
+
let read;
|
|
223
|
+
try {
|
|
224
|
+
read = await readGraphQLParams(request, options);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
const e = error instanceof RequestError ? error : new RequestError('Unreadable request', 400, 'BAD_REQUEST');
|
|
228
|
+
return reject(e.status, [{ message: e.message, extensions: { code: e.code } }], e, e.status === 405 ? { allow: 'GET, POST' } : undefined);
|
|
229
|
+
}
|
|
230
|
+
if (read === undefined)
|
|
231
|
+
return { status: 'free' };
|
|
232
|
+
if (Array.isArray(read) && !options.batching) {
|
|
233
|
+
return rejectWith(400, 'BATCHING_NOT_SUPPORTED', 'Batched requests are not accepted; send one operation per request');
|
|
234
|
+
}
|
|
235
|
+
const list = Array.isArray(read) ? read : [read];
|
|
236
|
+
if (list.length === 0)
|
|
237
|
+
return rejectWith(400, 'BAD_REQUEST', 'Empty batch');
|
|
238
|
+
if (list.length > maxBatchSize)
|
|
239
|
+
return rejectWith(400, 'BATCH_TOO_LARGE', `A batch may hold at most ${maxBatchSize} operations`);
|
|
240
|
+
const operations = [];
|
|
241
|
+
for (const params of list) {
|
|
242
|
+
const query = await queryOf(params);
|
|
243
|
+
if (typeof query !== 'string')
|
|
244
|
+
return query;
|
|
245
|
+
const parsed = documents.get(options.schema, query);
|
|
246
|
+
if (parsed.errors) {
|
|
247
|
+
return reject(400, parsed.errors.map((e) => ({ message: e.message, extensions: { code: 'GRAPHQL_VALIDATION_FAILED' } })), new RequestError(parsed.errors[0]?.message ?? 'Invalid document', 400, 'GRAPHQL_VALIDATION_FAILED'));
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const op = selectOperation(parsed.document, params.operationName);
|
|
251
|
+
if (request.method !== 'POST' && op.operation !== 'query') {
|
|
252
|
+
return rejectWith(405, 'METHOD_NOT_ALLOWED', `Can only perform a ${op.operation} operation from a POST request`, { allow: 'POST' });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error instanceof PricingError)
|
|
257
|
+
return reject(400, [pricingErrorBody(error, exposePrice)], error);
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
operations.push({ document: parsed.document, operationName: params.operationName, variables: params.variables });
|
|
261
|
+
}
|
|
262
|
+
const outcome = await chargeOperations(request, options.schema, operations, options);
|
|
263
|
+
switch (outcome.status) {
|
|
264
|
+
case 'free':
|
|
265
|
+
return { status: 'free', price: outcome.price };
|
|
266
|
+
case 'rejected':
|
|
267
|
+
return reject(400, [pricingErrorBody(outcome.error, exposePrice)], outcome.error);
|
|
268
|
+
case 'payment-required':
|
|
269
|
+
return { status: 'payment-required', price: outcome.price, response: await graphqlChallenge(outcome.challenge, outcome.price, exposePrice) };
|
|
270
|
+
case 'paid':
|
|
271
|
+
return { status: 'paid', price: outcome.price, withReceipt: outcome.withReceipt };
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Wraps any Fetch-API GraphQL handler (graphql-http, Apollo's fetch integrations, a Hono or
|
|
277
|
+
* Next.js route). Free operations pass through; priced ones get a 402 Challenge, or run and
|
|
278
|
+
* receive a Payment-Receipt. For GraphQL Yoga and Apollo Server, prefer their plugins: they
|
|
279
|
+
* price after the server's own validation.
|
|
280
|
+
*/
|
|
281
|
+
export function withPayment(handler, options) {
|
|
282
|
+
const gate = createPaymentGate(options);
|
|
283
|
+
return async (request) => {
|
|
284
|
+
const r = await gate(request);
|
|
285
|
+
if (r.status === 'free')
|
|
286
|
+
return handler(request);
|
|
287
|
+
if (r.status === 'paid')
|
|
288
|
+
return r.withReceipt(await handler(request));
|
|
289
|
+
return r.response;
|
|
290
|
+
};
|
|
291
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { priceOperation, selectOperation, parseUnits, formatUnits, fareDirectiveTypeDefs, PricingError, PriceLimitError, type PricingOptions, type PricingErrorCode, type OperationPrice, type PriceBreakdownItem, type FieldPriceContext, type PriceInput, } from './price.js';
|
|
2
|
+
export { chargeOperations, operationScope, type ChargeFactory, type ChargeOptions, type ChargeResult, type ChargeOutcome, type FareOptions, type OperationInfo, type OperationInput, } from './core.js';
|
|
3
|
+
export { createPaymentGate, withPayment, readGraphQLParams, RequestError, type GateOptions, type GateResult, type GraphQLParams, } from './fetch.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { priceOperation, selectOperation, parseUnits, formatUnits, fareDirectiveTypeDefs, PricingError, PriceLimitError, } from './price.js';
|
|
2
|
+
export { chargeOperations, operationScope, } from './core.js';
|
|
3
|
+
export { createPaymentGate, withPayment, readGraphQLParams, RequestError, } from './fetch.js';
|
package/dist/price.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type DocumentNode, type GraphQLField, type GraphQLObjectType, type GraphQLSchema, type OperationDefinitionNode } from 'graphql';
|
|
2
|
+
/**
|
|
3
|
+
* SDL for the directives FareQL reads. Add it to your type definitions.
|
|
4
|
+
*
|
|
5
|
+
* `@listSize` follows the IBM GraphQL Cost Directives draft, so schemas that already
|
|
6
|
+
* use it for complexity analysis price connections correctly without changes.
|
|
7
|
+
* Code-first schemas can set the same values in `extensions`
|
|
8
|
+
* (`{ price: '0.001' }` or `{ directives: { price: { amount: '0.001' } } }`).
|
|
9
|
+
*/
|
|
10
|
+
export declare const fareDirectiveTypeDefs = "\n \"\"\"\n Price of resolving this field, or of any field returning this type, in token units (e.g. \"0.001\").\n \"\"\"\n directive @price(amount: String!) on FIELD_DEFINITION | OBJECT | INTERFACE\n\n \"\"\"\n How many items a list or connection field returns, for pricing.\n \"\"\"\n directive @listSize(\n assumedSize: Int\n slicingArguments: [String!]\n sizedFields: [String!]\n requireOneSlicingArgument: Boolean = true\n ) on FIELD_DEFINITION\n";
|
|
11
|
+
export type FieldPriceContext = {
|
|
12
|
+
/** The concrete object type the field is resolved on. */
|
|
13
|
+
parentType: GraphQLObjectType;
|
|
14
|
+
field: GraphQLField<unknown, unknown>;
|
|
15
|
+
/** Argument values with variables and defaults applied. */
|
|
16
|
+
args: Record<string, unknown>;
|
|
17
|
+
/** Response path, using aliases. */
|
|
18
|
+
path: readonly string[];
|
|
19
|
+
};
|
|
20
|
+
export type PricingOptions = {
|
|
21
|
+
/** Token decimals used for exact arithmetic. Default: 6 (the TIP-20 stablecoins on Tempo). */
|
|
22
|
+
decimals?: number;
|
|
23
|
+
/** Price of a field that has no `@price` and whose type has none. Default: "0". */
|
|
24
|
+
defaultFieldPrice?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Argument names read as a list length, also one level deep inside input objects
|
|
27
|
+
* (`page: { limit: 50 }`). Default: first, last, limit, take, top, pageSize, perPage.
|
|
28
|
+
*/
|
|
29
|
+
listSizeArguments?: readonly string[];
|
|
30
|
+
/** List length assumed when no size argument is given, and for inner levels of nested lists. Default: 10. */
|
|
31
|
+
defaultListSize?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Upper bound for a list size. Larger sizes, `null` and non-finite sizes are charged at this
|
|
34
|
+
* value. Set it to your server's real page cap. Default: 100.
|
|
35
|
+
*/
|
|
36
|
+
maxListSize?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Pagination wrappers: a non-list field given a size argument (`users(first: 50)`) passes it to
|
|
39
|
+
* the list fields it selects (`edges`, `nodes`, `items`…). `sizedFields` limits it to those
|
|
40
|
+
* names; `false` turns it off. `@listSize(sizedFields:)` always applies. Default: all list children.
|
|
41
|
+
*/
|
|
42
|
+
connections?: false | {
|
|
43
|
+
sizedFields?: readonly string[];
|
|
44
|
+
};
|
|
45
|
+
/** Reject operations (and batches) priced above this amount (token units). */
|
|
46
|
+
maxPrice?: string;
|
|
47
|
+
/** Reject operations that need more than this many selection visits to price. Default: 10000. */
|
|
48
|
+
maxFieldVisits?: number;
|
|
49
|
+
/** Reject operations nested deeper than this. Default: 100. */
|
|
50
|
+
maxDepth?: number;
|
|
51
|
+
/** Price a field in code. Return undefined to fall back to `@price`. */
|
|
52
|
+
fieldPrice?: (ctx: FieldPriceContext) => string | undefined;
|
|
53
|
+
};
|
|
54
|
+
export type PriceBreakdownItem = {
|
|
55
|
+
/** Response path of the priced field, e.g. "search.snippet" (shortened when very long). */
|
|
56
|
+
path: string;
|
|
57
|
+
/** Price of one instance, in token units. */
|
|
58
|
+
unitAmount: string;
|
|
59
|
+
/** How many instances the operation can resolve. */
|
|
60
|
+
count: string;
|
|
61
|
+
/** unitAmount × count, in token units. */
|
|
62
|
+
amount: string;
|
|
63
|
+
};
|
|
64
|
+
export type OperationPrice = {
|
|
65
|
+
/** Total price in token units as a decimal string, e.g. "0.0125". */
|
|
66
|
+
amount: string;
|
|
67
|
+
/** Total price in base units (10^-decimals). */
|
|
68
|
+
units: bigint;
|
|
69
|
+
decimals: number;
|
|
70
|
+
/** Priced fields that make up the total (at most 100; see `breakdownTruncated`). */
|
|
71
|
+
breakdown: PriceBreakdownItem[];
|
|
72
|
+
/** True when more priced fields exist than `breakdown` lists. */
|
|
73
|
+
breakdownTruncated?: boolean;
|
|
74
|
+
};
|
|
75
|
+
export type PricingErrorCode = 'INVALID_PRICE' | 'INVALID_LIST_SIZE' | 'UNKNOWN_FIELD' | 'UNKNOWN_FRAGMENT' | 'UNKNOWN_OPERATION' | 'INVALID_VARIABLES' | 'SLICING_ARGUMENT_REQUIRED' | 'TOO_COMPLEX' | 'PRICE_LIMIT_EXCEEDED' | 'NO_PRICES';
|
|
76
|
+
export declare class PricingError extends Error {
|
|
77
|
+
readonly code: PricingErrorCode;
|
|
78
|
+
name: string;
|
|
79
|
+
constructor(message: string, code: PricingErrorCode);
|
|
80
|
+
}
|
|
81
|
+
export declare class PriceLimitError extends PricingError {
|
|
82
|
+
readonly amount: string;
|
|
83
|
+
readonly maxPrice: string;
|
|
84
|
+
name: string;
|
|
85
|
+
constructor(amount: string, maxPrice: string);
|
|
86
|
+
}
|
|
87
|
+
/** Parses a decimal token amount ("0.001") into base units at `decimals`. */
|
|
88
|
+
export declare function parseUnits(value: string, decimals: number): bigint;
|
|
89
|
+
/** Formats base units as a decimal token amount without trailing zeros. */
|
|
90
|
+
export declare function formatUnits(units: bigint, decimals: number): string;
|
|
91
|
+
export type PriceInput = {
|
|
92
|
+
schema: GraphQLSchema;
|
|
93
|
+
document: DocumentNode;
|
|
94
|
+
operationName?: string | null;
|
|
95
|
+
variables?: Record<string, unknown> | null;
|
|
96
|
+
};
|
|
97
|
+
/** Selects the operation the request will execute, following GraphQL's rules. */
|
|
98
|
+
export declare function selectOperation(document: DocumentNode, operationName?: string | null): OperationDefinitionNode;
|
|
99
|
+
/**
|
|
100
|
+
* Computes the price of the operation a GraphQL request will run. Pure and deterministic:
|
|
101
|
+
* the same schema, document and variables always give the same price.
|
|
102
|
+
*/
|
|
103
|
+
export declare function priceOperation(input: PriceInput, options?: PricingOptions): OperationPrice;
|