Source

mastodon.js

const axios = require("axios");
const helpers = require("./helpers");
const STATUS_CODES_TO_ABORT_ON = require("./settings").STATUS_CODES_TO_ABORT_ON;

const DEFAULT_REST_ROOT = "https://mastodon.social/api/v1/";
const REQUIRED_FOR_USER_AUTH = ["access_token"];

/**
 * Tusk class for interacting with the Mastodon API.
 */
class Tusk {
    /**
     * Create a new Tusk instance.
     * @param {Object} config - Configuration object.
     */
    constructor(config) {
        this._validateConfigOrThrow(config);
        this.config = config;
        this.apiUrl = config.api_url || DEFAULT_REST_ROOT;
        this._mastodon_time_minus_local_time_ms = 0;
    }

    /**
     * Make a PUT request to the Mastodon API.
     * @param {string} path - API endpoint path.
     * @param {Object} params - Request parameters.
     * @returns {Promise<Object>} - The API response.
     */
    async put(path, params = {}) {
        return this.request("PUT", path, params);
    }

    /**
     * Make a GET request to the Mastodon API.
     * @param {string} path - API endpoint path.
     * @param {Object} params - Request parameters.
     * @returns {Promise<Object>} - The API response.
     */
    async get(path, params = {}) {
        return this.request("GET", path, params);
    }

    /**
     * Make a POST request to the Mastodon API.
     * @param {string} path - API endpoint path.
     * @param {Object} params - Request parameters.
     * @returns {Promise<Object>} - The API response.
     */
    async post(path, params = {}) {
        return this.request("POST", path, params);
    }

    /**
     * Make a PATCH request to the Mastodon API.
     * @param {string} path - API endpoint path.
     * @param {Object} params - Request parameters.
     * @returns {Promise<Object>} - The API response.
     */
    async patch(path, params = {}) {
        return this.request("PATCH", path, params);
    }

    /**
     * Make a DELETE request to the Mastodon API.
     * @param {string} path - API endpoint path.
     * @param {Object} params - Request parameters.
     * @returns {Promise<Object>} - The API response.
     */
    async delete(path, params = {}) {
        return this.request("DELETE", path, params);
    }

    /**
     * Make a request to the Mastodon API.
     * @param {string} method - HTTP method.
     * @param {string} path - API endpoint path.
     * @param {Object} params - Request parameters.
     * @returns {Promise<Object>} - The API response.
     */
    async request(method, path, params = {}) {
        if (!["GET", "POST", "PATCH", "DELETE", "PUT"].includes(method)) {
            throw new Error(`Invalid HTTP method: ${method}`);
        }

        const reqOpts = await this._buildReqOpts(method, path, params);
        return this._doRestApiRequest(reqOpts, method);
    }

    /**
     * Get the client's authentication tokens.
     * @returns {Object} - The authentication tokens.
     */
    getAuth() {
        return {
            access_token: this.config.access_token,
        };
    }

    /**
     * Update the client's authentication tokens.
     * @param {Object} tokens - The new authentication tokens.
     */
    setAuth(tokens) {
        if (tokens && tokens.access_token) {
            this.config.access_token = tokens.access_token;
        } else {
            throw new Error("Invalid tokens: Must include access_token.");
        }
    }

    /**
     * Build request options for an API request.
     * @private
     * @param {string} method - HTTP method.
     * @param {string} path - API endpoint path.
     * @param {Object} params - Request parameters.
     * @returns {Promise<Object>} - The request options.
     */
    async _buildReqOpts(method, path, params) {
        const reqOpts = {
            headers: {
                Accept: "*/*",
                "User-Agent": "tusk-mastodon-client",
                Authorization: `Bearer ${this.config.access_token}`,
            },
            timeout: this.config.timeout_ms,
        };

        try {
            path = helpers.moveParamsIntoPath(params, path);
        } catch (e) {
            throw e;
        }

        reqOpts.url = path.startsWith("http") ? path : `${this.apiUrl}${path}`;

        if (params.file) {
            reqOpts.headers["Content-type"] = "multipart/form-data";
            reqOpts.formData = params;
        } else if (Object.keys(params).length > 0) {
            reqOpts.url += this.formEncodeParams(params);
        }

        return reqOpts;
    }

    /**
     * Perform the actual API request.
     * @private
     * @param {Object} reqOpts - Request options.
     * @param {string} method - HTTP method.
     * @returns {Promise<Object>} - The API response.
     */
    async _doRestApiRequest(reqOpts, method) {
        const maxRetries = 3;
        const retryDelay = 1000;

        for (let attempt = 1; attempt <= maxRetries; attempt++) {
            try {
                const response = await axios({
                    method: method.toLowerCase(),
                    url: reqOpts.url,
                    headers: reqOpts.headers,
                    data: reqOpts.formData,
                    timeout: reqOpts.timeout,
                    responseType: "json",
                });

                const body = response.data;

                if (body.error || body.errors) {
                    const err = helpers.makeMastodonError("Mastodon API Error");
                    err.statusCode = response.status;
                    helpers.attachBodyInfoToError(err, body);
                    throw err;
                }

                return { data: body, resp: response };
            } catch (error) {
                const statusCode = error.response ? error.response.status : null;

                if (attempt < maxRetries && (!statusCode || !STATUS_CODES_TO_ABORT_ON.includes(statusCode))) {
                    await new Promise((resolve) => setTimeout(resolve, retryDelay * attempt));
                    continue;
                }

                const err = helpers.makeMastodonError("Request error");
                err.statusCode = statusCode;
                if (error.response && error.response.data) {
                    helpers.attachBodyInfoToError(err, error.response.data);
                }
                throw err;
            }
        }
    }

    /**
     * URL-encode request parameters.
     * @param {Object} params - Request parameters.
     * @returns {string} - URL-encoded parameters.
     */
    formEncodeParams(params) {
        let encoded = "";
        for (const [key, value] of Object.entries(params)) {
            encoded += encoded ? "&" : "?";
            if (Array.isArray(value)) {
                value.forEach((v) => {
                    encoded += `${encodeURIComponent(key)}[]=${encodeURIComponent(v)}&`;
                });
            } else {
                encoded += `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
            }
        }
        return encoded;
    }

    /**
     * Validate the configuration object.
     * @private
     * @param {Object} config - Configuration object.
     */
    _validateConfigOrThrow(config) {
        if (typeof config !== "object") {
            throw new TypeError(`config must be object, got ${typeof config}`);
        }

        if (config.timeout_ms !== undefined && isNaN(Number(config.timeout_ms))) {
            throw new TypeError(`Tusk config timeout_ms must be a Number. Got: ${config.timeout_ms}.`);
        }

        REQUIRED_FOR_USER_AUTH.forEach((reqKey) => {
            if (!config[reqKey]) {
                throw new Error(`Tusk config must include ${reqKey} when using user auth.`);
            }
        });
    }
}

module.exports = Tusk;