@swell/cli
Version:
Swell's command line interface/utility
275 lines (274 loc) • 9.7 kB
JavaScript
import fetch from 'node-fetch';
import { stringify } from 'qs';
import { getCurrentAppSlugId, hasAppContext } from './apps/index.js';
import config from './config.js';
import { API_BASE_URL, getAdminApiBaseUrl, getFrontendApiBaseUrl, } from './constants.js';
import style from './style.js';
export var HttpMethod;
(function (HttpMethod) {
HttpMethod["GET"] = "get";
HttpMethod["POST"] = "post";
HttpMethod["PUT"] = "put";
HttpMethod["DELETE"] = "delete";
})(HttpMethod || (HttpMethod = {}));
const GET_ALL_LIMIT = 1000;
function isHtmlResponse(contentType, data) {
if (contentType?.toLowerCase().includes('text/html')) {
return true;
}
return /^\s*<(?:!doctype\s+html|html)[\s>]/i.test(data);
}
const defaultHeaders = (opts) => ({
'Content-Type': 'application/json',
...opts,
});
const authenticatedHeaders = (storeId, opts = {}) => {
let { publicKey, secretKey, sessionId, ...moreOpts } = opts;
sessionId ??= config.getSessionId(storeId);
if (secretKey) {
return defaultHeaders({
Authorization: `Basic ${Buffer.from(`${storeId}:${secretKey}`).toString('base64')}`,
...moreOpts,
});
}
if (publicKey) {
return defaultHeaders({
Authorization: `Basic ${Buffer.from(publicKey).toString('base64')}`,
...moreOpts,
});
}
if (sessionId) {
return defaultHeaders({
'X-Session': sessionId,
...moreOpts,
});
}
return defaultHeaders(moreOpts);
};
export default class Api {
storeId;
envId;
secretKey;
publicKey;
constructor(storeId, envId, publicKey, secretKey) {
this.storeId = storeId;
this.envId = envId;
this.publicKey = publicKey;
this.secretKey = secretKey;
}
async api(pathOpts, options) {
const storeId = this.storeId || config.getDefaultStore();
let path;
const fetchOptions = {
...options,
headers: {
'User-Agent': 'swell-cli/2.0',
...options?.headers,
...(this.envId ? { 'Swell-Env': this.envId } : undefined),
...authenticatedHeaders(storeId, {
publicKey: this.publicKey,
secretKey: this.secretKey,
sessionId: options.sessionId,
}),
},
};
path = this.isFrontend(pathOpts)
? `${getFrontendApiBaseUrl(storeId)}/${this.truncatePath(pathOpts.frontendPath)}`
: this.isAdmin(pathOpts)
? `${getAdminApiBaseUrl(storeId)}/${this.truncatePath(pathOpts.adminPath)}`
: `${API_BASE_URL}/${this.truncatePath(pathOpts.backendPath)}`;
if (options.query) {
path += `?${stringify(options.query)}`;
}
path = path
.replace('${STORE_ID}', storeId) // replace store id in template string
.replaceAll(/([^:]\/)\/+/g, '$1'); // remove duplicate slashes
if (!path)
throw new Error('Operation not supported.');
const res = await fetch(path, fetchOptions);
if (options.rawResponse) {
return res;
}
const resData = await res.text();
const contentType = res.headers.get('content-type');
if (isHtmlResponse(contentType, resData)) {
const error = new Error(`Expected JSON from API route '${path}', but received HTML. This usually means the admin API proxy did not match the route.`);
error.status = res.status;
throw error;
}
if (!res.ok) {
// Try to parse error message
let errorJson;
try {
errorJson = JSON.parse(resData);
}
catch {
// ignore
}
const error = new Error(errorJson?.error?.message || errorJson?.error || resData);
error.status = res.status;
throw error;
}
try {
return JSON.parse(resData);
}
catch (error) {
// respData could be "{}" (length: >=2) but it shouldn't be "{", " " (whitespace - length: 1) or
// "" (empty response - length: 0) while logging the error.
if (resData.length >= 2)
console.log(error);
return null;
}
}
async get(pathOpts, options = {}) {
return this.api(pathOpts, {
method: HttpMethod.GET,
...options,
});
}
async getAll(pathOpts, options = {}) {
const result = {
results: [],
count: 0,
};
let page = 1;
do {
// eslint-disable-next-line no-await-in-loop
const pageResult = await this.api(pathOpts, {
method: HttpMethod.GET,
...options,
query: {
limit: GET_ALL_LIMIT,
...options.query,
page,
},
});
if (pageResult && pageResult.results) {
result.results = [...result.results, ...pageResult.results];
result.count = pageResult.count;
page++;
}
else {
break;
}
} while (result.count > result.results.length && result.results.length > 0);
return result;
}
async post(pathOpts, options = {}) {
// ensure post body is stringified
if (options.body)
options.body = JSON.stringify(options.body);
const response = await this.api(pathOpts, {
method: HttpMethod.POST,
...options,
});
return this.waitForAsyncResponse(response, pathOpts, options);
}
async put(pathOpts, options = {}) {
// ensure post body is stringified
if (options.body)
options.body = JSON.stringify(options.body);
const response = await this.api(pathOpts, {
method: HttpMethod.PUT,
...options,
});
return this.waitForAsyncResponse(response, pathOpts, options);
}
async delete(pathOpts, options = {}) {
// ensure post body is stringified
if (options.body)
options.body = JSON.stringify(options.body);
const response = await this.api(pathOpts, {
method: HttpMethod.DELETE,
...options,
});
return this.waitForAsyncResponse(response, pathOpts, options);
}
async isTestEnvEnabled() {
const clientRecord = await this.get({
adminPath: `/client`,
});
return clientRecord.test_enabled !== false;
}
async setEnv(envId) {
const hasTestEnv = await this.isTestEnvEnabled();
if (!hasTestEnv) {
throw new Error('Test environment is not enabled for this store.');
}
this.envId = envId;
}
async setStoreEnv(storeId, envId) {
if (envId) {
await this.setEnv(envId);
}
this.storeId = storeId;
this.envId = envId;
}
async setPublicKey(publicKey) {
if (publicKey) {
this.publicKey = publicKey;
return;
}
const appSlugId = await getCurrentAppSlugId();
const response = await this.post({ adminPath: '/data/:batch' }, {
query: [
{
method: 'get',
url: '/:clients/:self/keys/:last',
data: {
scope: 'user',
public: { $exists: true },
revoked: { $ne: true },
},
},
appSlugId && {
method: 'get',
url: '/:clients/:self/apps/:last',
data: {
app_private_id: `_${appSlugId}`,
},
},
],
});
const [userKey, installedApp] = Object.values(response);
this.publicKey = installedApp?.public_key || userKey?.public;
if (!this.publicKey) {
throw new Error('No API public key available. Run inside a deployed app or create a user-scoped public key.');
}
else if ((await hasAppContext()) && !installedApp?.public_key) {
console.warn(style.funcWarn('⚠ No app context found. Running in user scope.'));
}
}
isFrontend(pathOpts) {
return Boolean(pathOpts.frontendPath) && Boolean(this.publicKey);
}
isAdmin(pathOpts) {
return Boolean(pathOpts.adminPath) && !this.secretKey;
}
truncatePath(path) {
return path?.startsWith('/') ? path.slice(1) : path;
}
async waitForAsyncResponse(response, pathOpts, options = {}) {
const { onAsyncGetPath, spinner } = options;
if (!response?.async_progressing || !onAsyncGetPath) {
return response;
}
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
const asyncResponse = await this.api(onAsyncGetPath(response), {
method: HttpMethod.GET,
onAsyncGetPath,
});
if (asyncResponse?.async_error) {
throw new Error(asyncResponse.async_error);
}
if (!asyncResponse?.async_progressing && spinner) {
spinner.suffixText = '';
}
else if (asyncResponse?.async_progress > 0 && spinner) {
spinner.suffixText = `${asyncResponse.async_progress}%`;
}
return this.waitForAsyncResponse(asyncResponse, pathOpts, options);
}
}