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

Add BaseGraphqlService, support [github] V4 API #3763

Merged
merged 11 commits into from
Jul 29, 2019
48 changes: 48 additions & 0 deletions core/base-service/base-graphql.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict'

const { parse } = require('graphql')
const BaseJsonService = require('./base-json')
const { InvalidResponse } = require('./errors')

function defaultGraphqlErrorHandler(errors) {
throw new InvalidResponse({ prettyMessage: errors[0].message })
}

class BaseGraphqlService extends BaseJsonService {
_validateQuery(query) {
// Attempting to parse the query string
// will throw a descriptive exception if it isn't valid
parse(query)
}

async _requestGraphql({
schema,
url,
query,
variables = {},
Copy link
Member Author

Choose a reason for hiding this comment

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

I've made query and variables top-level params here. I'm in 2 minds about whether it makes more sense to collect them into an object. Also note here I deliberately haven't implemented mutations. In general, calling a shields badge should read data from upstream service, not write data to it, so we shouldn't need mutations.

options = {},
httpErrorMessages = {},
graphqlErrorHandler = defaultGraphqlErrorHandler,
chris48s marked this conversation as resolved.
Show resolved Hide resolved
}) {
this._validateQuery(query)

const mergedOptions = {
...{ headers: { Accept: 'application/json' } },
...options,
}
mergedOptions.method = 'POST'
mergedOptions.body = JSON.stringify({ query, variables })
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages: httpErrorMessages,
})
const json = this._parseJson(buffer)
if (json.errors) {
graphqlErrorHandler(json.errors)
}
return this.constructor._validate(json, schema)
}
}

module.exports = BaseGraphqlService
196 changes: 196 additions & 0 deletions core/base-service/base-graphql.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
'use strict'

const Joi = require('@hapi/joi')
const { expect } = require('chai')
const sinon = require('sinon')
const BaseGraphqlService = require('./base-graphql')
const { InvalidResponse } = require('./errors')

const dummySchema = Joi.object({
requiredString: Joi.string().required(),
}).required()

class DummyGraphqlService extends BaseGraphqlService {
static get category() {
return 'cat'
}

static get route() {
return {
base: 'foo',
}
}

async handle() {
const { requiredString } = await this._requestGraphql({
schema: dummySchema,
url: 'http://example.com/graphql',
query: 'query { requiredString }',
})
return { message: requiredString }
}
}

describe('BaseGraphqlService', function() {
describe('Making requests', function() {
let sendAndCacheRequest
beforeEach(function() {
sendAndCacheRequest = sinon.stub().returns(
Promise.resolve({
buffer: '{"some": "json"}',
res: { statusCode: 200 },
})
)
})

it('invokes _sendAndCacheRequest', async function() {
await DummyGraphqlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)

expect(sendAndCacheRequest).to.have.been.calledOnceWith(
'http://example.com/graphql',
{
body: '{"query":"query { requiredString }","variables":{}}',
headers: { Accept: 'application/json' },
method: 'POST',
}
)
})

it('forwards options to _sendAndCacheRequest', async function() {
class WithOptions extends DummyGraphqlService {
async handle() {
const { value } = await this._requestGraphql({
schema: dummySchema,
url: 'http://example.com/graphql',
query: 'query { requiredString }',
options: { qs: { queryParam: 123 } },
})
return { message: value }
}
}

await WithOptions.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)

expect(sendAndCacheRequest).to.have.been.calledOnceWith(
'http://example.com/graphql',
{
body: '{"query":"query { requiredString }","variables":{}}',
headers: { Accept: 'application/json' },
method: 'POST',
qs: { queryParam: 123 },
}
)
})
})

describe('Making badges', function() {
it('handles valid json responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: '{"requiredString": "some-string"}',
res: { statusCode: 200 },
})
expect(
await DummyGraphqlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
message: 'some-string',
})
})

it('handles json responses which do not match the schema', async function() {
const sendAndCacheRequest = async () => ({
buffer: '{"unexpectedKey": "some-string"}',
res: { statusCode: 200 },
})
expect(
await DummyGraphqlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'invalid response data',
})
})

it('handles unparseable json responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: 'not json',
res: { statusCode: 200 },
})
expect(
await DummyGraphqlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'unparseable json response',
})
})
})

describe('Error handling', function() {
it('handles generic error', async function() {
const sendAndCacheRequest = async () => ({
buffer: '{ "errors": [ { "message": "oh noes!!" } ] }',
res: { statusCode: 200 },
})
expect(
await DummyGraphqlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'oh noes!!',
})
})

it('handles custom error', async function() {
class WithErrorHandler extends DummyGraphqlService {
async handle() {
const { requiredString } = await this._requestGraphql({
schema: dummySchema,
url: 'http://example.com/graphql',
query: 'query { requiredString }',
graphqlErrorHandler: function(errors) {
if (errors[0].message === 'oh noes!!') {
throw new InvalidResponse({
prettyMessage: 'a terrible thing has happened',
})
}
},
})
return { message: requiredString }
}
}

const sendAndCacheRequest = async () => ({
buffer: '{ "errors": [ { "message": "oh noes!!" } ] }',
res: { statusCode: 200 },
})
expect(
await WithErrorHandler.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'a terrible thing has happened',
})
})
})
})
55 changes: 55 additions & 0 deletions core/base-service/graphql.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use strict'
/**
* @module
*/

const { parse } = require('graphql')
const { print } = require('graphql/language/printer')

/**
* Utility function to merge two graphql queries together
* This is basically copied from
* [graphql-query-merge](https://www.npmjs.com/package/graphql-query-merge)
* but can't use that due to incorrect packaging.
*
* @param {...string} queries queries to merge
* @returns {string} merged query
*/
function mergeQueries(...queries) {
chris48s marked this conversation as resolved.
Show resolved Hide resolved
const merged = {
kind: 'Document',
definitions: [
{
directives: [],
operation: 'query',
variableDefinitions: [],
kind: 'OperationDefinition',
selectionSet: { kind: 'SelectionSet', selections: [] },
},
],
}

queries.forEach(query => {
const parsedQuery = parse(query)
parsedQuery.definitions.forEach(definition => {
merged.definitions[0].directives = [
...merged.definitions[0].directives,
...definition.directives,
]

merged.definitions[0].variableDefinitions = [
...merged.definitions[0].variableDefinitions,
...definition.variableDefinitions,
]

merged.definitions[0].selectionSet.selections = [
...merged.definitions[0].selectionSet.selections,
...definition.selectionSet.selections,
]
})
})

return print(merged)
}

module.exports = { mergeQueries }
38 changes: 38 additions & 0 deletions core/base-service/graphql.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use strict'

const { expect } = require('chai')
const { mergeQueries } = require('./graphql')

require('../register-chai-plugins.spec')

describe('mergeQueries function', function() {
it('merges valid gql queries', function() {
expect(
mergeQueries('query ($param: String!) { foo(param: $param) { bar } }')
).to.equalIgnoreSpaces(
'query ($param: String!) { foo(param: $param) { bar } }'
)

expect(
mergeQueries(
'query ($param: String!) { foo(param: $param) { bar } }',
'query { baz }'
)
).to.equalIgnoreSpaces(
'query ($param: String!) { foo(param: $param) { bar } baz }'
)

expect(
mergeQueries('query { foo }', 'query { bar }', 'query { baz }')
).to.equalIgnoreSpaces('{ foo bar baz }')

expect(mergeQueries('{ foo }', '{ bar }')).to.equalIgnoreSpaces(
'{ foo bar }'
)
})

it('throws an error when passed invalid gql queries', function() {
expect(() => mergeQueries('', '')).to.throw(Error)
expect(() => mergeQueries(undefined, 17, true)).to.throw(Error)
})
})
2 changes: 2 additions & 0 deletions core/base-service/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const BaseService = require('./base')
const BaseJsonService = require('./base-json')
const BaseGraphqlService = require('./base-graphql')
const NonMemoryCachingBaseService = require('./base-non-memory-caching')
const BaseStaticService = require('./base-static')
const BaseSvgScrapingService = require('./base-svg-scraping')
Expand All @@ -20,6 +21,7 @@ const {
module.exports = {
BaseService,
BaseJsonService,
BaseGraphqlService,
NonMemoryCachingBaseService,
BaseStaticService,
BaseSvgScrapingService,
Expand Down
4 changes: 1 addition & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"gh-badges": "file:gh-badges",
"glob": "^7.1.4",
"ioredis": "^4.11.1",
"graphql": "^14.4.2",
"joi-extension-semver": "3.0.0",
"js-yaml": "^3.13.1",
"jsonpath": "~1.0.2",
Expand Down
Loading