google-apis-cli 0.2.0 → 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.
- package/README.md +19 -14
- package/dist/bin/gapi.js +10 -3
- package/dist/src/apis/ads.js +43 -1
- package/dist/src/apis/bigquery.js +191 -0
- package/dist/src/apis/business-information.js +32 -0
- package/dist/src/apis/cloudquotas.js +4 -0
- package/dist/src/apis/crux.js +4 -0
- package/dist/src/apis/ga4-admin.js +47 -0
- package/dist/src/apis/ga4-data-alpha.js +1 -0
- package/dist/src/apis/ga4-data.js +8 -0
- package/dist/src/apis/gsc.js +12 -1
- package/dist/src/apis/gtm.js +35 -0
- package/dist/src/apis/merchant-accounts.js +13 -0
- package/dist/src/apis/merchant-reports.js +1 -0
- package/dist/src/apis/pagespeed.js +4 -0
- package/dist/src/apis/places.js +4 -0
- package/dist/src/apis/sa360.js +1 -0
- package/dist/src/apis/web-risk.js +4 -0
- package/dist/src/apis/youtube.js +15 -0
- package/dist/src/catalog.js +49 -7
- package/dist/src/credentials.js +42 -16
- package/dist/src/entities.js +180 -0
- package/dist/src/plan.js +55 -8
- package/dist/src/render.js +95 -4
- package/dist/src/responses.js +37 -0
- package/dist/src/run.js +301 -21
- package/dist/src/search.js +208 -0
- package/dist/src/transport/download.js +7 -1
- package/dist/src/transport/retry.js +21 -0
- package/dist/src/transport/upload.js +46 -21
- package/dist/src/transport.js +72 -39
- package/package.json +7 -7
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
2
|
+
if (value !== null && value !== void 0) {
|
|
3
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
4
|
+
var dispose, inner;
|
|
5
|
+
if (async) {
|
|
6
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
7
|
+
dispose = value[Symbol.asyncDispose];
|
|
8
|
+
}
|
|
9
|
+
if (dispose === void 0) {
|
|
10
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
11
|
+
dispose = value[Symbol.dispose];
|
|
12
|
+
if (async) inner = dispose;
|
|
13
|
+
}
|
|
14
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
15
|
+
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
16
|
+
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
17
|
+
}
|
|
18
|
+
else if (async) {
|
|
19
|
+
env.stack.push({ async: true });
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
24
|
+
return function (env) {
|
|
25
|
+
function fail(e) {
|
|
26
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
27
|
+
env.hasError = true;
|
|
28
|
+
}
|
|
29
|
+
var r, s = 0;
|
|
30
|
+
function next() {
|
|
31
|
+
while (r = env.stack.pop()) {
|
|
32
|
+
try {
|
|
33
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
34
|
+
if (r.dispose) {
|
|
35
|
+
var result = r.dispose.call(r.value);
|
|
36
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
37
|
+
}
|
|
38
|
+
else s |= 1;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
fail(e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
45
|
+
if (env.hasError) throw env.error;
|
|
46
|
+
}
|
|
47
|
+
return next();
|
|
48
|
+
};
|
|
49
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
50
|
+
var e = new Error(message);
|
|
51
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
+
});
|
|
53
|
+
// Search: finds methods by intent across API entries with an in-memory SQLite FTS5 index (#182 §1).
|
|
54
|
+
// shortcut: DatabaseSync is a documentation-only deprecated alias of Database since Node v26.11.0
|
|
55
|
+
// (DEP0210); switch when @types/node types Database.
|
|
56
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
57
|
+
import { GapiError } from './errors.js';
|
|
58
|
+
import { isRecord } from './json.js';
|
|
59
|
+
import { brief, FIELDS_USAGE, SEARCH_USAGE } from './render.js';
|
|
60
|
+
// `conversionActions` → `conversion Actions`, `URLInspection` → `URL Inspection`, `a-b_c.d` → `a b c d`.
|
|
61
|
+
const split = (text) => text
|
|
62
|
+
.replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, '$1 $2')
|
|
63
|
+
.replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, '$1 $2')
|
|
64
|
+
.replace(/[-_.]/g, ' ');
|
|
65
|
+
// Tokens hold only letters and digits, so no FTS5 syntax reaches the match expression.
|
|
66
|
+
export function matchQuery(query, usage = SEARCH_USAGE) {
|
|
67
|
+
const tokens = split(query)
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.match(/[\p{L}\p{N}]+/gu);
|
|
70
|
+
if (!tokens)
|
|
71
|
+
throw new GapiError(3, `usage: ${usage}`);
|
|
72
|
+
return tokens.map((t) => `"${t}"`).join(' OR ');
|
|
73
|
+
}
|
|
74
|
+
export function search(models, query) {
|
|
75
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
76
|
+
try {
|
|
77
|
+
const match = matchQuery(query);
|
|
78
|
+
const db = __addDisposableResource(env_1, new DatabaseSync(':memory:'), false);
|
|
79
|
+
db.exec("create virtual table m using fts5(api unindexed, id unindexed, words, extra, descr, tokenize = 'porter unicode61')");
|
|
80
|
+
const insert = db.prepare('insert into m values (?, ?, ?, ?, ?)');
|
|
81
|
+
db.exec('begin');
|
|
82
|
+
for (const model of models) {
|
|
83
|
+
for (const m of model.byId.values()) {
|
|
84
|
+
const words = split([model.api, ...m.words].join(' '));
|
|
85
|
+
const extra = [
|
|
86
|
+
split([model.title, m.request, m.response].filter(Boolean).join(' ')),
|
|
87
|
+
m.upload && 'upload',
|
|
88
|
+
m.download && 'download',
|
|
89
|
+
].filter(Boolean);
|
|
90
|
+
insert.run(model.api, m.id, words, extra.join(' '), m.description ?? '');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
db.exec('commit');
|
|
94
|
+
const byApi = new Map(models.map((model) => [model.api, model]));
|
|
95
|
+
return db
|
|
96
|
+
.prepare('select api, id from m where m match ? order by bm25(m, 0, 0, 6, 2, 1) limit 20')
|
|
97
|
+
.all(match)
|
|
98
|
+
.flatMap(({ api, id }) => {
|
|
99
|
+
const model = byApi.get(String(api));
|
|
100
|
+
const method = model?.byId.get(String(id));
|
|
101
|
+
return model && method ? [{ model, method }] : [];
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (e_1) {
|
|
105
|
+
env_1.error = e_1;
|
|
106
|
+
env_1.hasError = true;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
__disposeResources(env_1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export const FIELD_APIS = ['ads', 'ga4-data', 'sa360'];
|
|
113
|
+
const snake = (camel) => split(camel).replaceAll(' ', '_').toLowerCase();
|
|
114
|
+
// Proto field behavior marks open most Ads descriptions; the sentence after them says more.
|
|
115
|
+
const FIELD_MARKS = /^(?:(?:Output only|Immutable|Required|Optional)\.\s*)+/;
|
|
116
|
+
const GAQL_ROOT = /(?:Resources__(\w+)|Common__(Metrics|Segments))$/;
|
|
117
|
+
const isResource = (ref) => typeof ref === 'string' && /Resources__/.test(ref);
|
|
118
|
+
// Singular message fields expand to their leaves (`campaign.network_settings.target_google_search`);
|
|
119
|
+
// arrays stay one field. A message that only holds other resources stays one field, as
|
|
120
|
+
// `change_event.old_resource` in Google's GAQL examples.
|
|
121
|
+
// shortcut: names follow the discovery shape, not Google's selectable list; selectability
|
|
122
|
+
// (`selectable_with`) needs googleAdsFields.search.
|
|
123
|
+
export function gaqlFields(model) {
|
|
124
|
+
const fields = [];
|
|
125
|
+
const walk = (schema, path, kind, seen) => {
|
|
126
|
+
const props = isRecord(schema.properties) ? Object.entries(schema.properties) : [];
|
|
127
|
+
for (const [key, prop] of props) {
|
|
128
|
+
if (!isRecord(prop))
|
|
129
|
+
continue;
|
|
130
|
+
const name = `${path}.${snake(key)}`;
|
|
131
|
+
const ref = typeof prop.$ref === 'string' ? prop.$ref : undefined;
|
|
132
|
+
const target = ref && !seen.includes(ref) ? model.schemas.get(ref) : undefined;
|
|
133
|
+
const inner = isRecord(target?.properties) ? Object.values(target.properties) : [];
|
|
134
|
+
if (target && ref && !inner.every((p) => isRecord(p) && isResource(p.$ref))) {
|
|
135
|
+
walk(target, name, kind, [...seen, ref]);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const text = typeof prop.description === 'string' ? prop.description : '';
|
|
139
|
+
fields.push({ name, kind, description: text.replace(FIELD_MARKS, '') });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
for (const [id, schema] of model.schemas) {
|
|
144
|
+
const m = GAQL_ROOT.exec(id);
|
|
145
|
+
if (!m)
|
|
146
|
+
continue;
|
|
147
|
+
const root = m[1] ? snake(m[1]) : m[2].toLowerCase();
|
|
148
|
+
const kind = m[1] ? 'attribute' : m[2] === 'Metrics' ? 'metric' : 'segment';
|
|
149
|
+
walk(schema, root, kind, [id]);
|
|
150
|
+
}
|
|
151
|
+
return fields;
|
|
152
|
+
}
|
|
153
|
+
// GA4 Metadata: dimensions and metrics; the UI name and deprecated names rank too.
|
|
154
|
+
export function ga4Fields(metadata) {
|
|
155
|
+
const list = (key, kind) => {
|
|
156
|
+
const items = isRecord(metadata) && Array.isArray(metadata[key]) ? metadata[key] : [];
|
|
157
|
+
return items.filter(isRecord).flatMap((d) => {
|
|
158
|
+
if (typeof d.apiName !== 'string')
|
|
159
|
+
return [];
|
|
160
|
+
const old = Array.isArray(d.deprecatedApiNames) ? d.deprecatedApiNames : [];
|
|
161
|
+
const words = [d.uiName, ...old].filter((w) => typeof w === 'string').join(' ');
|
|
162
|
+
const description = typeof d.description === 'string' ? d.description : undefined;
|
|
163
|
+
return [{ name: d.apiName, kind, words, description }];
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
return [...list('dimensions', 'dimension'), ...list('metrics', 'metric')];
|
|
167
|
+
}
|
|
168
|
+
// FTS5 bm25 normalizes by the whole row's length, so names and descriptions sit in two tables:
|
|
169
|
+
// a long description does not bury a short name. A description match counts a third.
|
|
170
|
+
export function rankFields(fields, query) {
|
|
171
|
+
const env_2 = { stack: [], error: void 0, hasError: false };
|
|
172
|
+
try {
|
|
173
|
+
const match = matchQuery(query, FIELDS_USAGE);
|
|
174
|
+
const db = __addDisposableResource(env_2, new DatabaseSync(':memory:'), false);
|
|
175
|
+
db.exec(`create virtual table n using fts5(i unindexed, name, words, tokenize = 'porter unicode61');
|
|
176
|
+
create virtual table d using fts5(i unindexed, descr, tokenize = 'porter unicode61')`);
|
|
177
|
+
const names = db.prepare('insert into n values (?, ?, ?)');
|
|
178
|
+
const descrs = db.prepare('insert into d values (?, ?)');
|
|
179
|
+
db.exec('begin');
|
|
180
|
+
fields.forEach((f, i) => {
|
|
181
|
+
// Each name word once: a nested path repeats words (`budget…budget`).
|
|
182
|
+
names.run(i, [...new Set(split(f.name).toLowerCase().split(' '))].join(' '), split(f.words ?? ''));
|
|
183
|
+
descrs.run(i, brief(f.description));
|
|
184
|
+
});
|
|
185
|
+
db.exec('commit');
|
|
186
|
+
const ranked = db
|
|
187
|
+
.prepare(`select i, sum(s) as score from (
|
|
188
|
+
select i, bm25(n, 0, 2, 1) as s from n where n match ?
|
|
189
|
+
union all select i, bm25(d) / 3 from d where d match ?)
|
|
190
|
+
group by i order by score, i`)
|
|
191
|
+
.all(match, match)
|
|
192
|
+
.map(({ i }) => fields[Number(i)]);
|
|
193
|
+
// Fields whose last name part is exactly the query come first, in rank order.
|
|
194
|
+
const key = (text) => split(text)
|
|
195
|
+
.toLowerCase()
|
|
196
|
+
.match(/[\p{L}\p{N}]+/gu)
|
|
197
|
+
?.join(' ');
|
|
198
|
+
const exact = (f) => key(f.name.slice(f.name.lastIndexOf('.') + 1)) === key(query);
|
|
199
|
+
return [...ranked.filter(exact), ...ranked.filter((f) => !exact(f))].slice(0, 20);
|
|
200
|
+
}
|
|
201
|
+
catch (e_2) {
|
|
202
|
+
env_2.error = e_2;
|
|
203
|
+
env_2.hasError = true;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
__disposeResources(env_2);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// Media download (epic #30 D7): the response body streams to the --output file.
|
|
2
2
|
import { createWriteStream } from 'node:fs';
|
|
3
|
-
import { rm } from 'node:fs/promises';
|
|
3
|
+
import { access, constants, rm } from 'node:fs/promises';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
4
5
|
import { Readable } from 'node:stream';
|
|
5
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { GapiError, reasonOf } from '../errors.js';
|
|
6
8
|
// The file is created only after a 2xx, so a Google error leaves no empty file behind.
|
|
7
9
|
export async function download(plan, call) {
|
|
10
|
+
// A bad local path is the caller's input (exit 3), found before Google sends any byte.
|
|
11
|
+
await access(dirname(plan.output), constants.W_OK).catch((e) => {
|
|
12
|
+
throw new GapiError(3, `--output: cannot write to ${dirname(plan.output)}: ${reasonOf(e)}`);
|
|
13
|
+
});
|
|
8
14
|
const res = await call(plan.url, { method: plan.httpMethod, headers: plan.headers });
|
|
9
15
|
const file = createWriteStream(plan.output);
|
|
10
16
|
await pipeline(res.body ?? Readable.from([]), file).catch(async (e) => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Retry policy shared by Transport and the resumable upload (Google client idioms, #194).
|
|
2
|
+
import { GapiError } from '../errors.js';
|
|
3
|
+
// The request never got an HTTP answer (DNS, reset, timeout): exit 1, like a Google error.
|
|
4
|
+
export class NetworkError extends GapiError {
|
|
5
|
+
}
|
|
6
|
+
// Google's retry rule: 408, 429, 5xx, and a 403 for a rate limit (google-api-python-client).
|
|
7
|
+
export const RETRIES = 3;
|
|
8
|
+
export const retryable = (status, text = '') => status === 408 ||
|
|
9
|
+
status === 429 ||
|
|
10
|
+
status >= 500 ||
|
|
11
|
+
(status === 403 && /"reason":\s*"(user)?[rR]ateLimitExceeded"/.test(text));
|
|
12
|
+
// Truncated exponential backoff with jitter: min(32 s, 1 s × 2^n + 0–1 s), Google's docs.
|
|
13
|
+
export const backoff = (attempt) => Math.min(32_000, 1000 * 2 ** attempt + Math.random() * 1000);
|
|
14
|
+
// Our choice, no Google client reads it: `Retry-After` (seconds or HTTP date) is a floor.
|
|
15
|
+
export const retryAfter = (res) => {
|
|
16
|
+
const v = res.headers.get('Retry-After');
|
|
17
|
+
if (!v)
|
|
18
|
+
return 0;
|
|
19
|
+
const ms = /^\d+$/.test(v) ? Number(v) * 1000 : Date.parse(v) - Date.now();
|
|
20
|
+
return Number.isFinite(ms) ? Math.max(0, ms) : 0;
|
|
21
|
+
};
|
|
@@ -4,19 +4,20 @@ import { openAsBlob } from 'node:fs';
|
|
|
4
4
|
import { open } from 'node:fs/promises';
|
|
5
5
|
import { GapiError, reasonOf } from '../errors.js';
|
|
6
6
|
import { parseOrText } from '../json.js';
|
|
7
|
+
import { backoff, NetworkError, RETRIES, retryable } from './retry.js';
|
|
7
8
|
// Google wants chunks in multiples of 256 KiB; 8 MiB keeps the request count low.
|
|
8
9
|
const CHUNK = 8 * 1024 * 1024;
|
|
9
10
|
// Consecutive 308s that move the upload no further before gapi gives up.
|
|
10
11
|
const MAX_STALLS = 3;
|
|
11
12
|
// Returns the final response; Transport prints it like a `once` call.
|
|
12
|
-
export async function upload(plan, call) {
|
|
13
|
+
export async function upload(plan, call, sleep) {
|
|
13
14
|
const file = await readable(plan.upload.path);
|
|
14
15
|
const { protocol, contentType } = plan.upload;
|
|
15
16
|
if (protocol === 'media') {
|
|
16
17
|
return call(plan.url, { method: plan.httpMethod, headers: plan.headers, body: file });
|
|
17
18
|
}
|
|
18
19
|
if (protocol === 'resumable')
|
|
19
|
-
return resumable(plan, file, call);
|
|
20
|
+
return resumable(plan, file, call, sleep);
|
|
20
21
|
// multipart/related: the JSON metadata part, then the media part.
|
|
21
22
|
const boundary = randomUUID();
|
|
22
23
|
const body = new Blob([
|
|
@@ -45,7 +46,9 @@ async function readable(path) {
|
|
|
45
46
|
return openAsBlob(path);
|
|
46
47
|
}
|
|
47
48
|
// Session request on the upload URL, then PUT chunks to the session URI; 308 = send more.
|
|
48
|
-
|
|
49
|
+
// A 5xx or a network error on a chunk asks the session what it kept (`bytes */<size>`) and
|
|
50
|
+
// goes on from there (google-api-python-client `next_chunk`, @google-cloud/storage).
|
|
51
|
+
async function resumable(plan, file, call, sleep) {
|
|
49
52
|
const session = await call(plan.url, {
|
|
50
53
|
method: plan.httpMethod,
|
|
51
54
|
headers: { ...plan.headers, 'X-Upload-Content-Length': String(file.size) },
|
|
@@ -58,24 +61,46 @@ async function resumable(plan, file, call) {
|
|
|
58
61
|
}
|
|
59
62
|
// undici holds the connection until a body is read or cancelled; only headers matter here.
|
|
60
63
|
await session.body?.cancel();
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
64
|
+
// A retryable answer or no answer at all is undefined: the caller asks the session again.
|
|
65
|
+
const put = (range, body) => call(location, { method: 'PUT', headers: { 'Content-Range': range }, body }, (s) => s === 308 || retryable(s)).then(async (res) => (retryable(res.status) ? void (await res.body?.cancel()) : res), (e) => {
|
|
66
|
+
if (e instanceof NetworkError)
|
|
67
|
+
return undefined;
|
|
68
|
+
throw e;
|
|
69
|
+
});
|
|
70
|
+
// Every final failure names the session, so the operator sees where the upload stopped.
|
|
71
|
+
try {
|
|
72
|
+
let start = 0;
|
|
73
|
+
let stalls = 0;
|
|
74
|
+
let failures = 0;
|
|
75
|
+
for (;;) {
|
|
76
|
+
const end = Math.min(start + CHUNK, file.size);
|
|
77
|
+
const range = file.size === 0 ? 'bytes */0' : `bytes ${start}-${end - 1}/${file.size}`;
|
|
78
|
+
let res = await put(range, file.slice(start, end));
|
|
79
|
+
while (!res) {
|
|
80
|
+
if (++failures > RETRIES)
|
|
81
|
+
throw new GapiError(1, `resumable upload: no answer at byte ${start}`);
|
|
82
|
+
await sleep(backoff(failures - 1));
|
|
83
|
+
res = await put(`bytes */${file.size}`);
|
|
84
|
+
}
|
|
85
|
+
if (res.status !== 308)
|
|
86
|
+
return res;
|
|
87
|
+
// `Range: bytes=0-<last>` names the bytes Google kept; no Range means it kept none.
|
|
88
|
+
const kept = /^bytes=0-(\d+)$/.exec(res.headers.get('Range') ?? '');
|
|
89
|
+
const next = kept ? Number(kept[1]) + 1 : 0;
|
|
90
|
+
stalls = next > start ? 0 : stalls + 1;
|
|
91
|
+
if (next > start)
|
|
92
|
+
failures = 0;
|
|
93
|
+
if (stalls === MAX_STALLS || next >= file.size) {
|
|
94
|
+
const body = parseOrText(await res.text());
|
|
95
|
+
throw new GapiError(1, `resumable upload: 308 at byte ${next} of ${file.size}, no progress`, body);
|
|
96
|
+
}
|
|
97
|
+
await res.body?.cancel();
|
|
98
|
+
start = next;
|
|
77
99
|
}
|
|
78
|
-
|
|
79
|
-
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (!(e instanceof GapiError))
|
|
103
|
+
throw e;
|
|
104
|
+
throw new GapiError(e.exit, `${e.message}; session ${location}`, e.body);
|
|
80
105
|
}
|
|
81
106
|
}
|
package/dist/src/transport.js
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
|
-
// Transport: sends a CallPlan and prints
|
|
1
|
+
// Transport: sends a CallPlan and yields or prints its responses (epic #30 D13, #200 D3).
|
|
2
2
|
// `authorize` adds the credential before every request; a non-2xx exits 1, a missing scope 2.
|
|
3
|
-
import {
|
|
3
|
+
import { setTimeout } from 'node:timers/promises';
|
|
4
|
+
import { GapiError, reasonOf } from './errors.js';
|
|
4
5
|
import { isRecord, parseOrText } from './json.js';
|
|
5
6
|
import { batch } from './transport/batch.js';
|
|
6
7
|
import { download } from './transport/download.js';
|
|
8
|
+
import { backoff, NetworkError, RETRIES, retryAfter, retryable, } from './transport/retry.js';
|
|
7
9
|
import { upload } from './transport/upload.js';
|
|
10
|
+
// Prints each response as one compact JSON line; `run` picks the final form (epic #200 D2).
|
|
8
11
|
export async function send(plan, deps) {
|
|
12
|
+
for await (const data of receive(plan, deps)) {
|
|
13
|
+
deps.out.write(`${JSON.stringify(data)}\n`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Yields each decoded response of a plan; a decode result of `undefined` is not yielded.
|
|
17
|
+
// A batch yields its results before it throws a failure: the parts sent were acted on.
|
|
18
|
+
export async function* receive(plan, deps) {
|
|
9
19
|
const call = caller(plan, deps);
|
|
10
|
-
const
|
|
20
|
+
const decode = (data, step) => (plan.decode ? plan.decode(data, step) : data);
|
|
11
21
|
if (plan.send === 'upload') {
|
|
12
|
-
|
|
22
|
+
yield* some(decode(await json(await upload(plan, call, deps.sleep ?? setTimeout))));
|
|
13
23
|
return;
|
|
14
24
|
}
|
|
15
25
|
if (plan.send === 'download') {
|
|
16
|
-
|
|
26
|
+
yield* some(decode(await download(plan, call)));
|
|
17
27
|
return;
|
|
18
28
|
}
|
|
19
29
|
if (plan.send === 'batch') {
|
|
@@ -22,51 +32,72 @@ export async function send(plan, deps) {
|
|
|
22
32
|
status,
|
|
23
33
|
body: text ? parseOrText(text) : undefined,
|
|
24
34
|
}));
|
|
25
|
-
// Results print before any failure: the parts already sent were acted on.
|
|
26
35
|
if (results.length > 0 || !sent.error)
|
|
27
|
-
|
|
36
|
+
yield* some(decode(results));
|
|
28
37
|
if (sent.error)
|
|
29
38
|
throw sent.error;
|
|
30
|
-
const failed = results.filter((r) => r.status < 200 || r.status > 299)
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
const failed = sent.results.filter((r) => r.status < 200 || r.status > 299);
|
|
40
|
+
// A part that lacks a scope gets the same exit 2 and hint as a single call.
|
|
41
|
+
const scope = failed.map((r) => failure(plan, r.status, r.text)).find((e) => e.exit === 2);
|
|
42
|
+
if (scope)
|
|
43
|
+
throw scope;
|
|
44
|
+
if (failed.length > 0) {
|
|
45
|
+
throw new GapiError(1, `${failed.length} of ${results.length} batch parts failed`);
|
|
46
|
+
}
|
|
33
47
|
return;
|
|
34
48
|
}
|
|
35
|
-
const
|
|
36
|
-
method:
|
|
49
|
+
const fetchStep = async (step) => json(await call(step.url, {
|
|
50
|
+
method: step.httpMethod,
|
|
37
51
|
headers: plan.headers,
|
|
38
|
-
body:
|
|
39
|
-
};
|
|
52
|
+
body: step.body === undefined ? undefined : JSON.stringify(step.body),
|
|
53
|
+
}));
|
|
54
|
+
let step = { httpMethod: plan.httpMethod, url: plan.url, body: plan.body };
|
|
40
55
|
if (plan.send === 'once') {
|
|
41
|
-
|
|
42
|
-
print(plan.decode ? plan.decode(data) : data);
|
|
56
|
+
yield* some(decode(await fetchStep(step), step));
|
|
43
57
|
return;
|
|
44
58
|
}
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const token = isRecord(data) ? data.nextPageToken : undefined;
|
|
53
|
-
if (typeof token !== 'string' || !token)
|
|
54
|
-
return;
|
|
55
|
-
if (plan.paging === 'query') {
|
|
56
|
-
url.searchParams.set('pageToken', token);
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
pageInit = { ...init, body: JSON.stringify({ ...plan.body, pageToken: token }) };
|
|
59
|
+
// D3: follow the rule until it stops or pageLimit responses were yielded.
|
|
60
|
+
for (let yielded = 0; step && yielded < plan.pageLimit;) {
|
|
61
|
+
const data = await fetchStep(step);
|
|
62
|
+
const decoded = decode(data, step);
|
|
63
|
+
if (decoded !== undefined) {
|
|
64
|
+
yielded++;
|
|
65
|
+
yield decoded;
|
|
60
66
|
}
|
|
67
|
+
step = plan.follow(data, step);
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
|
-
function
|
|
70
|
+
function* some(data) {
|
|
71
|
+
if (data !== undefined)
|
|
72
|
+
yield data;
|
|
73
|
+
}
|
|
74
|
+
// Uploads and batches never retry here; a resumable upload resumes on its own.
|
|
75
|
+
function caller(plan, { fetch, authorize, signal, sleep = (ms) => setTimeout(ms, undefined, { signal }) }) {
|
|
76
|
+
const retry = plan.retry && plan.send !== 'upload' && plan.send !== 'batch';
|
|
64
77
|
return async (url, init, allowed) => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
for (let attempt = 0;; attempt++) {
|
|
79
|
+
signal?.throwIfAborted(); // a deadline that passed during a backoff stops the retries
|
|
80
|
+
const headers = { ...init.headers, ...(await authorize(plan.auth)) };
|
|
81
|
+
const res = await fetch(url, { ...init, headers, ...(signal && { signal }) }).catch((e) => {
|
|
82
|
+
if (signal?.aborted)
|
|
83
|
+
throw signal.reason; // a deadline is never retried
|
|
84
|
+
const cause = e instanceof Error && e.cause !== undefined ? e.cause : e;
|
|
85
|
+
return new NetworkError(1, `network: ${reasonOf(cause)}`);
|
|
86
|
+
});
|
|
87
|
+
const last = !retry || attempt === RETRIES;
|
|
88
|
+
if (res instanceof NetworkError) {
|
|
89
|
+
if (last)
|
|
90
|
+
throw res;
|
|
91
|
+
await sleep(backoff(attempt));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (res.ok || allowed?.(res.status))
|
|
95
|
+
return res;
|
|
96
|
+
const text = await res.text();
|
|
97
|
+
if (last || !retryable(res.status, text))
|
|
98
|
+
throw failure(plan, res.status, text);
|
|
99
|
+
await sleep(Math.max(backoff(attempt), retryAfter(res)));
|
|
100
|
+
}
|
|
70
101
|
};
|
|
71
102
|
}
|
|
72
103
|
// A 2xx body that is not JSON is Google's surprise, not gapi's: exit 1 with the body.
|
|
@@ -81,9 +112,11 @@ async function json(res) {
|
|
|
81
112
|
throw new GapiError(1, `HTTP ${res.status}: the response body is not JSON`, text);
|
|
82
113
|
}
|
|
83
114
|
}
|
|
84
|
-
function failure(plan, status, text) {
|
|
115
|
+
export function failure(plan, status, text) {
|
|
85
116
|
// A front-end error page can be HTML; keep it as text rather than fail on parse.
|
|
86
|
-
|
|
117
|
+
// A streaming method wraps the error in an array (Go googleapi errorReplyFromBody).
|
|
118
|
+
const parsed = parseOrText(text);
|
|
119
|
+
const body = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
87
120
|
const error = isRecord(body) && isRecord(body.error) ? body.error : {};
|
|
88
121
|
// D8: the stored grant lacks this method's scope; a new consent fixes it. AIP-193: the
|
|
89
122
|
// (reason, domain) pair of the ErrorInfo detail identifies the error.
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "google-apis-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "One discovery-driven CLI (gapi) for
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "One discovery-driven CLI (gapi) for 49 Google marketing and data APIs: Ads, GA4, Search Console, Tag Manager, Indexing, Merchant, Business Profile, SA360, CM360, DV360, YouTube, PageSpeed, CrUX, BigQuery, Sheets, Places and more. One login, read+write.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"gapi": "dist/bin/gapi.js"
|
|
8
8
|
},
|
|
9
9
|
"engines": {
|
|
10
|
-
"node": ">=
|
|
10
|
+
"node": ">=26"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"test": "node --test",
|
|
@@ -55,13 +55,13 @@
|
|
|
55
55
|
"url": "https://github.com/realroboto/google-apis-cli/issues"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"google-auth-library": "^11.3.
|
|
58
|
+
"google-auth-library": "^11.3.4"
|
|
59
59
|
},
|
|
60
60
|
"license": "MIT",
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@biomejs/biome": "^2.
|
|
63
|
-
"@types/node": "^
|
|
62
|
+
"@biomejs/biome": "^2.6.7",
|
|
63
|
+
"@types/node": "^26.6.6",
|
|
64
64
|
"typescript": "^7.0.2"
|
|
65
65
|
},
|
|
66
|
-
"packageManager": "pnpm@12.
|
|
66
|
+
"packageManager": "pnpm@12.18.0+sha512.9d7882d5dc82147ecfc4e062aa34834e0ad0296710a1837a1115ae012127d36b7109e321ac1598a1dd0a65597681b1e99917e12ed0d749eae91187943363beb3"
|
|
67
67
|
}
|