@shopify/shopify-api
Version:
Shopify API Library for Node - accelerate development with support for authentication, graphql proxy, webhooks
408 lines (344 loc) • 10.3 kB
text/typescript
import {RestResourceError} from '../lib/error';
import {Session} from '../lib/session/session';
import {PageInfo, RestRequestReturn} from '../lib/clients/admin/types';
import {DataType} from '../lib/clients/types';
import {RestClient} from '../lib/clients/admin/rest/client';
import {ApiVersion} from '../lib/types';
import {ConfigInterface} from '../lib/base-types';
import {Headers} from '../runtime/http';
import {IdSet, Body, ResourcePath, ParamSet, ResourceNames} from './types';
interface BaseFindArgs {
session: Session;
params?: ParamSet;
urlIds: IdSet;
requireIds?: boolean;
}
interface BaseConstructorArgs {
session: Session;
fromData?: Body | null;
}
interface SaveArgs {
update?: boolean;
}
interface RequestArgs extends BaseFindArgs {
http_method: string;
operation: string;
body?: Body | null;
entity?: Base | null;
}
interface GetPathArgs {
http_method: string;
operation: string;
urlIds: IdSet;
entity?: Base | null;
}
interface SetClassPropertiesArgs {
Client: typeof RestClient;
config: ConfigInterface;
}
export interface FindAllResponse<T = Base> {
data: T[];
headers: Headers;
pageInfo?: PageInfo;
}
export class Base {
// For instance attributes
[key: string]: any;
public static Client: typeof RestClient;
public static config: ConfigInterface;
public static apiVersion: string;
protected static resourceNames: ResourceNames[] = [];
protected static primaryKey = 'id';
protected static customPrefix: string | null = null;
protected static readOnlyAttributes: string[] = [];
protected static hasOne: Record<string, typeof Base> = {};
protected static hasMany: Record<string, typeof Base> = {};
protected static paths: ResourcePath[] = [];
public static setClassProperties({Client, config}: SetClassPropertiesArgs) {
this.Client = Client;
this.config = config;
}
protected static async baseFind<T extends Base = Base>({
session,
urlIds,
params,
requireIds = false,
}: BaseFindArgs): Promise<FindAllResponse<T>> {
if (requireIds) {
const hasIds = Object.entries(urlIds).some(([_key, value]) => value);
if (!hasIds) {
throw new RestResourceError(
'No IDs given for request, cannot find path',
);
}
}
const response = await this.request<T>({
http_method: 'get',
operation: 'get',
session,
urlIds,
params,
});
return {
data: this.createInstancesFromResponse<T>(session, response.body as Body),
headers: response.headers,
pageInfo: response.pageInfo,
};
}
protected static async request<T = unknown>({
session,
http_method,
operation,
urlIds,
params,
body,
entity,
}: RequestArgs): Promise<RestRequestReturn<T>> {
const client = new this.Client({
session,
apiVersion: this.apiVersion as ApiVersion,
});
const path = this.getPath({http_method, operation, urlIds, entity});
const cleanParams: Record<string, string | number> = {};
if (params) {
for (const key in params) {
if (params[key] !== null) {
cleanParams[key] = params[key];
}
}
}
switch (http_method) {
case 'get':
return client.get<T>({path, query: cleanParams});
case 'post':
return client.post<T>({
path,
query: cleanParams,
data: body!,
type: DataType.JSON,
});
case 'put':
return client.put<T>({
path,
query: cleanParams,
data: body!,
type: DataType.JSON,
});
case 'delete':
return client.delete<T>({path, query: cleanParams});
default:
throw new Error(`Unrecognized HTTP method "${http_method}"`);
}
}
protected static getJsonBodyName(): string {
return this.name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
}
protected static getPath({
http_method,
operation,
urlIds,
entity,
}: GetPathArgs): string {
let match: string | null = null;
let specificity = -1;
const potentialPaths: ResourcePath[] = [];
this.paths.forEach((path: ResourcePath) => {
if (
http_method !== path.http_method ||
operation !== path.operation ||
path.ids.length <= specificity
) {
return;
}
potentialPaths.push(path);
let pathUrlIds: IdSet = {...urlIds};
path.ids.forEach((id) => {
if (!pathUrlIds[id] && entity && entity[id]) {
pathUrlIds[id] = entity[id];
}
});
pathUrlIds = Object.entries(pathUrlIds).reduce(
(acc: IdSet, [key, value]: [string, string | number | null]) => {
if (value) {
acc[key] = value;
}
return acc;
},
{},
);
// If we weren't given all of the path's required ids, we can't use it
const diff = path.ids.reduce(
(acc: string[], id: string) => (pathUrlIds[id] ? acc : acc.concat(id)),
[],
);
if (diff.length > 0) {
return;
}
specificity = path.ids.length;
match = path.path.replace(
/(<([^>]+)>)/g,
(_m1, _m2, id) => `${pathUrlIds[id]}`,
);
});
if (!match) {
const pathOptions = potentialPaths.map((path) => path.path);
throw new RestResourceError(
`Could not find a path for request. If you are trying to make a request to one of the following paths, ensure all relevant IDs are set. :\n - ${pathOptions.join(
'\n - ',
)}`,
);
}
if (this.customPrefix) {
return `${this.customPrefix}/${match}`;
} else {
return match;
}
}
protected static createInstancesFromResponse<T extends Base = Base>(
session: Session,
data: Body,
): T[] {
let instances: T[] = [];
this.resourceNames.forEach((resourceName) => {
const singular = resourceName.singular;
const plural = resourceName.plural;
if (data[plural] || Array.isArray(data[singular])) {
instances = instances.concat(
(data[plural] || data[singular]).reduce(
(acc: T[], entry: Body) =>
acc.concat(this.createInstance<T>(session, entry)),
[],
),
);
} else if (data[singular]) {
instances.push(this.createInstance<T>(session, data[singular]));
}
});
return instances;
}
protected static createInstance<T extends Base = Base>(
session: Session,
data: Body,
prevInstance?: T,
): T {
const instance: T = prevInstance
? prevInstance
: new (this as any)({session});
if (data) {
instance.setData(data);
}
return instance;
}
#session: Session;
get session(): Session {
return this.#session;
}
constructor({session, fromData}: BaseConstructorArgs) {
this.#session = session;
if (fromData) {
this.setData(fromData);
}
}
public async save({update = false}: SaveArgs = {}): Promise<void> {
const {primaryKey, resourceNames} = this.resource();
const method = this[primaryKey] ? 'put' : 'post';
const data = this.serialize(true);
const response = await this.resource().request({
http_method: method,
operation: method,
session: this.session,
urlIds: {},
body: {[this.resource().getJsonBodyName()]: data},
entity: this,
});
const flattenResourceNames: string[] = resourceNames.reduce<string[]>(
(acc, obj) => {
return acc.concat(Object.values(obj));
},
[],
);
const matchResourceName = Object.keys(response.body as Body).filter(
(key: string) => flattenResourceNames.includes(key),
);
const body: Body | undefined = (response.body as Body)[
matchResourceName[0]
];
if (update && body) {
this.setData(body);
}
}
public async saveAndUpdate(): Promise<void> {
await this.save({update: true});
}
public async delete(): Promise<void> {
await this.resource().request({
http_method: 'delete',
operation: 'delete',
session: this.session,
urlIds: {},
entity: this,
});
}
public serialize(saving = false): Body {
const {hasMany, hasOne, readOnlyAttributes} = this.resource();
return Object.entries(this).reduce((acc: Body, [attribute, value]) => {
if (
['#session'].includes(attribute) ||
(saving && readOnlyAttributes.includes(attribute))
) {
return acc;
}
if (attribute in hasMany && value) {
acc[attribute] = value.reduce((attrAcc: Body, entry: Base) => {
return attrAcc.concat(this.serializeSubAttribute(entry, saving));
}, []);
} else if (attribute in hasOne && value) {
acc[attribute] = this.serializeSubAttribute(value, saving);
} else {
acc[attribute] = value;
}
return acc;
}, {});
}
public toJSON(): Body {
return this.serialize();
}
public request<T = unknown>(args: RequestArgs) {
return this.resource().request<T>(args);
}
protected setData(data: Body): void {
const {hasMany, hasOne} = this.resource();
Object.entries(data).forEach(([attribute, val]) => {
if (attribute in hasMany) {
const HasManyResource: typeof Base = hasMany[attribute];
this[attribute] = [];
val.forEach((entry: Body) => {
const obj = new HasManyResource({session: this.session});
if (entry) {
obj.setData(entry);
}
this[attribute].push(obj);
});
} else if (attribute in hasOne) {
const HasOneResource: typeof Base = hasOne[attribute];
const obj = new HasOneResource({session: this.session});
if (val) {
obj.setData(val);
}
this[attribute] = obj;
} else {
this[attribute] = val;
}
});
}
protected resource(): typeof Base {
return this.constructor as unknown as typeof Base;
}
private serializeSubAttribute(attribute: Base, saving: boolean): Body {
return attribute.serialize
? attribute.serialize(saving)
: this.resource()
.createInstance(this.session, attribute)
.serialize(saving);
}
}