From 7574752f759ed89cde4fb94eda970384d335f95d Mon Sep 17 00:00:00 2001 From: Kanad Gupta <8854718+kanadgupta@users.noreply.github.com> Date: Wed, 1 Feb 2023 15:21:31 -0600 Subject: [PATCH] feat(GHA): add req header containing file URL (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(GHA): add req header containing file URL * test: fix coverage for ci.name * chore: add eslint rule for importing `ci-info` * chore: add smol jsdoc * chore: another JSdoc * chore: fix whitespace * refactor: separate GHA runner setup/teardown ... into separate functions * chore: rename variable * chore: misc JSDocs * chore: include sanitized headers in debug logs * fix: normalize relative file paths * test: a buttload of tests * test: consolidate some CI tests * chore: fix a JSDoc * revert: restore a test to its original place it's failing now but not sure why 😬 * chore: try different approach to GHA env vars feedback: https://github.com/readmeio/rdme/pull/735#discussion_r1092663989 https://www.webtips.dev/how-to-mock-processenv-in-jest * refactor: use shorthand Co-Authored-By: Ryan Park * fix: better approach to normalizing file path feedback: https://github.com/readmeio/rdme/pull/735#discussion_r1092643454 * chore: fix JSDoc --------- Co-authored-by: Ryan Park --- .eslintrc | 4 + __tests__/cmds/docs/index.test.ts | 139 ++++++++++++++++++++++- __tests__/cmds/docs/single.test.ts | 110 +++++++++++++++++- __tests__/cmds/openapi/index.test.ts | 159 ++++++++++++++++++++++----- __tests__/helpers/get-gha-setup.ts | 2 +- __tests__/helpers/setup-gha-env.ts | 36 ++++++ __tests__/lib/fetch.test.ts | 106 +++++++++++++----- src/cmds/openapi/index.ts | 14 ++- src/lib/fetch.ts | 76 ++++++++++++- src/lib/isCI.ts | 13 ++- src/lib/prepareOas.ts | 8 ++ src/lib/syncDocsPath.ts | 80 ++++++++------ 12 files changed, 638 insertions(+), 109 deletions(-) create mode 100644 __tests__/helpers/setup-gha-env.ts diff --git a/.eslintrc b/.eslintrc index f7be50cfa..4cbfa7974 100644 --- a/.eslintrc +++ b/.eslintrc @@ -55,6 +55,10 @@ "name": "node-fetch", "importNames": ["default"], "message": "Avoid using `node-fetch` directly and instead use the fetch wrapper located in `lib/fetch.ts`. See CONTRIBUTING.md for more information." + }, + { + "name": "ci-info", + "message": "The `ci-info` package is difficult to test because misleading results will appear when running tests in the GitHub Actions runner. Instead of importing this package directly, create a wrapper function in `lib/isCI.ts` and import that instead." } ] } diff --git a/__tests__/cmds/docs/index.test.ts b/__tests__/cmds/docs/index.test.ts index 94a6b348e..566204912 100644 --- a/__tests__/cmds/docs/index.test.ts +++ b/__tests__/cmds/docs/index.test.ts @@ -14,6 +14,7 @@ import configstore from '../../../src/lib/configstore'; import getAPIMock, { getAPIMockWithVersionHeader } from '../../helpers/get-api-mock'; import { after, before } from '../../helpers/get-gha-setup'; import hashFileContents from '../../helpers/hash-file-contents'; +import { after as afterGHAEnv, before as beforeGHAEnv } from '../../helpers/setup-gha-env'; const docs = new DocsCommand(); const guides = new GuidesCommand(); @@ -68,12 +69,6 @@ describe('rdme docs', () => { jest.resetAllMocks(); }); - it('should error in CI if no API key provided', async () => { - process.env.TEST_RDME_CI = 'true'; - await expect(docs.run({})).rejects.toStrictEqual(new Error('No project API key provided. Please use `--key`.')); - delete process.env.TEST_RDME_CI; - }); - it('should error if no path provided', async () => { const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); @@ -623,6 +618,138 @@ describe('rdme docs', () => { versionMock.done(); }); }); + + describe('command execution in GitHub Actions runner', () => { + beforeEach(beforeGHAEnv); + + afterEach(afterGHAEnv); + + it('should error in CI if no API key provided', () => { + return expect(docs.run({})).rejects.toStrictEqual(new Error('No project API key provided. Please use `--key`.')); + }); + + it('should sync new docs directory with correct headers', async () => { + const slug = 'new-doc'; + const id = '1234'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getAPIMockWithVersionHeader(version) + .get(`/api/v1/docs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock({ + 'x-rdme-ci': 'GitHub Actions (test)', + 'x-readme-source': 'cli-gh', + 'x-readme-source-url': + 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/__tests__/__fixtures__/docs/new-docs/new-doc.md', + 'x-readme-version': version, + }) + .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, _id: id, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect(docs.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs`, key, version })).resolves.toBe( + `🌱 successfully created 'new-doc' (ID: 1234) with contents from __tests__/${fixturesBaseDir}/new-docs/new-doc.md` + ); + + getMock.done(); + postMock.done(); + versionMock.done(); + }); + + it('should sync existing docs directory with correct headers', () => { + let fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); + const simpleDoc = { + slug: 'simple-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + + fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/subdir/another-doc.md')); + const anotherDoc = { + slug: 'another-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + + expect.assertions(1); + + const getMocks = getAPIMockWithVersionHeader(version) + .get('/api/v1/docs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) + .get('/api/v1/docs/another-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: anotherDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const firstUpdateMock = getAPIMock({ + 'x-rdme-ci': 'GitHub Actions (test)', + 'x-readme-source': 'cli-gh', + 'x-readme-source-url': + 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/__tests__/__fixtures__/docs/existing-docs/simple-doc.md', + 'x-readme-version': version, + }) + .put('/api/v1/docs/simple-doc', { + body: simpleDoc.doc.content, + lastUpdatedHash: simpleDoc.hash, + ...simpleDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { + category, + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + }); + + const secondUpdateMock = getAPIMock({ + 'x-rdme-ci': 'GitHub Actions (test)', + 'x-readme-source': 'cli-gh', + 'x-readme-source-url': + 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/__tests__/__fixtures__/docs/existing-docs/subdir/another-doc.md', + 'x-readme-version': version, + }) + .put('/api/v1/docs/another-doc', { + body: anotherDoc.doc.content, + lastUpdatedHash: anotherDoc.hash, + ...anotherDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { category, slug: anotherDoc.slug, body: anotherDoc.doc.content }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + return docs.run({ filePath: `__tests__/${fixturesBaseDir}/existing-docs`, key, version }).then(updatedDocs => { + // All docs should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `✏️ successfully updated 'simple-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, + `✏️ successfully updated 'another-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/subdir/another-doc.md`, + ].join('\n') + ); + + getMocks.done(); + firstUpdateMock.done(); + secondUpdateMock.done(); + versionMock.done(); + }); + }); + }); }); describe('rdme guides', () => { diff --git a/__tests__/cmds/docs/single.test.ts b/__tests__/cmds/docs/single.test.ts index b973315e1..ab98c5a28 100644 --- a/__tests__/cmds/docs/single.test.ts +++ b/__tests__/cmds/docs/single.test.ts @@ -10,6 +10,7 @@ import DocsCommand from '../../../src/cmds/docs'; import APIError from '../../../src/lib/apiError'; import getAPIMock, { getAPIMockWithVersionHeader } from '../../helpers/get-api-mock'; import hashFileContents from '../../helpers/hash-file-contents'; +import { after as afterGHAEnv, before as beforeGHAEnv } from '../../helpers/setup-gha-env'; const docs = new DocsCommand(); @@ -32,12 +33,6 @@ describe('rdme docs (single)', () => { consoleInfoSpy.mockRestore(); }); - it('should error in CI if no API key provided', async () => { - process.env.TEST_RDME_CI = 'true'; - await expect(docs.run({})).rejects.toStrictEqual(new Error('No project API key provided. Please use `--key`.')); - delete process.env.TEST_RDME_CI; - }); - it('should error if no file path provided', async () => { const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); @@ -347,4 +342,107 @@ describe('rdme docs (single)', () => { }); }); }); + + describe('command execution in GitHub Actions runner', () => { + beforeEach(beforeGHAEnv); + + afterEach(afterGHAEnv); + + it('should error in CI if no API key provided', () => { + return expect(docs.run({})).rejects.toStrictEqual(new Error('No project API key provided. Please use `--key`.')); + }); + + it('should sync new doc with correct headers', async () => { + const slug = 'new-doc'; + const id = '1234'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getAPIMockWithVersionHeader(version) + .get(`/api/v1/docs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock({ + 'x-rdme-ci': 'GitHub Actions (test)', + 'x-readme-source': 'cli-gh', + 'x-readme-source-url': + 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/__tests__/__fixtures__/docs/new-docs/new-doc.md', + 'x-readme-version': version, + }) + .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, _id: id, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect( + docs.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key, version }) + ).resolves.toBe( + `🌱 successfully created 'new-doc' (ID: 1234) with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md` + ); + + getMock.done(); + postMock.done(); + versionMock.done(); + }); + + it('should sync existing doc with correct headers', () => { + const fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); + const simpleDoc = { + slug: 'simple-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + + const getMock = getAPIMockWithVersionHeader(version) + .get('/api/v1/docs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const updateMock = getAPIMock({ + 'x-rdme-ci': 'GitHub Actions (test)', + 'x-readme-source': 'cli-gh', + 'x-readme-source-url': + 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/__tests__/__fixtures__/docs/existing-docs/simple-doc.md', + 'x-readme-version': version, + }) + .put('/api/v1/docs/simple-doc', { + body: simpleDoc.doc.content, + lastUpdatedHash: simpleDoc.hash, + ...simpleDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { + category, + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + return docs + .run({ filePath: `__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key, version }) + .then(updatedDocs => { + expect(updatedDocs).toBe( + `✏️ successfully updated 'simple-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/simple-doc.md` + ); + + getMock.done(); + updateMock.done(); + versionMock.done(); + }); + }); + }); }); diff --git a/__tests__/cmds/openapi/index.test.ts b/__tests__/cmds/openapi/index.test.ts index 57a73f6ab..a2af117df 100644 --- a/__tests__/cmds/openapi/index.test.ts +++ b/__tests__/cmds/openapi/index.test.ts @@ -9,8 +9,10 @@ import prompts from 'prompts'; import OpenAPICommand from '../../../src/cmds/openapi'; import SwaggerCommand from '../../../src/cmds/swagger'; import APIError from '../../../src/lib/apiError'; +import petstoreWeird from '../../__fixtures__/petstore-simple-weird-version.json'; import getAPIMock, { getAPIMockWithVersionHeader } from '../../helpers/get-api-mock'; import { after, before } from '../../helpers/get-gha-setup'; +import { after as afterGHAEnv, before as beforeGHAEnv } from '../../helpers/setup-gha-env'; const openapi = new OpenAPICommand(); const swagger = new SwaggerCommand(); @@ -446,25 +448,6 @@ describe('rdme openapi', () => { mockWithHeader.done(); return mock.done(); }); - - describe('CI spec selection', () => { - beforeEach(() => { - process.env.TEST_RDME_CI = 'true'; - }); - - afterEach(() => { - delete process.env.TEST_RDME_CI; - }); - - it('should error out if multiple possible spec matches were found', () => { - return expect( - openapi.run({ - key, - version, - }) - ).rejects.toStrictEqual(new Error('Multiple API definitions found in current directory. Please specify file.')); - }); - }); }); describe('updates / resyncs', () => { @@ -885,14 +868,6 @@ describe('rdme openapi', () => { return expect(openapi.run({})).rejects.toStrictEqual(new Error('You must provide a valid email address.')); }); - it('should error in CI if no API key provided', async () => { - process.env.TEST_RDME_CI = 'true'; - await expect(openapi.run({})).rejects.toStrictEqual( - new Error('No project API key provided. Please use `--key`.') - ); - delete process.env.TEST_RDME_CI; - }); - it('should error if `--create` and `--update` flags are passed simultaneously', () => { return expect(openapi.run({ key, create: true, update: true })).rejects.toStrictEqual( new Error('The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!') @@ -1478,6 +1453,136 @@ describe('rdme openapi', () => { return mock.done(); }); }); + + describe('command execution in GitHub Actions runner', () => { + beforeEach(beforeGHAEnv); + + afterEach(afterGHAEnv); + + it('should error in CI if no API key provided', () => { + return expect(openapi.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + }); + + it('should error out if multiple possible spec matches were found', () => { + return expect( + openapi.run({ + key, + version, + }) + ).rejects.toStrictEqual(new Error('Multiple API definitions found in current directory. Please specify file.')); + }); + + it('should send proper headers in GitHub Actions CI for local spec file', async () => { + const registryUUID = getRandomRegistryId(); + + const mock = getAPIMock() + .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) + .reply(201, { registryUUID }); + + const putMock = getAPIMock({ + 'x-rdme-ci': 'GitHub Actions (test)', + 'x-readme-source': 'cli-gh', + 'x-readme-source-url': + 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/__tests__/__fixtures__/ref-oas/petstore.json', + 'x-readme-version': version, + }) + .put(`/api/v1/api-specification/${id}`, { registryUUID }) + .basicAuth({ user: key }) + .reply(201, { _id: 1 }, { location: exampleRefLocation }); + + const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; + + await expect( + openapi.run({ + spec, + key, + id, + version, + }) + ).resolves.toBe(successfulUpdate(spec)); + + putMock.done(); + return mock.done(); + }); + + it('should send proper headers in GitHub Actions CI for spec hosted at URL', async () => { + const registryUUID = getRandomRegistryId(); + const spec = 'https://example.com/openapi.json'; + + const mock = getAPIMock() + .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) + .reply(201, { registryUUID }); + + const exampleMock = nock('https://example.com').get('/openapi.json').reply(200, petstoreWeird); + + const putMock = getAPIMock({ + 'x-rdme-ci': 'GitHub Actions (test)', + 'x-readme-source': 'cli-gh', + 'x-readme-source-url': spec, + 'x-readme-version': version, + }) + .put(`/api/v1/api-specification/${id}`, { registryUUID }) + .basicAuth({ user: key }) + .reply(201, { _id: 1 }, { location: exampleRefLocation }); + + await expect( + openapi.run({ + spec, + key, + id, + version, + }) + ).resolves.toBe(successfulUpdate(spec)); + + putMock.done(); + exampleMock.done(); + return mock.done(); + }); + + it('should contain request header with correct URL with working directory', async () => { + const registryUUID = getRandomRegistryId(); + const mock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version: '1.0.0' }) + .post('/api/v1/api-registry') + .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); + + const getMock = getAPIMockWithVersionHeader(version) + .get('/api/v1/api-specification') + .basicAuth({ user: key }) + .reply(200, []); + + const postMock = getAPIMock({ + 'x-rdme-ci': 'GitHub Actions (test)', + 'x-readme-source': 'cli-gh', + 'x-readme-source-url': + 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/__tests__/__fixtures__/relative-ref-oas/petstore.json', + 'x-readme-version': version, + }) + .post('/api/v1/api-specification', { registryUUID }) + .basicAuth({ user: key }) + .reply(201, { _id: 1 }, { location: exampleRefLocation }); + + const spec = 'petstore.json'; + + await expect( + openapi.run({ + spec, + key, + version, + workingDirectory: './__tests__/__fixtures__/relative-ref-oas', + }) + ).resolves.toBe(successfulUpload(spec)); + + getMock.done(); + postMock.done(); + mock.done(); + return after(); + }); + }); }); describe('rdme swagger', () => { diff --git a/__tests__/helpers/get-gha-setup.ts b/__tests__/helpers/get-gha-setup.ts index 4e055c609..c0c11acc5 100644 --- a/__tests__/helpers/get-gha-setup.ts +++ b/__tests__/helpers/get-gha-setup.ts @@ -11,7 +11,7 @@ import getGitRemoteMock from './get-git-mock'; const testWorkingDir = process.cwd(); /** - * A helper function for setting up tests for our GitHub Action onboarding. + * A helper function for setting up tests for our GitHub Action onboarding. * * @param writeFileSyncCb the mock function that should be called * in place of `fs.writeFileSync` diff --git a/__tests__/helpers/setup-gha-env.ts b/__tests__/helpers/setup-gha-env.ts new file mode 100644 index 000000000..14edabae3 --- /dev/null +++ b/__tests__/helpers/setup-gha-env.ts @@ -0,0 +1,36 @@ +import * as isCI from '../../src/lib/isCI'; + +const env = process.env; + +/** + * A helper function for setting up tests for simulating a GitHub Actions runner environment + */ +export function before() { + jest.resetModules(); + // List of all GitHub Actions env variables: + // https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables + process.env.GITHUB_ACTION = '__repo-owner_name-of-action-repo'; + process.env.GITHUB_ACTIONS = 'true'; + process.env.GITHUB_REPOSITORY = 'octocat/Hello-World'; + process.env.GITHUB_RUN_ATTEMPT = '3'; + process.env.GITHUB_RUN_ID = '1658821493'; + process.env.GITHUB_RUN_NUMBER = '3'; + process.env.GITHUB_SERVER_URL = 'https://github.com'; + process.env.GITHUB_SHA = 'ffac537e6cbbf934b08745a378932722df287a53'; + process.env.TEST_RDME_CI = 'true'; + + const isGHASpy = jest.spyOn(isCI, 'isGHA'); + isGHASpy.mockReturnValue(true); + + const ciNameSpy = jest.spyOn(isCI, 'ciName'); + ciNameSpy.mockReturnValue('GitHub Actions (test)'); +} + +/** + * A helper function for tearing down tests after simulating a GitHub Actions runner environment + */ +export function after() { + process.env = env; + delete process.env.TEST_RDME_CI; + jest.resetAllMocks(); +} diff --git a/__tests__/lib/fetch.test.ts b/__tests__/lib/fetch.test.ts index 458fda0d0..3b1fdd080 100644 --- a/__tests__/lib/fetch.test.ts +++ b/__tests__/lib/fetch.test.ts @@ -4,37 +4,14 @@ import { Headers } from 'node-fetch'; import pkg from '../../package.json'; import fetch, { cleanHeaders, handleRes } from '../../src/lib/fetch'; -import * as isCI from '../../src/lib/isCI'; import getAPIMock from '../helpers/get-api-mock'; +import { after, before } from '../helpers/setup-gha-env'; describe('#fetch()', () => { describe('GitHub Actions environment', () => { - let spy: jest.SpyInstance; + beforeEach(before); - // List of all GitHub Actions env variables: - // https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables - beforeEach(() => { - process.env.GITHUB_ACTION = '__repo-owner_name-of-action-repo'; - process.env.GITHUB_ACTIONS = 'true'; - process.env.GITHUB_REPOSITORY = 'octocat/Hello-World'; - process.env.GITHUB_RUN_ATTEMPT = '3'; - process.env.GITHUB_RUN_ID = '1658821493'; - process.env.GITHUB_RUN_NUMBER = '3'; - process.env.GITHUB_SHA = 'ffac537e6cbbf934b08745a378932722df287a53'; - spy = jest.spyOn(isCI, 'isGHA'); - spy.mockReturnValue(true); - }); - - afterEach(() => { - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_ACTIONS; - delete process.env.GITHUB_REPOSITORY; - delete process.env.GITHUB_RUN_ATTEMPT; - delete process.env.GITHUB_RUN_ID; - delete process.env.GITHUB_RUN_NUMBER; - delete process.env.GITHUB_SHA; - spy.mockReset(); - }); + afterEach(after); it('should have correct headers for requests in GitHub Action env', async () => { const key = 'API_KEY'; @@ -58,8 +35,85 @@ describe('#fetch()', () => { expect(headers['x-github-run-id'].shift()).toBe('1658821493'); expect(headers['x-github-run-number'].shift()).toBe('3'); expect(headers['x-github-sha'].shift()).toBe('ffac537e6cbbf934b08745a378932722df287a53'); + expect(headers['x-rdme-ci'].shift()).toBe('GitHub Actions (test)'); mock.done(); }); + + describe('source URL header', () => { + it('should include source URL header with simple path', async () => { + const key = 'API_KEY'; + + const mock = getAPIMock() + .get('/api/v1') + .basicAuth({ user: key }) + .reply(200, function () { + return this.req.headers; + }); + + const headers = await fetch( + `${config.get('host')}/api/v1`, + { + method: 'get', + headers: cleanHeaders(key), + }, + { filePath: 'openapi.json', fileType: 'path' } + ).then(handleRes); + + expect(headers['x-readme-source-url'].shift()).toBe( + 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/openapi.json' + ); + mock.done(); + }); + + it('should include source URL header with relative path', async () => { + const key = 'API_KEY'; + + const mock = getAPIMock() + .get('/api/v1') + .basicAuth({ user: key }) + .reply(200, function () { + return this.req.headers; + }); + + const headers = await fetch( + `${config.get('host')}/api/v1`, + { + method: 'get', + headers: cleanHeaders(key), + }, + { filePath: './openapi.json', fileType: 'path' } + ).then(handleRes); + + expect(headers['x-readme-source-url'].shift()).toBe( + 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/openapi.json' + ); + mock.done(); + }); + + it('should include source URL header with URL path', async () => { + const key = 'API_KEY'; + const filePath = 'https://example.com/openapi.json'; + + const mock = getAPIMock() + .get('/api/v1') + .basicAuth({ user: key }) + .reply(200, function () { + return this.req.headers; + }); + + const headers = await fetch( + `${config.get('host')}/api/v1`, + { + method: 'get', + headers: cleanHeaders(key), + }, + { filePath, fileType: 'url' } + ).then(handleRes); + + expect(headers['x-readme-source-url'].shift()).toBe(filePath); + mock.done(); + }); + }); }); it('should wrap all requests with standard user-agent and source headers', async () => { diff --git a/src/cmds/openapi/index.ts b/src/cmds/openapi/index.ts index ec2ac7c11..4b7f3f6ee 100644 --- a/src/cmds/openapi/index.ts +++ b/src/cmds/openapi/index.ts @@ -133,7 +133,9 @@ export default class OpenAPICommand extends Command { // Reason we're hardcoding in command here is because `swagger` command // relies on this and we don't want to use `swagger` in this function - const { preparedSpec, specPath, specType, specVersion } = await prepareOas(spec, 'openapi', { title }); + const { preparedSpec, specFileType, specPath, specType, specVersion } = await prepareOas(spec, 'openapi', { + title, + }); if (useSpecVersion) { Command.info( @@ -235,7 +237,10 @@ export default class OpenAPICommand extends Command { options.method = 'post'; spinner.start('Creating your API docs in ReadMe...'); - return fetch(`${config.get('host')}/api/v1/api-specification`, options).then(res => { + return fetch(`${config.get('host')}/api/v1/api-specification`, options, { + filePath: specPath, + fileType: specFileType, + }).then(res => { if (res.ok) { spinner.succeed(`${spinner.text} done! 🦉`); return success(res); @@ -253,7 +258,10 @@ export default class OpenAPICommand extends Command { isUpdate = true; options.method = 'put'; spinner.start('Updating your API docs in ReadMe...'); - return fetch(`${config.get('host')}/api/v1/api-specification/${specId}`, options).then(res => { + return fetch(`${config.get('host')}/api/v1/api-specification/${specId}`, options, { + filePath: specPath, + fileType: specFileType, + }).then(res => { if (res.ok) { spinner.succeed(`${spinner.text} done! 🦉`); return success(res); diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 324235e7f..a9ce972f0 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -1,5 +1,8 @@ +import type { SpecFileType } from './prepareOas'; import type { RequestInit, Response } from 'node-fetch'; +import path from 'path'; + import mime from 'mime-types'; // eslint-disable-next-line no-restricted-imports import nodeFetch, { Headers } from 'node-fetch'; @@ -7,11 +10,23 @@ import nodeFetch, { Headers } from 'node-fetch'; import pkg from '../../package.json'; import APIError from './apiError'; -import { isGHA } from './isCI'; +import { git } from './createGHA'; +import isCI, { ciName, isGHA } from './isCI'; import { debug, warn } from './logger'; const SUCCESS_NO_CONTENT = 204; +/** + * This contains a few pieces of information about a file so + * we can properly construct a source URL for it. + */ +interface FilePathDetails { + /** The URL or local file path */ + filePath: string; + /** This is derived from the `oas-normalize` `type` property. */ + fileType: SpecFileType; +} + function getProxy() { // this is something of an industry standard env var, hence the checks for different casings const proxy = process.env.HTTPS_PROXY || process.env.https_proxy; @@ -84,11 +99,42 @@ function getUserAgent() { return `rdme${gh}/${pkg.version}`; } +/** + * Creates a relative path for the file from the root of the repo, + * otherwise returns the path + */ +async function normalizeFilePath(opts: FilePathDetails) { + if (opts.fileType === 'path') { + const repoRoot = await git.revparse(['--show-toplevel']).catch(e => { + debug(`[fetch] error grabbing git root: ${e.message}`); + return ''; + }); + + return path.relative(repoRoot, opts.filePath); + } + return opts.filePath; +} + +/** + * Sanitizes and stringifies the `Headers` object for logging purposes + */ +function sanitizeHeaders(headers: Headers) { + const raw = new Headers(headers).raw(); + if (raw.Authorization) raw.Authorization = ['redacted']; + return JSON.stringify(raw); +} + /** * Wrapper for the `fetch` API so we can add rdme-specific headers to all API requests. * + * @param fileOpts optional object containing information about the file being sent. + * We use this to construct a full source URL for the file. */ -export default function fetch(url: string, options: RequestInit = { headers: new Headers() }) { +export default async function fetch( + url: string, + options: RequestInit = { headers: new Headers() }, + fileOpts: FilePathDetails = { filePath: '', fileType: false } +) { let source = 'cli'; let headers = options.headers as Headers; @@ -105,13 +151,37 @@ export default function fetch(url: string, options: RequestInit = { headers: new headers.set('x-github-run-id', process.env.GITHUB_RUN_ID); headers.set('x-github-run-number', process.env.GITHUB_RUN_NUMBER); headers.set('x-github-sha', process.env.GITHUB_SHA); + + const filePath = await normalizeFilePath(fileOpts); + + if (filePath) { + /** + * Constructs a full URL to the file using GitHub Actions runner variables + * @see {@link https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables} + * @example https://github.com/readmeio/rdme/blob/cb4129d5c7b51ff3b50f933a9c7d0c3d0d33d62c/documentation/rdme.md + */ + headers.set( + 'x-readme-source-url', + `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/blob/${process.env.GITHUB_SHA}/${filePath}` + ); + } + } + + if (isCI()) { + headers.set('x-rdme-ci', ciName()); } headers.set('x-readme-source', source); + if (fileOpts.filePath && fileOpts.fileType === 'url') { + headers.set('x-readme-source-url', fileOpts.filePath); + } + const fullUrl = `${getProxy()}${url}`; - debug(`making ${(options.method || 'get').toUpperCase()} request to ${fullUrl}`); + debug( + `making ${(options.method || 'get').toUpperCase()} request to ${fullUrl} with headers: ${sanitizeHeaders(headers)}` + ); return nodeFetch(fullUrl, { ...options, diff --git a/src/lib/isCI.ts b/src/lib/isCI.ts index 12773a4b4..178a232de 100644 --- a/src/lib/isCI.ts +++ b/src/lib/isCI.ts @@ -1,4 +1,15 @@ -import ci from 'ci-info'; +import ci from 'ci-info'; // eslint-disable-line no-restricted-imports + +/** + * Wrapper function that returns the name of the current CI environment + * (or "n/a" if it's not available). + * + * Full list of vendors available here: + * https://github.com/watson/ci-info#supported-ci-tools + */ +export function ciName() { + return ci.name || 'n/a'; +} /** * Small env check to determine if we're running our testbed diff --git a/src/lib/prepareOas.ts b/src/lib/prepareOas.ts index d244dba92..3a3460025 100644 --- a/src/lib/prepareOas.ts +++ b/src/lib/prepareOas.ts @@ -9,6 +9,8 @@ import { debug, info, oraOptions } from './logger'; import promptTerminal from './promptWrapper'; import readdirRecursive from './readdirRecursive'; +export type SpecFileType = OASNormalize['type']; + interface FoundSpecFile { /** path to the spec file */ filePath: string; @@ -188,6 +190,8 @@ export default async function prepareOas( api.info.title = opts.title; } + const specFileType = oas.type; + // No need to optional chain here since `info.version` is required to pass validation const specVersion: string = api.info.version; debug(`version in spec: ${specVersion}`); @@ -200,7 +204,11 @@ export default async function prepareOas( return { preparedSpec: JSON.stringify(api), + /** A string indicating whether the spec file is a local path, a URL, etc. */ + specFileType, + /** The path/URL to the spec file */ specPath, + /** A string indicating whether the spec file is OpenAPI, Swagger, etc. */ specType, /** * The `info.version` field, extracted from the normalized spec. diff --git a/src/lib/syncDocsPath.ts b/src/lib/syncDocsPath.ts index 9bd4d288c..f225f3c34 100644 --- a/src/lib/syncDocsPath.ts +++ b/src/lib/syncDocsPath.ts @@ -19,7 +19,7 @@ import readDoc from './readDoc'; * @param key the project API key * @param selectedVersion the project version * @param dryRun boolean indicating dry run mode - * @param filepath path to file + * @param filePath path to file * @param type module within ReadMe to update (e.g. docs, changelogs, etc.) * @returns A promise-wrapped string with the result */ @@ -27,16 +27,16 @@ async function pushDoc( key: string, selectedVersion: string, dryRun: boolean, - filepath: string, + filePath: string, type: CommandCategories ) { - const { content, data, hash, slug } = readDoc(filepath); + const { content, data, hash, slug } = readDoc(filePath); // TODO: ideally we should offer a zero-configuration approach that doesn't // require YAML front matter, but that will have to be a breaking change if (!Object.keys(data).length) { - debug(`No front matter attributes found for ${filepath}, not syncing`); - return `⏭️ no front matter attributes found for ${filepath}, skipping`; + debug(`No front matter attributes found for ${filePath}, not syncing`); + return `⏭️ no front matter attributes found for ${filePath}, skipping`; } let payload: { @@ -47,7 +47,7 @@ async function pushDoc( } = { body: content, ...data, lastUpdatedHash: hash }; if (type === CommandCategories.CUSTOM_PAGES) { - if (filepath.endsWith('.html')) { + if (filePath.endsWith('.html')) { payload = { html: content, htmlmode: true, ...data, lastUpdatedHash: hash }; } else { payload = { body: content, htmlmode: false, ...data, lastUpdatedHash: hash }; @@ -56,29 +56,33 @@ async function pushDoc( function createDoc() { if (dryRun) { - return `🎭 dry run! This will create '${slug}' with contents from ${filepath} with the following metadata: ${JSON.stringify( + return `🎭 dry run! This will create '${slug}' with contents from ${filePath} with the following metadata: ${JSON.stringify( data )}`; } return ( - fetch(`${config.get('host')}/api/v1/${type}`, { - method: 'post', - headers: cleanHeaders( - key, - new Headers({ - 'x-readme-version': selectedVersion, - 'Content-Type': 'application/json', - }) - ), - body: JSON.stringify({ - slug, - ...payload, - }), - }) + fetch( + `${config.get('host')}/api/v1/${type}`, + { + method: 'post', + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + 'Content-Type': 'application/json', + }) + ), + body: JSON.stringify({ + slug, + ...payload, + }), + }, + { filePath, fileType: 'path' } + ) .then(handleRes) // eslint-disable-next-line no-underscore-dangle - .then(res => `🌱 successfully created '${res.slug}' (ID: ${res._id}) with contents from ${filepath}`) + .then(res => `🌱 successfully created '${res.slug}' (ID: ${res._id}) with contents from ${filePath}`) ); } @@ -90,24 +94,28 @@ async function pushDoc( } if (dryRun) { - return `🎭 dry run! This will update '${slug}' with contents from ${filepath} with the following metadata: ${JSON.stringify( + return `🎭 dry run! This will update '${slug}' with contents from ${filePath} with the following metadata: ${JSON.stringify( data )}`; } - return fetch(`${config.get('host')}/api/v1/${type}/${slug}`, { - method: 'put', - headers: cleanHeaders( - key, - new Headers({ - 'x-readme-version': selectedVersion, - 'Content-Type': 'application/json', - }) - ), - body: JSON.stringify(payload), - }) + return fetch( + `${config.get('host')}/api/v1/${type}/${slug}`, + { + method: 'put', + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + 'Content-Type': 'application/json', + }) + ), + body: JSON.stringify(payload), + }, + { filePath, fileType: 'path' } + ) .then(handleRes) - .then(res => `✏️ successfully updated '${res.slug}' with contents from ${filepath}`); + .then(res => `✏️ successfully updated '${res.slug}' with contents from ${filePath}`); } return fetch(`${config.get('host')}/api/v1/${type}/${slug}`, { @@ -132,7 +140,7 @@ async function pushDoc( }) .catch(err => { // eslint-disable-next-line no-param-reassign - err.message = `Error uploading ${chalk.underline(filepath)}:\n\n${err.message}`; + err.message = `Error uploading ${chalk.underline(filePath)}:\n\n${err.message}`; throw err; }); }