-
Notifications
You must be signed in to change notification settings - Fork 10
/
index.mjs
275 lines (258 loc) · 12.3 KB
/
index.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
import { SignJWT, importPKCS8 } from 'jose';
// issuerId and apiKey from https://appstoreconnect.apple.com/access/api
// p8 file was generated initially, and somebody stored it in ~/.appstoreconnect/private_keys (iTMSTransporter?)
export const api = async function AppStoreConnectApiFetcher({ issuerId, apiKey, privateKey, version = 1, urlBase,
tokenExpiresInSeconds = 1200, automaticRetries = 10, logRequests = false, fetch = globalThis.fetch
} = {}) {
if (!privateKey) throw new Error("You must pass a privateKey parameter");
if (!urlBase) urlBase = `https://api.appstoreconnect.apple.com`;
async function _getBearerToken(issuerId, apiKey, privateKey) {
const alg = 'ES256';
const secret = await importPKCS8(privateKey, alg);
const jwt = await new SignJWT({})
.setProtectedHeader({ alg, kid: apiKey, typ: 'JWT' })
.setIssuedAt()
.setIssuer(issuerId)
.setAudience('appstoreconnect-v1')
.setExpirationTime('20m')
.sign(secret)
return jwt;
}
const bearerToken = await _getBearerToken(issuerId, apiKey, privateKey);
const authFetch = async function authFetch(url, options) {
if (!options) options = {};
if (!options.headers) options.headers = {};
options.headers.Authorization = `Bearer ${bearerToken}`;
const retries = options.automaticRetries ?? automaticRetries;
const log = options.logRequests ?? logRequests;
if (!/^https:\/\//.test(url)) {
// strip leading slash
url = url.replace(/^\//, "");
if (/^v\d+\//.test(url)) {
// URL includes version number
url = `${urlBase}/${url}`;
} else {
// No version number; add our own
const v = options.version ?? version;
url = `${urlBase}/v${v}/${url}`;
}
}
// try-try-again; sometimes Apple rejects perfectly good bearer tokens
let response;
for (let i = 0; i < (retries+1); i++) {
if (log) console.log(`node-app-store-connect-api: requesting ${url}${options.body ? ` ${options.body}`: ''}`);
try {
response = await fetch(url, options);
} catch (e) {
if (e.code === 'ETIMEDOUT' || e.cause?.code === 'UND_ERR_CONNECT_TIMEOUT') {
if (log) console.log(`node-app-store-connect-api: timed out ${url}`);
if (i === retries) throw e;
continue;
} else {
throw e;
}
}
if (response.status != 401 && response.status != 429 && response.status != 500) return response;
if (log) console.log(`node-app-store-connect-api: failed with ${response.status} ${url}`);
}
return response;
}
async function read(url, options) {
const { data, included, meta, links } = await fetchJson(url, { crawlAllPages: false, inclusions: 'tree', ...options});
return { data, included, meta, links };
}
async function readAll(url, options) {
const { data, included } = await fetchJson(url, { crawlAllPages: true, inclusions: 'tree', ...options });
return { data, included };
}
async function fetchJson(url, options) {
const inclusions = options?.inclusions;
if (inclusions && inclusions !== true && inclusions !== 'tree') {
throw new Error(`inclusions parameter '${inclusions}' must be either boolean true or a string 'tree'`);
}
if (options?.params) {
if (!/^https:\/\//.test(url)) {
// strip leading slash
url = url.replace(/^\//, "");
if (/^v\d+\//.test(url)) {
// URL includes version number
url = `${urlBase}/${url}`;
} else {
// No version number; add our own
const v = options.version ?? version;
url = `${urlBase}/v${v}/${url}`;
}
}
const parsed = new URL(url);
const usp = new URLSearchParams(parsed.search);
for (const key in options.params) {
usp.set(key, options.params[key]);
}
parsed.search = usp.toString();
url = parsed.toString();
}
const response = await authFetch(url, options);
const text = await response.text();
const contentType = response.headers.get('content-type');
const isJson = (contentType === 'application/json' || contentType === 'application/vnd.api+json');
const crawlAllPages = options?.crawlAllPages ?? true;
if (response.ok) {
if (isJson) {
const result = JSON.parse(text);
if (crawlAllPages && Array.isArray(result.data) && result.links.next) {
if (inclusions) {
const otherResults = await fetchJson(result.links.next, {...options, inclusions: true});
result.data = result.data.concat(otherResults.data);
if (otherResults.included) {
result.included = (result.included || []).concat(otherResults.included);
}
} else {
return result.data.concat(await fetchJson(result.links.next, options));
}
}
if (inclusions === 'tree') {
const included = {};
for (const data of result.included || []) {
if (!included[data.type]) included[data.type] = {};
included[data.type][data.id] = data;
}
if (crawlAllPages) {
return { data: result.data, included };
} else {
return { data: result.data, included, meta: result.meta, links: result.links };
}
} else if (inclusions) {
return { data: result.data, included: result.included };
} else {
return result.data;
}
} else {
return text;
}
} else {
if (isJson) {
const error = new Error(text);
error.data = JSON.parse(text);
throw error;
} else {
throw new Error(text);
}
}
}
async function postJson(url, data, options) {
return fetchJson(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data),
...options
})
}
// allow users to pass in entire objects, but just send down types and ids
function _trimRelationships(relationships) {
const output = {};
for (const [key, value] of Object.entries(relationships)) {
if (typeof value === 'object' && value && 'data' in value) {
output[key] = value;
} else if (Array.isArray(value)) {
output[key] = { data: value.map(relation => ({ type: relation.type, id: relation.id })) };
} else {
output[key] = { data: { type: value.type, id: value.id } };
}
}
return output;
}
async function create({ type, attributes, relationships, included, version }) {
const data = { type, attributes };
if (relationships) data.relationships = _trimRelationships(relationships);
if (included) {
for (const inclusion of included) {
if (inclusion.relationships) inclusion.relationships = _trimRelationships(inclusion.relationships);
}
}
const body = { data };
if (included) body.included = included;
return postJson(type, body, { version });
}
async function update(data, {attributes, relationships, included, version}) {
const requestData = { type: data.type, id: data.id, attributes };
if (relationships) requestData.relationships = _trimRelationships(relationships);
if (included) {
for (const inclusion of included) {
if (inclusion.relationships) inclusion.relationships = _trimRelationships(inclusion.relationships);
}
}
const body = { data: requestData };
if (included) body.included = included;
return postJson(`${data.type}/${data.id}`, body, {version, method: 'PATCH'});
}
async function remove(data, {version} = {}) {
return fetchJson(`${data.type}/${data.id}`, {version, method: 'DELETE'});
}
async function uploadAsset(assetData, buffer, maxTriesPerPart = 10, version) {
const targetStart = 0;
await Promise.all(assetData.attributes.uploadOperations.map(async (uploadOperation, i) => {
const body = Buffer.alloc(uploadOperation.length);
const sourceStart = uploadOperation.offset;
const sourceEnd = uploadOperation.offset + uploadOperation.length;
buffer.copy(body, targetStart, sourceStart, sourceEnd);
const method = uploadOperation.method;
const headers = {};
for (const requestHeader of uploadOperation.requestHeaders) {
headers[requestHeader.name] = requestHeader.value;
}
for (let tries = 1; tries <= maxTriesPerPart; tries++) {
// https://developer.apple.com/documentation/appstoreconnectapi/uploading_assets_to_app_store_connect
// The provided upload URLs are unauthenticated and time-limited.
// You don’t need to supply a JWT; don’t share the URLs.
if (logRequests) console.log("node-app-store-connect-api: uploading to",
uploadOperation.url, method, "headers: ", JSON.stringify(headers));
const response = await fetch(uploadOperation.url, { method, headers, body });
if (response.ok) {
if (logRequests) console.log('node-app-store-connect-api: upload success', response.status, response.statusText, response.headers);
break;
} else {
const errorText = `Failed uploading chunk ${i} of ${assetData.data.type}/${assetData.data.id}: ` +
`${response.status} ${response.statusText} ${await response.text()}`;
if (logRequests) console.log('node-app-store-connect-api:', errorText);
if (tries >= maxTriesPerPart) {
throw new Error(errorText);
}
}
}
}));
await update(assetData, { version, attributes: {
uploaded: true,
}});
}
async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
async function pollForUploadSuccess(assetUrl, logHeader = "", delayInMilliseconds = 1000, maxTries = 60) {
if (logHeader) logHeader += ' ';
let tries = 0;
while (true) {
if (maxTries) {
tries++;
if (tries >= maxTries) throw new Error(`${logHeader}${assetUrl} upload state was ${state} after ${maxTries} tries`);
}
let assetData;
try {
assetData = await fetchJson(assetUrl);
} catch (e) {
if (e?.data?.errors?.[0]?.status == 500) {
console.log(`${logHeader}${assetUrl} 500 error`);
await sleep(delayInMilliseconds);
continue;
}
}
const assetDeliveryState = assetData?.attributes?.assetDeliveryState;
const state = assetDeliveryState?.state;
if (!state) throw new Error(`${logHeader}${assetUrl} couldn't find attributes.assetDeliveryState.state: ${JSON.stringify(assetData)}`);
if (state === 'COMPLETE') return;
if (state === 'FAILED') throw new Error(`${logHeader}${assetUrl} upload failed: ${JSON.stringify(assetDeliveryState.errors)}`);
//console.log(`${logHeader} ${state} ${assetUrl}`);
await sleep(delayInMilliseconds);
}
}
return { read, readAll, fetch: authFetch, fetchJson, postJson, create, update, remove, uploadAsset, pollForUploadSuccess };
}