Refactor JSONPath based services, run [DynamicJson DynamicYaml] (#4272)

* Subclass factory for JSON path services

* Common methods moved to JSON path class

* should throw error if _getData is not overridden

* Test JSON path factory using chai-as-promised

* Using chai-as-promised in more tests

* JSDoc for json-path

* Error message adopted to JSON and YAML

* Dynamic YAML badge handles YAML with a string

* 'fetch' naming covention

* Strict string validation in error message
This commit is contained in:
Marcin Mielnicki
2019-12-05 01:03:05 +01:00
committed by GitHub
parent 436d2ba3a0
commit dfcb6defc8
9 changed files with 161 additions and 117 deletions

View File

@@ -1,7 +1,8 @@
'use strict'
const Joi = require('@hapi/joi')
const { expect } = require('chai')
const chai = require('chai')
const { expect } = chai
const sinon = require('sinon')
const trace = require('./trace')
const {
@@ -14,6 +15,7 @@ const {
const BaseService = require('./base')
require('../register-chai-plugins.spec')
chai.use(require('chai-as-promised'))
const queryParamSchema = Joi.object({
queryParamA: Joi.string(),
@@ -97,17 +99,14 @@ describe('BaseService', function() {
describe('Required overrides', function() {
it('Should throw if render() is not overridden', function() {
expect(() => BaseService.render()).to.throw(
'render() function not implemented for BaseService'
/^render\(\) function not implemented for BaseService$/
)
})
it('Should throw if route is not overridden', async function() {
try {
await BaseService.invoke({}, {}, {})
expect.fail('Expected to throw')
} catch (e) {
expect(e.message).to.equal('Route not defined for BaseService')
}
it('Should throw if route is not overridden', function() {
return expect(BaseService.invoke({}, {}, {})).to.be.rejectedWith(
/^Route not defined for BaseService$/
)
})
class WithRoute extends BaseService {
@@ -115,18 +114,15 @@ describe('BaseService', function() {
return {}
}
}
it('Should throw if handle() is not overridden', async function() {
try {
await WithRoute.invoke({}, {}, {})
expect.fail('Expected to throw')
} catch (e) {
expect(e.message).to.equal('Handler not implemented for WithRoute')
}
it('Should throw if handle() is not overridden', function() {
return expect(WithRoute.invoke({}, {}, {})).to.be.rejectedWith(
/^Handler not implemented for WithRoute$/
)
})
it('Should throw if category is not overridden', function() {
expect(() => BaseService.category).to.throw(
'Category not set for BaseService'
/^Category not set for BaseService$/
)
})
})
@@ -427,16 +423,15 @@ describe('BaseService', function() {
requiredString: Joi.string().required(),
}).required()
it('throws error for invalid responses', async function() {
try {
it('throws error for invalid responses', function() {
expect(() =>
DummyService._validate(
{ requiredString: ['this', "shouldn't", 'work'] },
dummySchema
)
expect.fail('Expected to throw')
} catch (e) {
expect(e).to.be.an.instanceof(InvalidResponse)
}
)
.to.throw()
.instanceof(InvalidResponse)
})
})

26
package-lock.json generated
View File

@@ -1395,15 +1395,6 @@
"@babel/helper-plugin-utils": "^7.0.0"
}
},
"@babel/plugin-syntax-dynamic-import": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.2.0.tgz",
"integrity": "sha512-mVxuJ0YroI/h/tbFTPGZR8cv6ai+STMKNBq0f8hFxsxWjl94qqhsb+wXbpNMDPU3cfR1TIsVFzU3nXyZMqyK4w==",
"dev": true,
"requires": {
"@babel/helper-plugin-utils": "^7.0.0"
}
},
"@babel/plugin-syntax-json-strings": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.7.4.tgz",
@@ -2032,15 +2023,6 @@
}
}
},
"@babel/plugin-transform-spread": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.6.2.tgz",
"integrity": "sha512-DpSvPFryKdK1x+EDJYCy28nmAaIMdxmhot62jAXF/o99iA33Zj2Lmcp3vDmz+MUh0LNYVPvfj5iC3feb3/+PFg==",
"dev": true,
"requires": {
"@babel/helper-plugin-utils": "^7.0.0"
}
},
"@babel/plugin-transform-sticky-regex": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.7.4.tgz",
@@ -7137,6 +7119,14 @@
}
}
},
"chai-as-promised": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz",
"integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==",
"requires": {
"check-error": "^1.0.2"
}
},
"chai-datetime": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/chai-datetime/-/chai-datetime-1.5.0.tgz",

View File

@@ -27,6 +27,7 @@
"bytes": "^3.1.0",
"camelcase": "^5.3.1",
"camp": "~17.2.4",
"chai-as-promised": "^7.1.1",
"chalk": "^3.0.0",
"check-node-version": "^4.0.1",
"chrome-web-store-item-property": "~1.2.0",

View File

@@ -1,62 +1,19 @@
'use strict'
const Joi = require('@hapi/joi')
const jp = require('jsonpath')
const { renderDynamicBadge, errorMessages } = require('../dynamic-common')
const { createRoute } = require('./dynamic-helpers')
const { BaseJsonService, InvalidParameter, InvalidResponse } = require('..')
module.exports = class DynamicJson extends BaseJsonService {
static get category() {
return 'dynamic'
}
const jsonPath = require('./json-path')
const { BaseJsonService } = require('..')
module.exports = class DynamicJson extends jsonPath(BaseJsonService) {
static get route() {
return createRoute('json')
}
static get defaultBadgeData() {
return {
label: 'custom badge',
}
}
async handle(namedParams, { url, query: pathExpression, prefix, suffix }) {
const data = await this._requestJson({
schema: Joi.any(),
async fetch({ schema, url, errorMessages }) {
return this._requestJson({
schema,
url,
errorMessages,
})
// JSONPath only works on objects and arrays.
// https://github.com/badges/shields/issues/4018
if (typeof data !== 'object') {
throw new InvalidResponse({
prettyMessage: 'json must contain an object or array',
})
}
let values
try {
values = jp.query(data, pathExpression)
} catch (e) {
const { message } = e
if (
message.startsWith('Lexical error') ||
message.startsWith('Parse error')
) {
throw new InvalidParameter({
prettyMessage: 'unparseable jsonpath query',
})
} else {
throw e
}
}
if (!values.length) {
throw new InvalidResponse({ prettyMessage: 'no result' })
}
return renderDynamicBadge({ value: values, prefix, suffix })
}
}

View File

@@ -181,6 +181,6 @@ t.create('JSON contains a string')
)
.expectBadge({
label: 'custom badge',
message: 'json must contain an object or array',
message: 'resource must contain an object or array',
color: 'lightgrey',
})

View File

@@ -1,39 +1,19 @@
'use strict'
const Joi = require('@hapi/joi')
const jp = require('jsonpath')
const { renderDynamicBadge, errorMessages } = require('../dynamic-common')
const { createRoute } = require('./dynamic-helpers')
const { BaseYamlService, InvalidResponse } = require('..')
module.exports = class DynamicYaml extends BaseYamlService {
static get category() {
return 'dynamic'
}
const jsonPath = require('./json-path')
const { BaseYamlService } = require('..')
module.exports = class DynamicYaml extends jsonPath(BaseYamlService) {
static get route() {
return createRoute('yaml')
}
static get defaultBadgeData() {
return {
label: 'custom badge',
}
}
async handle(namedParams, { url, query: pathExpression, prefix, suffix }) {
const data = await this._requestYaml({
schema: Joi.any(),
async fetch({ schema, url, errorMessages }) {
return this._requestYaml({
schema,
url,
errorMessages,
})
const values = jp.query(data, pathExpression)
if (!values.length) {
throw new InvalidResponse({ prettyMessage: 'no result' })
}
return renderDynamicBadge({ value: values, prefix, suffix })
}
}

View File

@@ -101,3 +101,16 @@ t.create('YAML from url | error color overrides user specified')
message: 'invalid query parameter: url',
color: 'red',
})
t.create('YAML contains a string')
.get('.json?url=https://example.test/yaml&query=$.foo,')
.intercept(nock =>
nock('https://example.test')
.get('/yaml')
.reply(200, '"foo"')
)
.expectBadge({
label: 'custom badge',
message: 'resource must contain an object or array',
color: 'lightgrey',
})

View File

@@ -0,0 +1,86 @@
/**
* @module
*/
'use strict'
const Joi = require('@hapi/joi')
const jp = require('jsonpath')
const { renderDynamicBadge, errorMessages } = require('../dynamic-common')
const { InvalidParameter, InvalidResponse } = require('..')
/**
* Dynamic service class factory which wraps {@link module:core/base-service/base~BaseService} with support of {@link https://jsonpath.com/|JSONPath}.
*
* @param {Function} superclass class to extend
* @returns {Function} wrapped class
*/
module.exports = superclass =>
class extends superclass {
static get category() {
return 'dynamic'
}
static get defaultBadgeData() {
return {
label: 'custom badge',
}
}
/**
* Request data from an upstream API, transform it to JSON and validate against a schema
*
* @param {object} attrs Refer to individual attrs
* @param {Joi} attrs.schema Joi schema to validate the response transformed to JSON
* @param {string} attrs.url URL to request
* @param {object} [attrs.errorMessages={}] Key-value map of status codes
* and custom error messages e.g: `{ 404: 'package not found' }`.
* This can be used to extend or override the
* [default](https://github.com/badges/shields/blob/master/services/dynamic-common.js#L8)
* @returns {object} Parsed response
*/
async fetch({ schema, url, errorMessages }) {
throw new Error(
`fetch() function not implemented for ${this.constructor.name}`
)
}
async handle(namedParams, { url, query: pathExpression, prefix, suffix }) {
const data = await this.fetch({
schema: Joi.any(),
url,
errorMessages,
})
// JSONPath only works on objects and arrays.
// https://github.com/badges/shields/issues/4018
if (typeof data !== 'object') {
throw new InvalidResponse({
prettyMessage: 'resource must contain an object or array',
})
}
let values
try {
values = jp.query(data, pathExpression)
} catch (e) {
const { message } = e
if (
message.startsWith('Lexical error') ||
message.startsWith('Parse error')
) {
throw new InvalidParameter({
prettyMessage: 'unparseable jsonpath query',
})
} else {
throw e
}
}
if (!values.length) {
throw new InvalidResponse({ prettyMessage: 'no result' })
}
return renderDynamicBadge({ value: values, prefix, suffix })
}
}

View File

@@ -0,0 +1,22 @@
'use strict'
const chai = require('chai')
const { expect } = chai
const jsonPath = require('./json-path')
chai.use(require('chai-as-promised'))
describe('JSON Path service factory', function() {
describe('fetch()', function() {
it('should throw error if it is not overridden', function() {
class BaseService {}
class JsonPathService extends jsonPath(BaseService) {}
const jsonPathServiceInstance = new JsonPathService()
return expect(jsonPathServiceInstance.fetch({})).to.be.rejectedWith(
Error,
'fetch() function not implemented for JsonPathService'
)
})
})
})