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(openapi): Update oas file based on title #586

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ You can add `--update` to the command so if there's only one API definition for
rdme openapi [path-to-file.json] --version={project-version} --update
```

You can add `--matchOnTitleAndVersion` to the command so if there's an existing API definition for the given project version that has the same title as the spec being being synced, then it will select it without and prompts.

```sh
rdme openapi [path-to-file.json] --version={project-version} --matchOnTitleAndVersion
```

#### Omitting the File Path

If you run `rdme` within a directory that contains your OpenAPI or Swagger definition, you can omit the file path. `rdme` will then look for JSON or YAML files (including in sub-directories) that contain a top-level [`openapi`](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#fixed-fields) or [`swagger`](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md#fixed-fields) property.
Expand Down
151 changes: 150 additions & 1 deletion __tests__/cmds/openapi/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,156 @@ describe('rdme openapi', () => {
});
});

it.todo('should paginate to next and previous pages of specs');
describe('--matchOnTitleAndVersion', () => {
it('should update a spec file without prompts if providing `matchOnTitleAndVersion` and an existing api spec has the same title', async () => {
const registryUUID = getRandomRegistryId();

const mock = getAPIMock()
.get(`/api/v1/version/${version}`)
.basicAuth({ user: key })
.reply(200, [{ version }])
.post('/api/v1/api-registry', body => body.match('form-data; name="spec"'))
.reply(201, { registryUUID, spec: { openapi: '3.0.0' } })
.get('/api/v1/api-specification?perPage=20&page=1')
.basicAuth({ user: key })
.reply(200, [{ _id: 'spec1', title: 'Example petstore to demo our handling of external $ref pointers' }], {
'x-total-count': '21',
})
.get('/api/v1/api-specification?perPage=20&page=2')
.basicAuth({ user: key })
.reply(200, [{ _id: 'spec2', title: 'Example petstore to demo our handling of external $ref pointers2' }], {
'x-total-count': '21',
})
.put('/api/v1/api-specification/spec1', { registryUUID })
.delayConnection(1000)
.basicAuth({ user: key })
.reply(201, { _id: 1 }, { location: exampleRefLocation });

const spec = './__tests__/__fixtures__/ref-oas/petstore.json';

await expect(
openapi.run({
key,
version,
spec,
matchOnTitleAndVersion: true,
})
).resolves.toBe(successfulUpdate(spec));
return mock.done();
});

it('should error if providing `matchOnTitleAndVersion` and an existing api spec does not have a matching title.', async () => {
const registryUUID = getRandomRegistryId();

const mock = getAPIMock()
.get(`/api/v1/version/${version}`)
.basicAuth({ user: key })
.reply(200, [{ version }])
.post('/api/v1/api-registry', body => body.match('form-data; name="spec"'))
.reply(201, { registryUUID, spec: { openapi: '3.0.0' } })
.get('/api/v1/api-specification?perPage=20&page=1')
.basicAuth({ user: key })
.reply(200, [{ _id: 'spec3', title: 'Example petstore1' }], {
'x-total-count': '21',
})
.get('/api/v1/api-specification?perPage=20&page=2')
.basicAuth({ user: key })
.reply(200, [{ _id: 'spec2', title: 'Example petstore2' }], {
'x-total-count': '21',
});

const spec = './__tests__/__fixtures__/ref-oas/petstore.json';

await expect(
openapi.run({
key,
version,
spec,
matchOnTitleAndVersion: true,
})
).rejects.toStrictEqual(
new Error(
'No API specifcation with a title of Example petstore to demo our handling of external $ref pointers was found for version 1.0.0.'
)
);
return mock.done();
});

it('should warn if providing both `matchOnTitleAndVersion` and `id`', async () => {
const registryUUID = getRandomRegistryId();

const mock = getAPIMock()
.post('/api/v1/api-registry', body => body.match('form-data; name="spec"'))
.reply(201, { registryUUID, spec: { openapi: '3.0.0' } })
.put('/api/v1/api-specification/spec1', { registryUUID })
.delayConnection(1000)
.basicAuth({ user: key })
.reply(201, { _id: 1 }, { location: exampleRefLocation });
const spec = './__tests__/__fixtures__/ref-oas/petstore.json';

await expect(
openapi.run({
key,
spec,
matchOnTitleAndVersion: true,
id: 'spec1',
})
).resolves.toBe(successfulUpdate(spec));

expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.info).toHaveBeenCalledTimes(0);

const output = getCommandOutput();
expect(output).toMatch(/the `--matchOnTitleAndVersion` parameter will be ignored./);
return mock.done();
});

it('should warn if providing both `update` and `matchOnTitleAndVersion`', async () => {
const registryUUID = getRandomRegistryId();

const mock = getAPIMock()
.get(`/api/v1/version/${version}`)
.basicAuth({ user: key })
.reply(200, [{ version }])
.post('/api/v1/api-registry', body => body.match('form-data; name="spec"'))
.reply(201, { registryUUID, spec: { openapi: '3.0.0' } })
.get('/api/v1/api-specification?perPage=20&page=1')
.basicAuth({ user: key })
.reply(200, [{ _id: 'spec1', title: 'Example petstore to demo our handling of external $ref pointers' }], {
'x-total-count': '21',
})
.get('/api/v1/api-specification?perPage=20&page=2')
.basicAuth({ user: key })
.reply(200, [{ _id: 'spec2', title: 'Example petstore to demo our handling of external $ref pointers2' }], {
'x-total-count': '21',
})
.put('/api/v1/api-specification/spec1', { registryUUID })
.delayConnection(1000)
.basicAuth({ user: key })
.reply(201, { _id: 1 }, { location: exampleRefLocation });

const spec = './__tests__/__fixtures__/ref-oas/petstore.json';

await expect(
openapi.run({
key,
spec,
version,
update: true,
matchOnTitleAndVersion: true,
})
).resolves.toBe(successfulUpdate(spec));

expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.info).toHaveBeenCalledTimes(0);

const output = getCommandOutput();
expect(output).toMatch(/the `--update` parameter will be ignored./);
return mock.done();
});

it.todo('should paginate to next and previous pages of specs');
});
});

describe('versioning', () => {
Expand Down
44 changes: 42 additions & 2 deletions src/cmds/openapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import parse from 'parse-link-header';

import Command, { CommandCategories } from '../../lib/baseCommand';
import fetch, { cleanHeaders, handleRes } from '../../lib/fetch';
import getSpecifications from '../../lib/getSpecifications';
import { oraOptions } from '../../lib/logger';
import prepareOas from '../../lib/prepareOas';
import * as promptHandler from '../../lib/prompts';
Expand All @@ -24,6 +25,12 @@ export type Options = {
useSpecVersion?: boolean;
workingDirectory?: string;
update?: boolean;
matchOnTitleAndVersion?: boolean;
};

type Spec = {
_id: string;
title: string;
};

export default class OpenAPICommand extends Command {
Expand Down Expand Up @@ -77,13 +84,18 @@ export default class OpenAPICommand extends Command {
description:
"Automatically update an existing API definition in ReadMe if it's the only one associated with the current version.",
},
{
name: 'matchOnTitleAndVersion',
type: Boolean,
description: 'Automatically update an existing API definition if the title and version match.',
},
];
}

async run(opts: CommandOptions<Options>) {
super.run(opts);

const { key, id, spec, create, useSpecVersion, version, workingDirectory, update } = opts;
const { key, id, spec, create, useSpecVersion, version, workingDirectory, update, matchOnTitleAndVersion } = opts;

let selectedVersion = version;
let isUpdate: boolean;
Expand Down Expand Up @@ -115,9 +127,21 @@ export default class OpenAPICommand extends Command {
);
}

if (matchOnTitleAndVersion && id) {
Command.warn(
"We'll be updating the API definition associated with the `--id` parameter, so the `--matchOnTitleAndVersion` parameter will be ignored."
);
}

if (update && matchOnTitleAndVersion) {
Command.warn(
"We'll be updating the API definition with the a matching `title`, so the `--update` parameter will be ignored."
);
}

// 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 { bundledSpec, specPath, specType, specVersion } = await prepareOas(spec, 'openapi');
const { bundledSpec, specPath, specType, specVersion, specTitle } = await prepareOas(spec, 'openapi');

if (useSpecVersion) {
Command.info(
Expand Down Expand Up @@ -243,9 +267,25 @@ export default class OpenAPICommand extends Command {
});
}

async function findSpecBasedOnTitleAndVersion() {
const allSpecifications = await getSpecifications(key, version);
return allSpecifications.find((apiSpec: Spec) => {
return apiSpec.title.trim().toLowerCase() === specTitle.trim().toLowerCase();
});
}

if (create) return createSpec();

if (!id) {
if (matchOnTitleAndVersion) {
const matchedSpec = await findSpecBasedOnTitleAndVersion();
if (typeof matchedSpec !== 'undefined') {
// eslint-disable-next-line no-underscore-dangle
return updateSpec(matchedSpec._id);
}

throw new Error(`No API specifcation with a title of ${specTitle} was found for version ${version}.`);
}
Command.debug('no id parameter, retrieving list of API specs');
const apiSettings = await getSpecs('/api/v1/api-specification');

Expand Down
55 changes: 55 additions & 0 deletions src/lib/getSpecifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import config from 'config';
import { Headers } from 'node-fetch';

import fetch, { cleanHeaders, handleRes } from './fetch';

/**
* Returns all specification for a given project and version
*
* @param {String} key project API key
* @param {String} selectedVersion project version
* @returns An array of specification objects
*/
export default async function getSpecification(key: string, selectedVersion: string) {
function getNumberOfPages() {
let totalCount = 0;
return fetch(`${config.get('host')}/api/v1/api-specification?perPage=20&page=1`, {
method: 'get',
headers: cleanHeaders(
key,
new Headers({
'x-readme-version': selectedVersion,
Accept: 'application/json',
})
),
})
.then(res => {
totalCount = Math.ceil(parseInt(res.headers.get('x-total-count'), 10) / 20);
return handleRes(res);
})
.then(res => {
return { firstPage: res, totalCount };
});
}

const { firstPage, totalCount } = await getNumberOfPages();
const allSpecifications = firstPage.concat(
...(await Promise.all(
// retrieves all specifications beyond first page
[...new Array(totalCount + 1).keys()].slice(2).map(async page => {
return fetch(`${config.get('host')}/api/v1/api-specification?perPage=20&page=${page}`, {
method: 'get',
headers: cleanHeaders(
key,
new Headers({
'x-readme-version': selectedVersion,
Accept: 'application/json',
})
),
}).then(res => handleRes(res));
})
))
);

return allSpecifications;
}
5 changes: 4 additions & 1 deletion src/lib/prepareOas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ export default async function prepareOas(path: string, command: 'openapi' | 'ope
const specVersion = api.info.version;
debug(`version in spec: ${specVersion}`);

const specTitle = api.info.title;
debug(`title in this spec: ${specTitle}`);

let bundledSpec = '';

if (command === 'openapi' || command === 'openapi:reduce') {
Expand All @@ -148,5 +151,5 @@ export default async function prepareOas(path: string, command: 'openapi' | 'ope
debug('spec bundled');
}

return { bundledSpec, specPath, specType, specVersion };
return { bundledSpec, specPath, specType, specVersion, specTitle };
}