Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adding openapi upload #1116

Draft
wants to merge 31 commits into
base: kanad-2024-12-06/remove-openapi
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
84636c3
refactor: filterOutFalsyHeaders function
kanadgupta Dec 9, 2024
5876c59
chore: api v2 url
kanadgupta Dec 10, 2024
d441ffe
chore: ora no longer pollutes tests
kanadgupta Dec 10, 2024
12519e9
feat: set up APIv2 fetch
kanadgupta Dec 10, 2024
109ff23
test: setup a few test helpers
kanadgupta Dec 10, 2024
91ccc59
chore(deps): install some deps
kanadgupta Dec 10, 2024
aff69ec
feat: first commit for openapi upload command
kanadgupta Dec 10, 2024
69fca4a
test: lotta command tests
kanadgupta Dec 10, 2024
ab81859
Merge branch 'kanad-2024-12-06/remove-openapi' into kanad-2024-12-06/…
kanadgupta Dec 10, 2024
c6f44d2
test: remove redundant test
kanadgupta Dec 10, 2024
37fe00f
test: better testing around polling/timeouts
kanadgupta Dec 10, 2024
0269d73
test: add test for `useSpecVersion`
kanadgupta Dec 10, 2024
2ad046a
test: flag test
kanadgupta Dec 10, 2024
d9417f4
chore: remove `--action` flag for now
kanadgupta Dec 10, 2024
f9fc29c
chore: temp change to debug failure
kanadgupta Dec 10, 2024
8ebc8dd
test: fix tests in GHA
kanadgupta Dec 10, 2024
7619692
refactor: slight DRY
kanadgupta Dec 10, 2024
2ecc22a
Merge branch 'kanad-2024-12-06/remove-openapi' into kanad-2024-12-06/…
kanadgupta Dec 10, 2024
17c6154
Merge branch 'kanad-2024-12-06/remove-openapi' into kanad-2024-12-06/…
kanadgupta Dec 10, 2024
8110144
chore: redo the knip changes
kanadgupta Dec 10, 2024
6b45f4d
Merge branch 'kanad-2024-12-06/remove-openapi' into kanad-2024-12-06/…
kanadgupta Dec 11, 2024
f558cbd
chore: rename command
kanadgupta Dec 11, 2024
1549d28
docs: consolidate description of spec arg
kanadgupta Dec 11, 2024
f55dce3
Merge branch 'kanad-2024-12-06/remove-openapi' into kanad-2024-12-06/…
kanadgupta Dec 11, 2024
81e1f73
Merge branch 'kanad-2024-12-06/remove-openapi' into kanad-2024-12-06/…
kanadgupta Dec 11, 2024
fda02d9
Merge branch 'kanad-2024-12-06/remove-openapi' into kanad-2024-12-06/…
kanadgupta Dec 11, 2024
2392d84
chore: lint
kanadgupta Dec 11, 2024
df443ce
docs: command edits
kanadgupta Dec 11, 2024
2c68a95
docs: autogenerate docs again
kanadgupta Dec 11, 2024
58557dc
Merge branch 'kanad-2024-12-06/remove-openapi' into kanad-2024-12-06/…
kanadgupta Dec 11, 2024
17ff6b6
docs: update link
kanadgupta Dec 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 333 additions & 0 deletions __tests__/commands/openapi/upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
import nock from 'nock';
import prompts from 'prompts';
import slugify from 'slugify';
import { describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest';

import Command from '../../../src/commands/openapi/upload.js';
import petstore from '../../__fixtures__/petstore-simple-weird-version.json' with { type: 'json' };
import { getAPIv2Mock, getAPIv2MockForGHA } from '../../helpers/get-api-mock.js';
import { runCommand, type OclifOutput } from '../../helpers/oclif.js';
import { after, before } from '../../helpers/setup-gha-env.js';

const key = 'rdme_123';
const version = '1.0.0';
const filename = '__tests__/__fixtures__/petstore-simple-weird-version.json';
const fileUrl = 'https://example.com/openapi.json';
const slugifiedFilename = slugify.default(filename);

describe('rdme openapi upload', () => {
let run: (args?: string[]) => OclifOutput;

beforeAll(() => {
nock.disableNetConnect();
run = runCommand(Command);
});

afterEach(() => {
nock.cleanAll();
});

describe('flag error handling', () => {
it('should throw if an error if both `--version` and `--useSpecVersion` flags are passed', async () => {
const result = await run(['--useSpecVersion', '--version', version, filename, '--key', key]);
expect(result.error.message).toContain('--version cannot also be provided when using --useSpecVersion');
});

it('should throw if an error if neither version flag is passed', async () => {
const result = await run([filename, '--key', key]);
expect(result.error.message).toContain(
'Exactly one of the following must be provided: --useSpecVersion, --version',
);
});
});

describe('given that the API definition is a local file', () => {
it('should create a new API definition in ReadMe', async () => {
const mock = getAPIv2Mock({ authorization: `Bearer ${key}` })
.get(`/versions/${version}/apis`)
.reply(200, { data: [] })
.post(`/versions/${version}/apis`, body =>
body.match(`form-data; name="schema"; filename="${slugifiedFilename}"`),
)
.reply(200, {
data: {
upload: { status: 'done' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
});

const result = await run(['--version', version, filename, '--key', key]);
expect(result.stdout).toContain('was successfully created in ReadMe!');

mock.done();
});

it('should update an existing API definition in ReadMe', async () => {
prompts.inject([true]);

const mock = getAPIv2Mock({ authorization: `Bearer ${key}` })
.get(`/versions/${version}/apis`)
.reply(200, { data: [{ filename: slugifiedFilename }] })
.put(`/versions/1.0.0/apis/${slugifiedFilename}`, body =>
body.match(`form-data; name="schema"; filename="${slugifiedFilename}"`),
)
.reply(200, {
data: {
upload: { status: 'done' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
});

const result = await run(['--version', version, filename, '--key', key]);
expect(result.stdout).toContain('was successfully updated in ReadMe!');

mock.done();
});

it('should handle upload failures', async () => {
const mock = getAPIv2Mock({ authorization: `Bearer ${key}` })
.get(`/versions/${version}/apis`)
.reply(200, { data: [] })
.post(`/versions/${version}/apis`, body =>
body.match(`form-data; name="schema"; filename="${slugifiedFilename}"`),
)
.reply(200, {
data: {
upload: { status: 'fail' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
});

const result = await run(['--version', version, filename, '--key', key]);
expect(result.error.message).toBe(
'Your API definition upload failed with an unexpected error. Please get in touch with us at [email protected].',
);

mock.done();
});

describe('and the upload status initially is a pending state', () => {
it('should poll the API until the upload is complete', async () => {
const mock = getAPIv2Mock({ authorization: `Bearer ${key}` })
.get(`/versions/${version}/apis`)
.reply(200, { data: [] })
.post(`/versions/${version}/apis`, body =>
body.match(`form-data; name="schema"; filename="${slugifiedFilename}"`),
)
.reply(200, {
data: {
upload: { status: 'pending' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
})
.get(`/versions/${version}/apis/${slugifiedFilename}`)
.times(9)
.reply(200, {
data: {
upload: { status: 'pending' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
})
.get(`/versions/${version}/apis/${slugifiedFilename}`)
.reply(200, {
data: {
upload: { status: 'done' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
});

const result = await run(['--version', version, filename, '--key', key]);
expect(result.stdout).toContain('was successfully created in ReadMe!');

mock.done();
});

it('should poll the API and handle timeouts', async () => {
const mock = getAPIv2Mock({ authorization: `Bearer ${key}` })
.get(`/versions/${version}/apis`)
.reply(200, { data: [] })
.post(`/versions/${version}/apis`, body =>
body.match(`form-data; name="schema"; filename="${slugifiedFilename}"`),
)
.reply(200, {
data: {
upload: { status: 'pending' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
})
.get(`/versions/${version}/apis/${slugifiedFilename}`)
.times(10)
.reply(200, {
data: {
upload: { status: 'pending' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
});

const result = await run(['--version', version, filename, '--key', key]);
expect(result.error.message).toBe('Sorry, this upload timed out. Please try again later.');

mock.done();
});

it('should poll the API once and handle a failure state with a 4xx', async () => {
const mock = getAPIv2Mock({ authorization: `Bearer ${key}` })
.get(`/versions/${version}/apis`)
.reply(200, { data: [] })
.post(`/versions/${version}/apis`, body =>
body.match(`form-data; name="schema"; filename="${slugifiedFilename}"`),
)
.reply(200, {
data: {
upload: { status: 'pending' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
})
.get(`/versions/${version}/apis/${slugifiedFilename}`)
.reply(400);

const result = await run(['--version', version, filename, '--key', key]);
expect(result.error.message).toBe(
'The ReadMe API responded with an unexpected error. Please try again and if this issue persists, get in touch with us at [email protected].',
);

mock.done();
});

it('should poll the API once and handle an unexpected state with a 2xx', async () => {
const mock = getAPIv2Mock({ authorization: `Bearer ${key}` })
.get(`/versions/${version}/apis`)
.reply(200, { data: [] })
.post(`/versions/${version}/apis`, body =>
body.match(`form-data; name="schema"; filename="${slugifiedFilename}"`),
)
.reply(200, {
data: {
upload: { status: 'pending' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
})
.get(`/versions/${version}/apis/${slugifiedFilename}`)
.reply(200, {
data: {
upload: { status: 'something-unexpected' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
});

const result = await run(['--version', version, filename, '--key', key]);
expect(result.error).toStrictEqual(
new Error(
'Your API definition upload failed with an unexpected error. Please get in touch with us at [email protected].',
),
);

mock.done();
});
});

describe('and the command is being run in a CI environment', () => {
beforeEach(before);

afterEach(after);

it('should overwrite an existing API definition without asking for confirmation', async () => {
const mock = getAPIv2MockForGHA({ authorization: `Bearer ${key}` })
.get(`/versions/${version}/apis`)
.reply(200, { data: [{ filename: slugifiedFilename }] })
.put(`/versions/1.0.0/apis/${slugifiedFilename}`, body =>
body.match(`form-data; name="schema"; filename="${slugifiedFilename}"`),
)
.reply(200, {
data: {
upload: { status: 'done' },
uri: `/versions/${version}/apis/${slugifiedFilename}`,
},
});

const result = await run(['--version', version, filename, '--key', key]);
expect(result.stdout).toContain('was successfully updated in ReadMe!');

mock.done();
});
});

describe('given that the `useSpecVersion` flag is set', () => {
it('should use the version from the spec file', async () => {
const altVersion = '1.2.3';
const mock = getAPIv2Mock({ authorization: `Bearer ${key}` })
.get(`/versions/${altVersion}/apis`)
.reply(200, { data: [] })
.post(`/versions/${altVersion}/apis`, body =>
body.match(`form-data; name="schema"; filename="${slugifiedFilename}"`),
)
.reply(200, {
data: {
upload: { status: 'done' },
uri: `/versions/${altVersion}/apis/${slugifiedFilename}`,
},
});

const result = await run(['--useSpecVersion', filename, '--key', key]);
expect(result.stdout).toContain('was successfully created in ReadMe!');

mock.done();
});
});
});

describe('given that the API definition is a URL', () => {
it('should create a new API definition in ReadMe', async () => {
const fileMock = nock('https://example.com').get('/openapi.json').reply(200, petstore);

const mock = getAPIv2Mock({ authorization: `Bearer ${key}` })
.get(`/versions/${version}/apis`)
.reply(200, {})
.post(`/versions/${version}/apis`, body => body.match(`form-data; name="url"\r\n\r\n${fileUrl}`))
.reply(200, {
data: {
upload: { status: 'done' },
uri: `/versions/${version}/apis/openapi.json`,
},
});

const result = await run(['--version', version, fileUrl, '--key', key]);
expect(result.stdout).toContain('was successfully created in ReadMe!');

fileMock.done();
mock.done();
});

it('should update an existing API definition in ReadMe', async () => {
prompts.inject([true]);

const fileMock = nock('https://example.com').get('/openapi.json').reply(200, petstore);

const mock = getAPIv2Mock({ authorization: `Bearer ${key}` })
.get(`/versions/${version}/apis`)
.reply(200, { data: [{ filename: 'openapi.json' }] })
.put('/versions/1.0.0/apis/openapi.json', body => body.match(`form-data; name="url"\r\n\r\n${fileUrl}`))
.reply(200, {
data: {
upload: { status: 'done' },
uri: `/versions/${version}/apis/openapi.json`,
},
});

const result = await run(['--version', version, fileUrl, '--key', key]);
expect(result.stdout).toContain('was successfully updated in ReadMe!');

fileMock.done();
mock.done();
});

it('should handle issues fetching from the URL', async () => {
const fileMock = nock('https://example.com').get('/openapi.json').reply(400, {});

const result = await run(['--version', version, fileUrl, '--key', key]);
expect(result.error.message).toBe('Unknown file detected.');

fileMock.done();
});
});
});
27 changes: 27 additions & 0 deletions __tests__/helpers/get-api-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import nock from 'nock';
import config from '../../src/lib/config.js';
import { getUserAgent } from '../../src/lib/readmeAPIFetch.js';

import { mockVersion } from './oclif.js';

/**
* Nock wrapper for ReadMe API v1 that adds required
* `user-agent` request header so it gets properly picked up by nock.
Expand All @@ -15,3 +17,28 @@ export function getAPIv1Mock(reqHeaders = {}) {
},
});
}

/**
* Nock wrapper for ReadMe API v2 that adds required
* `user-agent` request header so it gets properly picked up by nock.
*/
export function getAPIv2Mock(reqHeaders: nock.Options['reqheaders'] = {}) {
return nock(config.host.v2, {
reqheaders: {
'User-Agent': ua => ua.startsWith(`rdme/${mockVersion}`),
'x-readme-source': 'cli',
...reqHeaders,
},
});
}

/**
* Variant of `getAPIv2Mock` for mocking a GitHub Actions environment.
*/
export function getAPIv2MockForGHA(reqHeaders: nock.Options['reqheaders'] = {}) {
return getAPIv2Mock({
'User-Agent': ua => ua.startsWith(`rdme-github/${mockVersion}`),
'x-readme-source': 'cli-gh',
...reqHeaders,
});
}
Loading
Loading