Client.js

const Fetch = require('node-fetch').default,
	util = require('util'); // eslint-disable-line no-unused-vars

const { isObject, check } = require('./util/');
const { Ratelimit, FetchError,
	DefaultOptions, FetchOpts, MultiFetchOpts,
	Stats, Guild, User, Upvote } = require('./structures/');
const ok = /2\d+/;
const Store = require('@ired_me/red-store');

/**
 * The Client for interacting with serverlist.space
 */
class Client {
	/**
	 * @param {ClientOptions} [options={}] Options to pass.
	 */
	constructor(options = {}) {
		/**
		 * The options.
		 * @type {ClientOptions}
		 */
		this.options = DefaultOptions;

		this.edit(options, true);

		/**
		 * Every guild cached, mapped by their ID.
		 * @type {Store<string, Guild>}
		 */
		this.guilds = new Store();

		/**
		 * Every user cached, mapped by their ID.
		 * @type {Store<string, User>}
		 */
		this.users = new Store();
	}

	/**
	 * The API URL of serverlist.space. Version is missing.
	 * @type {string}
	 */
	get endpoint() {
		return 'https://api.serverlist.space/v';
	}

	async get(endpoint, version, ...headers) {
		const i = await Fetch(this.endpoint + version + endpoint + headers.join(''), {
			headers: {
				'User-Agent': 'servers.space (owo)'
			}
		});
		if (i.status === 429) {
			throw new Ratelimit(i.headers, endpoint);
		} else {
			const contents = await i.json();
			if (contents.code && !ok.test(contents.code)) throw new FetchError(i, contents.message);
			else return contents;
		}
	}

	async authGet(endpoint, version, Authorization, ...headers) {
		const i = await Fetch(this.endpoint + version + endpoint + headers.join(''), {
			headers: {
				Authorization,
				'User-Agent': 'servers.space (owo)'
			}
		});
		if (i.status === 429) {
			throw new Ratelimit(i.headers, endpoint);
		} else {
			const contents = await i.json();
			if (contents.code && !ok.test(contents.code)) throw new FetchError(i, contents.message);
			else return contents;
		}
	}

	/**
	 * Edits ClientOptions.
	 * @param {ClientOptions} options Options to pass.
	 * @param {boolean} [preset=false] Whether or not to use the default ClientOptions. Otherwise uses [this.client.options]({@link Client#options})
	 * @returns {ClientOptions} The new ClientOptions.
	 * @example
	 * SpaceClient.edit({ guildID: 123 }); // TypeError: options.guildID must be a string
	 * SpaceClient.edit({ guildID: '123' }); // { ..., guildID: '123', ... };
	 */
	edit(options, preset = false) {
		if (typeof options === 'undefined') throw new ReferenceError('No options to pass for editing?');
		if (!isObject(options)) throw new TypeError('options must be an object.');
		const opts = check(Object.assign(preset ? DefaultOptions : this.options, options));

		FetchOpts.version = MultiFetchOpts.version = opts.version;
		FetchOpts.guildToken = MultiFetchOpts.guildToken = opts.guildToken;

		return this.options = opts;
	}

	/**
	 * Fetches a page of guilds from serverlist.space
	 * @param {MultiFetchOptions} [options={}] Options to pass.
	 * @returns {Promise<Guild[] | Store<string, Guild>>}
	 */
	async fetchGuilds(options = {}) {
		const { cache, mapify, page, version, raw } = Object.assign(MultiFetchOpts, options);
		if (typeof page !== 'number') throw new TypeError('page must be a number.');
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.get('/servers', version, `?page=${page}`);
		if (cache) for (const guild of contents.servers) this.guilds.set(guild.id, new Guild(guild));
		if (mapify) return new Store(contents.servers.map(guild => [guild.id, raw ? guild : new Guild(guild)]));
		else return raw ? contents : contents.servers.map(guild => new Guild(guild));
	}

	/**
	 * Fetches a single guild from serverlist.space
	 * @param {string | FetchOptions} [id=this.options.guildID] A guild ID listed on serverlist.space to fetch.
	 * Can also be FetchOptions; Uses [this.options.guildID]({@link ClientOptions#guildID}) for the ID if so.
	 * @param {FetchOptions} [options={}] Options to pass.
	 * @returns {Promise<Guild>}
	 */
	async fetchGuild(id = this.options.guildID, options = {}) {
		if (isObject(id)) {
			options = id;
			id = this.options.guildID;
		}
		const { cache, raw, version } = Object.assign(FetchOpts, options);

		if (typeof id === 'undefined' || id === null) throw new ReferenceError('id must be defined.');
		if (typeof id !== 'string' && !isObject(id)) throw new TypeError('id must be a string.');
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.get(`/servers/${id}`, version);
		if (cache) this.guilds.set(contents.id, new Guild(contents));
		return raw ? contents : new Guild(contents);
	}

	/**
	 * Fetches a page of guilds that a user lists on serverlist.space
	 * @param {string} id The ID of a user to get guilds from.
	 * @param {MultiFetchOptions} [options={}] Options to pass.
	 * @returns {Promise<Guild[] | Store<string, Guild>>}
	 * @example
	 * SpaceClient.fetchGuildsOfUser('235593018332282884')
	 * 	 .then(guilds => {
	 *     console.log(`All Guilds that User 235593018332282884 Owns:\n\n${guilds.map(g => g.name).join('\n')}`);
	 * 	 })
	 * 	 .catch(console.error);
	 */
	async fetchGuildsOfUser(id, options = {}) {
		const { cache, raw, version, mapify, page } = Object.assign(MultiFetchOpts, options);
		if (typeof id === 'undefined' || id === null) throw new ReferenceError('id must be defined.');
		if (typeof id !== 'string') throw new TypeError('id must be a string.');
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.get(`/users/${id}/servers`, version, `?page=${page}`);
		if (cache) for (const g of contents.servers) this.guilds.set(g.id, new Guild(g));
		if (mapify) return new Store(contents.servers.map(guild => [guild.id, raw ? guild : new Guild(guild)]));
		else return raw ? contents : contents.servers.map(g => new Guild(g));
	}

	/**
	 * serverlist.space Statistics
	 * @param {FetchOptions} [options={}] Options to pass.
	 * @returns {Promise<Stats>}
	 */
	async fetchStats(options = {}) {
		const { raw, version } = Object.assign(FetchOpts, options);
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.get('/statistics', version);
		return raw ? contents : new Stats(contents);
	}

	/**
	 * Fetches a guild's upvotes in the current month; Requires a Bot Token.
	 * @param {string | MultiFetchOptions} [id=this.options.guildID] A guild ID to check upvotes of.
	 * Can also be MultiFetchOptions; Uses [this.options.guildID]({@link ClientOptions#guildID}) for the ID if so.
	 * @param {MultiFetchOptions} [options={}] Options to pass.
	 * @returns {Promise<Upvote[] | Store<string, Upvote>>}
	 * @example
	 * async () => {
	 *   const upvotes = await SpaceClient.fetchUpvotes('guildID', { botToken: 'someAPIToken', mapify: false });
	 * 	 for (const { user } of upvotes) console.log(`${user.tag} has upvoted this month!`);
	 * }
	 */
	async fetchUpvotes(id = this.options.guildID, options = {}) {
		if (isObject(id)) {
			options = id;
			id = this.options.guildID;
		}

		const { cache, raw, version, guildToken, page, mapify } = Object.assign(MultiFetchOpts, options);
		if (!guildToken) throw new ReferenceError('options.guildToken must be defined.');

		if (typeof id === 'undefined' || id === null) throw new ReferenceError('id must be defined.');
		if (typeof id !== 'string' && !isObject(id)) throw new TypeError('id must be a string.');
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.authGet(`/servers/${id}/upvotes`, version, guildToken, `?page=${page}`);
		if (cache) this.users = this.users.concat(new Store(contents.upvotes.map(c => [c.user.id, new User(c.user)])));
		if (mapify) return new Store(contents.upvotes.map(c => [c.user.id, raw ? c : new Upvote(c, id)]));
		else return raw ? contents : contents.upvotes.map(c => new Upvote(c, id));
	}

	/**
	 * Fetches a user on serverlist.space
	 * @param {string} id A user ID to get properties of
	 * @param {FetchOptions} [options={}] Options to pass.
	 * @returns {Promise<User>}
	 */
	async fetchUser(id, options = {}) {
		const { cache, raw, version } = Object.assign(FetchOpts, options);
		if (typeof id === 'undefined' || id === null) throw new ReferenceError('id must be defined.');
		if (typeof id !== 'string') throw new TypeError('id must be a string.');
		if (!isObject(options)) throw new TypeError('options must be an object.');

		const contents = await this.get(`/users/${id}`, version);
		if (cache) this.users.set(contents.id, new User(contents));
		return raw ? contents : new User(contents);
	}
}

module.exports = Client;