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
52 changes: 52 additions & 0 deletions core/base-service/base-graphql.js
@@ -0,0 +1,52 @@
'use strict'

const { print } = require('graphql/language/printer')
const BaseService = require('./base')
const { InvalidResponse, ShieldsRuntimeError } = require('./errors')
const { parseJson } = require('./json')

function defaultTransformErrors(errors) {
return new InvalidResponse({ prettyMessage: errors[0].message })
}

class BaseGraphqlService extends BaseService {
_parseJson(buffer) {
return parseJson(buffer)
}

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 = {},
transformErrors = defaultTransformErrors,
}) {
const mergedOptions = {
...{ headers: { Accept: 'application/json' } },
...options,
}
mergedOptions.method = 'POST'
mergedOptions.body = JSON.stringify({ query: print(query), variables })
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages: httpErrorMessages,
})
const json = this._parseJson(buffer)
if (json.errors) {
const exception = transformErrors(json.errors)
if (exception instanceof ShieldsRuntimeError) {
throw exception
} else {
throw Error(
`transformErrors() must return a ShieldsRuntimeError; got ${exception}`
)
}
}
return this.constructor._validate(json, schema)
}
}

module.exports = BaseGraphqlService
209 changes: 209 additions & 0 deletions core/base-service/base-graphql.spec.js
@@ -0,0 +1,209 @@
'use strict'

const Joi = require('@hapi/joi')
const { expect } = require('chai')
const gql = require('graphql-tag')
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: gql`
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":"{\\n requiredString\\n}\\n","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: gql`
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":"{\\n requiredString\\n}\\n","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: gql`
query {
requiredString
}
`,
transformErrors: function(errors) {
if (errors[0].message === 'oh noes!!') {
return 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',
})
})
})
})
21 changes: 2 additions & 19 deletions core/base-service/base-json.js
@@ -1,28 +1,11 @@
'use strict'

// See available emoji at http://emoji.muan.co/
const emojic = require('emojic')
const BaseService = require('./base')
const trace = require('./trace')
const { InvalidResponse } = require('./errors')
const { parseJson } = require('./json')

class BaseJsonService extends BaseService {
_parseJson(buffer) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
let json
try {
json = JSON.parse(buffer)
} catch (err) {
logTrace(emojic.dart, 'Response JSON (unparseable)', buffer)
throw new InvalidResponse({
prettyMessage: 'unparseable json response',
underlyingError: err,
})
}
logTrace(emojic.dart, 'Response JSON (before validation)', json, {
deep: true,
})
return json
return parseJson(buffer)
}

async _requestJson({ schema, url, options = {}, errorMessages = {} }) {
Expand Down
52 changes: 52 additions & 0 deletions core/base-service/graphql.js
@@ -0,0 +1,52 @@
'use strict'
/**
* @module
*/

/**
* 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 {...object} queries queries to merge
* @returns {object} 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 = 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 merged
}

module.exports = { mergeQueries }