Skip to content

Commit

Permalink
Add BaseGraphqlService, support [github] V4 API (#3763)
Browse files Browse the repository at this point in the history
* add base class for Graphql APIs
* add GithubAuthV4Service + updates to GH token pool
* update github forks to use GithubAuthV4Service
* rename GithubAuthService to GithubAuthV3Service
  • Loading branch information
chris48s authored Jul 29, 2019
1 parent 320de79 commit 75ee413
Show file tree
Hide file tree
Showing 36 changed files with 755 additions and 119 deletions.
52 changes: 52 additions & 0 deletions core/base-service/base-graphql.js
Original file line number Diff line number Diff line change
@@ -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 = {},
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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) {
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 }
Loading

0 comments on commit 75ee413

Please sign in to comment.