confluence-sdk.js

/**
 * @module confluence-sdk
 */
import qs from 'node:querystring';
import fs from 'node:fs';
import axios from 'axios';
import FormData from 'form-data';
import { RequestError } from './confluence-sdk-errors.js';
import util from './util.js';
import logger from './logger.js';
import retryPolicy from './retry-policy.js';
import { Meta, RemotePage } from './models/index.js';

/**
 * Default api prefix
 * @constant
 */
const CONTENT_PATH = '/wiki/rest/api/content';

/**
 * Default content properties to request
 * @constant
 */
const EXPAND_PROPERTIES = [
    'version',
    'metadata.properties.repo',
    'metadata.properties.path',
    'metadata.properties.sha',
    'metadata.properties.git_ref',
    'metadata.properties.git_sha',
    'metadata.properties.publisher_version'
].join(',');

/**
 * An SDK to access the Confluence API
 * 
 * @see {@link https://developer.atlassian.com/cloud/confluence/rest/v1/intro/}
 */
class ConfluenceSdk {
    constructor({ host, user, token, spaceKey, pageLimit }) {
        util.validateType('host', host, 'string');
        this.host = host;

        util.validateType('spaceKey', spaceKey, 'string');
        this.spaceKey = spaceKey;

        util.validateType('user', user, 'string');
        util.validateType('token', token, 'string');
        this.authHeader =
            'Basic ' + Buffer.from(`${user}:${token}`).toString('base64');
        this.pageLimit = pageLimit;
        this.api = axios.create({
            validateStatus: (status) => status < 500,
            baseURL: `${host}`,
            headers: {
                'Authorization': this.authHeader,
                'Accept': 'application/json'
            }
        });
        // Add retry policy
        retryPolicy(this.api);
    }

    /**
     * Return the children of a Confluence page
     * 
     * @param {number} parentPageId - The `id` of the Confluence page
     * @returns {Promise<Map<string,RemotePage>>} A `Map` of `RemotePages` indexed by their `path`
     */
    async getChildPages(parentPageId) {
        util.validateType('parentPage', parentPageId, 'number');
        const query = qs.stringify({
            expand: EXPAND_PROPERTIES,
            start: 0,
            limit: this.pageLimit
        });
        const pages = new Map();
        let nextUri = `${CONTENT_PATH}/${parentPageId}/child/page?${query}`;

        while (nextUri) {
            const response = await this.api.get(nextUri);
            const data = this.validateResponse(response);
            if (data.size === 0) {
                break;
            }
            data.results.forEach((pageData) => {
                const page = this.remotePage(pageData, parentPageId);
                pages.set(page.meta.path, page);
            });
            nextUri = data._links?.next ? data._links.context + data._links.next : null;
        }

        return pages;
    }

    async _getCurrentUser() {
        if (!this.currentUser) {
            const response = await this.api.get(
                '/wiki/rest/api/user/current'
            );
            const { type, accountId, accountType } = this.validateResponse(response);
            this.currentUser = { type, accountId, accountType };
        }
        return this.currentUser;
    }
    /**
     * Find page by title
     * 
     * @param {string} title - The page title to find
     * @returns {Promise<RemotePage|undefined>} The remote page or `undefined` if not found
     */
    async findPage(title) {
        util.validateType('title', title, 'string');
        const query = qs.stringify({
            title,
            type: 'page',
            spaceKey: this.spaceKey,
            expand: EXPAND_PROPERTIES
        });
        // find the page
        const response = await this.api.get(
            `${CONTENT_PATH}?${query}`
        );

        const data = this.validateResponse(response);

        if (data.size === 0) {
            return;
        }

        // return page info
        const page = data.results[0];
        return this.remotePage(page);
    }

    /**
     * 
     * @param {object} page - Confluence page data
     * @param {number} parentId - The `id` of the parent of `page`
     * @returns {RemotePage} A `RemotePage` instance created from the `page` data
     */
    remotePage(page, parentId) {
        return new RemotePage(
            Number.parseInt(page.id, 10),
            page.version.number,
            page.title,
            this.pageMeta(page),
            parentId
        );
    }

    /**
     * 
     * @param {object} page - Confluence page data
     * @returns {Meta} A `Meta` instance created from the `page.metadata`
     */
    pageMeta(page) {
        const meta = page.metadata?.properties;
        return new Meta(
            meta?.repo?.value,
            meta?.path?.value,
            meta?.sha?.value,
            meta?.git_ref?.value,
            meta?.git_sha?.value,
            meta?.publisher_version?.value
        );
    }

    /**
     * Create a `LocalPage` in Confluence
     * 
     * @param {LocalPage} page - The local page to create in Confluence 
     * @returns {Promise<RemotePage>} The `RemotePage` created 
     */
    async createPage(page) {
        util.validateType('title', page.title, 'string');
        util.validateType('html', page.html, 'string');

        const payload = {
            title: page.title,
            type: 'page',
            space: { key: this.spaceKey },
            version: { number: 1 },
            ancestors: [],
            body: {
                storage: { value: page.html, representation: 'storage' }
            },
            metadata: {
                properties: {
                    editor: {
                        key: 'editor',
                        value: 'v2'
                    }
                }
            },
            restrictions: {
                update: {
                    operation: 'update',
                    restrictions: {
                        user: { results: [] },
                        group: { results: [] }
                    }
                }
            }
        };

        await this._getCurrentUser().then((user) => {
            payload.restrictions.update.restrictions.user.results.push(user);
        });

        if (page.meta) {
            if (page.meta instanceof Meta) {
                Object.assign(payload.metadata.properties, page.meta.toConfluenceProperties());
            } else {
                throw new Error('meta is not an instance of Meta class');
            }
        }

        if (page.parentPageId) {
            util.validateType('parentPage', page.parentPageId, 'number');
            payload.ancestors.push({ id: page.parentPageId });
        }

        const response = await this.api.post(
            CONTENT_PATH,
            payload,
            { headers: { 'Content-Type': 'application/json' } }
        );

        const { id } = this.validateResponse(response);

        // return a `RemotePage` instance
        const remotePage = new RemotePage(Number.parseInt(id, 10), 1, page.title, page.meta, page.parentPageId);
        remotePage.localPage = page;
        return remotePage;
    }

    /**
     * Update the content of an existing page.
     *
     * @param {RemotePage} remotePage - The page to be updated
     * @returns {Promise<RemotePage>} The updated `RemotePage`
     */
    async updatePage(remotePage) {
        const { localPage } = remotePage;
        const title = localPage.title;
        const html = localPage.html;
        util.validateType('id', remotePage.id, 'number');
        util.validateType('version', remotePage.version, 'number');
        util.validateType('title', title, 'string');
        util.validateType('html', html, 'string');
        const payload = {
            title,
            type: 'page',
            version: { number: remotePage.version + 1 }, // bump version
            ancestors: [],
            body: {
                storage: { value: html, representation: 'storage' }
            },
            metadata: {
                properties: {
                    editor: {
                        key: 'editor',
                        value: 'v2'
                    }
                }
            },
            restrictions: {
                update: {
                    operation: 'update',
                    restrictions: {
                        user: { results: [] },
                        group: { results: [] }
                    }
                }
            }
        };

        await this._getCurrentUser().then((user) => {
            payload.restrictions.update.restrictions.user.results.push(user);
        });

        if (localPage.meta) {
            if (localPage.meta instanceof Meta) {
                Object.assign(payload.metadata.properties, localPage.meta.toConfluenceProperties());
            } else {
                throw new Error('meta is not an instance of Meta class');
            }
        }

        if (localPage.parentPageId) {
            util.validateType('parentPage', localPage.parentPageId, 'number');
            payload.ancestors.push({ id: localPage.parentPageId });
        }

        const response = await this.api.put(
            `${CONTENT_PATH}/${remotePage.id}`,
            payload,
            { headers: { 'Content-Type': 'application/json' } }
        );

        this.validateResponse(response);
        remotePage.version++;
        remotePage.meta = localPage.meta;
        return remotePage;
    }

    /**
     * Delete a Confluence page
     * 
     * @param {number} id - The `id` of the Confluence page
     * @returns {Promise<void>}
     */
    async deletePage(id) {
        //TODO: check for children first
        const response = await this.api.delete(
            `${CONTENT_PATH}/${id}`
        );
        this.validateResponse(response, [204, 404]);
    }

    /**
     * Create an attachment to a Confluence page from a local file
     * 
     * @param {number} pageId - The `id` of the Confluence page
     * @param {string} path - The `path` of the file to be attached
     * @returns {Promise<void>}
     */
    async createAttachment(pageId, path) {
        util.validateType('pageId', pageId, 'number');
        util.validateType('path', path, 'string');
        if (!fs.existsSync(path)) {
            throw new Error(`Attachment '${path}' not exists`);
        }
        const formData = new FormData();
        formData.append('minorEdit', 'true');
        formData.append('file', fs.createReadStream(path));
        const headers = Object.assign({ 'X-Atlassian-Token': 'nocheck' }, formData.getHeaders());
        const response = await this.api.put(
            `${CONTENT_PATH}/${pageId}/child/attachment`, formData, { headers }
        );

        this.validateResponse(response);
    }

    /**
     * 
     * @param {AxiosResponse} response - An `AxiosResponse` object
     * @param {Array<number>} validStatuses - An array of http statuses to consider successful
     * @returns {object} The `response.data`
     * @throws `RequestError` if `status` is not successful
     */
    validateResponse({ status, statusText, data }, validStatuses = [200]) {
        if (!validStatuses.includes(status)) {
            logger.error(JSON.stringify({ status, statusText, data }, undefined, 2));
            throw new RequestError(status, statusText, data.message);
        }
        return data;
    }
}

export default ConfluenceSdk;