diff --git a/README.md b/README.md index 3aaa7ead6..578acc554 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,24 @@ If you are logged in, this will open the project in your browser: rdme open ``` +### Categories + +#### Get All Categories Associated to Your Project Version + +```sh +rdme categories --version={project-version} +``` + +#### Create a New Category for your Project Version + +```sh +rdme categories:create --categoryType={category-type} --version={project-version} +``` + +`categoryType` is required and must be set to either `guide` or `reference` + +If you want to prevent the creation of a duplicate category with a matching `title` and `categoryType`, supply the `--preventDuplicates` flag. + ## Future We are continually expanding and improving the offerings of this application as we expand our public API and are able. Some interactions may change over time, but we will do our best to retain backwards compatibility. diff --git a/__tests__/cmds/categories.test.js b/__tests__/cmds/categories.test.js new file mode 100644 index 000000000..0c582ab9f --- /dev/null +++ b/__tests__/cmds/categories.test.js @@ -0,0 +1,251 @@ +const nock = require('nock'); + +const getApiNock = require('../get-api-nock'); + +const CategoriesCommand = require('../../src/cmds/categories'); +const CategoriesCreateCommand = require('../../src/cmds/categories/create'); + +const categories = new CategoriesCommand(); +const categoriesCreate = new CategoriesCreateCommand(); + +const key = 'API_KEY'; +const version = '1.0.0'; + +function getNockWithVersionHeader(v) { + return getApiNock({ + 'x-readme-version': v, + }); +} + +describe('rdme categories', () => { + beforeAll(() => nock.disableNetConnect()); + + afterEach(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(categories.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + }); + + it('should return all categories for a single page', async () => { + const getMock = getNockWithVersionHeader(version) + .persist() + .get('/api/v1/categories?perPage=20&page=1') + .basicAuth({ user: key }) + .reply(200, [{ title: 'One Category', slug: 'one-category', type: 'guide' }], { + 'x-total-count': '1', + }); + + const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect(categories.run({ key, version: '1.0.0' })).resolves.toBe( + JSON.stringify([{ title: 'One Category', slug: 'one-category', type: 'guide' }], null, 2) + ); + + getMock.done(); + versionMock.done(); + }); + + it('should return all categories for multiple pages', async () => { + const getMock = getNockWithVersionHeader(version) + .persist() + .get('/api/v1/categories?perPage=20&page=1') + .basicAuth({ user: key }) + .reply(200, [{ title: 'One Category', slug: 'one-category', type: 'guide' }], { + 'x-total-count': '21', + }) + .get('/api/v1/categories?perPage=20&page=2') + .basicAuth({ user: key }) + .reply(200, [{ title: 'Another Category', slug: 'another-category', type: 'guide' }], { + 'x-total-count': '21', + }); + + const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect(categories.run({ key, version: '1.0.0' })).resolves.toBe( + JSON.stringify( + [ + { title: 'One Category', slug: 'one-category', type: 'guide' }, + { title: 'Another Category', slug: 'another-category', type: 'guide' }, + ], + null, + 2 + ) + ); + + getMock.done(); + versionMock.done(); + }); +}); + +describe('rdme categories:create', () => { + beforeAll(() => nock.disableNetConnect()); + + afterEach(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(categoriesCreate.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + }); + + it('should error if no title provided', () => { + return expect(categoriesCreate.run({ key: '123' })).rejects.toStrictEqual( + new Error('No title provided. Usage `rdme categories:create <title> [options]`.') + ); + }); + + it('should error if categoryType is blank', () => { + return expect(categoriesCreate.run({ key: '123', title: 'Test Title' })).rejects.toStrictEqual( + new Error('`categoryType` must be `guide` or `reference`.') + ); + }); + + it('should error if categoryType is not `guide` or `reference`', () => { + return expect( + categoriesCreate.run({ key: '123', title: 'Test Title', categoryType: 'test' }) + ).rejects.toStrictEqual(new Error('`categoryType` must be `guide` or `reference`.')); + }); + + it('should create a new category if the title and type do not match and preventDuplicates=true', async () => { + const getMock = getNockWithVersionHeader(version) + .persist() + .get('/api/v1/categories?perPage=20&page=1') + .basicAuth({ user: key }) + .reply(200, [{ title: 'Existing Category', slug: 'existing-category', type: 'guide' }], { + 'x-total-count': '1', + }); + + const postMock = getNockWithVersionHeader(version) + .post('/api/v1/categories') + .basicAuth({ user: key }) + .reply(201, { title: 'New Category', slug: 'new-category', type: 'guide', id: '123' }); + + const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect( + categoriesCreate.run({ + title: 'New Category', + categoryType: 'guide', + key, + version: '1.0.0', + preventDuplicates: true, + }) + ).resolves.toBe("🌱 successfully created 'New Category' with a type of 'guide' and an id of '123'"); + + getMock.done(); + postMock.done(); + versionMock.done(); + }); + + it('should create a new category if the title matches but the type does not match and preventDuplicates=true', async () => { + const getMock = getNockWithVersionHeader(version) + .persist() + .get('/api/v1/categories?perPage=20&page=1') + .basicAuth({ user: key }) + .reply(200, [{ title: 'Category', slug: 'category', type: 'guide' }], { + 'x-total-count': '1', + }); + + const postMock = getNockWithVersionHeader(version) + .post('/api/v1/categories') + .basicAuth({ user: key }) + .reply(201, { title: 'Category', slug: 'category', type: 'reference', id: '123' }); + + const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect( + categoriesCreate.run({ + title: 'Category', + categoryType: 'reference', + key, + version: '1.0.0', + preventDuplicates: true, + }) + ).resolves.toBe("🌱 successfully created 'Category' with a type of 'reference' and an id of '123'"); + + getMock.done(); + postMock.done(); + versionMock.done(); + }); + + it('should create a new category if the title and type match and preventDuplicates=false', async () => { + const postMock = getNockWithVersionHeader(version) + .post('/api/v1/categories') + .basicAuth({ user: key }) + .reply(201, { title: 'Category', slug: 'category', type: 'reference', id: '123' }); + + const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect( + categoriesCreate.run({ + title: 'Category', + categoryType: 'guide', + key, + version: '1.0.0', + }) + ).resolves.toBe("🌱 successfully created 'Category' with a type of 'reference' and an id of '123'"); + + postMock.done(); + versionMock.done(); + }); + + it('should not create a new category if the title and type match and preventDuplicates=true', async () => { + const getMock = getNockWithVersionHeader(version) + .persist() + .get('/api/v1/categories?perPage=20&page=1') + .basicAuth({ user: key }) + .reply(200, [{ title: 'Category', slug: 'category', type: 'guide', id: '123' }], { + 'x-total-count': '1', + }); + + const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect( + categoriesCreate.run({ + title: 'Category', + categoryType: 'guide', + key, + version: '1.0.0', + preventDuplicates: true, + }) + ).rejects.toStrictEqual( + new Error( + "The 'Category' category with a type of 'guide' already exists with an id of '123'. A new category was not created." + ) + ); + + getMock.done(); + versionMock.done(); + }); + + it('should not create a new category if the non case sensitive title and type match and preventDuplicates=true', async () => { + const getMock = getNockWithVersionHeader(version) + .persist() + .get('/api/v1/categories?perPage=20&page=1') + .basicAuth({ user: key }) + .reply(200, [{ title: 'Category', slug: 'category', type: 'guide', id: '123' }], { + 'x-total-count': '1', + }); + + const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect( + categoriesCreate.run({ + title: 'category', + categoryType: 'guide', + key, + version: '1.0.0', + preventDuplicates: true, + }) + ).rejects.toStrictEqual( + new Error( + "The 'Category' category with a type of 'guide' already exists with an id of '123'. A new category was not created." + ) + ); + + getMock.done(); + versionMock.done(); + }); +}); diff --git a/src/cmds/categories/create.js b/src/cmds/categories/create.js new file mode 100644 index 000000000..529452246 --- /dev/null +++ b/src/cmds/categories/create.js @@ -0,0 +1,107 @@ +const chalk = require('chalk'); +const { cleanHeaders, handleRes } = require('../../lib/fetch'); +const config = require('config'); +const { debug } = require('../../lib/logger'); +const fetch = require('../../lib/fetch'); +const getCategories = require('../../lib/getCategories'); +const { getProjectVersion } = require('../../lib/versionSelect'); + +module.exports = class CategoriesCreateCommand { + constructor() { + this.command = 'categories:create'; + this.usage = 'categories:create <title> [options]'; + this.description = 'Create a category with the specified title and guide in your ReadMe project'; + this.category = 'categories'; + this.position = 2; + + this.hiddenargs = ['title']; + this.args = [ + { + name: 'key', + type: String, + description: 'Project API key', + }, + { + name: 'version', + type: String, + description: 'Project version', + }, + { + name: 'title', + type: String, + defaultOption: true, + }, + { + name: 'categoryType', + type: String, + description: 'Category type, must be `guide` or `reference`', + }, + { + name: 'preventDuplicates', + type: Boolean, + description: + 'Prevents the creation of a new category if their is an existing category with a matching `categoryType` and `title`', + }, + ]; + } + + async run(opts) { + const { categoryType, title, key, version, preventDuplicates } = opts; + debug(`command: ${this.command}`); + debug(`opts: ${JSON.stringify(opts)}`); + + if (!key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + + if (!title) { + return Promise.reject(new Error(`No title provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); + } + + if (categoryType !== 'guide' && categoryType !== 'reference') { + return Promise.reject(new Error('`categoryType` must be `guide` or `reference`.')); + } + + const selectedVersion = await getProjectVersion(version, key, false); + + debug(`selectedVersion: ${selectedVersion}`); + + async function matchCategory() { + const allCategories = await getCategories(key, selectedVersion); + + return allCategories.find(category => { + return category.title.trim().toLowerCase() === title.trim().toLowerCase() && category.type === categoryType; + }); + } + + async function createCategory() { + if (preventDuplicates) { + const matchedCategory = await matchCategory(); + if (typeof matchedCategory !== 'undefined') { + return Promise.reject( + new Error( + `The '${matchedCategory.title}' category with a type of '${matchedCategory.type}' already exists with an id of '${matchedCategory.id}'. A new category was not created.` + ) + ); + } + } + return fetch(`${config.get('host')}/api/v1/categories`, { + method: 'post', + headers: cleanHeaders(key, { + 'x-readme-version': selectedVersion, + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + title, + type: categoryType, + }), + }) + .then(res => handleRes(res)) + .then(res => `🌱 successfully created '${res.title}' with a type of '${res.type}' and an id of '${res.id}'`); + } + + const createdCategory = chalk.green(await createCategory()); + + return Promise.resolve(createdCategory); + } +}; diff --git a/src/cmds/categories/index.js b/src/cmds/categories/index.js new file mode 100644 index 000000000..8cc7353aa --- /dev/null +++ b/src/cmds/categories/index.js @@ -0,0 +1,44 @@ +const { debug } = require('../../lib/logger'); +const { getProjectVersion } = require('../../lib/versionSelect'); +const getCategories = require('../../lib/getCategories'); + +module.exports = class CategoriesCommand { + constructor() { + this.command = 'categories'; + this.usage = 'categories [options]'; + this.description = 'Get all categories in your ReadMe project'; + this.category = 'categories'; + this.position = 1; + + this.args = [ + { + name: 'key', + type: String, + description: 'Project API key', + }, + { + name: 'version', + type: String, + description: 'Project version', + }, + ]; + } + + async run(opts) { + const { key, version } = opts; + + debug(`command: ${this.command}`); + debug(`opts: ${JSON.stringify(opts)}`); + + if (!key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + const selectedVersion = await getProjectVersion(version, key, true); + + debug(`selectedVersion: ${selectedVersion}`); + + const allCategories = await getCategories(key, selectedVersion); + + return Promise.resolve(JSON.stringify(allCategories, null, 2)); + } +}; diff --git a/src/cmds/docs/edit.js b/src/cmds/docs/edit.js index 4f189ef6e..8c631a5b0 100644 --- a/src/cmds/docs/edit.js +++ b/src/cmds/docs/edit.js @@ -54,9 +54,7 @@ module.exports = class EditDocsCommand { return Promise.reject(new Error(`No slug provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } - const selectedVersion = await getProjectVersion(version, key, true).catch(e => { - return Promise.reject(e); - }); + const selectedVersion = await getProjectVersion(version, key, true); debug(`selectedVersion: ${selectedVersion}`); diff --git a/src/lib/commands.js b/src/lib/commands.js index 6d36edd2b..d83f89cce 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -84,6 +84,10 @@ exports.getCategories = () => { description: 'Versions', commands: [], }, + categories: { + description: 'Categories', + commands: [], + }, utilities: { description: 'Other useful commands', commands: [], diff --git a/src/lib/getCategories.js b/src/lib/getCategories.js new file mode 100644 index 000000000..a76e3e472 --- /dev/null +++ b/src/lib/getCategories.js @@ -0,0 +1,49 @@ +const config = require('config'); +const fetch = require('./fetch'); +const { cleanHeaders, handleRes } = require('./fetch'); + +/** + * Returns all categories for a given project and version + * + * @param {String} key project API key + * @param {String} selectedVersion project version + * @returns An array of category objects + */ +module.exports = async function getCategories(key, selectedVersion) { + function getNumberOfPages() { + let totalCount = 0; + return fetch(`${config.get('host')}/api/v1/categories?perPage=20&page=1`, { + method: 'get', + headers: cleanHeaders(key, { + 'x-readme-version': selectedVersion, + Accept: 'application/json', + }), + }) + .then(res => { + totalCount = Math.ceil(res.headers.get('x-total-count') / 20); + return handleRes(res); + }) + .then(res => { + return { firstPage: res, totalCount }; + }); + } + + const { firstPage, totalCount } = await getNumberOfPages(); + + const allCategories = firstPage.concat( + ...(await Promise.all( + // retrieves all categories beyond first page + [...new Array(totalCount + 1).keys()].slice(2).map(async page => { + return fetch(`${config.get('host')}/api/v1/categories?perPage=20&page=${page}`, { + method: 'get', + headers: cleanHeaders(key, { + 'x-readme-version': selectedVersion, + Accept: 'application/json', + }), + }).then(res => handleRes(res)); + }) + )) + ); + + return allCategories; +};