storebound/cli/api/client.ts

366 lines
19 KiB
TypeScript

// Generic client for every App Store Connect and Google Play operation: the generated rows (apple.gen.ts, play.gen.ts)
// plus a few helpers instead of a function per endpoint. Request building is pure (tests cover URLs and parameters);
// sending retries 429 (and 5xx / network errors of requests that are safe to send twice: not a POST create) with
// backoff (Retry-After honoured) and follows pages.
// Auth: the App Store Connect team key JWT from cli/apple/asc.ts, the Play service account token from
// cli/lib/google-auth.ts (one scope per Google API). Credentials only come from env or .storebound/.
import { AmbiguousWriteError, retrySafe } from '../lib/upsert';
import { APPLE_OPS, APPLE_SPEC } from './apple.gen';
import { PLAY_APIS, PLAY_OPS } from './play.gen';
import type { AppleBody, AppleOp, AppleParams, AppleResponse, ParamValue, PlayBody, PlayOp, PlayParams, PlayResponse } from './types';
import type { Store } from './gate';
import { errorBody } from '../lib/http';
import * as rt from '../lib/rt';
export interface QueryParam { name: string; required: boolean; repeated: boolean }
export interface Op {
store: Store;
id: string;
method: string;
/** Path template: Apple "/v1/apps/{id}", Google "androidpublisher/v3/applications/{packageName}/edits" */
path: string;
query: QueryParam[];
/** Apple: resource type ("type", "type[]", "json", ""). Google: request schema name ("" for none). */
request: string;
/** Apple: "many:type" | "one:type" | "json" | "raw:<media type>" | "none". Google: response schema name. */
response: string;
/** Google: m media upload, d media download, p pageToken paging, t token paging. Apple: "". */
flags: string;
/** Google API name (androidpublisher, playdeveloperreporting); "" for Apple. */
api: string;
/** Google: the shape of each {+param}, e.g. name -> "apps/* /crashRateMetricSet" (no space; * is one segment). */
shapes: Record<string, string>;
}
/**
* App Store Connect query parameters the spec declares but the live API answers 400 to, per operation id, with what to
* use instead. `storebound api` refuses them, and tests/asc-query-guard.test.ts fails when storebound's own code uses one.
*/
export const APPLE_LIVE_REJECTED: Record<string, Record<string, string>> = {
// seen live 2026-10-13 (apple testflight distribute): GET /v1/betaGroups?filter[app]=..&filter[builds]=.. -> 400
betaGroups_getCollection: { 'filter[builds]': 'seen live: 400; read the builds of each group with GET /v1/betaGroups/{id}/relationships/builds' },
};
/** Query parameters every Google API accepts on top of a method's own. */
export const GOOGLE_GLOBAL = ['fields', 'prettyPrint', 'quotaUser'];
const parseQuery = (s: string): QueryParam[] => (s ? s.split(' ') : []).map(t => ({ name: t.replace(/[!*]/g, ''), required: t.includes('!'), repeated: t.includes('*') }));
const cache: Partial<Record<Store, Op[]>> = {};
export function allOps(store: Store): Op[] {
if (cache[store]) return cache[store]!;
const rows = store === 'apple' ? APPLE_OPS as Record<string, readonly string[]> : PLAY_OPS as Record<string, readonly string[]>;
return (cache[store] = Object.entries(rows).map(([id, r]) => ({
store, id, method: r[0]!, path: r[1]!, query: parseQuery(r[2]!), request: r[3]!, response: r[4]!, flags: r[5] ?? '',
api: store === 'play' ? id.split('.')[0]! : '',
shapes: Object.fromEntries((r[6] ?? '').split(' ').filter(Boolean).map(s => [s.slice(0, s.indexOf('=')), s.slice(s.indexOf('=') + 1)])),
})));
}
export function getOp(store: Store, id: string): Op | undefined {
const ops = allOps(store);
return ops.find(o => o.id === id) ?? (store === 'play' ? ops.find(o => o.id.slice(o.api.length + 1) === id) : undefined);
}
const esc = (s: string) => s.replace(/[.*+?^$()|[\]\\]/g, '\\$&');
/** "apps/* /crashRateMetricSet" (no space) -> a regex source; * is one path segment. */
const shapeSource = (shape: string) => shape.split('*').map(esc).join('[^/]+');
/** Path template -> RegExp with one group per path parameter ({+param}s narrowed by their shape). */
function templateRe(op: Op): { re: RegExp; names: string[]; fixed: number } {
const names: string[] = [];
const body = op.path.replace(/^\//, '').split(/(\{\+?[^}]+\})/).map(part => {
const m = /^\{(\+?)([^}]+)\}$/.exec(part);
if (!m) return esc(part);
names.push(m[2]!);
if (!m[1]) return '([^/:]+)';
const shape = op.shapes[m[2]!];
return shape ? `(${shapeSource(shape)})` : '(.+)';
}).join('');
return { re: new RegExp('^' + body + '$'), names, fixed: op.path.split('{')[0]!.length };
}
/**
* An operation from what the user typed: an operation id (Google ids also without the API prefix), or "METHOD path"
* with a concrete path (and query string), whose values become parameters.
*/
export function resolveOp(store: Store, ref: string, path?: string): { op: Op; params: Record<string, string> } {
if (path === undefined) {
const op = getOp(store, ref);
if (!op) throw new Error(`${store}: no operation "${ref}" (storebound api list --store ${store} --search <text>)`);
return { op, params: {} };
}
const method = ref.toUpperCase();
if (!/^(GET|POST|PUT|PATCH|DELETE)$/.test(method)) throw new Error(`"${ref}": expected an operation id or METHOD path`);
let p = path.replace(/^https:\/\/[^/]+/, '');
if (store === 'play') p = p.replace(/^\/?upload\//, '/');
const [rawPath, qs = ''] = p.split('?', 2) as [string, string?];
const clean = decodeURI(rawPath).replace(/^\//, '');
const hits = allOps(store).filter(o => o.method === method).map(o => ({ o, t: templateRe(o) }))
.map(x => ({ ...x, m: x.t.re.exec(clean) })).filter(x => x.m).sort((a, b) => b.t.fixed - a.t.fixed);
const best = hits[0];
if (!best) throw new Error(`${store}: no ${method} operation matches ${rawPath}`);
const params: Record<string, string> = {};
best.t.names.forEach((n, i) => { params[n] = best.m![i + 1]!; });
for (const [k, v] of new URLSearchParams(qs)) params[k] = v;
return { op: best.o, params };
}
export interface BuiltRequest {
op: Op;
method: string;
url: string;
/** JSON text or file bytes */
body?: string | Uint8Array;
contentType?: string;
/** Apple vendor media types (perfPowerMetrics: application/vnd.apple.xcode-metrics+json, reports: application/a-gzip) */
accept?: string;
}
const encSeg = (v: string) => encodeURIComponent(v);
// Apple writes brackets and commas plainly in its docs and links (filter[app]=1,2); keep them readable
const encName = (k: string) => encodeURIComponent(k).replace(/%5B/gi, '[').replace(/%5D/gi, ']');
const encVal = (v: string) => encodeURIComponent(v).replace(/%2C/gi, ',');
const str = (v: string | number | boolean) => String(v);
/** Builds the URL and body of one call. Throws on a missing path / required parameter or an unknown parameter. Pure. */
export function buildRequest(op: Op, params: Record<string, ParamValue | undefined>, body?: unknown, o: { upload?: { bytes: Uint8Array; contentType: string }; download?: boolean } = {}): BuiltRequest {
const left = new Map(Object.entries(params).filter(([, v]) => v !== undefined) as [string, ParamValue][]);
const path = op.path.replace(/\{(\+?)([^}]+)\}/g, (_, plus: string, name: string) => {
const v = left.get(name);
if (v === undefined || v === '') throw new Error(`${op.id}: path parameter "${name}" is required`);
left.delete(name);
const s = Array.isArray(v) ? v.join(',') : str(v as string);
const shape = op.shapes[name];
if (shape && !new RegExp(`^${shapeSource(shape)}$`).test(s)) throw new Error(`${op.id}: "${name}" must look like ${shape} (* is one segment), got "${s}"`);
return plus ? s.split('/').map(encSeg).join('/') : encSeg(s);
});
const known = new Map(op.query.map(q => [q.name, q] as const));
for (const q of op.query) if (q.required && !left.has(q.name)) throw new Error(`${op.id}: query parameter "${q.name}" is required`);
const parts: string[] = [];
for (const [k, v] of left) {
const q = known.get(k);
if (!q && !(op.store === 'play' && GOOGLE_GLOBAL.includes(k))) {
const names = op.query.map(x => x.name);
throw new Error(`${op.id}: unknown parameter "${k}"${names.length ? ` (takes: ${names.slice(0, 30).join(', ')}${names.length > 30 ? ', …' : ''})` : ' (takes no query parameters)'}`);
}
const refused = op.store === 'apple' ? APPLE_LIVE_REJECTED[op.id]?.[k] : undefined;
if (refused) throw new Error(`${op.id}: App Store Connect refuses "${k}" although the spec declares it (${refused})`);
const values = Array.isArray(v) ? v.map(str) : [str(v as string)];
if (q?.repeated) for (const x of values) parts.push(`${encName(k)}=${encVal(x)}`);
else parts.push(`${encName(k)}=${encVal(values.join(','))}`);
}
let base: string;
if (op.store === 'apple') base = APPLE_SPEC.base + path;
else {
const api = PLAY_APIS[op.api as keyof typeof PLAY_APIS];
if (!api) throw new Error(`${op.id}: unknown Google API ${op.api}`);
const upload = !!o.upload && op.flags.includes('m');
if (o.upload && !upload) throw new Error(`${op.id}: takes no file upload`);
base = api.root + (upload ? 'upload/' : '') + path;
if (upload) parts.unshift('uploadType=media');
if (o.download) {
if (!op.flags.includes('d')) throw new Error(`${op.id}: has no media download`);
parts.push('alt=media');
}
}
const req: BuiltRequest = { op, method: op.method, url: base + (parts.length ? '?' + parts.join('&') : '') };
if (op.response.startsWith('raw:')) req.accept = op.response.slice(4);
if (o.upload) { req.body = o.upload.bytes; req.contentType = o.upload.contentType; }
else if (body !== undefined) {
if (op.method === 'GET' || op.method === 'DELETE' && op.store === 'play') throw new Error(`${op.id}: ${op.method} takes no body`);
req.body = JSON.stringify(body);
req.contentType = 'application/json';
}
return req;
}
// ---------------------------------------------------------------------------------------------------- sending
/** Authorization header value for a request ("Bearer ..."). */
export type Auth = (req: BuiltRequest) => Promise<string> | string;
export interface SendOptions {
fetch?: (url: string, init: RequestInit) => Promise<Response>;
sleep?: (ms: number) => Promise<unknown>;
/** attempts for 429 / 5xx / network errors (default 5) */
attempts?: number;
/** each retry is logged here (stderr in the CLI) */
onRetry?: (msg: string) => void;
}
export interface ApiResult {
status: number;
/** parsed JSON, or null for an empty body */
json?: unknown;
/** non-JSON body (gzip reports, media downloads) */
bytes?: Uint8Array;
/** Apple's X-Rate-Limit header ("user-hour-lim:3600;user-hour-rem:3412;") */
rateLimit?: string;
}
export class ApiError extends Error {
constructor(readonly status: number, message: string) { super(message); }
}
/** Wait before retry `attempt` (1-based): Retry-After when the server sent one (max 60 s), else 1, 2, 4, 8 ... s (max 30 s). */
export function backoffMs(attempt: number, retryAfter?: string | null): number {
const s = retryAfter ? Number(retryAfter) : NaN;
if (Number.isFinite(s) && s >= 0) return Math.min(s, 60) * 1000;
return Math.min(1000 * 2 ** (attempt - 1), 30_000);
}
const short = (url: string) => url.replace(/^https:\/\/[^/]+/, '').replace(/\?.*$/, '');
export async function send(req: BuiltRequest, auth: Auth, o: SendOptions = {}): Promise<ApiResult> {
const doFetch = o.fetch ?? ((u: string, i: RequestInit) => fetch(u, i));
const sleep = o.sleep ?? ((ms: number) => rt.sleep(ms));
const attempts = o.attempts ?? 5;
for (let attempt = 1; ; attempt++) {
const headers: Record<string, string> = { Authorization: await auth(req) };
if (req.contentType) headers['Content-Type'] = req.contentType;
if (req.accept) headers.Accept = req.accept;
// 5xx and network errors are retried only for requests that end the same when sent twice: a POST create without
// a clear answer may have been applied, so it is reported (AmbiguousWriteError) and never sent again by itself
const safe = retrySafe(req.method, req.url);
let r: Response;
try {
r = await doFetch(req.url, { method: req.method, headers, body: req.body as BodyInit | undefined });
} catch (e) {
if (!safe) throw new AmbiguousWriteError(`${req.method} ${short(req.url)}: no answer (${(e as Error).message}); not sent again: it may have been applied, check with a GET before you run it again`);
if (attempt >= attempts) throw e;
o.onRetry?.(`${req.method} ${short(req.url)}: ${(e as Error).message}, retry ${attempt}/${attempts - 1}`);
await sleep(backoffMs(attempt));
continue;
}
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${req.method} ${short(req.url)} -> ${r.status} ${errorBody(await r.text())}; not sent again: it may have been applied, check with a GET before you run it again`, r.status);
if ((r.status === 429 || r.status >= 500) && attempt < attempts) {
const wait = backoffMs(attempt, r.headers.get('retry-after'));
await r.body?.cancel();
o.onRetry?.(`${req.method} ${short(req.url)} -> ${r.status}, retry ${attempt}/${attempts - 1} in ${Math.round(wait / 1000)} s`);
await sleep(wait);
continue;
}
const rateLimit = r.headers.get('x-rate-limit') ?? undefined;
const type = r.headers.get('content-type') ?? '';
if (!r.ok) throw new ApiError(r.status, `${req.method} ${short(req.url)} -> ${r.status} ${errorBody(await r.text())}`);
if (/json/.test(type) || (!type && r.status !== 204)) {
const t = await r.text();
return { status: r.status, json: t ? JSON.parse(t) : null, rateLimit };
}
if (r.status === 204) return { status: 204, json: null, rateLimit };
return { status: r.status, bytes: new Uint8Array(await r.arrayBuffer()), rateLimit };
}
}
/** Merges two pages: arrays are concatenated (Apple's included de-duplicated), the page tokens dropped. Pure. */
export function mergePages(a: Record<string, unknown>, b: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...a };
for (const [k, v] of Object.entries(b)) {
const cur = out[k];
if (Array.isArray(cur) && Array.isArray(v)) {
if (k === 'included') {
const seen = new Set(cur.map(x => `${(x as { type?: string }).type}/${(x as { id?: string }).id}`));
out[k] = [...cur, ...v.filter(x => !seen.has(`${(x as { type?: string }).type}/${(x as { id?: string }).id}`))];
} else out[k] = [...cur, ...v];
} else if (cur === undefined) out[k] = v;
}
delete out.nextPageToken;
if (out.tokenPagination) delete out.tokenPagination;
if (out.links && typeof out.links === 'object') out.links = { ...(out.links as Record<string, unknown>), next: undefined };
// Apple's paging meta of the first page ("nextCursor", the page limit) would say there is more: keep only the total
const meta = out.meta as { paging?: Record<string, unknown> } | undefined;
if (meta?.paging && typeof meta.paging === 'object') {
const { nextCursor: _c, limit: _l, ...rest } = meta.paging;
out.meta = { ...meta, paging: rest };
}
return out;
}
/** The next page of a result, or null. Apple: links.next (same host only, the token never leaves it); Google: the page token. */
export function nextRequest(req: BuiltRequest, json: unknown): BuiltRequest | null {
const j = (json ?? {}) as { links?: { next?: string }; nextPageToken?: string; tokenPagination?: { nextPageToken?: string } };
if (req.op.store === 'apple') {
const next = j.links?.next;
if (!next) return null;
if (!next.startsWith(APPLE_SPEC.base + '/')) throw new Error(`refusing to follow a next link to another host: ${next.slice(0, 80)}`);
return { ...req, url: next };
}
const [name, token] = req.op.flags.includes('p') ? ['pageToken', j.nextPageToken] : req.op.flags.includes('t') ? ['token', j.tokenPagination?.nextPageToken] : ['', undefined];
if (!token) return null;
const u = new URL(req.url);
u.searchParams.set(name, token);
return { ...req, url: u.toString() };
}
/** Every page of a GET (or a paged query), merged. `maxPages` guards against a server that never stops. */
export async function sendAll(req: BuiltRequest, auth: Auth, o: SendOptions & { maxPages?: number; onPage?: (n: number) => void } = {}): Promise<ApiResult & { pages: number }> {
let first = await send(req, auth, o);
let pages = 1;
let cur: BuiltRequest | null = req;
let last = first.json;
while ((cur = nextRequest(cur, last))) {
if (pages >= (o.maxPages ?? 1000)) throw new Error(`more than ${o.maxPages ?? 1000} pages: stopped`);
const r = await send(cur, auth, o);
pages++;
o.onPage?.(pages);
last = r.json;
first = { ...first, json: mergePages(first.json as Record<string, unknown>, r.json as Record<string, unknown>), rateLimit: r.rateLimit ?? first.rateLimit };
}
return { ...first, pages };
}
// ---------------------------------------------------------------------------------------------------- typed facades
/** Typed App Store Connect client: `api.call('apps_getCollection', { 'filter[bundleId]': 'com.example.app' })`. */
export function appleApi(auth: Auth, o: SendOptions = {}) {
const build = <O extends AppleOp>(op: O, params: AppleParams<O>, body?: AppleBody<O>) =>
buildRequest(getOp('apple', op)!, params as Record<string, ParamValue>, body);
const out = (r: ApiResult) => (r.bytes ?? r.json ?? null);
return {
build,
async call<O extends AppleOp>(op: O, params: AppleParams<O>, body?: AppleBody<O>): Promise<AppleResponse<O>> {
return out(await send(build(op, params, body), auth, o)) as AppleResponse<O>;
},
async all<O extends AppleOp>(op: O, params: AppleParams<O>): Promise<AppleResponse<O>> {
return out(await sendAll(build(op, params), auth, o)) as AppleResponse<O>;
},
};
}
/** Typed Google Play client: `api.call('androidpublisher.edits.tracks.list', { packageName, editId })`. */
export function playApi(auth: Auth, o: SendOptions = {}) {
const build = <O extends PlayOp>(op: O, params: PlayParams<O>, body?: PlayBody<O>) =>
body instanceof Uint8Array
? buildRequest(getOp('play', op)!, params as Record<string, ParamValue>, undefined, { upload: { bytes: body, contentType: 'application/octet-stream' } })
: buildRequest(getOp('play', op)!, params as Record<string, ParamValue>, body);
const out = (r: ApiResult) => (r.bytes ?? r.json ?? {});
return {
build,
async call<O extends PlayOp>(op: O, params: PlayParams<O>, body?: PlayBody<O>): Promise<PlayResponse<O>> {
return out(await send(build(op, params, body), auth, o)) as PlayResponse<O>;
},
async all<O extends PlayOp>(op: O, params: PlayParams<O>): Promise<PlayResponse<O>> {
return out(await sendAll(build(op, params), auth, o)) as PlayResponse<O>;
},
};
}
/** Auth from the project's credentials (env or .storebound/): Apple team key JWT, or a Play token for the op's API scope. */
export async function authFor(store: Store, root: string): Promise<Auth> {
if (store === 'apple') {
const { ascJwt } = await import('../apple/asc');
const { jwt } = ascJwt(root);
return () => 'Bearer ' + jwt();
}
const { playServiceAccount } = await import('../lib/credentials');
const { tokenSource } = await import('../lib/google-auth');
const sa = playServiceAccount(root);
const sources = new Map<string, () => Promise<string>>();
return async req => {
const scope = PLAY_APIS[req.op.api as keyof typeof PLAY_APIS].scope;
if (!sources.has(scope)) sources.set(scope, tokenSource(sa, scope));
return 'Bearer ' + (await sources.get(scope)!());
};
}