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

Delete BaseHTTPService and implement new BaseXmlService (affects [eclipse-marketplace f-droid]; also testing on [uptimerobot circleci]) #2037

Merged
merged 15 commits into from
Sep 3, 2018
Merged
25 changes: 0 additions & 25 deletions lib/all-badge-examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -735,16 +735,6 @@ const allBadgeExamples = [
previewUrl: '/vscode-marketplace/d/ritwickdey.LiveServer.svg',
keywords: ['vscode-marketplace'],
},
{
title: 'Eclipse Marketplace',
previewUrl: '/eclipse-marketplace/dt/notepad4e.svg',
keywords: ['eclipse', 'marketplace'],
},
{
title: 'Eclipse Marketplace',
previewUrl: '/eclipse-marketplace/dm/notepad4e.svg',
keywords: ['eclipse', 'marketplace'],
},
{
title: 'JetBrains IntelliJ plugins',
previewUrl: '/jetbrains/plugin/d/1347-scala.svg',
Expand Down Expand Up @@ -1406,11 +1396,6 @@ const allBadgeExamples = [
previewUrl: '/vscode-marketplace/v/ritwickdey.LiveServer.svg',
keywords: ['vscode-marketplace'],
},
{
title: 'Eclipse Marketplace',
previewUrl: '/eclipse-marketplace/v/notepad4e.svg',
keywords: ['eclipse', 'marketplace'],
},
{
title: 'iTunes App Store',
previewUrl: '/itunes/v/803453959.svg',
Expand Down Expand Up @@ -1635,16 +1620,6 @@ const allBadgeExamples = [
previewUrl:
'/swagger/valid/2.0/https/raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v2.0/json/petstore-expanded.json.svg',
},
{
title: 'Eclipse Marketplace',
previewUrl: '/eclipse-marketplace/favorites/notepad4e.svg',
keywords: ['eclipse', 'marketplace'],
},
{
title: 'Eclipse Marketplace',
previewUrl: '/eclipse-marketplace/last-update/notepad4e.svg',
keywords: ['eclipse', 'marketplace'],
},
{
title: 'Vaadin Directory',
previewUrl: '/vaadin-directory/status/vaadinvaadin-grid.svg',
Expand Down
12 changes: 0 additions & 12 deletions lib/error-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,6 @@ checkErrorResponse.asPromise = function(errorMessages = {}) {
}
}

async function asJson({ buffer, res }) {
try {
return JSON.parse(buffer)
} catch (err) {
throw new InvalidResponse({
prettyMessage: 'unparseable json response',
underlyingError: err,
})
}
}

module.exports = {
checkErrorResponse,
asJson,
}
34 changes: 34 additions & 0 deletions lib/response-parsers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict'

const { InvalidResponse } = require('../services/errors')
const xml2js = require('xml2js')
Copy link
Member

Choose a reason for hiding this comment

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

We're only using this code in one place. Would it be more direct to move it there, inline?

Copy link
Member Author

Choose a reason for hiding this comment

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

With fast-xml-parser, the parser is now used in two places, so keeping the require up there. 😄

Copy link
Member

Choose a reason for hiding this comment

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

Ah, sorry, I meant the functions themselves, not the requires. Instead of having these in a separate module, could they just be inlined into the _requestX methods in the base services?


async function asJson({ buffer, res }) {
try {
return JSON.parse(buffer)
} catch (err) {
throw new InvalidResponse({
prettyMessage: 'unparseable json response',
underlyingError: err,
})
}
}

async function asXml({ buffer, res }) {
let xml
xml2js.parseString(buffer, (err, parsedData) => {
Copy link
Member Author

Choose a reason for hiding this comment

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

This uses a callback and works fine because the underlying implementation is not async (see Leonidas-from-XIV/node-xml2js#159 (comment)), but there is probably a better way of doing this. Any ideas/guidance?

Copy link
Member

Choose a reason for hiding this comment

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

Aw, that is a sadly unpleasant thread. ☹️

Two thoughts:

  1. For the new services, we could adopt the parser mentioned here, which is synchronous: Sync version of parseString Leonidas-from-XIV/node-xml2js#159 (comment) They say it's faster.
  2. You could wrap the callback function in a promise using util.promisify:
const { promisify } = require('util')
const parseXml = promisify(require('xml2js').parseString)

let xml
try {
  // `parseXml` is returning an awaitable promise so if you were assigning its
  // return value to a variable, you'd need `await`. However on a return from
  // an async function, there's no need to specify `await`.
  return parseXml(buffer)
} catch (err) {
  throw 
}

Copy link
Member Author

@PyvesB PyvesB Sep 1, 2018

Choose a reason for hiding this comment

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

I tied solution 1, switching to fast-xml-parser.
Good points:

  • cleaner code in response-parser.js.
  • xml2js has a tendency of introducing arrays everywhere even when not necessary. Switching has simplified the Joi schemas and the constructed JS objects.
  • claimed to be faster.

Bad point:

  • no helpful exception message if the XML is not parsable.

I ran npm i fast-xml-parser to update package-lock.json, is this the correct approach? A huge diff is generated on the file.

if (err != null) {
throw new InvalidResponse({
prettyMessage: 'unparseable xml response',
underlyingError: err,
})
}
xml = parsedData
})
return xml
}

module.exports = {
asJson,
asXml,
}
47 changes: 47 additions & 0 deletions lib/response-parsers.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use strict'

const { expect } = require('chai')
const { asJson, asXml } = require('./response-parsers')
const { InvalidResponse } = require('../services/errors')

describe('Json parser', function() {
it('parses json', async function() {
const json = await asJson({ buffer: '{"foo": "bar"}' })

expect(json).to.deep.equal({ foo: 'bar' })
})

it('thows invalid response if json parsing fails', async function() {
try {
await asJson({ buffer: 'not json' })
expect.fail('Expected to throw')
} catch (e) {
expect(e).to.be.an.instanceof(InvalidResponse)
expect(e.message).to.equal(
'Invalid Response: Unexpected token o in JSON at position 1'
)
expect(e.prettyMessage).to.equal('unparseable json response')
}
})

describe('Xml parser', function() {
it('parses xml', async function() {
const xml = await asXml({ buffer: '<foo>bar</foo>' })

expect(xml).to.deep.include({ foo: 'bar' })
})

it('thows invalid response if xml parsing fails', async function() {
try {
await asXml({ buffer: 'not xml' })
expect.fail('Expected to throw')
} catch (e) {
expect(e).to.be.an.instanceof(InvalidResponse)
expect(e.message).to.equal(
'Invalid Response: Non-whitespace before first tag.\nLine: 0\nColumn: 1\nChar: n'
)
expect(e.prettyMessage).to.equal('unparseable xml response')
}
})
})
Copy link
Member

Choose a reason for hiding this comment

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

Thanks for adding these tests! Well worth keeping and adjusting if this functionality is moved inline, which would widen the bracket a bit.

We aren't using nock directly anywhere right now, though it's probably a good idea to start. I've written service tests for things that ought to be unit tests, and smaller-bracket unit tests for things, e.g. the one in #2036, which probably should mock an HTTP response.

})
22 changes: 0 additions & 22 deletions services/base-http.js

This file was deleted.

58 changes: 13 additions & 45 deletions services/base-json.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,27 @@

// See available emoji at http://emoji.muan.co/
const emojic = require('emojic')
const Joi = require('joi')
const { asJson } = require('../lib/error-helper')
const BaseHTTPService = require('./base-http')
const { InvalidResponse } = require('./errors')
const { asJson } = require('../lib/response-parsers')
const BaseService = require('./base')
const trace = require('./trace')

class BaseJsonService extends BaseHTTPService {
static _validate(json, schema) {
const { error, value } = Joi.validate(json, schema, {
allowUnknown: true,
stripUnknown: true,
})
if (error) {
trace.logTrace(
'validate',
emojic.womanShrugging,
'Response did not match schema',
error.message
)
throw new InvalidResponse({
prettyMessage: 'invalid json response',
underlyingError: error,
})
} else {
trace.logTrace(
'validate',
emojic.bathtub,
'JSON after validation',
value,
{ deep: true }
)
return value
}
}

class BaseJsonService extends BaseService {
async _requestJson({ schema, url, options = {}, errorMessages = {} }) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
if (!schema || !schema.isJoi) {
throw Error('A Joi schema is required')
}
const mergedOptions = {
...{ headers: { Accept: 'application/json' } },
...options,
}
return this._requestHTTP({ url, options: mergedOptions, errorMessages })
.then(asJson)
.then(json => {
logTrace(emojic.dart, 'Response JSON (before validation)', json, {
deep: true,
})
return json
})
.then(json => this.constructor._validate(json, schema))
const jsonData = await this._request({
url,
options: mergedOptions,
errorMessages,
})
const json = await asJson(jsonData)
logTrace(emojic.dart, 'Response JSON (before validation)', json, {
deep: true,
})
return this.constructor._validate(json, schema)
Copy link
Member

Choose a reason for hiding this comment

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

Wow, this is much clearer!

}
}

Expand Down
81 changes: 16 additions & 65 deletions services/base-json.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ const { expect } = chai
const sinon = require('sinon')

const BaseJsonService = require('./base-json')
const { invalidJSON } = require('./response-fixtures')
const trace = require('./trace')

chai.use(require('chai-as-promised'))

Expand All @@ -27,11 +25,11 @@ class DummyJsonService extends BaseJsonService {
}

async handle() {
const { value } = await this._requestJson({
const { requiredString } = await this._requestJson({
schema: dummySchema,
url: 'http://example.com/foo.json',
})
return { message: value }
return { message: requiredString }
}
}

Expand Down Expand Up @@ -87,83 +85,36 @@ describe('BaseJsonService', function() {
})
})

it('handles unparseable json responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: invalidJSON,
res: { statusCode: 200 },
})
const serviceInstance = new DummyJsonService(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
const serviceData = await serviceInstance.invokeHandler({}, {})
expect(serviceData).to.deep.equal({
color: 'lightgray',
message: 'unparseable json response',
})
})

context('a schema is not provided', function() {
it('throws the expected error', async function() {
const serviceInstance = new DummyJsonService(
{},
{ handleInternalErrors: false }
)
expect(
serviceInstance._requestJson({ schema: undefined })
).to.be.rejectedWith('A Joi schema is required')
})
})

describe('logging', function() {
let sandbox
beforeEach(function() {
sandbox = sinon.createSandbox()
})
afterEach(function() {
sandbox.restore()
})
beforeEach(function() {
sandbox.stub(trace, 'logTrace')
})

it('logs valid responses', async function() {
describe('Making badges', function() {
it('handles json responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: JSON.stringify({ requiredString: 'bar' }),
buffer: '{"requiredString": "some-string"}',
res: { statusCode: 200 },
})
const serviceInstance = new DummyJsonService(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
await serviceInstance.invokeHandler({}, {})
expect(trace.logTrace).to.be.calledWithMatch(
'validate',
sinon.match.string,
'JSON after validation',
{ requiredString: 'bar' },
{ deep: true }
)
const serviceData = await serviceInstance.invokeHandler({}, {})
expect(serviceData).to.deep.equal({
message: 'some-string',
})
})

it('logs invalid responses', async function() {
it('handles unparseable json responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: JSON.stringify({
requiredString: ['this', "shouldn't", 'work'],
}),
buffer: 'not json',
res: { statusCode: 200 },
})
const serviceInstance = new DummyJsonService(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
await serviceInstance.invokeHandler({}, {})
expect(trace.logTrace).to.be.calledWithMatch(
'validate',
sinon.match.string,
'Response did not match schema',
'child "requiredString" fails because ["requiredString" must be a string]'
)
const serviceData = await serviceInstance.invokeHandler({}, {})
expect(serviceData).to.deep.equal({
color: 'lightgray',
message: 'unparseable json response',
})
})
})
})
29 changes: 29 additions & 0 deletions services/base-xml.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict'

// See available emoji at http://emoji.muan.co/
const emojic = require('emojic')
const { asXml } = require('../lib/response-parsers')
const BaseService = require('./base')
const trace = require('./trace')

class BaseXmlService extends BaseService {
async _requestXml({ schema, url, options = {}, errorMessages = {} }) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
const mergedOptions = {
...{ headers: { Accept: 'application/xml, text/xml' } },
...options,
}
const xmlData = await this._request({
url,
options: mergedOptions,
errorMessages,
})
const xml = await asXml(xmlData)
logTrace(emojic.dart, 'Response XML (before validation)', xml, {
deep: true,
})
return this.constructor._validate(xml, schema)
}
}

module.exports = BaseXmlService
Loading