diff --git a/.gitignore b/.gitignore index 7b4e402..a8d4c23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -node_modules pnpm-lock.yaml +node_modules lib \ No newline at end of file diff --git a/src/APIManager.ts b/src/APIManager.ts index 86715fd..80420f1 100644 --- a/src/APIManager.ts +++ b/src/APIManager.ts @@ -2,15 +2,17 @@ import { APIResponse, RawUserData } from './typings'; import axios, { AxiosRequestConfig } from 'axios'; export class SquareCloudAPIError extends Error { - constructor(code: string) { + constructor(code: string, message?: string) { super(); this.name = 'SquareCloudAPIError'; - this.message = code - .replaceAll('_', ' ') - .toLowerCase() - .replace(/(^|\s)\S/g, (L) => L.toUpperCase()); + this.message = + code + .replaceAll('_', ' ') + .toLowerCase() + .replace(/(^|\s)\S/g, (L) => L.toUpperCase()) + + (message ? `: ${message}` : ''); } } diff --git a/src/Assertions.ts b/src/Assertions.ts index e2331ff..d08a358 100644 --- a/src/Assertions.ts +++ b/src/Assertions.ts @@ -1,22 +1,58 @@ +import { SquareCloudAPIError } from './APIManager'; import { ReadStream } from 'fs'; import z from 'zod'; -export function validateString(value: any): asserts value is string { - z.string().parse(value); +export function validateString( + value: any, + code?: string, + starts: string = '' +): asserts value is string { + if (starts) validateString(starts); + + handleParser( + () => z.string().parse(value), + 'Expect string, got ' + typeof value, + code + ); } -export function validateBoolean(value: any): asserts value is boolean { - z.boolean().parse(value); +export function validateBoolean( + value: any, + code?: string +): asserts value is boolean { + handleParser( + () => z.boolean().parse(value), + 'Expect boolean, got ' + typeof value, + code + ); } export function validateCommitLike( - value: any -): asserts value is string | ReadStream { - z.string() - .or( + value: any, + code?: string +): asserts value is string | ReadStream | Buffer { + handleParser( + () => z - .custom((value) => value instanceof ReadStream) - .or(z.custom((value) => value instanceof Buffer)) - ) - .parse(value); + .string() + .or( + z + .custom((value) => value instanceof ReadStream) + .or(z.custom((value) => value instanceof Buffer)) + ) + .parse(value), + 'Expect string, ReadStream or Buffer, got ' + typeof value, + code + ); +} + +function handleParser(func: any, message: string, code?: string) { + try { + func(); + } catch { + throw new SquareCloudAPIError( + code ? `INVALID_${code}` : 'VALIDATION_ERROR', + message + ); + } } diff --git a/src/index.ts b/src/index.ts index fa0643d..4feb726 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ export class SquareCloudAPI { baseUrl: 'https://api.squarecloud.app/v1/public/', }; - private apiManager: APIManager; + public readonly apiManager: APIManager; /** * Creates an API instance @@ -17,7 +17,7 @@ export class SquareCloudAPI { * @param apiKey - Your API Token (you can get it at [SquareCloud Dashboard](https://squarecloud.app/dashboard)) */ constructor(apiKey: string) { - validateString(apiKey); + validateString(apiKey, 'API_KEY'); this.apiManager = new APIManager(apiKey); } @@ -30,7 +30,7 @@ export class SquareCloudAPI { async getUser(): Promise; async getUser(userId: string): Promise; async getUser(userId?: string): Promise { - if (userId) validateString(userId); + if (userId) validateString(userId, 'USER_ID'); const userData = await this.apiManager.user(userId); const hasAccess = userData.user.email !== 'Access denied'; @@ -44,7 +44,7 @@ export class SquareCloudAPI { * @param appId - The application id, you must own the application */ async getApplication(appId: string): Promise { - validateString(appId); + validateString(appId, 'APP_ID'); const { applications } = await this.apiManager.user(); const appData = applications.find((app) => app.id === appId); diff --git a/src/structures/Application.ts b/src/structures/Application.ts index e91ac3b..8b53b0c 100644 --- a/src/structures/Application.ts +++ b/src/structures/Application.ts @@ -1,5 +1,9 @@ import { RawApplicationData, ApplicationStatusData } from '../typings'; -import { validateBoolean, validateCommitLike } from '../Assertions'; +import { + validateBoolean, + validateCommitLike, + validateString, +} from '../Assertions'; import { createReadStream, ReadStream } from 'fs'; import { APIManager } from '../APIManager'; import FormData from 'form-data'; @@ -81,7 +85,7 @@ export class Application { * @param full - Whether you want the complete logs (true) or the recent ones (false) */ async getLogs(full: boolean = false) { - validateBoolean(full); + validateBoolean(full, '[LOGS_FULL]'); return ( await this.apiManager.application(`${full ? 'full-' : ''}logs`, this.id) @@ -134,22 +138,40 @@ export class Application { * Commit changes to a specific file inside your application folder * * - This action is irreversible. - * - Tip: use `require('path').join(__dirname, 'fileName')` to get an absolute path. + * - Tip: use this to get an absolute path. + * ```ts + * require('path').join(__dirname, 'fileName') + * ``` * - Tip2: use zip file to commit more than one file * * @param file - The absolute file path or a ReadStream */ - // async commit(file: string | ReadStream): Promise - // async commit(file: Buffer, fileName: string, fileExtension: string): Promise - async commit(file: string | ReadStream | Buffer, fileName?: string, fileExtension?: string): Promise { - validateCommitLike(file); + async commit(file: string | ReadStream): Promise; + async commit( + file: Buffer, + fileName: string, + fileExtension: `.${string}` + ): Promise; + async commit( + file: string | ReadStream | Buffer, + fileName?: string, + fileExtension?: `.${string}` + ): Promise { + validateCommitLike(file, 'COMMIT_DATA'); const formData = new FormData(); - formData.append( - 'file', - file instanceof ReadStream ? file : createReadStream(file) - ); + if (file instanceof Buffer) { + validateString(fileName, 'FILE_NAME'); + validateString(fileExtension, 'FILE_EXTENSION'); + + formData.append('file', file, { filename: fileName + fileExtension }); + } else { + formData.append( + 'file', + file instanceof ReadStream ? file : createReadStream(file) + ); + } const { code } = await this.apiManager.application('commit', this.id, { method: 'POST', diff --git a/src/structures/Collection.ts b/src/structures/Collection.ts new file mode 100644 index 0000000..31d005a --- /dev/null +++ b/src/structures/Collection.ts @@ -0,0 +1,357 @@ +/** + * @internal + */ +export interface CollectionConstructor { + new (): Collection; + new (entries?: readonly (readonly [K, V])[] | null): Collection; + new (iterable: Iterable): Collection; + readonly prototype: Collection; + readonly [Symbol.species]: CollectionConstructor; +} + +/** + * Separate interface for the constructor so that emitted js does not have a constructor that overwrites itself + * + * @internal + */ +export interface Collection extends Map { + constructor: CollectionConstructor; +} + +/** + * A Map with additional utility methods. This is used throughout discord.js rather than Arrays for anything that has + * an ID, for significantly improved performance and ease-of-use. + * + * @typeParam K - The key type this collection holds + * @typeParam V - The value type this collection holds + */ +export class Collection extends Map { + /** + * Obtains the first value(s) in this collection. + * + * @param amount - Amount of values to obtain from the beginning + * @returns A single value if no amount is provided or an array of values, starting from the end if amount is negative + */ + public first(): V | undefined; + public first(amount: number): V[]; + public first(amount?: number): V | V[] | undefined { + if (typeof amount === 'undefined') return this.values().next().value; + if (amount < 0) return this.last(amount * -1); + amount = Math.min(this.size, amount); + const iter = this.values(); + return Array.from({ length: amount }, (): V => iter.next().value); + } + + /** + * Obtains the last value(s) in this collection. + * + * @param amount - Amount of values to obtain from the end + * @returns A single value if no amount is provided or an array of values, starting from the start if + * amount is negative + */ + public last(): V | undefined; + public last(amount: number): V[]; + public last(amount?: number): V | V[] | undefined { + const arr = [...this.values()]; + if (typeof amount === 'undefined') return arr[arr.length - 1]; + if (amount < 0) return this.first(amount * -1); + if (!amount) return []; + return arr.slice(-amount); + } + + /** + * Identical to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reverse Array.reverse()} + * but returns a Collection instead of an Array. + */ + public reverse() { + const entries = [...this.entries()].reverse(); + this.clear(); + for (const [key, value] of entries) this.set(key, value); + return this; + } + + /** + * Searches for a single item where the given function returns a truthy value. This behaves like + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find Array.find()}. + * + * @param fn - The function to test with (should return boolean) + * @param thisArg - Value to use as `this` when executing function + * @example + * ```ts + * collection.find(user => user.username === 'Bob'); + * ``` + */ + public find( + fn: (value: V, key: K, collection: this) => value is V2 + ): V2 | undefined; + public find( + fn: (value: V, key: K, collection: this) => unknown + ): V | undefined; + public find( + fn: (this: This, value: V, key: K, collection: this) => value is V2, + thisArg: This + ): V2 | undefined; + public find( + fn: (this: This, value: V, key: K, collection: this) => unknown, + thisArg: This + ): V | undefined; + public find( + fn: (value: V, key: K, collection: this) => unknown, + thisArg?: unknown + ): V | undefined { + if (typeof fn !== 'function') + throw new TypeError(`${fn} is not a function`); + if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg); + for (const [key, val] of this) { + if (fn(val, key, this)) return val; + } + + return; + } + + /** + * Identical to + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter Array.filter()}, + * but returns a Collection instead of an Array. + * + * @param fn - The function to test with (should return boolean) + * @param thisArg - Value to use as `this` when executing function + * @example + * ```ts + * collection.filter(user => user.username === 'Bob'); + * ``` + */ + public filter( + fn: (value: V, key: K, collection: this) => key is K2 + ): Collection; + public filter( + fn: (value: V, key: K, collection: this) => value is V2 + ): Collection; + public filter( + fn: (value: V, key: K, collection: this) => unknown + ): Collection; + public filter( + fn: (this: This, value: V, key: K, collection: this) => key is K2, + thisArg: This + ): Collection; + public filter( + fn: (this: This, value: V, key: K, collection: this) => value is V2, + thisArg: This + ): Collection; + public filter( + fn: (this: This, value: V, key: K, collection: this) => unknown, + thisArg: This + ): Collection; + public filter( + fn: (value: V, key: K, collection: this) => unknown, + thisArg?: unknown + ): Collection { + if (typeof fn !== 'function') + throw new TypeError(`${fn} is not a function`); + if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg); + const results = new this.constructor[Symbol.species](); + for (const [key, val] of this) { + if (fn(val, key, this)) results.set(key, val); + } + + return results; + } + + /** + * Maps each item to another value into an array. Identical in behavior to + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map Array.map()}. + * + * @param fn - Function that produces an element of the new array, taking three arguments + * @param thisArg - Value to use as `this` when executing function + * @example + * ```ts + * collection.map(user => user.tag); + * ``` + */ + public map(fn: (value: V, key: K, collection: this) => T): T[]; + public map( + fn: (this: This, value: V, key: K, collection: this) => T, + thisArg: This + ): T[]; + public map( + fn: (value: V, key: K, collection: this) => T, + thisArg?: unknown + ): T[] { + if (typeof fn !== 'function') + throw new TypeError(`${fn} is not a function`); + if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg); + const iter = this.entries(); + return Array.from({ length: this.size }, (): T => { + const [key, value] = iter.next().value; + return fn(value, key, this); + }); + } + + /** + * Checks if there exists an item that passes a test. Identical in behavior to + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some Array.some()}. + * + * @param fn - Function used to test (should return a boolean) + * @param thisArg - Value to use as `this` when executing function + * @example + * ```ts + * collection.some(user => user.discriminator === '0000'); + * ``` + */ + public some(fn: (value: V, key: K, collection: this) => unknown): boolean; + public some( + fn: (this: T, value: V, key: K, collection: this) => unknown, + thisArg: T + ): boolean; + public some( + fn: (value: V, key: K, collection: this) => unknown, + thisArg?: unknown + ): boolean { + if (typeof fn !== 'function') + throw new TypeError(`${fn} is not a function`); + if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg); + for (const [key, val] of this) { + if (fn(val, key, this)) return true; + } + + return false; + } + + /** + * Checks if all items passes a test. Identical in behavior to + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/every Array.every()}. + * + * @param fn - Function used to test (should return a boolean) + * @param thisArg - Value to use as `this` when executing function + * @example + * ```ts + * collection.every(user => !user.bot); + * ``` + */ + public every( + fn: (value: V, key: K, collection: this) => key is K2 + ): this is Collection; + public every( + fn: (value: V, key: K, collection: this) => value is V2 + ): this is Collection; + public every(fn: (value: V, key: K, collection: this) => unknown): boolean; + public every( + fn: (this: This, value: V, key: K, collection: this) => key is K2, + thisArg: This + ): this is Collection; + public every( + fn: (this: This, value: V, key: K, collection: this) => value is V2, + thisArg: This + ): this is Collection; + public every( + fn: (this: This, value: V, key: K, collection: this) => unknown, + thisArg: This + ): boolean; + public every( + fn: (value: V, key: K, collection: this) => unknown, + thisArg?: unknown + ): boolean { + if (typeof fn !== 'function') + throw new TypeError(`${fn} is not a function`); + if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg); + for (const [key, val] of this) { + if (!fn(val, key, this)) return false; + } + + return true; + } + + /** + * Applies a function to produce a single value. Identical in behavior to + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce Array.reduce()}. + * + * @param fn - Function used to reduce, taking four arguments; `accumulator`, `currentValue`, `currentKey`, + * and `collection` + * @param initialValue - Starting value for the accumulator + * @example + * ```ts + * collection.reduce((acc, guild) => acc + guild.memberCount, 0); + * ``` + */ + public reduce( + fn: (accumulator: T, value: V, key: K, collection: this) => T, + initialValue?: T + ): T { + if (typeof fn !== 'function') + throw new TypeError(`${fn} is not a function`); + let accumulator!: T; + + if (typeof initialValue !== 'undefined') { + accumulator = initialValue; + for (const [key, val] of this) + accumulator = fn(accumulator, val, key, this); + return accumulator; + } + + let first = true; + for (const [key, val] of this) { + if (first) { + accumulator = val as unknown as T; + first = false; + continue; + } + + accumulator = fn(accumulator, val, key, this); + } + + // No items iterated. + if (first) { + throw new TypeError('Reduce of empty collection with no initial value'); + } + + return accumulator; + } + + /** + * Identical to + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/forEach Map.forEach()}, + * but returns the collection instead of undefined. + * + * @param fn - Function to execute for each element + * @param thisArg - Value to use as `this` when executing function + * @example + * ```ts + * collection + * .each(user => console.log(user.username)) + * .filter(user => user.bot) + * .each(user => console.log(user.username)); + * ``` + */ + public each(fn: (value: V, key: K, collection: this) => void): this; + public each( + fn: (this: T, value: V, key: K, collection: this) => void, + thisArg: T + ): this; + public each( + fn: (value: V, key: K, collection: this) => void, + thisArg?: unknown + ): this { + if (typeof fn !== 'function') + throw new TypeError(`${fn} is not a function`); + // eslint-disable-next-line unicorn/no-array-method-this-argument + this.forEach(fn as (value: V, key: K, map: Map) => void, thisArg); + return this; + } + + /** + * Creates an identical shallow copy of this collection. + * + * @example + * ```ts + * const newColl = someColl.clone(); + * ``` + */ + public clone(): Collection { + return new this.constructor[Symbol.species](this); + } + + public toJSON() { + // toJSON is called recursively by JSON.stringify. + return [...this.values()]; + } +} diff --git a/src/structures/User.ts b/src/structures/User.ts index 59dcae3..fb03456 100644 --- a/src/structures/User.ts +++ b/src/structures/User.ts @@ -1,6 +1,7 @@ import { AccountPlan, RawUserData } from '../typings'; import { Application } from './Application'; import { APIManager } from '../APIManager'; +import { Collection } from './Collection'; /** * Represents a SquareCloud user @@ -46,8 +47,8 @@ export class User { export class FullUser extends User { /** The user's registered email */ email: string; - /** The user's registered applications Map */ - applications = new Map(); + /** The user's registered applications Collection */ + applications = new Collection(); constructor(apiManager: APIManager, data: RawUserData) { super(apiManager, data);