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
package/dist/src/render.js
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
import { isRecord } from './json.js';
|
|
2
|
+
export const SCHEMA_USAGE = 'gapi schema <api> <resource>[.<sub-resource>…].<method> | <SchemaName> [--resolve-refs | --find <word>]';
|
|
2
3
|
export const DISCOVERY_USAGE = 'gapi discovery refresh [<api>…] | path';
|
|
3
4
|
export const AUTH_USAGE = 'gapi auth setup | login [--manual] | status | logout';
|
|
5
|
+
export const SEARCH_USAGE = 'gapi search <words…>';
|
|
6
|
+
export const FIELDS_USAGE = 'gapi fields ads|sa360 <words…> | ga4-data <words…> --property <id>';
|
|
7
|
+
export const FIELDS_HELP = `${FIELDS_USAGE}
|
|
8
|
+
Max 20 ranked lines: <name> <kind> <first sentence of the description>.
|
|
9
|
+
ads, sa360 GAQL names (resource attributes, metrics., segments.) from the discovery document, no request
|
|
10
|
+
ga4-data GA4 dimensions and metrics, custom ones included: one properties getMetadata request
|
|
11
|
+
(--property 123 or properties/123; 0 = the ones common to all properties)
|
|
12
|
+
No --cache. Discovery has no GAQL compatibility (selectable_with): check it with
|
|
13
|
+
googleAdsFields.search (gapi ads googleAdsFields search; sa360: searchAds360Fields search)`;
|
|
14
|
+
export const ENTITIES_USAGE = 'gapi entities sync [<api>…] [--page-limit <n>] | find <words…> [--api <api>]';
|
|
4
15
|
const USAGE = `gapi <api> <resource> [<sub-resource>…] <method> [--params '<json>'] [--body '<json>']
|
|
5
16
|
[--upload <file> [--upload-content-type <type>] [--upload-protocol media|multipart|resumable]]
|
|
6
|
-
[--output <file>] [--page-all [--page-limit <n>]] [--
|
|
7
|
-
[<entry header flags, e.g. ads --login-customer-id <id>>]
|
|
17
|
+
[--output <file>] [--page-all [--page-limit <n>]] [--cache <age>] [--dry-run]
|
|
18
|
+
[--project <id>] [--api-key <key>] [<entry header flags, e.g. ads --login-customer-id <id>>]
|
|
8
19
|
gapi <api> [<resource>…] list sub-resources and methods
|
|
9
20
|
gapi <api> <resource>… <method> --help method help
|
|
10
|
-
gapi <api> +<helper> … [--dry-run] [--project <id>] [--api-key <key>] [<entry header flags>]
|
|
21
|
+
gapi <api> +<helper> … [--dry-run] [--cache <age>] [--project <id>] [--api-key <key>] [<entry header flags>]
|
|
22
|
+
${SEARCH_USAGE} find methods by intent, max 20 hits
|
|
23
|
+
${FIELDS_USAGE}
|
|
24
|
+
${ENTITIES_USAGE}
|
|
11
25
|
${SCHEMA_USAGE}
|
|
12
26
|
${DISCOVERY_USAGE}
|
|
13
27
|
${AUTH_USAGE}
|
|
@@ -95,6 +109,12 @@ const oneLine = (description = '') => description
|
|
|
95
109
|
.trim()
|
|
96
110
|
.split('\n')[0]
|
|
97
111
|
.split(/(?<=\.)\s+(?=[A-Z*])/)[0];
|
|
112
|
+
// The first sentence, max 100 chars: one line of search hits and of `schema --find`.
|
|
113
|
+
export const brief = (description) => {
|
|
114
|
+
const sentence = oneLine(description);
|
|
115
|
+
const chars = [...sentence]; // code points: a cut never splits a surrogate pair
|
|
116
|
+
return chars.length > 100 ? `${chars.slice(0, 99).join('')}…` : sentence;
|
|
117
|
+
};
|
|
98
118
|
// Code-point order, so the output does not depend on the machine's locale.
|
|
99
119
|
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
100
120
|
const byKey = (map) => [...map].sort(([a], [b]) => compare(a, b));
|
|
@@ -142,6 +162,31 @@ function referenceRow(model, m) {
|
|
|
142
162
|
const marked = markList(m, { reasons: true }).join('; ');
|
|
143
163
|
return `| \`gapi ${model.api} ${m.words.join(' ')}\` | ${m.httpMethod} | ${cell(oneLine(m.description))} | ${required.join(', ')} | ${m.request ? `\`${m.request}\`` : ''} | ${cell(marked)} |`;
|
|
144
164
|
}
|
|
165
|
+
// One line per hit: command, marks, HTTP method, first sentence of the description.
|
|
166
|
+
export function searchText(hits) {
|
|
167
|
+
return hits
|
|
168
|
+
.map(({ model, method: m }) => {
|
|
169
|
+
const line = `gapi ${model.api} ${m.words.join(' ')}${marks(m)} ${m.httpMethod} ${brief(m.description)}`;
|
|
170
|
+
return `${line.trimEnd()}\n`;
|
|
171
|
+
})
|
|
172
|
+
.join('');
|
|
173
|
+
}
|
|
174
|
+
// #200 D7: one line per field, the shape of search hits.
|
|
175
|
+
export function fieldsText(fields) {
|
|
176
|
+
return fields
|
|
177
|
+
.map((f) => `${`${f.name} ${f.kind} ${brief(f.description)}`.trimEnd()}\n`)
|
|
178
|
+
.join('');
|
|
179
|
+
}
|
|
180
|
+
// #200 D6: tabs keep empty cells (no name, parent or domain) unambiguous for `cut`.
|
|
181
|
+
export function entitiesText(hits) {
|
|
182
|
+
return hits
|
|
183
|
+
.map((h) => {
|
|
184
|
+
const date = new Date(h.synced).toISOString().slice(0, 10);
|
|
185
|
+
const cells = [h.api, h.kind, h.id, h.name, h.parent, h.domain, date];
|
|
186
|
+
return `${cells.map((c = '') => c.replace(/[\t\r\n]+/g, ' ')).join('\t')}\n`;
|
|
187
|
+
})
|
|
188
|
+
.join('');
|
|
189
|
+
}
|
|
145
190
|
// D9: a method's shape (request/response as schema names), or one document schema.
|
|
146
191
|
export function schemaView(model, target, { resolveRefs }) {
|
|
147
192
|
// A method shape holds schema names, never a $ref, so --resolve-refs leaves it as is.
|
|
@@ -165,6 +210,52 @@ export function schemaView(model, target, { resolveRefs }) {
|
|
|
165
210
|
const out = resolveRefs && schema ? expandObject(schema, model.schemas, [target.schema]) : schema;
|
|
166
211
|
return `${JSON.stringify(out, null, 2)}\n`;
|
|
167
212
|
}
|
|
213
|
+
// `--find`: one line per leaf of the expanded schema (a method: request and response) whose
|
|
214
|
+
// path or printed sentence contains `word`, case-insensitive. A $ref the cycle guard left is a leaf.
|
|
215
|
+
export function schemaFind(model, target, word) {
|
|
216
|
+
const roots = 'method' in target
|
|
217
|
+
? [
|
|
218
|
+
['request', target.method.request],
|
|
219
|
+
['response', target.method.response],
|
|
220
|
+
]
|
|
221
|
+
: [['', target.schema]];
|
|
222
|
+
const needle = word.toLowerCase();
|
|
223
|
+
const lines = [];
|
|
224
|
+
// An array or map item without a description takes its field's (Google puts it there).
|
|
225
|
+
const walk = (node, path, fieldDescription = '') => {
|
|
226
|
+
const own = typeof node.description === 'string' ? node.description : fieldDescription;
|
|
227
|
+
const props = isRecord(node.properties) ? Object.entries(node.properties) : [];
|
|
228
|
+
const children = [
|
|
229
|
+
...props.map(([key, child]) => [
|
|
230
|
+
path ? `${path}.${key}` : key,
|
|
231
|
+
child,
|
|
232
|
+
'',
|
|
233
|
+
]),
|
|
234
|
+
[`${path}[]`, node.items, own],
|
|
235
|
+
[`${path}{}`, node.additionalProperties, own],
|
|
236
|
+
];
|
|
237
|
+
let leaf = true;
|
|
238
|
+
for (const [childPath, child, description] of children) {
|
|
239
|
+
if (!isRecord(child))
|
|
240
|
+
continue;
|
|
241
|
+
leaf = false;
|
|
242
|
+
walk(child, childPath, description);
|
|
243
|
+
}
|
|
244
|
+
const ref = typeof node.$ref === 'string' ? node.$ref : undefined;
|
|
245
|
+
const text = ref ? '' : brief(own);
|
|
246
|
+
if (!leaf || !`${path}\n${text}`.toLowerCase().includes(needle))
|
|
247
|
+
return;
|
|
248
|
+
const type = ref ? `$ref ${ref}` : `${String(node.type ?? 'any')}${node.enum ? ' enum' : ''}`;
|
|
249
|
+
lines.push(`${path}: ${type}${text ? ` — ${text}` : ''}`);
|
|
250
|
+
};
|
|
251
|
+
for (const [path, name] of roots) {
|
|
252
|
+
const schema = name ? model.schemas.get(name) : undefined;
|
|
253
|
+
const expanded = name && schema ? expandObject(schema, model.schemas, [name]) : undefined;
|
|
254
|
+
if (isRecord(expanded))
|
|
255
|
+
walk(expanded, path);
|
|
256
|
+
}
|
|
257
|
+
return lines.map((line) => `${line}\n`).join('');
|
|
258
|
+
}
|
|
168
259
|
function expandRefs(value, schemas, path) {
|
|
169
260
|
if (Array.isArray(value))
|
|
170
261
|
return value.map((v) => expandRefs(v, schemas, path));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Response cache (ADR 0001): one file per key under <config dir>/responses/, dir 700, files
|
|
2
|
+
// 600, written by temp file + rename. Holds Google account data: login and logout clear it.
|
|
3
|
+
import { hash } from 'node:crypto';
|
|
4
|
+
import { readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { writeAtomic } from './catalog.js';
|
|
7
|
+
export const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
|
+
export function openResponses(dir) {
|
|
9
|
+
const file = (key) => join(dir, key);
|
|
10
|
+
// Every value that shapes the request or its output; `url` carries every query value.
|
|
11
|
+
const key = (call, identity) => hash('sha256', JSON.stringify([
|
|
12
|
+
identity,
|
|
13
|
+
call.httpMethod,
|
|
14
|
+
call.url,
|
|
15
|
+
call.headers,
|
|
16
|
+
call.body,
|
|
17
|
+
call.send,
|
|
18
|
+
call.send === 'pages' ? call.pageLimit : undefined,
|
|
19
|
+
call.decode !== undefined, // a helper's decoded output differs from the raw one
|
|
20
|
+
]));
|
|
21
|
+
// When the entry was stored (epoch ms); undefined when there is none.
|
|
22
|
+
const mtime = (k) => stat(file(k)).then((s) => s.mtimeMs, () => undefined);
|
|
23
|
+
const get = (k) => readFile(file(k), 'utf8').catch(() => undefined);
|
|
24
|
+
const put = (k, text) => writeAtomic(file(k), text);
|
|
25
|
+
// Deletes every file (temp files too) older than the max age. Every run calls it, so
|
|
26
|
+
// account data never outlives 7 days on disk (YouTube policy, ADR 0001).
|
|
27
|
+
async function sweep() {
|
|
28
|
+
for (const name of await readdir(dir).catch(() => [])) {
|
|
29
|
+
const f = join(dir, name);
|
|
30
|
+
const s = await stat(f).catch(() => undefined);
|
|
31
|
+
if (s && Date.now() - s.mtimeMs > MAX_AGE_MS)
|
|
32
|
+
await rm(f, { force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const clear = () => rm(dir, { recursive: true, force: true });
|
|
36
|
+
return { key, mtime, get, put, sweep, clear };
|
|
37
|
+
}
|
package/dist/src/run.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// Process io is injected so tests drive the whole CLI in-process.
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
3
4
|
import { parseArgs } from 'node:util';
|
|
4
5
|
import { openCatalog } from './catalog.js';
|
|
5
6
|
import { configDir, openCredentials } from './credentials.js';
|
|
7
|
+
import { openEntities } from './entities.js';
|
|
6
8
|
import { GapiError, messageOf, reasonOf } from './errors.js';
|
|
7
9
|
import { isRecord } from './json.js';
|
|
8
10
|
import { parseJson, plan, resolve } from './plan.js';
|
|
9
|
-
import { AUTH_USAGE, DISCOVERY_USAGE, levelText, methodHelp, rootHelp, SCHEMA_USAGE, schemaView, } from './render.js';
|
|
10
|
-
import {
|
|
11
|
+
import { AUTH_USAGE, DISCOVERY_USAGE, ENTITIES_USAGE, entitiesText, FIELDS_HELP, FIELDS_USAGE, fieldsText, levelText, methodHelp, rootHelp, SCHEMA_USAGE, SEARCH_USAGE, schemaFind, schemaView, searchText, } from './render.js';
|
|
12
|
+
import { MAX_AGE_MS, openResponses } from './responses.js';
|
|
13
|
+
import { FIELD_APIS, ga4Fields, gaqlFields, matchQuery, rankFields, search, } from './search.js';
|
|
14
|
+
import { receive, send } from './transport.js';
|
|
11
15
|
const GLOBAL_OPTIONS = {
|
|
12
16
|
params: { type: 'string' },
|
|
13
17
|
body: { type: 'string' },
|
|
@@ -20,6 +24,7 @@ const GLOBAL_OPTIONS = {
|
|
|
20
24
|
output: { type: 'string' },
|
|
21
25
|
project: { type: 'string' },
|
|
22
26
|
'api-key': { type: 'string' },
|
|
27
|
+
cache: { type: 'string' },
|
|
23
28
|
help: { type: 'boolean' },
|
|
24
29
|
};
|
|
25
30
|
// D10: a helper takes the global flags that do not shape the request, plus its own options.
|
|
@@ -27,8 +32,19 @@ const HELPER_OPTIONS = {
|
|
|
27
32
|
'dry-run': GLOBAL_OPTIONS['dry-run'],
|
|
28
33
|
project: GLOBAL_OPTIONS.project,
|
|
29
34
|
'api-key': GLOBAL_OPTIONS['api-key'],
|
|
35
|
+
cache: GLOBAL_OPTIONS.cache,
|
|
30
36
|
help: GLOBAL_OPTIONS.help,
|
|
31
37
|
};
|
|
38
|
+
const AGE_UNIT_MS = { s: 1e3, m: 60e3, h: 3600e3, d: 86400e3 };
|
|
39
|
+
// `--cache <n>s|m|h|d`, from 1 s to the 7-day store limit (ADR 0001).
|
|
40
|
+
function parseAge(age) {
|
|
41
|
+
const m = /^(\d+)([smhd])$/.exec(age);
|
|
42
|
+
const ms = m ? Number(m[1]) * AGE_UNIT_MS[m[2]] : 0;
|
|
43
|
+
if (ms < 1000 || ms > MAX_AGE_MS) {
|
|
44
|
+
throw new GapiError(3, `--cache: ${age} is not an age from 1s to 7d`);
|
|
45
|
+
}
|
|
46
|
+
return ms;
|
|
47
|
+
}
|
|
32
48
|
const isHelperName = (word) => word.startsWith('+');
|
|
33
49
|
// D4: an entry header flag is accepted only on the APIs whose entry declares it.
|
|
34
50
|
const headerOptions = (entry) => Object.fromEntries((entry?.headers ?? []).map((h) => [h.flag, { type: 'string' }]));
|
|
@@ -46,16 +62,22 @@ export async function run(argv, io) {
|
|
|
46
62
|
return err.exit;
|
|
47
63
|
}
|
|
48
64
|
}
|
|
49
|
-
// Strict argv parsing; an unknown flag
|
|
65
|
+
// Strict argv parsing; an unknown flag, a missing flag value or a repeated flag (parseArgs
|
|
66
|
+
// would keep the last one) is a validation error.
|
|
50
67
|
function parse(args, options) {
|
|
51
68
|
try {
|
|
52
|
-
|
|
69
|
+
const parsed = parseArgs({ args, options, allowPositionals: true, strict: true, tokens: true });
|
|
70
|
+
const names = parsed.tokens.flatMap((t) => (t.kind === 'option' ? [t.name] : []));
|
|
71
|
+
const twice = names.find((n, i) => names.indexOf(n) !== i);
|
|
72
|
+
if (twice !== undefined)
|
|
73
|
+
throw new GapiError(3, `--${twice} given twice`);
|
|
74
|
+
return parsed;
|
|
53
75
|
}
|
|
54
76
|
catch (e) {
|
|
55
|
-
throw new GapiError(3, messageOf(e));
|
|
77
|
+
throw e instanceof GapiError ? e : new GapiError(3, messageOf(e));
|
|
56
78
|
}
|
|
57
79
|
}
|
|
58
|
-
async function runAuth(sub, values, { io, creds, catalog }) {
|
|
80
|
+
async function runAuth(sub, values, { io, creds, catalog, responses, entities, }) {
|
|
59
81
|
// --help never starts a side effect: no server, no fetch, no file write.
|
|
60
82
|
if (values.help) {
|
|
61
83
|
io.stdout.write(`${AUTH_USAGE}\n`);
|
|
@@ -63,6 +85,8 @@ async function runAuth(sub, values, { io, creds, catalog }) {
|
|
|
63
85
|
}
|
|
64
86
|
if (sub === 'login') {
|
|
65
87
|
const consent = await consentScopes(catalog);
|
|
88
|
+
// A new account starts with no stored responses and no entities.
|
|
89
|
+
await Promise.all([responses.clear(), entities.clear()]);
|
|
66
90
|
const tokens = await creds.login({ consent, manual: values.manual === true });
|
|
67
91
|
io.stderr.write(`gapi: authorized (${tokens.refresh_token ? 'refresh token stored' : 'no refresh token'})\n`);
|
|
68
92
|
return;
|
|
@@ -77,15 +101,162 @@ async function runAuth(sub, values, { io, creds, catalog }) {
|
|
|
77
101
|
}
|
|
78
102
|
if (sub === 'status') {
|
|
79
103
|
const consent = await consentScopes(catalog);
|
|
80
|
-
io
|
|
104
|
+
printJson(io, `${JSON.stringify(await creds.status(consent))}\n`);
|
|
81
105
|
return;
|
|
82
106
|
}
|
|
83
107
|
if (sub === 'logout') {
|
|
84
|
-
|
|
108
|
+
try {
|
|
109
|
+
await creds.logout();
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
// Also when the revoke fails: no account data stays on disk.
|
|
113
|
+
await Promise.all([responses.clear(), entities.clear()]);
|
|
114
|
+
}
|
|
85
115
|
io.stderr.write('gapi: credentials removed\n');
|
|
86
116
|
return;
|
|
87
117
|
}
|
|
88
|
-
throw new GapiError(3, `unknown auth command: ${sub}`);
|
|
118
|
+
throw new GapiError(3, sub === undefined ? `usage: ${AUTH_USAGE}` : `unknown auth command: ${sub}`);
|
|
119
|
+
}
|
|
120
|
+
// #200 D6: `sync` stores each API's entity sources; `find` searches them offline.
|
|
121
|
+
async function runEntities(args, { io, creds, catalog, entities }) {
|
|
122
|
+
const parsed = parse(args, {
|
|
123
|
+
help: { type: 'boolean' },
|
|
124
|
+
api: { type: 'string' },
|
|
125
|
+
'page-limit': { type: 'string' },
|
|
126
|
+
});
|
|
127
|
+
const { values } = parsed;
|
|
128
|
+
const [sub, ...positionals] = parsed.positionals;
|
|
129
|
+
if (values.help) {
|
|
130
|
+
io.stdout.write(`${ENTITIES_USAGE}\n`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (sub !== 'find' && sub !== 'sync')
|
|
134
|
+
throw new GapiError(3, `usage: ${ENTITIES_USAGE}`);
|
|
135
|
+
const other = sub === 'find' ? 'page-limit' : 'api';
|
|
136
|
+
if (values[other] !== undefined)
|
|
137
|
+
throw new GapiError(3, `--${other}: not an option of ${sub}`);
|
|
138
|
+
const identity = await creds.identity({ kind: 'oauth', scopes: [] });
|
|
139
|
+
const sourcesOf = (api) => {
|
|
140
|
+
if (!Object.hasOwn(catalog.entries, api))
|
|
141
|
+
throw new GapiError(3, `unknown api: ${api}`);
|
|
142
|
+
const sources = catalog.entries[api].entities ?? [];
|
|
143
|
+
if (sources.length === 0)
|
|
144
|
+
throw new GapiError(3, `${api} has no entity sources`);
|
|
145
|
+
return sources;
|
|
146
|
+
};
|
|
147
|
+
if (sub === 'find') {
|
|
148
|
+
const api = typeof values.api === 'string' ? values.api : undefined;
|
|
149
|
+
if (api !== undefined)
|
|
150
|
+
sourcesOf(api);
|
|
151
|
+
const { hits, empty } = await entities.find(positionals.join(' '), { api, identity });
|
|
152
|
+
if (empty)
|
|
153
|
+
io.stderr.write('gapi: entities index is empty or older than 7 days; run gapi entities sync\n');
|
|
154
|
+
if (hits.length === 0)
|
|
155
|
+
io.stderr.write('gapi: no match\n');
|
|
156
|
+
io.stdout.write(entitiesText(hits));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const limit = typeof values['page-limit'] === 'string' ? values['page-limit'] : '10';
|
|
160
|
+
if (!/^[1-9]\d*$/.test(limit))
|
|
161
|
+
throw new GapiError(3, '--page-limit must be a positive integer');
|
|
162
|
+
const apis = positionals.length > 0
|
|
163
|
+
? [...new Set(positionals)]
|
|
164
|
+
: Object.keys(catalog.entries)
|
|
165
|
+
.filter((a) => catalog.entries[a].entities?.length)
|
|
166
|
+
.toSorted();
|
|
167
|
+
const sources = apis.map((a) => ({ api: a, sources: sourcesOf(a) }));
|
|
168
|
+
if (identity === undefined)
|
|
169
|
+
throw new GapiError(2, 'no refresh token; run gapi auth login');
|
|
170
|
+
// Cost first: one call per top-level source, then one per parent entity of a child source.
|
|
171
|
+
const children = new Set();
|
|
172
|
+
const each = (api, list) => {
|
|
173
|
+
for (const s of list) {
|
|
174
|
+
for (const [kind, child] of Object.entries(s.children ?? {})) {
|
|
175
|
+
children.add(`${api} ${kind}`);
|
|
176
|
+
each(api, [child]);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
for (const s of sources)
|
|
181
|
+
each(s.api, s.sources);
|
|
182
|
+
const top = sources.reduce((n, s) => n + s.sources.length, 0);
|
|
183
|
+
const then = [...children].map((c) => `, then 1 per ${c}`).join('');
|
|
184
|
+
io.stderr.write(`gapi: entities sync: ${top} list calls${then}; max ${limit} pages per list\n`);
|
|
185
|
+
let failed = 0;
|
|
186
|
+
for (const { api, sources: list } of sources) {
|
|
187
|
+
try {
|
|
188
|
+
// A source may call another entry's method (`api`); its settings and headers apply.
|
|
189
|
+
const request = async function* ({ api: owner = api, method, params, body, headers, }) {
|
|
190
|
+
const model = await catalog.load(owner);
|
|
191
|
+
const settings = await creds.settings({}, model.headers);
|
|
192
|
+
const paged = model.byId.get(method)?.paging !== undefined;
|
|
193
|
+
const call = plan(model, method, { params, body, pageAll: paged, pageLimit: paged ? limit : undefined }, { ...settings, headers: { ...settings.headers, ...headers } });
|
|
194
|
+
for (const w of call.warnings)
|
|
195
|
+
io.stderr.write(`gapi: warning: ${w}\n`);
|
|
196
|
+
yield* receive(call, { fetch: io.fetch, authorize: creds.authorize, sleep: io.sleep });
|
|
197
|
+
};
|
|
198
|
+
const n = await entities.sync(api, list, identity, request);
|
|
199
|
+
io.stdout.write(`${api}: ${n} entities\n`);
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
failed++;
|
|
203
|
+
io.stdout.write(`${api}: failed, old rows kept: ${messageOf(e)}\n`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (failed > 0)
|
|
207
|
+
throw new GapiError(1, `${failed} of ${sources.length} entity sources failed`);
|
|
208
|
+
}
|
|
209
|
+
// #200 D7: GAQL names offline from the discovery document, GA4 names from one getMetadata call.
|
|
210
|
+
async function runFields(args, { io, creds, catalog }) {
|
|
211
|
+
const { values, positionals } = parse(args, {
|
|
212
|
+
property: { type: 'string' },
|
|
213
|
+
cache: { type: 'string' },
|
|
214
|
+
help: { type: 'boolean' },
|
|
215
|
+
});
|
|
216
|
+
if (values.help) {
|
|
217
|
+
io.stdout.write(`${FIELDS_HELP}\n`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// ADR 0001 keys a cache entry by the request, so two queries would share one entry.
|
|
221
|
+
if (values.cache !== undefined)
|
|
222
|
+
throw new GapiError(3, '--cache: fields is not cacheable');
|
|
223
|
+
const [name, ...words] = positionals;
|
|
224
|
+
if (name === undefined)
|
|
225
|
+
throw new GapiError(3, `usage: ${FIELDS_USAGE}`);
|
|
226
|
+
if (!FIELD_APIS.includes(name)) {
|
|
227
|
+
throw new GapiError(3, `fields: ${name} has no field source; supported: ${FIELD_APIS.join(', ')}`);
|
|
228
|
+
}
|
|
229
|
+
const query = words.join(' ');
|
|
230
|
+
matchQuery(query, FIELDS_USAGE); // no word → exit 3 before any document loads
|
|
231
|
+
const { property } = values;
|
|
232
|
+
if (name !== 'ga4-data' && property !== undefined) {
|
|
233
|
+
throw new GapiError(3, `--property: not an option of fields ${name}`);
|
|
234
|
+
}
|
|
235
|
+
if (name === 'ga4-data' && property === undefined) {
|
|
236
|
+
throw new GapiError(3, '--property is required');
|
|
237
|
+
}
|
|
238
|
+
const id = property?.replace(/^properties\//, '');
|
|
239
|
+
if (id !== undefined && !/^\d+$/.test(id)) {
|
|
240
|
+
throw new GapiError(3, `--property: ${property} is not a GA4 property id`);
|
|
241
|
+
}
|
|
242
|
+
const model = await catalog.load(name);
|
|
243
|
+
let fields = [];
|
|
244
|
+
if (id === undefined) {
|
|
245
|
+
fields = gaqlFields(model);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
const settings = await creds.settings({}, model.headers);
|
|
249
|
+
const call = plan(model, 'analyticsdata.properties.getMetadata', { params: { name: `properties/${id}/metadata` } }, settings);
|
|
250
|
+
for (const w of call.warnings)
|
|
251
|
+
io.stderr.write(`gapi: warning: ${w}\n`);
|
|
252
|
+
const opts = { fetch: io.fetch, authorize: creds.authorize, sleep: io.sleep };
|
|
253
|
+
for await (const data of receive(call, opts))
|
|
254
|
+
fields = ga4Fields(data);
|
|
255
|
+
}
|
|
256
|
+
const hits = rankFields(fields, query);
|
|
257
|
+
if (hits.length === 0)
|
|
258
|
+
io.stderr.write('gapi: no match\n');
|
|
259
|
+
io.stdout.write(fieldsText(hits));
|
|
89
260
|
}
|
|
90
261
|
// D8 consent set: every entry's per-method scope plus its entry scopes (identity is added by login).
|
|
91
262
|
async function consentScopes(catalog) {
|
|
@@ -105,10 +276,19 @@ async function consentScopes(catalog) {
|
|
|
105
276
|
function schemaTarget(model, path) {
|
|
106
277
|
if (!model.methods.has(path) && model.schemas.has(path))
|
|
107
278
|
return { schema: path };
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
279
|
+
try {
|
|
280
|
+
const found = resolve(model, path.split('.'));
|
|
281
|
+
if (!found.method)
|
|
282
|
+
throw new GapiError(3, `${path} is a resource, not a method or schema`);
|
|
283
|
+
return { method: found.method };
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
// resolve knows only the tree; a one-word path can also name a schema.
|
|
287
|
+
if (e instanceof GapiError && /^unknown /.test(e.message) && !path.includes('.')) {
|
|
288
|
+
throw new GapiError(3, `${e.message}, or a schema name`);
|
|
289
|
+
}
|
|
290
|
+
throw e;
|
|
291
|
+
}
|
|
112
292
|
}
|
|
113
293
|
async function dispatch(argv, io) {
|
|
114
294
|
const dir = configDir(io.env);
|
|
@@ -121,6 +301,29 @@ async function dispatch(argv, io) {
|
|
|
121
301
|
stdin: io.stdin,
|
|
122
302
|
stderr: io.stderr,
|
|
123
303
|
});
|
|
304
|
+
const responses = openResponses(join(dir, 'responses'));
|
|
305
|
+
await responses.sweep();
|
|
306
|
+
const entities = openEntities(join(dir, 'entities.db'));
|
|
307
|
+
// shortcut: a lock or disk error skips this run's sweep with a warning, the next run retries.
|
|
308
|
+
await entities.sweep().catch((e) => warn(`entities sweep skipped: ${messageOf(e)}`));
|
|
309
|
+
// ADR 0001 eligibility, before any network call: the entry allows caching, the call is
|
|
310
|
+
// one request or pages, the method is a GET or a `reads` POST.
|
|
311
|
+
const cacheFor = async (call, model, values) => {
|
|
312
|
+
if (values.cache === undefined)
|
|
313
|
+
return undefined;
|
|
314
|
+
const maxAgeMs = parseAge(values.cache);
|
|
315
|
+
const { nocache } = catalog.entries[model.api];
|
|
316
|
+
if (nocache) {
|
|
317
|
+
throw new GapiError(3, `--cache: ${model.api} responses must not be cached: ${nocache.reason} (${nocache.source})`);
|
|
318
|
+
}
|
|
319
|
+
const method = model.byId.get(call.methodId);
|
|
320
|
+
const once = call.send === 'once' || call.send === 'pages';
|
|
321
|
+
if (!once || (method?.httpMethod !== 'GET' && !method?.read)) {
|
|
322
|
+
const command = method ? `${model.api} ${method.words.join(' ')}` : call.methodId;
|
|
323
|
+
throw new GapiError(3, `--cache: ${command} is not a cacheable read`);
|
|
324
|
+
}
|
|
325
|
+
return { key: responses.key(call, await creds.identity(call.auth)), maxAgeMs };
|
|
326
|
+
};
|
|
124
327
|
if (argv.length === 0 || (argv[0] === '--help' && argv.length === 1)) {
|
|
125
328
|
io.stdout.write(rootHelp(Object.keys(catalog.entries)));
|
|
126
329
|
return;
|
|
@@ -131,11 +334,31 @@ async function dispatch(argv, io) {
|
|
|
131
334
|
manual: { type: 'boolean' },
|
|
132
335
|
help: { type: 'boolean' },
|
|
133
336
|
});
|
|
134
|
-
return runAuth(positionals[0], values, { io, creds, catalog });
|
|
337
|
+
return runAuth(positionals[0], values, { io, creds, catalog, responses, entities });
|
|
338
|
+
}
|
|
339
|
+
if (api === 'entities')
|
|
340
|
+
return runEntities(rest, { io, creds, catalog, entities });
|
|
341
|
+
if (api === 'search') {
|
|
342
|
+
// No option parse: a word like `-list` is query text (story 11).
|
|
343
|
+
if (rest.includes('--help')) {
|
|
344
|
+
io.stdout.write(`${SEARCH_USAGE}\n`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const query = rest.join(' ');
|
|
348
|
+
matchQuery(query); // a query with no token exits 3 before any document loads
|
|
349
|
+
const models = await Promise.all(Object.keys(catalog.entries).map((a) => catalog.load(a)));
|
|
350
|
+
const hits = search(models, query);
|
|
351
|
+
if (hits.length === 0)
|
|
352
|
+
io.stderr.write('gapi: no match\n');
|
|
353
|
+
io.stdout.write(searchText(hits));
|
|
354
|
+
return;
|
|
135
355
|
}
|
|
356
|
+
if (api === 'fields')
|
|
357
|
+
return runFields(rest, { io, creds, catalog });
|
|
136
358
|
if (api === 'schema') {
|
|
137
359
|
const { values, positionals } = parse(rest, {
|
|
138
360
|
'resolve-refs': { type: 'boolean' },
|
|
361
|
+
find: { type: 'string' },
|
|
139
362
|
help: { type: 'boolean' },
|
|
140
363
|
});
|
|
141
364
|
if (values.help) {
|
|
@@ -145,9 +368,19 @@ async function dispatch(argv, io) {
|
|
|
145
368
|
if (positionals.length !== 2)
|
|
146
369
|
throw new GapiError(3, `usage: ${SCHEMA_USAGE}`);
|
|
147
370
|
const [name, path] = positionals;
|
|
371
|
+
if (values.find === '')
|
|
372
|
+
throw new GapiError(3, '--find: needs a word');
|
|
148
373
|
const model = await catalog.load(name);
|
|
374
|
+
const target = schemaTarget(model, path);
|
|
375
|
+
if (values.find !== undefined) {
|
|
376
|
+
const found = schemaFind(model, target, values.find);
|
|
377
|
+
if (found === '')
|
|
378
|
+
io.stderr.write('gapi: no match\n');
|
|
379
|
+
io.stdout.write(found);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
149
382
|
const resolveRefs = values['resolve-refs'] === true;
|
|
150
|
-
io.stdout.write(schemaView(model,
|
|
383
|
+
io.stdout.write(schemaView(model, target, { resolveRefs }));
|
|
151
384
|
return;
|
|
152
385
|
}
|
|
153
386
|
if (api === 'discovery') {
|
|
@@ -205,13 +438,18 @@ async function dispatch(argv, io) {
|
|
|
205
438
|
throw new GapiError(3, `cannot read ${path}: ${reasonOf(e)}`);
|
|
206
439
|
}),
|
|
207
440
|
});
|
|
208
|
-
|
|
441
|
+
const cache = await cacheFor(call, model, values);
|
|
442
|
+
return perform(call, values['dry-run'] === true, io, creds.authorize, { responses, cache });
|
|
209
443
|
}
|
|
210
444
|
const entry = Object.hasOwn(catalog.entries, api) ? catalog.entries[api] : undefined;
|
|
211
445
|
const { values, positionals } = parse(rest, { ...GLOBAL_OPTIONS, ...headerOptions(entry) });
|
|
212
446
|
const model = await catalog.load(api);
|
|
213
447
|
const target = resolve(model, positionals);
|
|
214
448
|
if (!target.method) {
|
|
449
|
+
// A listing sends nothing: a request flag here means the method word is missing.
|
|
450
|
+
const flag = Object.keys(values).find((k) => k !== 'help');
|
|
451
|
+
if (flag)
|
|
452
|
+
throw new GapiError(3, `--${flag}: ${[api, ...positionals].join(' ')} is not a method`);
|
|
215
453
|
const helpers = positionals.length === 0 ? catalog.entries[api].helpers : undefined;
|
|
216
454
|
io.stdout.write(levelText(model, positionals, target.node, helpers));
|
|
217
455
|
return;
|
|
@@ -232,12 +470,35 @@ async function dispatch(argv, io) {
|
|
|
232
470
|
uploadProtocol: values['upload-protocol'],
|
|
233
471
|
output: values.output,
|
|
234
472
|
}, await creds.settings(values, model.headers));
|
|
235
|
-
|
|
473
|
+
const cache = await cacheFor(call, model, values);
|
|
474
|
+
return perform(call, dryRun, io, creds.authorize, { responses, cache });
|
|
475
|
+
}
|
|
476
|
+
// The one JSON writer (epic #200 D2): compact lines stay as they are off a terminal (agents), each
|
|
477
|
+
// line gets a 2-space indent on a terminal (operators).
|
|
478
|
+
function printJson(io, lines) {
|
|
479
|
+
if (!io.stdout.isTTY)
|
|
480
|
+
return io.stdout.write(lines);
|
|
481
|
+
let text;
|
|
482
|
+
try {
|
|
483
|
+
const values = lines
|
|
484
|
+
.split('\n')
|
|
485
|
+
.filter(Boolean)
|
|
486
|
+
.map((l) => JSON.parse(l));
|
|
487
|
+
text = values.map((v) => `${JSON.stringify(v, null, 2)}\n`).join('');
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
text = lines; // a cache entry stored before #205 is already indented
|
|
491
|
+
}
|
|
492
|
+
return io.stdout.write(text);
|
|
236
493
|
}
|
|
237
|
-
// Prints the plan's warnings, then the redacted request (--dry-run)
|
|
238
|
-
|
|
494
|
+
// Prints the plan's warnings, then the redacted request (--dry-run), the stored response
|
|
495
|
+
// (a --cache hit) or the live response, which a --cache miss stores after a full success.
|
|
496
|
+
async function perform(call, dryRun, io, authorize, { responses, cache }) {
|
|
239
497
|
for (const w of call.warnings)
|
|
240
498
|
io.stderr.write(`gapi: warning: ${w}\n`);
|
|
499
|
+
const mtime = cache && (await responses.mtime(cache.key));
|
|
500
|
+
const fresh = cache && mtime !== undefined && Date.now() - mtime < cache.maxAgeMs;
|
|
501
|
+
const stored = fresh ? await responses.get(cache.key) : undefined;
|
|
241
502
|
if (dryRun) {
|
|
242
503
|
const headers = { ...call.headers, ...(await authorize(call.auth, { redact: true })) };
|
|
243
504
|
const request = {
|
|
@@ -255,9 +516,28 @@ async function perform(call, dryRun, io, authorize) {
|
|
|
255
516
|
body: p.body,
|
|
256
517
|
}))
|
|
257
518
|
: undefined,
|
|
519
|
+
cache: cache && (stored === undefined ? 'miss' : 'hit'),
|
|
258
520
|
};
|
|
259
|
-
io
|
|
521
|
+
printJson(io, `${JSON.stringify(request)}\n`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (stored !== undefined && mtime !== undefined) {
|
|
525
|
+
printJson(io, stored);
|
|
526
|
+
io.stderr.write(`gapi: cached response from ${new Date(mtime).toISOString()}\n`);
|
|
260
527
|
return;
|
|
261
528
|
}
|
|
262
|
-
|
|
529
|
+
const chunks = [];
|
|
530
|
+
const out = {
|
|
531
|
+
write: (chunk) => {
|
|
532
|
+
chunks.push(chunk);
|
|
533
|
+
return printJson(io, chunk);
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
await send(call, { fetch: io.fetch, authorize, out, sleep: io.sleep });
|
|
537
|
+
if (!cache)
|
|
538
|
+
return;
|
|
539
|
+
// Best effort: the response is already printed, so a failed write only warns.
|
|
540
|
+
await responses.put(cache.key, chunks.join('')).catch((e) => {
|
|
541
|
+
io.stderr.write(`gapi: warning: cache write failed: ${reasonOf(e)}\n`);
|
|
542
|
+
});
|
|
263
543
|
}
|