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

breaking: Make the API browser more generic #24

Merged
merged 1 commit into from
Jul 5, 2019
Merged
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
119 changes: 44 additions & 75 deletions src/services/twilio-api/api-browser.js
Original file line number Diff line number Diff line change
@@ -1,90 +1,62 @@
const fs = require('fs');
const url = require('url');
const { doesObjectHaveProperty } = require('../javascript-utilities');
const ResourcePathParser = require('../resource-path-parser');
const { camelCase } = require('../naming-conventions');
let apiSpec; // Lazy-loaded below.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why lazy-load here, don't we always need the apiSpec? If so, loading it up front might be best so that during operations there are no delays. Or are you preparing for cases where we can allow users to just provide a subset of the full Twilio apiSpec for leaner operations?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lazy so it's only done if needed and only happens once. May optimize later to not need it all the time.


const isApi2010 = (domain, version) => {
return domain === 'api' && (version === '2010-04-01' || version === 'v2010');
};

function translateLegacyVersions(domain, version) {
// In the Node helper library, api.twilio.com/2010-04-01 is represented as "v2010"
return isApi2010(domain, version) ? 'v2010' : version;
}

const listResourceMethodMap = {
get: 'list',
post: 'create'
};

const instanceResourceMethodMap = {
delete: 'remove',
get: 'fetch',
post: 'update'
};

/*

Notes:

Disambiguating like-named resources:
If same resource name exists under multiple domains/versions/paths, then have the command
map to a help message when it is ambiguous as to which domain/version/path they want.
Maybe have a setting to change this behavior? Have a custom shortcut map in the config
file? Have a mode of operation where it just picks the most recent version?

*/
const OPERATIONS = ['post', 'get', 'delete'];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to support patch?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not right now. Nor put.


class TwilioApiBrowser {
constructor(apiSpec) {
this.apiSpec = apiSpec || this.loadApiSpecFromDisk();
this.domains = this.loadDomains();
apiSpec = apiSpec || this.loadApiSpecFromDisk();
this.domains = this.loadDomains(apiSpec);
}

loadApiSpecFromDisk() {
// Assume all 'json' files in here are OpenAPI spec files.
return fs.readdirSync(__dirname)
.filter(filename => filename.endsWith('.json'))
.map(filename => require(`./${filename}`));
}
if (!apiSpec) {
const specPattern = /twilio_(.+)\.json/;
const specNameIndex = 1;

apiSpec = fs.readdirSync(__dirname)
.filter(filename => filename.match(specPattern))
.map(filename => {
const domainName = filename.match(specPattern)[specNameIndex];

loadDomains() {
const domains = {};
return { [domainName]: require(`./${filename}`) };
});

this.apiSpec.forEach(spec => {
Object.keys(spec.paths).forEach(path => {
// Naive assumption: The Twilio API's only have a single domain
const serverUrl = new url.URL(spec.paths[path].servers[0].url);
const domain = serverUrl.host.split('.')[0]; // e.g. 'api' from 'api.twilio.com'
apiSpec = Object.assign({}, ...apiSpec);
}

const resourcePathParser = new ResourcePathParser(path);
resourcePathParser.normalizePath(); // e.g /v1/foo/bar/{Sid}.json --> /foo/bar
const resourcePath = resourcePathParser.getFullPath();
return apiSpec;
}

const version = translateLegacyVersions(domain, resourcePathParser.version);
loadDomains(apiSpec) {
const domains = apiSpec;

if (!doesObjectHaveProperty(domains, domain)) {
domains[domain] = { versions: {}, tags: spec.tags };
}
Object.values(domains).forEach(spec => {
Object.values(spec.paths).forEach(path => {
// Naive assumption: The Twilio APIs only have a single server.
path.server = path.servers[0].url;
delete path.servers;

if (!doesObjectHaveProperty(domains[domain].versions, version)) {
domains[domain].versions[version] = { resources: {} };
}
path.operations = {};
path.description = path.description.replace(/(\r\n|\n|\r)/gm, ' ');

// Move the operations into an operations object.
OPERATIONS.forEach(operationName => {
if (operationName in path) {
path.operations[operationName] = path[operationName];
delete path[operationName];
}
});

const resources = domains[domain].versions[version].resources;
if (!doesObjectHaveProperty(resources, resourcePath)) {
resources[resourcePath] = {
actions: {},
description: spec.paths[path].description.replace(/(\r\n|\n|\r)/gm, ' '),
defaultOutputProperties: spec.paths[path]['x-default-output-properties']
};
}
// Convert extensions to camel-cased properties.
Object.entries(path).forEach(([key, value]) => {
const extensionMatch = key.match(/x-(.+)/);

const actions = resources[resourcePath].actions;
const methodMap = resourcePathParser.isInstanceResource ? instanceResourceMethodMap : listResourceMethodMap;
Object.keys(methodMap).forEach(method => {
if (doesObjectHaveProperty(spec.paths[path], method)) {
actions[methodMap[method]] = spec.paths[path][method];
if (extensionMatch) {
const newKey = camelCase(extensionMatch[1]);
path[newKey] = value;
delete path[key];
}
});
});
Expand All @@ -94,7 +66,4 @@ class TwilioApiBrowser {
}
}

module.exports = {
TwilioApiBrowser,
isApi2010
};
module.exports = TwilioApiBrowser;
5 changes: 2 additions & 3 deletions src/services/twilio-api/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const { TwilioApiBrowser, isApi2010 } = require('./api-browser');
const TwilioApiBrowser = require('./api-browser');

module.exports = {
TwilioApiBrowser,
isApi2010
TwilioApiBrowser
};
186 changes: 95 additions & 91 deletions test/services/twilio-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,80 +8,89 @@ describe('services', () => {
test.it('loads the JSON from disk', () => {
const browser = new TwilioApiBrowser();
// Check some known api endpoints that should be relatively stable
expect(browser.domains.api.versions.v2010.resources['/Accounts/{AccountSid}/Calls'].actions.create).to.exist;
expect(browser.domains.api.versions.v2010.resources['/Accounts/{AccountSid}/Calls/{Sid}'].actions.fetch).to.exist;
expect(browser.domains.api.paths['/2010-04-01/Accounts/{AccountSid}/Calls.json'].operations.post).to.exist;
expect(browser.domains.api.paths['/2010-04-01/Accounts/{AccountSid}/Calls/{Sid}.json'].operations.get).to.exist;
});

test.it('loads a specific api spec', () => {
const browser = new TwilioApiBrowser([{
paths: {
'/2010-04-01/Widgets.json': {
servers: [
{
url: 'https://api.twilio.com'
}
],
description: 'Widgets here\nsecond line of text',
'x-default-output-properties': ['sid']
},
'/v1/Gadgets.json': {
servers: [
{
url: 'https://neato.twilio.com'
}
],
description: 'v1 Gadgets here',
'x-default-output-properties': ['sid']
},
'/v2/Gadgets.json': {
servers: [
{
url: 'https://neato.twilio.com'
}
],
post: { createStuff: '' },
get: { listStuff: '' },
description: 'v2 list Gadgets here',
'x-default-output-properties': ['sid', 'name']
const browser = new TwilioApiBrowser({
api: {
paths: {
'/2010-04-01/Widgets.json': {
servers: [
{
url: 'https://api.twilio.com'
}
],
description: 'Widgets here\nsecond line of text',
'x-default-output-properties': ['sid']
}
},
'/v2/Gadgets/{Sid}.json': {
servers: [
{
url: 'https://neato.twilio.com'
}
],
post: { updateStuff: '' },
get: { fetchStuff: '' },
delete: { removeStuff: '' },
description: 'v2 instance Gadgets here',
'x-default-output-properties': ['sid', 'description']
}
tags: [
{
name: 'Beta',
description: 'Betamax!'
}
]
},
tags: [
{
name: 'GA',
description: 'Generally Available!'
}
]
}]);
neato: {
paths: {
'/v1/Gadgets.json': {
servers: [
{
url: 'https://neato.twilio.com'
}
],
description: 'v1 Gadgets here',
'x-default-output-properties': ['sid']
},
'/v2/Gadgets.json': {
servers: [
{
url: 'https://neato.twilio.com'
}
],
post: { createStuff: '' },
get: { listStuff: '' },
description: 'v2 list Gadgets here',
'x-default-output-properties': ['sid', 'name']
},
'/v2/Gadgets/{Sid}.json': {
servers: [
{
url: 'https://neato.twilio.com'
}
],
post: { updateStuff: '' },
get: { fetchStuff: '' },
delete: { removeStuff: '' },
description: 'v2 instance Gadgets here',
'x-default-output-properties': ['sid', 'description']
}
},
tags: [
{
name: 'GA',
description: 'Generally Available!'
}
]
}
});

expect(browser.domains).to.deep.equal({
api: {
tags: [
{
name: 'GA',
description: 'Generally Available!'
name: 'Beta',
description: 'Betamax!'
}
],
versions: {
v2010: {
resources: {
'/Widgets': {
actions: {},
description: 'Widgets here second line of text',
defaultOutputProperties: ['sid']
}
}
paths: {
'/2010-04-01/Widgets.json': {
operations: {},
server: 'https://api.twilio.com',
description: 'Widgets here second line of text',
defaultOutputProperties: ['sid']
}
}
},
Expand All @@ -92,36 +101,31 @@ describe('services', () => {
description: 'Generally Available!'
}
],
versions: {
v1: {
resources: {
'/Gadgets': {
actions: {},
description: 'v1 Gadgets here',
defaultOutputProperties: ['sid']
}
}
paths: {
'/v1/Gadgets.json': {
operations: {},
server: 'https://neato.twilio.com',
description: 'v1 Gadgets here',
defaultOutputProperties: ['sid']
},
v2: {
resources: {
'/Gadgets': {
actions: {
create: { createStuff: '' },
list: { listStuff: '' }
},
description: 'v2 list Gadgets here',
defaultOutputProperties: ['sid', 'name']
},
'/Gadgets/{Sid}': {
actions: {
fetch: { fetchStuff: '' },
update: { updateStuff: '' },
remove: { removeStuff: '' }
},
description: 'v2 instance Gadgets here',
defaultOutputProperties: ['sid', 'description']
}
}
'/v2/Gadgets.json': {
operations: {
post: { createStuff: '' },
get: { listStuff: '' }
},
server: 'https://neato.twilio.com',
description: 'v2 list Gadgets here',
defaultOutputProperties: ['sid', 'name']
},
'/v2/Gadgets/{Sid}.json': {
operations: {
get: { fetchStuff: '' },
post: { updateStuff: '' },
delete: { removeStuff: '' }
},
server: 'https://neato.twilio.com',
description: 'v2 instance Gadgets here',
defaultOutputProperties: ['sid', 'description']
}
}
}
Expand Down