mirror of
https://github.com/BradNut/graphbrainz
synced 2025-09-08 17:40:32 +00:00
Add entities and lookup queries
This commit is contained in:
parent
abe507185b
commit
92801da402
25 changed files with 894 additions and 0 deletions
3
.babelrc
Normal file
3
.babelrc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"presets": ["es2015", "stage-2"]
|
||||||
|
}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -35,3 +35,5 @@ jspm_packages
|
||||||
|
|
||||||
# Optional REPL history
|
# Optional REPL history
|
||||||
.node_repl_history
|
.node_repl_history
|
||||||
|
|
||||||
|
lib
|
||||||
|
|
|
||||||
57
package.json
Normal file
57
package.json
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"name": "graphbrainz",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run build:lib",
|
||||||
|
"build:lib": "babel --out-dir lib src",
|
||||||
|
"clean": "npm run clean:lib",
|
||||||
|
"clean:lib": "rm -rf lib",
|
||||||
|
"check": "npm run lint && npm run test",
|
||||||
|
"lint": "standard --verbose | snazzy",
|
||||||
|
"prepublish": "npm run clean && npm run check && npm run build",
|
||||||
|
"start": "node lib/index.js",
|
||||||
|
"start:dev": "nodemon --exec babel-node src/index.js",
|
||||||
|
"test": "mocha --compilers js:babel-register"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"homepage": "https://github.com/exogen/graphbrainz",
|
||||||
|
"author": {
|
||||||
|
"name": "Brian Beck",
|
||||||
|
"email": "exogen@gmail.com",
|
||||||
|
"url": "http://brianbeck.com/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/exogen/graphbrainz.git"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bottleneck": "^1.12.0",
|
||||||
|
"chalk": "^1.1.3",
|
||||||
|
"dashify": "^0.2.2",
|
||||||
|
"dataloader": "^1.2.0",
|
||||||
|
"es6-error": "^3.0.1",
|
||||||
|
"express": "^4.14.0",
|
||||||
|
"express-graphql": "^0.5.3",
|
||||||
|
"graphql": "^0.6.2",
|
||||||
|
"qs": "^6.2.1",
|
||||||
|
"retry": "^0.9.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"babel-cli": "^6.11.4",
|
||||||
|
"babel-eslint": "^6.1.2",
|
||||||
|
"babel-preset-es2015": "^6.13.2",
|
||||||
|
"babel-preset-stage-2": "^6.13.0",
|
||||||
|
"babel-register": "^6.11.6",
|
||||||
|
"chai": "^3.5.0",
|
||||||
|
"mocha": "^3.0.1",
|
||||||
|
"nodemon": "^1.10.0",
|
||||||
|
"snazzy": "^4.0.1",
|
||||||
|
"standard": "^7.1.2"
|
||||||
|
},
|
||||||
|
"standard": {
|
||||||
|
"parser": "babel-eslint"
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/client.js
Normal file
152
src/client.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import request from 'request'
|
||||||
|
import retry from 'retry'
|
||||||
|
import qs from 'qs'
|
||||||
|
import chalk from 'chalk'
|
||||||
|
import ExtendableError from 'es6-error'
|
||||||
|
import RateLimit from './rate-limit'
|
||||||
|
import pkg from '../package.json'
|
||||||
|
|
||||||
|
const RETRY_CODES = {
|
||||||
|
ECONNRESET: true,
|
||||||
|
ENOTFOUND: true,
|
||||||
|
ESOCKETTIMEDOUT: true,
|
||||||
|
ETIMEDOUT: true,
|
||||||
|
ECONNREFUSED: true,
|
||||||
|
EHOSTUNREACH: true,
|
||||||
|
EPIPE: true,
|
||||||
|
EAI_AGAIN: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MusicBrainzError extends ExtendableError {
|
||||||
|
constructor (message, statusCode) {
|
||||||
|
super(message)
|
||||||
|
this.statusCode = statusCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class MusicBrainz {
|
||||||
|
constructor (options = {}) {
|
||||||
|
options = {
|
||||||
|
baseURL: 'http://musicbrainz.org/ws/2/',
|
||||||
|
userAgent: `${pkg.name}/${pkg.version} ` +
|
||||||
|
`( ${pkg.homepage || pkg.author.url || pkg.author.email} )`,
|
||||||
|
timeout: 60000,
|
||||||
|
limit: 3,
|
||||||
|
limitPeriod: 3000,
|
||||||
|
maxConcurrency: 10,
|
||||||
|
retries: 10,
|
||||||
|
minRetryDelay: 100,
|
||||||
|
maxRetryDelay: 60000,
|
||||||
|
randomizeRetry: true,
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
this.baseURL = options.baseURL
|
||||||
|
this.userAgent = options.userAgent
|
||||||
|
this.timeout = options.timeout
|
||||||
|
this.limiter = new RateLimit({
|
||||||
|
limit: options.limit,
|
||||||
|
period: options.limitPeriod,
|
||||||
|
maxConcurrency: options.maxConcurrency
|
||||||
|
})
|
||||||
|
// Even though `minTimeout` is lower than one second, the `Limiter` is
|
||||||
|
// making sure we don't exceed the API rate limit anyway. So we're not doing
|
||||||
|
// exponential backoff to wait for the rate limit to subside, but rather
|
||||||
|
// to be kind to MusicBrainz in case some other error occurred.
|
||||||
|
this.retryOptions = {
|
||||||
|
retries: options.retries,
|
||||||
|
minTimeout: options.minRetryTimeout,
|
||||||
|
maxTimeout: options.maxRetryDelay,
|
||||||
|
randomize: options.randomizeRetry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldRetry (err) {
|
||||||
|
if (err instanceof MusicBrainzError) {
|
||||||
|
return err.statusCode >= 500 && err.statusCode < 600
|
||||||
|
}
|
||||||
|
return RETRY_CODES[err.code] || false
|
||||||
|
}
|
||||||
|
|
||||||
|
_get (path, params) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
baseUrl: this.baseURL,
|
||||||
|
url: path,
|
||||||
|
qs: params,
|
||||||
|
json: true,
|
||||||
|
gzip: true,
|
||||||
|
headers: { 'User-Agent': this.userAgent },
|
||||||
|
timeout: this.timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('GET:', path, params)
|
||||||
|
|
||||||
|
request(options, (err, response, body) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
} else if (response.statusCode !== 200) {
|
||||||
|
const message = (body && body.error) || ''
|
||||||
|
reject(new MusicBrainzError(message, response.statusCode))
|
||||||
|
} else {
|
||||||
|
resolve(body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get (path, params) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fn = this._get.bind(this)
|
||||||
|
const operation = retry.operation(this.retryOptions)
|
||||||
|
operation.attempt(currentAttempt => {
|
||||||
|
const priority = currentAttempt
|
||||||
|
this.limiter.enqueue(fn, [path, params], priority)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(err => {
|
||||||
|
if (!this.shouldRetry(err) || !operation.retry(err)) {
|
||||||
|
reject(operation.mainError() || err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getLookupURL (entity, id, params) {
|
||||||
|
let url = `${entity}/${id}`
|
||||||
|
if (typeof params.inc === 'object') {
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
inc: params.inc.join('+')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const query = qs.stringify(params, {
|
||||||
|
skipNulls: true,
|
||||||
|
filter: (key, value) => value === '' ? undefined : value
|
||||||
|
})
|
||||||
|
if (query) {
|
||||||
|
url += `?${query}`
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup (entity, id, params = {}) {
|
||||||
|
const url = this.getLookupURL(entity, id, params)
|
||||||
|
return this.get(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const client = new MusicBrainz()
|
||||||
|
const fn = (id) => {
|
||||||
|
return client.lookup('artist', id).then(artist => {
|
||||||
|
console.log(chalk.green(`Done: ${id} ✔ ${artist.name}`))
|
||||||
|
}).catch(err => {
|
||||||
|
console.log(chalk.red(`Error: ${id} ✘ ${err}`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn('f1106b17-dcbb-45f6-b938-199ccfab50cc')
|
||||||
|
fn('a74b1b7f-71a5-4011-9441-d0b5e4122711')
|
||||||
|
fn('9b5ae4cc-15ae-4f0b-8a4e-8c44e42ba52a')
|
||||||
|
fn('26f77379-968b-4435-b486-fc9acb4590d3')
|
||||||
|
fn('8538e728-ca0b-4321-b7e5-cff6565dd4c0')
|
||||||
|
}
|
||||||
18
src/index.js
Normal file
18
src/index.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import express from 'express'
|
||||||
|
import graphqlHTTP from 'express-graphql'
|
||||||
|
import schema from './schema'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.use('/graphql', graphqlHTTP({
|
||||||
|
schema,
|
||||||
|
graphiql: true,
|
||||||
|
formatError: error => ({
|
||||||
|
message: error.message,
|
||||||
|
statusCode: error.statusCode,
|
||||||
|
locations: error.locations,
|
||||||
|
stack: error.stack
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.listen(3001)
|
||||||
13
src/loaders.js
Normal file
13
src/loaders.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import DataLoader from 'dataloader'
|
||||||
|
import MusicBrainz from './client'
|
||||||
|
|
||||||
|
const CLIENT = new MusicBrainz()
|
||||||
|
|
||||||
|
export const entityLoader = new DataLoader(keys => {
|
||||||
|
return Promise.all(keys.map(key => {
|
||||||
|
const [ entity, id, params ] = key
|
||||||
|
return CLIENT.lookup(entity, id, params)
|
||||||
|
}))
|
||||||
|
}, {
|
||||||
|
cacheKeyFn: (key) => CLIENT.getLookupURL(...key)
|
||||||
|
})
|
||||||
143
src/rate-limit.js
Normal file
143
src/rate-limit.js
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
export default class RateLimit {
|
||||||
|
constructor (options = {}) {
|
||||||
|
options = {
|
||||||
|
limit: 1,
|
||||||
|
period: 1000,
|
||||||
|
maxConcurrency: options.limit || 1,
|
||||||
|
defaultPriority: 1,
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
this.limit = options.limit
|
||||||
|
this.period = options.period
|
||||||
|
this.defaultPriority = options.defaultPriority
|
||||||
|
this.maxConcurrency = options.maxConcurrency
|
||||||
|
this.queues = []
|
||||||
|
this.numPending = 0
|
||||||
|
this.periodStart = null
|
||||||
|
this.periodCapacity = this.limit
|
||||||
|
this.timer = null
|
||||||
|
this.pendingFlush = false
|
||||||
|
this.paused = false
|
||||||
|
}
|
||||||
|
|
||||||
|
pause () {
|
||||||
|
this.paused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
unpause () {
|
||||||
|
this.paused = false
|
||||||
|
this.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
clear () {
|
||||||
|
this.queues.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue (fn, args, priority = this.defaultPriority) {
|
||||||
|
priority = Math.max(0, priority)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const queue = this.queues[priority] = this.queues[priority] || []
|
||||||
|
queue.push({ fn, args, resolve, reject })
|
||||||
|
if (!this.pendingFlush) {
|
||||||
|
this.pendingFlush = true
|
||||||
|
process.nextTick(() => {
|
||||||
|
this.pendingFlush = false
|
||||||
|
this.flush()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dequeue () {
|
||||||
|
let task
|
||||||
|
for (let i = this.queues.length - 1; i >= 0; i--) {
|
||||||
|
const queue = this.queues[i]
|
||||||
|
if (queue && queue.length) {
|
||||||
|
task = queue.shift()
|
||||||
|
}
|
||||||
|
if (!queue || !queue.length) {
|
||||||
|
this.queues.length = i
|
||||||
|
}
|
||||||
|
if (task) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
flush () {
|
||||||
|
if (this.paused) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.numPending < this.maxConcurrency && this.periodCapacity > 0) {
|
||||||
|
const task = this.dequeue()
|
||||||
|
if (task) {
|
||||||
|
const { resolve, reject, fn, args } = task
|
||||||
|
if (this.timer == null) {
|
||||||
|
const now = Date.now()
|
||||||
|
let timeout = this.period
|
||||||
|
if (this.periodStart != null) {
|
||||||
|
const delay = now - (this.periodStart + timeout)
|
||||||
|
if (delay > 0 && delay <= timeout) {
|
||||||
|
timeout -= delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.periodStart = now
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null
|
||||||
|
this.periodCapacity = this.limit
|
||||||
|
this.flush()
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
this.numPending += 1
|
||||||
|
this.periodCapacity -= 1
|
||||||
|
const onResolve = (value) => {
|
||||||
|
this.numPending -= 1
|
||||||
|
resolve(value)
|
||||||
|
this.flush()
|
||||||
|
}
|
||||||
|
const onReject = (err) => {
|
||||||
|
this.numPending -= 1
|
||||||
|
reject(err)
|
||||||
|
this.flush()
|
||||||
|
}
|
||||||
|
fn(...args).then(onResolve, onReject)
|
||||||
|
this.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const t0 = Date.now()
|
||||||
|
const logTime = (...args) => {
|
||||||
|
const t = Date.now()
|
||||||
|
console.log(`[t=${t - t0}]`, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
const limiter = new RateLimit({
|
||||||
|
limit: 3,
|
||||||
|
period: 3000,
|
||||||
|
maxConcurrency: 5
|
||||||
|
})
|
||||||
|
|
||||||
|
const fn = (i) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
logTime(`Finished task ${i}`)
|
||||||
|
resolve(i)
|
||||||
|
}, 7000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.enqueue(fn, [1])
|
||||||
|
limiter.enqueue(fn, [2])
|
||||||
|
limiter.enqueue(fn, [3])
|
||||||
|
limiter.enqueue(fn, [4], 2)
|
||||||
|
limiter.enqueue(fn, [5], 10)
|
||||||
|
limiter.enqueue(fn, [6])
|
||||||
|
limiter.enqueue(fn, [7])
|
||||||
|
limiter.enqueue(fn, [8])
|
||||||
|
limiter.enqueue(fn, [9])
|
||||||
|
limiter.enqueue(fn, [10])
|
||||||
|
}
|
||||||
118
src/schema.js
Normal file
118
src/schema.js
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { GraphQLSchema, GraphQLObjectType, GraphQLNonNull } from 'graphql'
|
||||||
|
import MBID from './types/mbid'
|
||||||
|
import ArtistType from './types/artist'
|
||||||
|
import WorkType from './types/work'
|
||||||
|
import RecordingType from './types/recording'
|
||||||
|
import ReleaseGroupType from './types/release-group'
|
||||||
|
import ReleaseType from './types/release'
|
||||||
|
import PlaceType from './types/place'
|
||||||
|
import { getFields } from './util'
|
||||||
|
import { entityLoader } from './loaders'
|
||||||
|
|
||||||
|
export default new GraphQLSchema({
|
||||||
|
query: new GraphQLObjectType({
|
||||||
|
name: 'RootQueryType',
|
||||||
|
fields: () => ({
|
||||||
|
artist: {
|
||||||
|
type: ArtistType,
|
||||||
|
args: { id: { type: new GraphQLNonNull(MBID) } },
|
||||||
|
resolve (root, { id }, context, info) {
|
||||||
|
const include = []
|
||||||
|
const params = { inc: include }
|
||||||
|
let releaseType
|
||||||
|
let releaseGroupType
|
||||||
|
const fields = getFields(info)
|
||||||
|
if (fields.aliases) {
|
||||||
|
include.push('aliases')
|
||||||
|
}
|
||||||
|
if (fields.works) {
|
||||||
|
include.push('works')
|
||||||
|
}
|
||||||
|
if (fields.recordings) {
|
||||||
|
include.push('recordings')
|
||||||
|
}
|
||||||
|
if (fields.releases) {
|
||||||
|
include.push('releases')
|
||||||
|
fields.releases.arguments.forEach(arg => {
|
||||||
|
if (arg.name.value === 'status' || arg.name.value === 'type') {
|
||||||
|
params[arg.name.value] = arg.value.value
|
||||||
|
releaseType = params.type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (fields.releaseGroups) {
|
||||||
|
include.push('release-groups')
|
||||||
|
fields.releaseGroups.arguments.forEach(arg => {
|
||||||
|
if (arg.name.value === 'type') {
|
||||||
|
params[arg.name.value] = arg.value.value
|
||||||
|
releaseGroupType = params.type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (releaseType !== releaseGroupType) {
|
||||||
|
throw new Error(
|
||||||
|
"You tried to fetch both 'releases' and 'releaseGroups', but " +
|
||||||
|
"specified a different 'type' value on each; they must be the " +
|
||||||
|
'same')
|
||||||
|
}
|
||||||
|
return entityLoader.load(['artist', id, params])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
work: {
|
||||||
|
type: WorkType,
|
||||||
|
args: { id: { type: MBID } },
|
||||||
|
resolve (root, { id }, context, info) {
|
||||||
|
const include = []
|
||||||
|
return entityLoader.load(['work', id, { inc: include }])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
recording: {
|
||||||
|
type: RecordingType,
|
||||||
|
args: { id: { type: MBID } },
|
||||||
|
resolve (root, { id }, context, info) {
|
||||||
|
const include = []
|
||||||
|
const fields = getFields(info)
|
||||||
|
if (fields.artists || fields.artistByline) {
|
||||||
|
include.push('artists')
|
||||||
|
}
|
||||||
|
return entityLoader.load(['recording', id, { inc: include }])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
release: {
|
||||||
|
type: ReleaseType,
|
||||||
|
args: { id: { type: MBID } },
|
||||||
|
resolve (root, { id }, context, info) {
|
||||||
|
const include = []
|
||||||
|
const fields = getFields(info)
|
||||||
|
if (fields.artists || fields.artistByline) {
|
||||||
|
include.push('artists')
|
||||||
|
}
|
||||||
|
return entityLoader.load(['release', id, { inc: include }])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
releaseGroup: {
|
||||||
|
type: ReleaseGroupType,
|
||||||
|
args: { id: { type: MBID } },
|
||||||
|
resolve (root, { id }, context, info) {
|
||||||
|
const include = []
|
||||||
|
const fields = getFields(info)
|
||||||
|
if (fields.artists || fields.artistByline) {
|
||||||
|
include.push('artists')
|
||||||
|
}
|
||||||
|
if (fields.releases) {
|
||||||
|
include.push('releases')
|
||||||
|
}
|
||||||
|
return entityLoader.load(['release-group', id, { inc: include }])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
place: {
|
||||||
|
type: PlaceType,
|
||||||
|
args: { id: { type: MBID } },
|
||||||
|
resolve (root, { id }, context, info) {
|
||||||
|
const include = []
|
||||||
|
return entityLoader.load(['place', id, { inc: include }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
19
src/types/alias.js
Normal file
19
src/types/alias.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import {
|
||||||
|
GraphQLObjectType,
|
||||||
|
GraphQLString,
|
||||||
|
GraphQLBoolean
|
||||||
|
} from 'graphql/type'
|
||||||
|
import MBID from './mbid'
|
||||||
|
import { getHyphenated } from './helpers'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'Alias',
|
||||||
|
fields: () => ({
|
||||||
|
name: { type: GraphQLString },
|
||||||
|
sortName: { type: GraphQLString, resolve: getHyphenated },
|
||||||
|
locale: { type: GraphQLString },
|
||||||
|
primary: { type: GraphQLBoolean },
|
||||||
|
type: { type: GraphQLString },
|
||||||
|
typeID: { type: MBID, resolve: getHyphenated }
|
||||||
|
})
|
||||||
|
})
|
||||||
20
src/types/area.js
Normal file
20
src/types/area.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import {
|
||||||
|
GraphQLObjectType,
|
||||||
|
GraphQLNonNull,
|
||||||
|
GraphQLString,
|
||||||
|
GraphQLList
|
||||||
|
} from 'graphql/type'
|
||||||
|
import MBID from './mbid'
|
||||||
|
import { getHyphenated } from './helpers'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'Area',
|
||||||
|
description: 'A country, region, city or the like.',
|
||||||
|
fields: () => ({
|
||||||
|
id: { type: new GraphQLNonNull(MBID) },
|
||||||
|
disambiguation: { type: GraphQLString },
|
||||||
|
name: { type: GraphQLString },
|
||||||
|
sortName: { type: GraphQLString, resolve: getHyphenated },
|
||||||
|
isoCodes: { type: new GraphQLList(GraphQLString), resolve: data => data['iso-3166-1-codes'] }
|
||||||
|
})
|
||||||
|
})
|
||||||
14
src/types/artist-credit.js
Normal file
14
src/types/artist-credit.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { GraphQLObjectType, GraphQLString } from 'graphql/type'
|
||||||
|
import ArtistType from './artist'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'ArtistCredit',
|
||||||
|
description:
|
||||||
|
'Artist, variation of artist name and piece of text to join the artist ' +
|
||||||
|
'name to the next.',
|
||||||
|
fields: () => ({
|
||||||
|
artist: { type: ArtistType },
|
||||||
|
name: { type: GraphQLString },
|
||||||
|
joinPhrase: { type: GraphQLString, resolve: data => data['joinphrase'] }
|
||||||
|
})
|
||||||
|
})
|
||||||
47
src/types/artist.js
Normal file
47
src/types/artist.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import {
|
||||||
|
GraphQLObjectType,
|
||||||
|
GraphQLNonNull,
|
||||||
|
GraphQLString,
|
||||||
|
GraphQLList
|
||||||
|
} from 'graphql/type'
|
||||||
|
import MBID from './mbid'
|
||||||
|
import AliasType from './alias'
|
||||||
|
import AreaType from './area'
|
||||||
|
import LifeSpanType from './life-span'
|
||||||
|
import WorkType from './work'
|
||||||
|
import RecordingType from './recording'
|
||||||
|
import ReleaseType from './release'
|
||||||
|
import ReleaseGroupType from './release-group'
|
||||||
|
import { getHyphenated, getUnderscored, fieldWithID } from './helpers'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'Artist',
|
||||||
|
description:
|
||||||
|
'An artist is generally a musician, a group of musicians, or another ' +
|
||||||
|
'music professional (composer, engineer, illustrator, producer, etc.)',
|
||||||
|
fields: () => ({
|
||||||
|
id: { type: new GraphQLNonNull(MBID) },
|
||||||
|
name: { type: GraphQLString },
|
||||||
|
sortName: { type: GraphQLString, resolve: getHyphenated },
|
||||||
|
aliases: { type: new GraphQLList(AliasType) },
|
||||||
|
disambiguation: { type: GraphQLString },
|
||||||
|
country: { type: GraphQLString },
|
||||||
|
area: { type: AreaType },
|
||||||
|
beginArea: { type: AreaType, resolve: getUnderscored },
|
||||||
|
endArea: { type: AreaType, resolve: getUnderscored },
|
||||||
|
...fieldWithID('gender'),
|
||||||
|
...fieldWithID('type'),
|
||||||
|
lifeSpan: { type: LifeSpanType, resolve: getHyphenated },
|
||||||
|
works: { type: new GraphQLList(WorkType) },
|
||||||
|
recordings: { type: new GraphQLList(RecordingType) },
|
||||||
|
releases: {
|
||||||
|
type: new GraphQLList(ReleaseType),
|
||||||
|
args: { type: { type: GraphQLString }, status: { type: GraphQLString } }
|
||||||
|
},
|
||||||
|
releaseGroups: {
|
||||||
|
type: new GraphQLList(ReleaseGroupType),
|
||||||
|
args: { type: { type: GraphQLString } },
|
||||||
|
resolve: getHyphenated
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
3
src/types/date.js
Normal file
3
src/types/date.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { GraphQLString } from 'graphql/type'
|
||||||
|
|
||||||
|
export default GraphQLString
|
||||||
9
src/types/entity.js
Normal file
9
src/types/entity.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { GraphQLInterfaceType, GraphQLNonNull } from 'graphql/type'
|
||||||
|
import MBID from './mbid'
|
||||||
|
|
||||||
|
export default new GraphQLInterfaceType({
|
||||||
|
name: 'Entity',
|
||||||
|
fields: () => ({
|
||||||
|
id: { type: new GraphQLNonNull(MBID) }
|
||||||
|
})
|
||||||
|
})
|
||||||
41
src/types/helpers.js
Normal file
41
src/types/helpers.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import dashify from 'dashify'
|
||||||
|
import { GraphQLString, GraphQLList } from 'graphql/type'
|
||||||
|
import MBID from './mbid'
|
||||||
|
|
||||||
|
export function fieldWithID (name, config = {}) {
|
||||||
|
config = {
|
||||||
|
type: GraphQLString,
|
||||||
|
resolve: getHyphenated,
|
||||||
|
...config
|
||||||
|
}
|
||||||
|
const isPlural = config.type instanceof GraphQLList
|
||||||
|
const singularName = isPlural && name.endsWith('s') ? name.slice(0, -1) : name
|
||||||
|
const idName = isPlural ? `${singularName}IDs` : `${name}ID`
|
||||||
|
const idConfig = {
|
||||||
|
type: isPlural ? new GraphQLList(MBID) : MBID,
|
||||||
|
resolve: getHyphenated
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
[name]: config,
|
||||||
|
[idName]: idConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHyphenated (source, args, context, info) {
|
||||||
|
const name = dashify(info.fieldName)
|
||||||
|
return source[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnderscored (source, args, context, info) {
|
||||||
|
const name = dashify(info.fieldName).replace('-', '_')
|
||||||
|
return source[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getByline (data) {
|
||||||
|
const credit = data['artist-credit']
|
||||||
|
if (credit && credit.length) {
|
||||||
|
return credit.reduce((byline, credit) => {
|
||||||
|
return byline + credit.name + credit.joinphrase
|
||||||
|
}, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/types/life-span.js
Normal file
13
src/types/life-span.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { GraphQLObjectType, GraphQLBoolean } from 'graphql/type'
|
||||||
|
import DateType from './date'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'LifeSpan',
|
||||||
|
description:
|
||||||
|
'Begin and end date of an entity that may have a finite lifetime.',
|
||||||
|
fields: () => ({
|
||||||
|
begin: { type: DateType },
|
||||||
|
end: { type: DateType },
|
||||||
|
ended: { type: GraphQLBoolean }
|
||||||
|
})
|
||||||
|
})
|
||||||
27
src/types/mbid.js
Normal file
27
src/types/mbid.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Kind } from 'graphql'
|
||||||
|
import { GraphQLScalarType } from 'graphql/type'
|
||||||
|
|
||||||
|
// e.g. 24fdb962-65ef-41ca-9ba3-7251a23a84fc
|
||||||
|
const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||||
|
|
||||||
|
function uuid (value) {
|
||||||
|
if (typeof value === 'string' && value.length === 36 && regex.test(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
throw new TypeError(`Malformed UUID: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new GraphQLScalarType({
|
||||||
|
name: 'MBID',
|
||||||
|
description:
|
||||||
|
'The `MBID` scalar represents MusicBrainz identifiers, which are ' +
|
||||||
|
'36-character UUIDs.',
|
||||||
|
serialize: uuid,
|
||||||
|
parseValue: uuid,
|
||||||
|
parseLiteral (ast) {
|
||||||
|
if (ast.kind === Kind.STRING) {
|
||||||
|
return uuid(ast.value)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
30
src/types/place.js
Normal file
30
src/types/place.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { GraphQLObjectType, GraphQLNonNull, GraphQLString } from 'graphql/type'
|
||||||
|
import MBID from './mbid'
|
||||||
|
import AreaType from './area'
|
||||||
|
import LifeSpanType from './life-span'
|
||||||
|
import { getHyphenated, fieldWithID } from './helpers'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'Place',
|
||||||
|
description:
|
||||||
|
'A venue, studio or other place where music is performed, recorded, ' +
|
||||||
|
'engineered, etc.',
|
||||||
|
fields: () => ({
|
||||||
|
id: { type: new GraphQLNonNull(MBID) },
|
||||||
|
name: { type: GraphQLString },
|
||||||
|
disambiguation: { type: GraphQLString },
|
||||||
|
address: { type: GraphQLString },
|
||||||
|
area: { type: AreaType },
|
||||||
|
coordinates: {
|
||||||
|
type: new GraphQLObjectType({
|
||||||
|
name: 'Coordinates',
|
||||||
|
fields: () => ({
|
||||||
|
latitude: { type: GraphQLString },
|
||||||
|
longitude: { type: GraphQLString }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
lifeSpan: { type: LifeSpanType, resolve: getHyphenated },
|
||||||
|
...fieldWithID('type')
|
||||||
|
})
|
||||||
|
})
|
||||||
32
src/types/recording.js
Normal file
32
src/types/recording.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import {
|
||||||
|
GraphQLObjectType,
|
||||||
|
GraphQLNonNull,
|
||||||
|
GraphQLString,
|
||||||
|
GraphQLInt,
|
||||||
|
GraphQLBoolean,
|
||||||
|
GraphQLList
|
||||||
|
} from 'graphql/type'
|
||||||
|
import MBID from './mbid'
|
||||||
|
import ArtistCreditType from './artist-credit'
|
||||||
|
import ReleaseType from './release'
|
||||||
|
import { getByline } from './helpers'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'Recording',
|
||||||
|
description:
|
||||||
|
'Represents a unique mix or edit. Has title, artist credit, duration, ' +
|
||||||
|
'list of PUIDs and ISRCs.',
|
||||||
|
fields: () => ({
|
||||||
|
id: { type: new GraphQLNonNull(MBID) },
|
||||||
|
title: { type: GraphQLString },
|
||||||
|
disambiguation: { type: GraphQLString },
|
||||||
|
length: { type: GraphQLInt },
|
||||||
|
video: { type: GraphQLBoolean },
|
||||||
|
artists: {
|
||||||
|
type: new GraphQLList(ArtistCreditType),
|
||||||
|
resolve: data => data['artist-credit']
|
||||||
|
},
|
||||||
|
artistByline: { type: GraphQLString, resolve: getByline },
|
||||||
|
releases: { type: new GraphQLList(ReleaseType) }
|
||||||
|
})
|
||||||
|
})
|
||||||
15
src/types/release-event.js
Normal file
15
src/types/release-event.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { GraphQLObjectType } from 'graphql/type'
|
||||||
|
import DateType from './date'
|
||||||
|
import AreaType from './area'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'ReleaseEvent',
|
||||||
|
description:
|
||||||
|
'Date on which a release was released in a country/region with a ' +
|
||||||
|
'particular label, catalog number, barcode, and what release format ' +
|
||||||
|
'was used.',
|
||||||
|
fields: () => ({
|
||||||
|
area: { type: AreaType },
|
||||||
|
date: { type: DateType }
|
||||||
|
})
|
||||||
|
})
|
||||||
38
src/types/release-group.js
Normal file
38
src/types/release-group.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import {
|
||||||
|
GraphQLObjectType,
|
||||||
|
GraphQLNonNull,
|
||||||
|
GraphQLString,
|
||||||
|
GraphQLList
|
||||||
|
} from 'graphql/type'
|
||||||
|
import MBID from './mbid'
|
||||||
|
import DateType from './date'
|
||||||
|
import ArtistCreditType from './artist-credit'
|
||||||
|
import ReleaseType from './release'
|
||||||
|
import { getHyphenated, fieldWithID, getByline } from './helpers'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'ReleaseGroup',
|
||||||
|
description:
|
||||||
|
'Represents an abstract "album" (or "single", or "EP") entity. ' +
|
||||||
|
'Technically it’s a group of releases, with a specified type.',
|
||||||
|
fields: () => ({
|
||||||
|
id: { type: new GraphQLNonNull(MBID) },
|
||||||
|
title: { type: GraphQLString },
|
||||||
|
disambiguation: { type: GraphQLString },
|
||||||
|
firstReleaseDate: { type: DateType, resolve: getHyphenated },
|
||||||
|
...fieldWithID('primaryType'),
|
||||||
|
...fieldWithID('secondaryTypes', { type: new GraphQLList(GraphQLString) }),
|
||||||
|
artists: {
|
||||||
|
type: new GraphQLList(ArtistCreditType),
|
||||||
|
resolve: data => data['artist-credit']
|
||||||
|
},
|
||||||
|
artistByline: { type: GraphQLString, resolve: getByline },
|
||||||
|
releases: {
|
||||||
|
type: new GraphQLList(ReleaseType),
|
||||||
|
args: {
|
||||||
|
type: { type: GraphQLString },
|
||||||
|
status: { type: GraphQLString }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
39
src/types/release.js
Normal file
39
src/types/release.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import {
|
||||||
|
GraphQLObjectType,
|
||||||
|
GraphQLNonNull,
|
||||||
|
GraphQLString,
|
||||||
|
GraphQLList
|
||||||
|
} from 'graphql/type'
|
||||||
|
import MBID from './mbid'
|
||||||
|
import DateType from './date'
|
||||||
|
import ArtistCreditType from './artist-credit'
|
||||||
|
import ReleaseEventType from './release-event'
|
||||||
|
import { getHyphenated, fieldWithID, getByline } from './helpers'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'Release',
|
||||||
|
description:
|
||||||
|
'Real-world release object you can buy in your music store. It has ' +
|
||||||
|
'release date and country, list of catalog number and label pairs, ' +
|
||||||
|
'packaging type and release status.',
|
||||||
|
fields: () => ({
|
||||||
|
id: { type: new GraphQLNonNull(MBID) },
|
||||||
|
title: { type: GraphQLString },
|
||||||
|
artists: {
|
||||||
|
type: new GraphQLList(ArtistCreditType),
|
||||||
|
resolve: data => data['artist-credit']
|
||||||
|
},
|
||||||
|
artistByline: { type: GraphQLString, resolve: getByline },
|
||||||
|
releaseEvents: {
|
||||||
|
type: new GraphQLList(ReleaseEventType),
|
||||||
|
resolve: getHyphenated
|
||||||
|
},
|
||||||
|
disambiguation: { type: GraphQLString },
|
||||||
|
date: { type: DateType },
|
||||||
|
country: { type: GraphQLString },
|
||||||
|
barcode: { type: GraphQLString },
|
||||||
|
...fieldWithID('status'),
|
||||||
|
...fieldWithID('packaging'),
|
||||||
|
quality: { type: GraphQLString }
|
||||||
|
})
|
||||||
|
})
|
||||||
23
src/types/work.js
Normal file
23
src/types/work.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import {
|
||||||
|
GraphQLObjectType,
|
||||||
|
GraphQLNonNull,
|
||||||
|
GraphQLString,
|
||||||
|
GraphQLList
|
||||||
|
} from 'graphql/type'
|
||||||
|
import MBID from './mbid'
|
||||||
|
import { fieldWithID } from './helpers'
|
||||||
|
|
||||||
|
export default new GraphQLObjectType({
|
||||||
|
name: 'Work',
|
||||||
|
description:
|
||||||
|
'A distinct intellectual or artistic creation, which can be expressed in ' +
|
||||||
|
'the form of one or more audio recordings',
|
||||||
|
fields: () => ({
|
||||||
|
id: { type: new GraphQLNonNull(MBID) },
|
||||||
|
title: { type: GraphQLString },
|
||||||
|
disambiguation: { type: GraphQLString },
|
||||||
|
iswcs: { type: new GraphQLList(GraphQLString) },
|
||||||
|
language: { type: GraphQLString },
|
||||||
|
...fieldWithID('type')
|
||||||
|
})
|
||||||
|
})
|
||||||
7
src/util.js
Normal file
7
src/util.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export function getFields (info) {
|
||||||
|
const selections = info.fieldASTs[0].selectionSet.selections
|
||||||
|
return selections.reduce((fields, selection) => {
|
||||||
|
fields[selection.name.value] = selection
|
||||||
|
return fields
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
11
test/rate-limit.spec.js
Normal file
11
test/rate-limit.spec.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
/* global describe, it */
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import RateLimit from '../src/rate-limit'
|
||||||
|
|
||||||
|
describe('RateLimit', () => {
|
||||||
|
it('defaults to 1 request per second', () => {
|
||||||
|
const limiter = new RateLimit()
|
||||||
|
expect(limiter.limit).to.equal(1)
|
||||||
|
expect(limiter.period).to.equal(1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue