365 lines
19 KiB
TypeScript
365 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 { PLAY_SCOPE, playToken } = await import('../lib/google-auth');
|
|
// the credential broker when it has the scope, else the local key; throws now when neither is there
|
|
const sources = new Map<string, () => Promise<string>>([[PLAY_SCOPE, playToken(root, PLAY_SCOPE)]]);
|
|
return async req => {
|
|
const scope = PLAY_APIS[req.op.api as keyof typeof PLAY_APIS].scope;
|
|
if (!sources.has(scope)) sources.set(scope, playToken(root, scope));
|
|
return 'Bearer ' + (await sources.get(scope)!());
|
|
};
|
|
}
|