diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..0558174 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "stage-2"] +} diff --git a/.gitignore b/.gitignore index 5148e52..fbd9508 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ jspm_packages # Optional REPL history .node_repl_history + +lib diff --git a/package.json b/package.json new file mode 100644 index 0000000..d88936e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..9d0e285 --- /dev/null +++ b/src/client.js @@ -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') +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..61bca55 --- /dev/null +++ b/src/index.js @@ -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) diff --git a/src/loaders.js b/src/loaders.js new file mode 100644 index 0000000..cb013b1 --- /dev/null +++ b/src/loaders.js @@ -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) +}) diff --git a/src/rate-limit.js b/src/rate-limit.js new file mode 100644 index 0000000..dfe845a --- /dev/null +++ b/src/rate-limit.js @@ -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]) +} diff --git a/src/schema.js b/src/schema.js new file mode 100644 index 0000000..313bce9 --- /dev/null +++ b/src/schema.js @@ -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 }]) + } + } + }) + }) +}) diff --git a/src/types/alias.js b/src/types/alias.js new file mode 100644 index 0000000..c46ed45 --- /dev/null +++ b/src/types/alias.js @@ -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 } + }) +}) diff --git a/src/types/area.js b/src/types/area.js new file mode 100644 index 0000000..932359e --- /dev/null +++ b/src/types/area.js @@ -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'] } + }) +}) diff --git a/src/types/artist-credit.js b/src/types/artist-credit.js new file mode 100644 index 0000000..f0993d8 --- /dev/null +++ b/src/types/artist-credit.js @@ -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'] } + }) +}) diff --git a/src/types/artist.js b/src/types/artist.js new file mode 100644 index 0000000..4802851 --- /dev/null +++ b/src/types/artist.js @@ -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 + } + }) +}) diff --git a/src/types/date.js b/src/types/date.js new file mode 100644 index 0000000..d1ad070 --- /dev/null +++ b/src/types/date.js @@ -0,0 +1,3 @@ +import { GraphQLString } from 'graphql/type' + +export default GraphQLString diff --git a/src/types/entity.js b/src/types/entity.js new file mode 100644 index 0000000..66b71e7 --- /dev/null +++ b/src/types/entity.js @@ -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) } + }) +}) diff --git a/src/types/helpers.js b/src/types/helpers.js new file mode 100644 index 0000000..6d3f1bc --- /dev/null +++ b/src/types/helpers.js @@ -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 + }, '') + } +} diff --git a/src/types/life-span.js b/src/types/life-span.js new file mode 100644 index 0000000..56130b5 --- /dev/null +++ b/src/types/life-span.js @@ -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 } + }) +}) diff --git a/src/types/mbid.js b/src/types/mbid.js new file mode 100644 index 0000000..9dfd04a --- /dev/null +++ b/src/types/mbid.js @@ -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 + } +}) diff --git a/src/types/place.js b/src/types/place.js new file mode 100644 index 0000000..a96e59b --- /dev/null +++ b/src/types/place.js @@ -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') + }) +}) diff --git a/src/types/recording.js b/src/types/recording.js new file mode 100644 index 0000000..6b1b556 --- /dev/null +++ b/src/types/recording.js @@ -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) } + }) +}) diff --git a/src/types/release-event.js b/src/types/release-event.js new file mode 100644 index 0000000..53a518b --- /dev/null +++ b/src/types/release-event.js @@ -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 } + }) +}) diff --git a/src/types/release-group.js b/src/types/release-group.js new file mode 100644 index 0000000..5c466af --- /dev/null +++ b/src/types/release-group.js @@ -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 } + } + } + }) +}) diff --git a/src/types/release.js b/src/types/release.js new file mode 100644 index 0000000..9b1677c --- /dev/null +++ b/src/types/release.js @@ -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 } + }) +}) diff --git a/src/types/work.js b/src/types/work.js new file mode 100644 index 0000000..ebfb65c --- /dev/null +++ b/src/types/work.js @@ -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') + }) +}) diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..96d2b3e --- /dev/null +++ b/src/util.js @@ -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 + }, {}) +} diff --git a/test/rate-limit.spec.js b/test/rate-limit.spec.js new file mode 100644 index 0000000..61f034b --- /dev/null +++ b/test/rate-limit.spec.js @@ -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) + }) +})