@substrio/sdk 0.0.0-stage → 0.1.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.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Substrio SDK: every registry operation as a typed method, grouped by the
3
+ * words of its dotted name the way the CLI groups them: kv.get is
4
+ * client.ops.kv.get(input), account.key.create is
5
+ * client.ops.account.key.create(input).
6
+ *
7
+ * What an operation is (its routes, path and query parameters, whether it
8
+ * takes a JSON body) comes only from substrio_operations.mjs, which
9
+ * scripts/gen_operations.mjs generates from the server's catalog
10
+ * (GET /v1/_openapi, projected from src/substrio/core/substrio_operation_registry.h).
11
+ * Nothing here knows one operation from another. Types for every input,
12
+ * result and the error envelope are in substrio_operations.d.mts.
13
+ *
14
+ * An input is one object: each {placeholder} of the route takes its field,
15
+ * declared query parameters go in the query string, and the rest is the
16
+ * query string (GET, HEAD, DELETE) or the JSON body. A route whose body is
17
+ * one input property's value (bodyProperty, such as kv.set's entries) sends
18
+ * that value as the body. A non-object input is the body as it is.
19
+ */
20
+
21
+ import { OPERATIONS } from './substrio_operations.mjs';
22
+
23
+ // Methods whose remaining inputs travel in the query string.
24
+ const QUERY_METHODS = new Set(['GET', 'HEAD', 'DELETE']);
25
+
26
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
27
+ const text = (value) => (typeof value === 'string' ? value : JSON.stringify(value));
28
+ const absent = (value) => value === undefined || value === null;
29
+
30
+ /** The {placeholders} of a route path, in order (pure). */
31
+ export function routeParams(path) {
32
+ return [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
33
+ }
34
+
35
+ /**
36
+ * One route of the server's catalog (GET /v1/_openapi) as this module calls
37
+ * it (pure): `op` is paths[path][method]. {method, path, params, query,
38
+ * parameters, body, bodyProperty, bodySchema}: `body` is true when the route
39
+ * takes a JSON body, and `bodyProperty` names the input property whose value
40
+ * is that whole body (x-substrio-body-property), else null.
41
+ */
42
+ export function catalogRoute(path, method, op) {
43
+ const parameters = Array.isArray(op?.parameters) ? op.parameters : [];
44
+ const bodySchema = op?.requestBody?.content?.['application/json']?.schema ?? null;
45
+ return {
46
+ method: method.toUpperCase(),
47
+ path,
48
+ params: routeParams(path),
49
+ query: parameters.filter((p) => p.in === 'query').map((p) => p.name),
50
+ parameters,
51
+ body: bodySchema !== null,
52
+ bodyProperty: op?.['x-substrio-body-property'] ?? null,
53
+ bodySchema,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * The route of an operation to call with `input` (pure): among its routes
59
+ * (those with `method`, when given) whose path parameters the input fills,
60
+ * the one that carries a JSON body (any field fits there), else the first
61
+ * that fits, else the first (its error names what is missing).
62
+ */
63
+ export function pickRoute(operation, input, method = null) {
64
+ const candidates = method ? operation.routes.filter((r) => r.method === method) : operation.routes;
65
+ if (candidates.length === 0) {
66
+ throw new TypeError(`${operation.name} has no ${method} route (it has ${[...new Set(operation.routes.map((r) => r.method))].join(', ')})`);
67
+ }
68
+ const fitting = candidates.filter((r) => r.params.every((p) => isObject(input) && !absent(input[p])));
69
+ return fitting.find((r) => r.body) || fitting[0] || candidates[0];
70
+ }
71
+
72
+ /**
73
+ * The request for one route and an input (pure): {method, path, body}.
74
+ * Throws when a path parameter is missing. With `rawBody` the caller sends the
75
+ * body itself as raw bytes (data.import's NDJSON, file.upload): the route's
76
+ * JSON body field is neither required nor built.
77
+ */
78
+ export function buildRequest(route, input, { rawBody = false } = {}) {
79
+ const rest = isObject(input) ? { ...input } : {};
80
+ const missing = route.params.filter((p) => absent(rest[p]));
81
+ if (missing.length) throw new TypeError(`missing ${missing.join(', ')} (the route is ${route.method} ${route.path})`);
82
+ const path = route.path.replace(/\{([^}]+)\}/g, (_, name) => {
83
+ const value = rest[name];
84
+ delete rest[name];
85
+ return encodeURIComponent(text(value));
86
+ });
87
+ const query = new URLSearchParams();
88
+ for (const name of route.query) {
89
+ if (!absent(rest[name])) query.append(name, text(rest[name]));
90
+ delete rest[name];
91
+ }
92
+ let body;
93
+ if (rawBody) {
94
+ body = undefined;
95
+ } else if (route.bodyProperty) {
96
+ if (absent(rest[route.bodyProperty])) {
97
+ throw new TypeError(`missing ${route.bodyProperty} (the body of ${route.method} ${route.path})`);
98
+ }
99
+ body = rest[route.bodyProperty];
100
+ } else if (QUERY_METHODS.has(route.method)) {
101
+ if (input !== undefined && !isObject(input)) {
102
+ throw new TypeError(`${route.method} ${route.path} takes named fields, not a bare JSON value`);
103
+ }
104
+ for (const [key, value] of Object.entries(rest)) if (!absent(value)) query.append(key, text(value));
105
+ } else {
106
+ body = isObject(input) ? rest : (input === undefined ? {} : input);
107
+ }
108
+ const qs = query.toString();
109
+ return { method: route.method, path: qs ? `${path}?${qs}` : path, body };
110
+ }
111
+
112
+ /** Calls `operation` over `http` (the SDK's Http: req(method, path, body)). */
113
+ export function callOperation(http, operation, input, options = {}) {
114
+ const request = buildRequest(pickRoute(operation, input, options.method ?? null), input);
115
+ return http.req(request.method, request.path, request.body);
116
+ }
117
+
118
+ /**
119
+ * The operation tree for `http`: one method per operation at the path of
120
+ * its name's words. A name that is also the prefix of others (function.logs,
121
+ * function.logs.clear) is a method that carries its children.
122
+ */
123
+ export function bindOperations(http) {
124
+ const root = {};
125
+ for (const operation of OPERATIONS) {
126
+ const words = operation.words;
127
+ let node = root;
128
+ for (const word of words.slice(0, -1)) node = node[word] ??= {};
129
+ const last = words[words.length - 1];
130
+ const call = (input, options) => callOperation(http, operation, input, options);
131
+ Object.defineProperty(call, 'name', { value: operation.name });
132
+ Object.defineProperty(call, 'operation', { value: operation });
133
+ // Children bound before their parent move onto the parent's method.
134
+ node[last] = Object.assign(call, node[last]);
135
+ }
136
+ return deepFreeze(root);
137
+ }
138
+
139
+ function deepFreeze(node) {
140
+ for (const child of Object.values(node)) deepFreeze(child);
141
+ return Object.freeze(node);
142
+ }
143
+
144
+ /**
145
+ * Makes each operation group reachable on `client` by its name (client.kv.get)
146
+ * next to client.ops. A group whose name the client already uses keeps the
147
+ * client's member; that member takes the group's operations whose names it
148
+ * does not have (a method gets its own bound copy), non-enumerable, so
149
+ * nothing the client already offered changes.
150
+ */
151
+ export function attachOperations(client, ops) {
152
+ for (const [group, node] of Object.entries(ops)) {
153
+ if (!(group in client)) {
154
+ Object.defineProperty(client, group, { value: node, enumerable: false });
155
+ continue;
156
+ }
157
+ const descriptor = findDescriptor(client, group);
158
+ if (typeof descriptor?.value === 'function') {
159
+ const method = descriptor.value.bind(client);
160
+ addMissing(method, node);
161
+ Object.defineProperty(client, group, { value: method, enumerable: false, writable: true, configurable: true });
162
+ } else if (descriptor?.get) {
163
+ // A getter's object is made when first read: it takes the group then.
164
+ const read = descriptor.get;
165
+ const merged = new WeakSet();
166
+ Object.defineProperty(client, group, {
167
+ enumerable: false,
168
+ configurable: true,
169
+ get() {
170
+ const value = read.call(this);
171
+ if (value !== null && typeof value === 'object' && !merged.has(value)) {
172
+ addMissing(value, node);
173
+ merged.add(value);
174
+ }
175
+ return value;
176
+ },
177
+ });
178
+ } else if (descriptor && descriptor.value !== null && typeof descriptor.value === 'object') {
179
+ addMissing(descriptor.value, node);
180
+ }
181
+ }
182
+ }
183
+
184
+ function findDescriptor(object, name) {
185
+ for (let o = object; o; o = Object.getPrototypeOf(o)) {
186
+ const d = Object.getOwnPropertyDescriptor(o, name);
187
+ if (d) return d;
188
+ }
189
+ return undefined;
190
+ }
191
+
192
+ function addMissing(target, node) {
193
+ for (const [word, child] of Object.entries(node)) {
194
+ if (!(word in target)) Object.defineProperty(target, word, { value: child, enumerable: false });
195
+ }
196
+ }