diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..063b78f --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: npm start diff --git a/README.md b/README.md index 8530b27..4682f83 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,490 @@ # graphbrainz + GraphQL server for the MusicBrainz API + +## Schema + +```graphql + +schema { + query: RootQuery +} + +type Alias { + name: String + sortName: String + locale: String + primary: Boolean + type: String + typeID: MBID +} + +type Area implements Entity { + id: MBID! + name: String + sortName: String + disambiguation: String + isoCodes: [String] + artists(limit: Int, offset: Int): [Artist] + events(limit: Int, offset: Int): [Event] + labels(limit: Int, offset: Int): [Label] + places(limit: Int, offset: Int): [Place] + releases(limit: Int, + offset: Int, + type: ReleaseGroupType, + types: [ReleaseGroupType], + status: ReleaseStatus, + statuses: [ReleaseStatus]): [Release] +} + +type AreaPage { + count: Int! + offset: Int! + created: Date + results: [Area]! +} + +type Artist implements Entity { + id: MBID! + name: String + sortName: String + disambiguation: String + aliases: [Alias] + country: String + area: Area + beginArea: Area + endArea: Area + lifeSpan: LifeSpan + gender: String + genderID: MBID + type: String + typeID: MBID + recordings(limit: Int, offset: Int): [Recording] + releases(limit: Int, + offset: Int, + type: ReleaseGroupType, + types: [ReleaseGroupType], + status: ReleaseStatus, + statuses: [ReleaseStatus]): [Release] + releaseGroups(limit: Int, + offset: Int, + type: ReleaseGroupType, + types: [ReleaseGroupType]): [ReleaseGroup] + works(limit: Int, offset: Int): [Work] + relations: Relations +} + +type ArtistCredit { + artist: Artist + name: String + joinPhrase: String +} + +type ArtistPage { + count: Int! + offset: Int! + created: Date + results: [Artist]! +} + +type BrowseQuery { + artists(limit: Int, + offset: Int, + area: MBID, + recording: MBID, + release: MBID, + releaseGroup: MBID, + work: MBID): ArtistPage + events(limit: Int, + offset: Int, + area: MBID, + artist: MBID, + place: MBID): EventPage + labels(limit: Int, + offset: Int, + area: MBID, + release: MBID): LabelPage + places(limit: Int, + offset: Int, + area: MBID): PlacePage + recordings(limit: Int, + offset: Int, + artist: MBID, + release: MBID): RecordingPage + releases(limit: Int, + offset: Int, + area: MBID, + artist: MBID, + label: MBID, + track: MBID, + trackArtist: MBID, + recording: MBID, + releaseGroup: MBID): ReleasePage + releaseGroups(limit: Int, + offset: Int, + artist: MBID, + release: MBID): ReleaseGroupPage + works(limit: Int, + offset: Int, + artist: MBID): WorkPage + urls(limit: Int, + offset: Int, + resource: URLString): URLPage +} + +type Coordinates { + latitude: Degrees + longitude: Degrees +} + +scalar Date + +scalar Degrees + +interface Entity { + id: MBID! +} + +type Event implements Entity { + id: MBID! + name: String + disambiguation: String + lifeSpan: LifeSpan + time: Time + cancelled: Boolean + setlist: String + type: String + typeID: MBID +} + +type EventPage { + count: Int! + offset: Int! + created: Date + results: [Event]! +} + +type Instrument implements Entity { + id: MBID! + name: String + disambiguation: String + description: String + type: String + typeID: MBID +} + +scalar IPI + +type Label implements Entity { + id: MBID! + name: String + sortName: String + disambiguation: String + country: String + area: Area + lifeSpan: LifeSpan + labelCode: Int + ipis: [IPI] + type: String + typeID: MBID + releases(limit: Int, + offset: Int, + type: ReleaseGroupType, + types: [ReleaseGroupType], + status: ReleaseStatus, + statuses: [ReleaseStatus]): [Release] +} + +type LabelPage { + count: Int! + offset: Int! + created: Date + results: [Label]! +} + +type LifeSpan { + begin: Date + end: Date + ended: Boolean +} + +type LookupQuery { + area(id: MBID!): Area + artist(id: MBID!): Artist + event(id: MBID!): Event + instrument(id: MBID!): Instrument + label(id: MBID!): Label + place(id: MBID!): Place + recording(id: MBID!): Recording + release(id: MBID!): Release + releaseGroup(id: MBID!): ReleaseGroup + url(id: MBID!): URL + work(id: MBID!): Work +} + +scalar MBID + +type Place implements Entity { + id: MBID! + name: String + disambiguation: String + address: String + area: Area + coordinates: Coordinates + lifeSpan: LifeSpan + type: String + typeID: MBID + events(limit: Int, offset: Int): [Event] +} + +type PlacePage { + count: Int! + offset: Int! + created: Date + results: [Place]! +} + +type Recording implements Entity { + id: MBID! + title: String + disambiguation: String + artistCredit: [ArtistCredit] + length: Int + video: Boolean + artists(limit: Int, offset: Int): [Artist] + releases(limit: Int, + offset: Int, + type: ReleaseGroupType, + types: [ReleaseGroupType], + status: ReleaseStatus, + statuses: [ReleaseStatus]): [Release] + relations: Relations +} + +type RecordingPage { + count: Int! + offset: Int! + created: Date + results: [Recording]! +} + +type Relation { + target: Entity! + direction: String! + targetType: String! + sourceCredit: String + targetCredit: String + begin: Date + end: Date + ended: Boolean + attributes: [String] + type: String + typeID: MBID +} + +type Relations { + area(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + artist(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + event(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + instrument(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + label(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + place(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + recording(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + release(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + releaseGroup(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + series(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + url(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] + work(limit: Int, + offset: Int, + direction: String, + type: String, + typeID: MBID): [Relation] +} + +type Release implements Entity { + id: MBID! + title: String + disambiguation: String + artistCredit: [ArtistCredit] + releaseEvents: [ReleaseEvent] + date: Date + country: String + barcode: String + status: String + statusID: MBID + packaging: String + packagingID: MBID + quality: String + artists(limit: Int, offset: Int): [Artist] + labels(limit: Int, offset: Int): [Label] + recordings(limit: Int, offset: Int): [Recording] + releaseGroups(limit: Int, + offset: Int, + type: ReleaseGroupType, + types: [ReleaseGroupType]): [ReleaseGroup] + relations: Relations +} + +type ReleaseEvent { + area: Area + date: Date +} + +type ReleaseGroup implements Entity { + id: MBID! + title: String + disambiguation: String + artistCredit: [ArtistCredit] + firstReleaseDate: Date + primaryType: String + primaryTypeID: MBID + secondaryTypes: [String] + secondaryTypeIDs: [MBID] + artists(limit: Int, offset: Int): [Artist] + releases(limit: Int, + offset: Int, + type: ReleaseGroupType, + types: [ReleaseGroupType], + status: ReleaseStatus, + statuses: [ReleaseStatus]): [Release] + relations: Relations +} + +type ReleaseGroupPage { + count: Int! + offset: Int! + created: Date + results: [ReleaseGroup]! +} + +enum ReleaseGroupType { + ALBUM + SINGLE + EP + OTHER + BROADCAST + COMPILATION + SOUNDTRACK + SPOKENWORD + INTERVIEW + AUDIOBOOK + LIVE + REMIX + DJMIX + MIXTAPE + DEMO + NAT +} + +type ReleasePage { + count: Int! + offset: Int! + created: Date + results: [Release]! +} + +enum ReleaseStatus { + OFFICIAL + PROMOTION + BOOTLEG + PSEUDORELEASE +} + +type RootQuery { + lookup: LookupQuery + browse: BrowseQuery + search: SearchQuery +} + +type SearchQuery { + areas(query: String!, limit: Int, offset: Int): AreaPage + artists(query: String!, limit: Int, offset: Int): ArtistPage + labels(query: String!, limit: Int, offset: Int): LabelPage + places(query: String!, limit: Int, offset: Int): PlacePage + recordings(query: String!, limit: Int, offset: Int): RecordingPage + releases(query: String!, limit: Int, offset: Int): ReleasePage + releaseGroups(query: String!, limit: Int, offset: Int): ReleaseGroupPage + works(query: String!, limit: Int, offset: Int): WorkPage +} + +scalar Time + +type URL implements Entity { + id: MBID! + resource: URLString! + relations: Relations +} + +type URLPage { + count: Int! + offset: Int! + created: Date + results: [URL]! +} + +scalar URLString + +type Work implements Entity { + id: MBID! + title: String + disambiguation: String + iswcs: [String] + language: String + type: String + typeID: MBID + artists(limit: Int, offset: Int): [Artist] + relations: Relations +} + +type WorkPage { + count: Int! + offset: Int! + created: Date + results: [Work]! +} + +``` diff --git a/package.json b/package.json index d88936e..0fe9443 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,21 @@ "name": "graphbrainz", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "lib/index.js", + "engines": { + "node": "^4.3.0", + "npm": "^3.10.5" + }, "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", + "deploy": "./scripts/deploy.sh", "lint": "standard --verbose | snazzy", "prepublish": "npm run clean && npm run check && npm run build", + "print-schema": "babel-node scripts/print-schema.js", "start": "node lib/index.js", "start:dev": "nodemon --exec babel-node src/index.js", "test": "mocha --compilers js:babel-register" @@ -28,7 +34,6 @@ }, "license": "MIT", "dependencies": { - "bottleneck": "^1.12.0", "chalk": "^1.1.3", "dashify": "^0.2.2", "dataloader": "^1.2.0", @@ -37,6 +42,7 @@ "express-graphql": "^0.5.3", "graphql": "^0.6.2", "qs": "^6.2.1", + "request": "^2.74.0", "retry": "^0.9.0" }, "devDependencies": { @@ -47,7 +53,7 @@ "babel-register": "^6.11.6", "chai": "^3.5.0", "mocha": "^3.0.1", - "nodemon": "^1.10.0", + "nodemon": "^1.10.2", "snazzy": "^4.0.1", "standard": "^7.1.2" }, diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..bab178b --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +GREEN='\033[0;32m' +RESET='\033[0m' + +# Fail if the `heroku` remote isn't there. +git remote show heroku + +git stash # Stash uncommitted changes. +git checkout -B deploy # Force branch creation/reset. +npm run build +git add -f lib # Force add ignored files. +# Use the same commit message, but add a little note. +git commit -m "$(git log -1 --pretty=%B) [deploy branch: do NOT push to GitHub]" +git push -f heroku deploy:master +git rm -r --cached lib # Otherwise switching branches will remove them. +git checkout - # Switch back to whatever branch we came from. +git branch -D deploy # Just to prevent someone accidentally pushing to GitHub. +git stash pop --index || true # Restore uncommitted changes, OK if none. + +echo -e "\n${GREEN}✔︎ Successfully deployed.${RESET}" diff --git a/scripts/print-schema.js b/scripts/print-schema.js new file mode 100644 index 0000000..b5e08ba --- /dev/null +++ b/scripts/print-schema.js @@ -0,0 +1,4 @@ +import { printSchema } from 'graphql' +import schema from '../src/schema' + +console.log(printSchema(schema)) diff --git a/src/client.js b/src/api.js similarity index 54% rename from src/client.js rename to src/api.js index 9d0e285..c4d7a7d 100644 --- a/src/client.js +++ b/src/api.js @@ -6,6 +6,9 @@ import ExtendableError from 'es6-error' import RateLimit from './rate-limit' import pkg from '../package.json' +// If the `request` callback returns an error, it indicates a failure at a lower +// level than the HTTP response itself. If it's any of the following error +// codes, we should retry. const RETRY_CODES = { ECONNRESET: true, ENOTFOUND: true, @@ -31,12 +34,23 @@ export default class MusicBrainz { userAgent: `${pkg.name}/${pkg.version} ` + `( ${pkg.homepage || pkg.author.url || pkg.author.email} )`, timeout: 60000, + // MusicBrainz API requests are limited to an *average* of 1 req/sec. + // That means if, for example, we only need to make a few API requests to + // fulfill a query, we might as well make them all at once - as long as + // we then wait a few seconds before making more. In practice this can + // seemingly be set to about 5 requests every 5 seconds before we're + // considered to exceed the rate limit. limit: 3, limitPeriod: 3000, - maxConcurrency: 10, + concurrency: 10, retries: 10, - minRetryDelay: 100, - maxRetryDelay: 60000, + // It's OK for `retryDelayMin` to be less than one second, even 0, because + // `RateLimit` will already make sure we don't exceed the API rate limit. + // We're not doing exponential backoff because it will help with being + // rate limited, but rather to be chill in case MusicBrainz is returning + // some other error or our network is failing. + retryDelayMin: 100, + retryDelayMax: 60000, randomizeRetry: true, ...options } @@ -46,20 +60,21 @@ export default class MusicBrainz { this.limiter = new RateLimit({ limit: options.limit, period: options.limitPeriod, - maxConcurrency: options.maxConcurrency + concurrency: options.concurrency }) - // 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, + minTimeout: options.retryDelayMin, + maxTimeout: options.retryDelayMax, randomize: options.randomizeRetry } } + /** + * Determine if we should retry the request based on the given error. + * Retry any 5XX response from MusicBrainz, as well as any error in + * `RETRY_CODES`. + */ shouldRetry (err) { if (err instanceof MusicBrainzError) { return err.statusCode >= 500 && err.statusCode < 600 @@ -67,19 +82,24 @@ export default class MusicBrainz { return RETRY_CODES[err.code] || false } - _get (path, params) { + /** + * Send a request without any retrying or rate limiting. + * Use `get` instead. + */ + _get (path, params, info = {}) { return new Promise((resolve, reject) => { const options = { baseUrl: this.baseURL, url: path, - qs: params, + qs: { ...params, fmt: 'json' }, json: true, gzip: true, headers: { 'User-Agent': this.userAgent }, timeout: this.timeout } - console.log('GET:', path, params) + const attempt = `(attempt #${info.currentAttempt})` + console.log('GET:', path, info.currentAttempt > 1 ? attempt : '') request(options, (err, response, body) => { if (err) { @@ -94,13 +114,18 @@ export default class MusicBrainz { }) } + /** + * Send a request with retrying and rate limiting. + */ get (path, params) { return new Promise((resolve, reject) => { const fn = this._get.bind(this) const operation = retry.operation(this.retryOptions) operation.attempt(currentAttempt => { + // This will increase the priority in our `RateLimit` queue for each + // retry, so that newer requests don't delay this one further. const priority = currentAttempt - this.limiter.enqueue(fn, [path, params], priority) + this.limiter.enqueue(fn, [path, params, { currentAttempt }], priority) .then(resolve) .catch(err => { if (!this.shouldRetry(err) || !operation.retry(err)) { @@ -111,28 +136,62 @@ export default class MusicBrainz { }) } - getLookupURL (entity, id, params) { - let url = `${entity}/${id}` + stringifyParams (params) { if (typeof params.inc === 'object') { params = { ...params, inc: params.inc.join('+') } } - const query = qs.stringify(params, { + if (typeof params.type === 'object') { + params = { + ...params, + type: params.type.join('|') + } + } + if (typeof params.status === 'object') { + params = { + ...params, + status: params.status.join('|') + } + } + return qs.stringify(params, { skipNulls: true, filter: (key, value) => value === '' ? undefined : value }) - if (query) { - url += `?${query}` - } - return url + } + + getURL (path, params) { + const query = params ? this.stringifyParams(params) : '' + return query ? `${path}?${query}` : path + } + + getLookupURL (entity, id, params) { + return this.getURL(`${entity}/${id}`, params) } lookup (entity, id, params = {}) { const url = this.getLookupURL(entity, id, params) return this.get(url) } + + getBrowseURL (entity, params) { + return this.getURL(entity, params) + } + + browse (entity, params = {}) { + const url = this.getBrowseURL(entity, params) + return this.get(url) + } + + getSearchURL (entity, query, params) { + return this.getURL(entity, { ...params, query }) + } + + search (entity, query, params = {}) { + const url = this.getSearchURL(entity, query, params) + return this.get(url) + } } if (require.main === module) { diff --git a/src/index.js b/src/index.js index 61bca55..3b3a799 100644 --- a/src/index.js +++ b/src/index.js @@ -1,18 +1,24 @@ import express from 'express' import graphqlHTTP from 'express-graphql' import schema from './schema' +import { lookupLoader, browseLoader, searchLoader } from './loaders' const app = express() app.use('/graphql', graphqlHTTP({ schema, + context: { lookupLoader, browseLoader, searchLoader }, + pretty: true, graphiql: true, formatError: error => ({ message: error.message, - statusCode: error.statusCode, locations: error.locations, stack: error.stack }) })) -app.listen(3001) +app.get('/graphiql', (req, res) => { + res.redirect(`/graph${req.url.slice(7)}`) +}) + +app.listen(process.env.PORT || 3001) diff --git a/src/loaders.js b/src/loaders.js index cb013b1..181d9c8 100644 --- a/src/loaders.js +++ b/src/loaders.js @@ -1,13 +1,48 @@ import DataLoader from 'dataloader' -import MusicBrainz from './client' +import MusicBrainz from './api' -const CLIENT = new MusicBrainz() +const client = new MusicBrainz() -export const entityLoader = new DataLoader(keys => { +export const lookupLoader = new DataLoader(keys => { return Promise.all(keys.map(key => { - const [ entity, id, params ] = key - return CLIENT.lookup(entity, id, params) + const [ entityType, id, params ] = key + return client.lookup(entityType, id, params).then(entity => { + if (entity) { + entity.entityType = entityType + } + return entity + }) })) }, { - cacheKeyFn: (key) => CLIENT.getLookupURL(...key) + cacheKeyFn: (key) => client.getLookupURL(...key) +}) + +export const browseLoader = new DataLoader(keys => { + return Promise.all(keys.map(key => { + const [ entityType, params ] = key + const pluralName = entityType.endsWith('s') ? entityType : `${entityType}s` + return client.browse(entityType, params).then(list => { + list[pluralName].forEach(entity => { + entity.entityType = entityType + }) + return list + }) + })) +}, { + cacheKeyFn: (key) => client.getBrowseURL(...key) +}) + +export const searchLoader = new DataLoader(keys => { + return Promise.all(keys.map(key => { + const [ entityType, query, params ] = key + const pluralName = entityType.endsWith('s') ? entityType : `${entityType}s` + return client.search(entityType, query, params).then(list => { + list[pluralName].forEach(entity => { + entity.entityType = entityType + }) + return list + }) + })) +}, { + cacheKeyFn: (key) => client.getSearchURL(...key) }) diff --git a/src/queries/browse.js b/src/queries/browse.js new file mode 100644 index 0000000..40194fe --- /dev/null +++ b/src/queries/browse.js @@ -0,0 +1,120 @@ +import { GraphQLObjectType, GraphQLInt } from 'graphql' +import { + MBID, + URLString, + ArtistPage, + EventPage, + LabelPage, + PlacePage, + RecordingPage, + ReleasePage, + ReleaseGroupPage, + URLPage, + WorkPage +} from '../types' +import { browseResolver } from '../resolvers' + +export default new GraphQLObjectType({ + name: 'BrowseQuery', + description: + 'Browse requests are a direct lookup of all the entities directly linked ' + + 'to another entity.', + fields: { + artists: { + type: ArtistPage, + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + area: { type: MBID }, + recording: { type: MBID }, + release: { type: MBID }, + releaseGroup: { type: MBID }, + work: { type: MBID } + }, + resolve: browseResolver() + }, + events: { + type: EventPage, + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + area: { type: MBID }, + artist: { type: MBID }, + place: { type: MBID } + }, + resolve: browseResolver() + }, + labels: { + type: LabelPage, + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + area: { type: MBID }, + release: { type: MBID } + }, + resolve: browseResolver() + }, + places: { + type: PlacePage, + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + area: { type: MBID } + }, + resolve: browseResolver() + }, + recordings: { + type: RecordingPage, + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + artist: { type: MBID }, + release: { type: MBID } + }, + resolve: browseResolver() + }, + releases: { + type: ReleasePage, + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + area: { type: MBID }, + artist: { type: MBID }, + label: { type: MBID }, + track: { type: MBID }, + trackArtist: { type: MBID }, + recording: { type: MBID }, + releaseGroup: { type: MBID } + }, + resolve: browseResolver() + }, + releaseGroups: { + type: ReleaseGroupPage, + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + artist: { type: MBID }, + release: { type: MBID } + }, + resolve: browseResolver() + }, + works: { + type: WorkPage, + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + artist: { type: MBID } + }, + resolve: browseResolver() + }, + urls: { + type: URLPage, + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + resource: { type: URLString } + }, + resolve: browseResolver() + } + } +}) diff --git a/src/queries/index.js b/src/queries/index.js new file mode 100644 index 0000000..24d49f9 --- /dev/null +++ b/src/queries/index.js @@ -0,0 +1,3 @@ +export { default as LookupQuery } from './lookup' +export { default as BrowseQuery } from './browse' +export { default as SearchQuery } from './search' diff --git a/src/queries/lookup.js b/src/queries/lookup.js new file mode 100644 index 0000000..fa8022f --- /dev/null +++ b/src/queries/lookup.js @@ -0,0 +1,35 @@ +import { GraphQLObjectType } from 'graphql' +import { + Area, + Artist, + Event, + Instrument, + Label, + Place, + Recording, + Release, + ReleaseGroup, + URL, + Work +} from '../types' +import { lookupQuery } from '../types/helpers' + +export default new GraphQLObjectType({ + name: 'LookupQuery', + description: + 'You can perform a lookup of an entity when you have the MBID for that ' + + 'entity.', + fields: { + area: lookupQuery(Area), + artist: lookupQuery(Artist), + event: lookupQuery(Event), + instrument: lookupQuery(Instrument), + label: lookupQuery(Label), + place: lookupQuery(Place), + recording: lookupQuery(Recording), + release: lookupQuery(Release), + releaseGroup: lookupQuery(ReleaseGroup), + url: lookupQuery(URL), + work: lookupQuery(Work) + } +}) diff --git a/src/queries/search.js b/src/queries/search.js new file mode 100644 index 0000000..584b718 --- /dev/null +++ b/src/queries/search.js @@ -0,0 +1,29 @@ +import { GraphQLObjectType } from 'graphql' +import { + AreaPage, + ArtistPage, + LabelPage, + PlacePage, + RecordingPage, + ReleasePage, + ReleaseGroupPage, + WorkPage +} from '../types' +import { searchQuery } from '../types/helpers' + +export default new GraphQLObjectType({ + name: 'SearchQuery', + description: + 'Search queries provide a way to search for MusicBrainz entities using ' + + 'Lucene query syntax.', + fields: { + areas: searchQuery(AreaPage), + artists: searchQuery(ArtistPage), + labels: searchQuery(LabelPage), + places: searchQuery(PlacePage), + recordings: searchQuery(RecordingPage), + releases: searchQuery(ReleasePage), + releaseGroups: searchQuery(ReleaseGroupPage), + works: searchQuery(WorkPage) + } +}) diff --git a/src/rate-limit.js b/src/rate-limit.js index dfe845a..73e2e10 100644 --- a/src/rate-limit.js +++ b/src/rate-limit.js @@ -3,14 +3,14 @@ export default class RateLimit { options = { limit: 1, period: 1000, - maxConcurrency: options.limit || 1, + concurrency: options.limit || 1, defaultPriority: 1, ...options } this.limit = options.limit this.period = options.period this.defaultPriority = options.defaultPriority - this.maxConcurrency = options.maxConcurrency + this.concurrency = options.concurrency this.queues = [] this.numPending = 0 this.periodStart = null @@ -69,7 +69,7 @@ export default class RateLimit { if (this.paused) { return } - if (this.numPending < this.maxConcurrency && this.periodCapacity > 0) { + if (this.numPending < this.concurrency && this.periodCapacity > 0) { const task = this.dequeue() if (task) { const { resolve, reject, fn, args } = task @@ -118,7 +118,7 @@ if (require.main === module) { const limiter = new RateLimit({ limit: 3, period: 3000, - maxConcurrency: 5 + concurrency: 5 }) const fn = (i) => { diff --git a/src/resolvers.js b/src/resolvers.js new file mode 100644 index 0000000..2c518b6 --- /dev/null +++ b/src/resolvers.js @@ -0,0 +1,127 @@ +import dashify from 'dashify' +import { getFields, extendIncludes } from './util' + +export function includeRelations (params, info) { + let fields = getFields(info) + if (info.fieldName !== 'relations') { + if (fields.relations) { + fields = getFields(fields.relations) + } else { + return params + } + } + if (fields) { + const relations = Object.keys(fields) + const includeRels = relations.map(key => `${dashify(key)}-rels`) + if (includeRels.length) { + params = { + ...params, + inc: extendIncludes(params.inc, includeRels) + } + } + } + return params +} + +export function includeSubqueries (params, info) { + const fields = getFields(info) + if (fields.artistCredit) { + params = { + ...params, + inc: extendIncludes(params.inc, ['artist-credits']) + } + } + return params +} + +export function lookupResolver (entityType, extraParams = {}) { + return (root, { id }, { lookupLoader }, info) => { + const params = includeRelations(extraParams, info) + entityType = entityType || dashify(info.fieldName) + return lookupLoader.load([entityType, id, params]) + } +} + +export function browseResolver () { + return (source, args, { browseLoader }, info) => { + const pluralName = dashify(info.fieldName) + let singularName = pluralName + if (pluralName.endsWith('s')) { + singularName = pluralName.slice(0, -1) + } + const params = args + return browseLoader.load([singularName, params]) + } +} + +export function searchResolver () { + return (source, args, { searchLoader }, info) => { + const pluralName = dashify(info.fieldName) + let singularName = pluralName + if (pluralName.endsWith('s')) { + singularName = pluralName.slice(0, -1) + } + const { query, ...params } = args + return searchLoader.load([singularName, query, params]) + } +} + +export function relationResolver () { + return (source, { offset = 0, + limit, + direction, + type, + typeID }, { lookupLoader }, info) => { + const targetType = dashify(info.fieldName).replace('-', '_') + return source.filter(relation => { + if (relation['target-type'] !== targetType) { + return false + } + if (direction != null && relation.direction !== direction) { + return false + } + if (type != null && relation.type !== type) { + return false + } + if (typeID != null && relation['type-id'] !== typeID) { + return false + } + return true + }).slice(offset, limit == null ? undefined : offset + limit) + } +} + +export function linkedResolver () { + return (source, args, { browseLoader }, info) => { + const pluralName = dashify(info.fieldName) + let singularName = pluralName + if (pluralName.endsWith('s')) { + singularName = pluralName.slice(0, -1) + } + const parentEntity = dashify(info.parentType.name) + let params = { + [parentEntity]: source.id, + type: [], + status: [], + limit: args.limit, + offset: args.offset + } + params = includeSubqueries(params, info) + params = includeRelations(params, info) + if (args.type) { + params.type.push(args.type) + } + if (args.types) { + params.type.push(...args.types) + } + if (args.status) { + params.status.push(args.status) + } + if (args.statuses) { + params.status.push(...args.statuses) + } + return browseLoader.load([singularName, params]).then(list => { + return list[pluralName] + }) + } +} diff --git a/src/schema.js b/src/schema.js index 313bce9..63345e7 100644 --- a/src/schema.js +++ b/src/schema.js @@ -1,118 +1,13 @@ -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' +import { GraphQLSchema, GraphQLObjectType } from 'graphql' +import { LookupQuery, BrowseQuery, SearchQuery } from './queries' export default new GraphQLSchema({ query: new GraphQLObjectType({ - name: 'RootQueryType', + name: 'RootQuery', 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 }]) - } - } + lookup: { type: LookupQuery, resolve: () => ({}) }, + browse: { type: BrowseQuery, resolve: () => ({}) }, + search: { type: SearchQuery, resolve: () => ({}) } }) }) }) diff --git a/src/types/alias.js b/src/types/alias.js index c46ed45..689e21b 100644 --- a/src/types/alias.js +++ b/src/types/alias.js @@ -3,7 +3,7 @@ import { GraphQLString, GraphQLBoolean } from 'graphql/type' -import MBID from './mbid' +import { MBID } from './scalars' import { getHyphenated } from './helpers' export default new GraphQLObjectType({ diff --git a/src/types/area.js b/src/types/area.js index 932359e..c249493 100644 --- a/src/types/area.js +++ b/src/types/area.js @@ -1,20 +1,38 @@ +import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql/type' +import Entity from './entity' import { - GraphQLObjectType, - GraphQLNonNull, - GraphQLString, - GraphQLList -} from 'graphql/type' -import MBID from './mbid' -import { getHyphenated } from './helpers' + id, + name, + sortName, + disambiguation, + artists, + events, + labels, + places, + releases, + createPageType +} from './helpers' -export default new GraphQLObjectType({ +const Area = new GraphQLObjectType({ name: 'Area', description: 'A country, region, city or the like.', + interfaces: () => [Entity], 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'] } + id, + name, + sortName, + disambiguation, + isoCodes: { + type: new GraphQLList(GraphQLString), + resolve: data => data['iso-3166-1-codes'] + }, + artists, + events, + labels, + places, + releases }) }) + +export const AreaPage = createPageType(Area) +export default Area diff --git a/src/types/artist-credit.js b/src/types/artist-credit.js index f0993d8..a47a66d 100644 --- a/src/types/artist-credit.js +++ b/src/types/artist-credit.js @@ -1,5 +1,6 @@ import { GraphQLObjectType, GraphQLString } from 'graphql/type' -import ArtistType from './artist' +import Artist from './artist' +import { name } from './helpers' export default new GraphQLObjectType({ name: 'ArtistCredit', @@ -7,8 +8,15 @@ export default new GraphQLObjectType({ 'Artist, variation of artist name and piece of text to join the artist ' + 'name to the next.', fields: () => ({ - artist: { type: ArtistType }, - name: { type: GraphQLString }, + artist: { + type: Artist, + resolve: (source) => { + const { artist } = source + artist.entityType = 'artist' + return artist + } + }, + name, joinPhrase: { type: GraphQLString, resolve: data => data['joinphrase'] } }) }) diff --git a/src/types/artist.js b/src/types/artist.js index 4802851..b4cc134 100644 --- a/src/types/artist.js +++ b/src/types/artist.js @@ -1,47 +1,69 @@ +import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql/type' +import Entity from './entity' +import Alias from './alias' +import Area from './area' 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' + getFallback, + fieldWithID, + id, + name, + sortName, + disambiguation, + lifeSpan, + recordings, + releases, + releaseGroups, + works, + relations, + createPageType +} from './helpers' -export default new GraphQLObjectType({ +const Artist = new GraphQLObjectType({ name: 'Artist', description: 'An artist is generally a musician, a group of musicians, or another ' + 'music professional (composer, engineer, illustrator, producer, etc.)', + interfaces: () => [Entity], fields: () => ({ - id: { type: new GraphQLNonNull(MBID) }, - name: { type: GraphQLString }, - sortName: { type: GraphQLString, resolve: getHyphenated }, - aliases: { type: new GraphQLList(AliasType) }, - disambiguation: { type: GraphQLString }, + id, + name, + sortName, + disambiguation, + aliases: { + type: new GraphQLList(Alias), + resolve: (source, args, { lookupLoader }, info) => { + const key = 'aliases' + if (key in source) { + return source[key] + } else { + const { entityType, id } = source + const params = { inc: ['aliases'] } + return lookupLoader.load([entityType, id, params]).then(entity => { + return entity[key] + }) + } + } + }, country: { type: GraphQLString }, - area: { type: AreaType }, - beginArea: { type: AreaType, resolve: getUnderscored }, - endArea: { type: AreaType, resolve: getUnderscored }, + area: { type: Area }, + beginArea: { + type: Area, + resolve: getFallback(['begin-area', 'begin_area']) + }, + endArea: { + type: Area, + resolve: getFallback(['end-area', 'end_area']) + }, + lifeSpan, ...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 - } + recordings, + releases, + releaseGroups, + works, + relations }) }) + +export const ArtistPage = createPageType(Artist) +export default Artist diff --git a/src/types/date.js b/src/types/date.js deleted file mode 100644 index d1ad070..0000000 --- a/src/types/date.js +++ /dev/null @@ -1,3 +0,0 @@ -import { GraphQLString } from 'graphql/type' - -export default GraphQLString diff --git a/src/types/entity.js b/src/types/entity.js index 66b71e7..cb4ce4b 100644 --- a/src/types/entity.js +++ b/src/types/entity.js @@ -1,9 +1,15 @@ -import { GraphQLInterfaceType, GraphQLNonNull } from 'graphql/type' -import MBID from './mbid' +import { GraphQLInterfaceType } from 'graphql/type' +import { id } from './helpers' export default new GraphQLInterfaceType({ name: 'Entity', + description: 'An entity in the MusicBrainz schema.', + resolveType (value) { + if (value.entityType && require.resolve(`./${value.entityType}`)) { + return require(`./${value.entityType}`).default + } + }, fields: () => ({ - id: { type: new GraphQLNonNull(MBID) } + id }) }) diff --git a/src/types/enums.js b/src/types/enums.js new file mode 100644 index 0000000..a4fafb6 --- /dev/null +++ b/src/types/enums.js @@ -0,0 +1,199 @@ +import { GraphQLEnumType } from 'graphql/type' + +/* +ReleaseStatus { + OFFICIAL + PROMOTION + BOOTLEG + PSEUDORELEASE +} +*/ +export const ReleaseStatus = new GraphQLEnumType({ + name: 'ReleaseStatus', + values: { + OFFICIAL: { + name: 'Official', + description: + 'Any release officially sanctioned by the artist and/or their record ' + + 'company. (Most releases will fit into this category.)', + value: 'official' + }, + PROMOTION: { + name: 'Promotion', + description: + 'A giveaway release or a release intended to promote an upcoming ' + + 'official release. (e.g. prerelease albums or releases included ' + + 'with a magazine)', + value: 'promotion' + }, + BOOTLEG: { + name: 'Bootleg', + description: + 'An unofficial/underground release that was not sanctioned by the ' + + 'artist and/or the record company.', + value: 'bootleg' + }, + PSEUDORELEASE: { + name: 'Pseudo-Release', + description: + 'A pseudo-release is a duplicate release for translation/' + + 'transliteration purposes.', + value: 'pseudo-release' + } + } +}) + +/* +enum ReleaseGroupType { + # Primary types + ALBUM + SINGLE + EP + OTHER + BROADCAST + + # Secondary types + COMPILATION + SOUNDTRACK + SPOKEN_WORD + INTERVIEW + AUDIOBOOK + LIVE + REMIX + DJMIX + MIXTAPE + DEMO + NAT +} +*/ +export const ReleaseGroupType = new GraphQLEnumType({ + name: 'ReleaseGroupType', + values: { + ALBUM: { + name: 'Album', + description: + 'An album, perhaps better defined as a “Long Play” (LP) release, ' + + 'generally consists of previously unreleased material (unless this ' + + 'type is combined with secondary types which change that, such as ' + + '“Compilation”). This includes album re-issues, with or without ' + + 'bonus tracks.', + value: 'album' + }, + SINGLE: { + name: 'Single', + description: + 'A single typically has one main song and possibly a handful of ' + + 'additional tracks or remixes of the main track. A single is usually ' + + 'named after its main song.', + value: 'single' + }, + EP: { + name: 'EP', + description: + 'An EP is a so-called “Extended Play” release and often contains the ' + + 'letters EP in the title. Generally an EP will be shorter than a ' + + 'full length release (an LP or “Long Play”) and the tracks are ' + + 'usually exclusive to the EP, in other words the tracks don’t come ' + + 'from a previously issued release. EP is fairly difficult to define; ' + + 'usually it should only be assumed that a release is an EP if the ' + + 'artist defines it as such.', + value: 'ep' + }, + OTHER: { + name: 'Other', + description: + 'Any release that does not fit any of the other categories.', + value: 'other' + }, + BROADCAST: { + name: 'Broadcast', + description: + 'An episodic release that was originally broadcast via radio, ' + + 'television, or the Internet, including podcasts.', + value: 'broadcast' + }, + COMPILATION: { + name: 'Compilation', + description: + 'A compilation is a collection of previously released tracks by one ' + + 'or more artists.', + value: 'compilation' + }, + SOUNDTRACK: { + name: 'Soundtrack', + description: + 'A soundtrack is the musical score to a movie, TV series, stage ' + + 'show, computer game etc.', + value: 'soundtrack' + }, + SPOKENWORD: { + name: 'Spoken Word', + description: 'A non-music spoken word release.', + value: 'spokenword' + }, + INTERVIEW: { + name: 'Interview', + description: + 'An interview release contains an interview, generally with an artist.', + value: 'interview' + }, + AUDIOBOOK: { + name: 'Audiobook', + description: 'An audiobook is a book read by a narrator without music.', + value: 'audiobook' + }, + LIVE: { + name: 'Live', + description: 'A release that was recorded live.', + value: 'live' + }, + REMIX: { + name: 'Remix', + description: + 'A release that was (re)mixed from previously released material.', + value: 'remix' + }, + DJMIX: { + name: 'DJ-mix', + description: + 'A DJ-mix is a sequence of several recordings played one after the ' + + 'other, each one modified so that they blend together into a ' + + 'continuous flow of music. A DJ mix release requires that the ' + + 'recordings be modified in some manner, and the DJ who does this ' + + 'modification is usually (although not always) credited in a fairly ' + + 'prominent way.', + value: 'dj-mix' + }, + MIXTAPE: { + name: 'Mixtape/Street', + description: + 'Promotional in nature (but not necessarily free), mixtapes and ' + + 'street albums are often released by artists to promote new artists, ' + + 'or upcoming studio albums by prominent artists. They are also ' + + 'sometimes used to keep fans’ attention between studio releases and ' + + 'are most common in rap & hip hop genres. They are often not ' + + 'sanctioned by the artist’s label, may lack proper sample or song ' + + 'clearances and vary widely in production and recording quality. ' + + 'While mixtapes are generally DJ-mixed, they are distinct from ' + + 'commercial DJ mixes (which are usually deemed compilations) and are ' + + 'defined by having a significant proportion of new material, ' + + 'including original production or original vocals over top of other ' + + 'artists’ instrumentals. They are distinct from demos in that they ' + + 'are designed for release directly to the public and fans; not ' + + 'to labels.', + value: 'mixtape/street' + }, + DEMO: { + name: 'Demo', + description: + 'A release that was recorded for limited circulation or reference ' + + 'use rather than for general public release.', + value: 'demo' + }, + NAT: { + name: 'Non-Album Track', + description: 'A non-album track (special case).', + value: 'nat' + } + } +}) diff --git a/src/types/event.js b/src/types/event.js new file mode 100644 index 0000000..bbfd505 --- /dev/null +++ b/src/types/event.js @@ -0,0 +1,32 @@ +import { GraphQLObjectType, GraphQLString, GraphQLBoolean } from 'graphql/type' +import Entity from './entity' +import { Time } from './scalars' +import { + fieldWithID, + id, + name, + disambiguation, + lifeSpan, + createPageType +} from './helpers' + +const Event = new GraphQLObjectType({ + name: 'Event', + description: + 'An organized event which people can attend, usually live performances ' + + 'like concerts and festivals.', + interfaces: () => [Entity], + fields: () => ({ + id, + name, + disambiguation, + lifeSpan, + time: { type: Time }, + cancelled: { type: GraphQLBoolean }, + setlist: { type: GraphQLString }, + ...fieldWithID('type') + }) +}) + +export const EventPage = createPageType(Event) +export default Event diff --git a/src/types/helpers.js b/src/types/helpers.js index 6d3f1bc..712ea6a 100644 --- a/src/types/helpers.js +++ b/src/types/helpers.js @@ -1,6 +1,40 @@ import dashify from 'dashify' -import { GraphQLString, GraphQLList } from 'graphql/type' -import MBID from './mbid' +import { + GraphQLObjectType, + GraphQLString, + GraphQLInt, + GraphQLList, + GraphQLNonNull +} from 'graphql/type' +import { MBID, DateType } from './scalars' +import { ReleaseGroupType, ReleaseStatus } from './enums' +import ArtistCredit from './artist-credit' +import Artist from './artist' +import Event from './event' +import Label from './label' +import LifeSpan from './life-span' +import Place from './place' +import Recording from './recording' +import Relation from './relation' +import Release from './release' +import ReleaseGroup from './release-group' +import Work from './work' +import { + lookupResolver, + linkedResolver, + relationResolver, + searchResolver, + includeRelations +} from '../resolvers' + +export function getByline (data) { + const credit = data['artist-credit'] + if (credit && credit.length) { + return credit.reduce((byline, credit) => { + return byline + credit.name + credit.joinphrase + }, '') + } +} export function fieldWithID (name, config = {}) { config = { @@ -26,16 +60,214 @@ export function getHyphenated (source, args, context, info) { 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 - }, '') +export function getFallback (keys) { + return (source) => { + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + if (key in source) { + return source[key] + } + } } } + +export function lookupQuery (entity, params) { + return { + type: entity, + description: `Look up a specific ${entity.name} by its MBID.`, + args: { id }, + resolve: lookupResolver(dashify(entity.name), params) + } +} + +export function searchQuery (entityPage) { + const entity = entityPage.getFields().results.type.ofType.ofType + return { + type: entityPage, + description: `Search for ${entity.name} entities.`, + args: { + query: { type: new GraphQLNonNull(GraphQLString) }, + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt } + }, + resolve: searchResolver() + } +} + +export function createPageType (type) { + const singularName = dashify(type.name) + const pluralName = singularName + (singularName.endsWith('s') ? '' : 's') + return new GraphQLObjectType({ + name: `${type.name}Page`, + description: `A page of ${type.name} results from browsing or searching.`, + fields: { + count: { + type: new GraphQLNonNull(GraphQLInt), + resolve: list => { + if (list.count != null) { + return list.count + } + return list[`${singularName}-count`] + } + }, + offset: { + type: new GraphQLNonNull(GraphQLInt), + resolve: list => { + if (list.offset != null) { + return list.offset + } + return list[`${singularName}-offset`] + } + }, + created: { type: DateType }, + results: { + type: new GraphQLNonNull(new GraphQLList(type)), + resolve: list => list[pluralName] + } + } + }) +} + +export const id = { type: new GraphQLNonNull(MBID) } +export const name = { type: GraphQLString } +export const sortName = { type: GraphQLString, resolve: getHyphenated } +export const title = { type: GraphQLString } +export const disambiguation = { type: GraphQLString } +export const lifeSpan = { type: LifeSpan, resolve: getHyphenated } + +export const relation = { + type: new GraphQLList(Relation), + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + direction: { type: GraphQLString }, + type: { type: GraphQLString }, + typeID: { type: MBID } + }, + resolve: relationResolver() +} + +export const relations = { + type: new GraphQLObjectType({ + name: 'Relations', + fields: () => ({ + area: relation, + artist: relation, + event: relation, + instrument: relation, + label: relation, + place: relation, + recording: relation, + release: relation, + releaseGroup: relation, + series: relation, + url: relation, + work: relation + }) + }), + resolve: (source, args, { lookupLoader }, info) => { + if (source.relations != null) { + return source.relations + } + const entityType = dashify(info.parentType.name) + const id = source.id + const params = includeRelations({}, info) + return lookupLoader.load([entityType, id, params]).then(entity => { + return entity.relations + }) + } +} + +export const artistCredit = { + type: new GraphQLList(ArtistCredit), + resolve: (source, args, { lookupLoader }, info) => { + const key = 'artist-credit' + if (key in source) { + return source[key] + } else { + const { entityType, id } = source + const params = { inc: ['artists'] } + return lookupLoader.load([entityType, id, params]).then(entity => { + return entity[key] + }) + } + } +} + +export const artists = { + type: new GraphQLList(Artist), + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt } + }, + resolve: linkedResolver() +} + +export const events = { + type: new GraphQLList(Event), + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt } + }, + resolve: linkedResolver() +} + +export const labels = { + type: new GraphQLList(Label), + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt } + }, + resolve: linkedResolver() +} + +export const places = { + type: new GraphQLList(Place), + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt } + }, + resolve: linkedResolver() +} + +export const recordings = { + type: new GraphQLList(Recording), + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt } + }, + resolve: linkedResolver() +} + +export const releases = { + type: new GraphQLList(Release), + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + type: { type: ReleaseGroupType }, + types: { type: new GraphQLList(ReleaseGroupType) }, + status: { type: ReleaseStatus }, + statuses: { type: new GraphQLList(ReleaseStatus) } + }, + resolve: linkedResolver() +} + +export const releaseGroups = { + type: new GraphQLList(ReleaseGroup), + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + type: { type: ReleaseGroupType }, + types: { type: new GraphQLList(ReleaseGroupType) } + }, + resolve: linkedResolver() +} + +export const works = { + type: new GraphQLList(Work), + args: { + limit: { type: GraphQLInt }, + offset: { type: GraphQLInt } + }, + resolve: linkedResolver() +} diff --git a/src/types/index.js b/src/types/index.js new file mode 100644 index 0000000..41e1514 --- /dev/null +++ b/src/types/index.js @@ -0,0 +1,14 @@ +export { MBID, DateType, IPI, URLString } from './scalars' +export { ReleaseGroupType, ReleaseStatus } from './enums' +export { default as Entity } from './entity' +export { default as Area, AreaPage } from './area' +export { default as Artist, ArtistPage } from './artist' +export { default as Event, EventPage } from './event' +export { default as Instrument } from './instrument' +export { default as Label, LabelPage } from './label' +export { default as Place, PlacePage } from './place' +export { default as Recording, RecordingPage } from './recording' +export { default as Release, ReleasePage } from './release' +export { default as ReleaseGroup, ReleaseGroupPage } from './release-group' +export { default as URL, URLPage } from './url' +export { default as Work, WorkPage } from './work' diff --git a/src/types/instrument.js b/src/types/instrument.js new file mode 100644 index 0000000..9a83cb1 --- /dev/null +++ b/src/types/instrument.js @@ -0,0 +1,24 @@ +import { GraphQLObjectType, GraphQLString } from 'graphql/type' +import Entity from './entity' +import { + fieldWithID, + id, + name, + disambiguation +} from './helpers' + +const Instrument = new GraphQLObjectType({ + name: 'Instrument', + description: + 'Instruments are devices created or adapted to make musical sounds.', + interfaces: () => [Entity], + fields: () => ({ + id, + name, + disambiguation, + description: { type: GraphQLString }, + ...fieldWithID('type') + }) +}) + +export default Instrument diff --git a/src/types/label.js b/src/types/label.js new file mode 100644 index 0000000..9c05733 --- /dev/null +++ b/src/types/label.js @@ -0,0 +1,41 @@ +import { + GraphQLObjectType, + GraphQLList, + GraphQLString, + GraphQLInt +} from 'graphql/type' +import Entity from './entity' +import { IPI } from './scalars' +import Area from './area' +import { + id, + name, + sortName, + disambiguation, + lifeSpan, + releases, + fieldWithID, + createPageType +} from './helpers' + +const Label = new GraphQLObjectType({ + name: 'Label', + description: 'Labels represent mostly (but not only) imprints.', + interfaces: () => [Entity], + fields: () => ({ + id, + name, + sortName, + disambiguation, + country: { type: GraphQLString }, + area: { type: Area }, + lifeSpan, + labelCode: { type: GraphQLInt }, + ipis: { type: new GraphQLList(IPI) }, + ...fieldWithID('type'), + releases + }) +}) + +export const LabelPage = createPageType(Label) +export default Label diff --git a/src/types/life-span.js b/src/types/life-span.js index 56130b5..4898bb9 100644 --- a/src/types/life-span.js +++ b/src/types/life-span.js @@ -1,5 +1,5 @@ import { GraphQLObjectType, GraphQLBoolean } from 'graphql/type' -import DateType from './date' +import { DateType } from './scalars' export default new GraphQLObjectType({ name: 'LifeSpan', diff --git a/src/types/mbid.js b/src/types/mbid.js deleted file mode 100644 index 9dfd04a..0000000 --- a/src/types/mbid.js +++ /dev/null @@ -1,27 +0,0 @@ -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 index a96e59b..7fdd628 100644 --- a/src/types/place.js +++ b/src/types/place.js @@ -1,30 +1,44 @@ -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' +import { GraphQLObjectType, GraphQLString } from 'graphql/type' +import Entity from './entity' +import { Degrees } from './scalars' +import Area from './area' +import { + id, + name, + disambiguation, + lifeSpan, + events, + fieldWithID, + createPageType +} from './helpers' -export default new GraphQLObjectType({ +export const Coordinates = new GraphQLObjectType({ + name: 'Coordinates', + description: 'Geographic coordinates with latitude and longitude.', + fields: () => ({ + latitude: { type: Degrees }, + longitude: { type: Degrees } + }) +}) + +const Place = new GraphQLObjectType({ name: 'Place', description: 'A venue, studio or other place where music is performed, recorded, ' + 'engineered, etc.', + interfaces: () => [Entity], fields: () => ({ - id: { type: new GraphQLNonNull(MBID) }, - name: { type: GraphQLString }, - disambiguation: { type: GraphQLString }, + id, + name, + disambiguation, 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') + area: { type: Area }, + coordinates: { type: Coordinates }, + lifeSpan, + ...fieldWithID('type'), + events }) }) + +export const PlacePage = createPageType(Place) +export default Place diff --git a/src/types/recording.js b/src/types/recording.js index 6b1b556..7e08d2e 100644 --- a/src/types/recording.js +++ b/src/types/recording.js @@ -1,32 +1,34 @@ +import { GraphQLObjectType, GraphQLInt, GraphQLBoolean } from 'graphql/type' +import Entity from './entity' 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' + id, + title, + disambiguation, + artistCredit, + artists, + releases, + relations, + createPageType +} from './helpers' -export default new GraphQLObjectType({ +const Recording = new GraphQLObjectType({ name: 'Recording', description: 'Represents a unique mix or edit. Has title, artist credit, duration, ' + 'list of PUIDs and ISRCs.', + interfaces: () => [Entity], fields: () => ({ - id: { type: new GraphQLNonNull(MBID) }, - title: { type: GraphQLString }, - disambiguation: { type: GraphQLString }, + id, + title, + disambiguation, + artistCredit, 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) } + artists, + releases, + relations }) }) + +export const RecordingPage = createPageType(Recording) +export default Recording diff --git a/src/types/relation.js b/src/types/relation.js new file mode 100644 index 0000000..650c9c8 --- /dev/null +++ b/src/types/relation.js @@ -0,0 +1,43 @@ +import { + GraphQLObjectType, + GraphQLNonNull, + GraphQLString, + GraphQLList, + GraphQLBoolean +} from 'graphql/type' +import { DateType } from './scalars' +import Entity from './entity' +import { + getHyphenated, + fieldWithID +} from './helpers' + +const Relation = new GraphQLObjectType({ + name: 'Relation', + fields: () => ({ + target: { + type: new GraphQLNonNull(Entity), + resolve: source => { + const targetType = source['target-type'] + const target = source[targetType] + target.entityType = targetType.replace('_', '-') + return target + } + }, + direction: { type: new GraphQLNonNull(GraphQLString) }, + targetType: { + type: new GraphQLNonNull(GraphQLString), + resolve: getHyphenated + }, + sourceCredit: { type: GraphQLString, resolve: getHyphenated }, + targetCredit: { type: GraphQLString, resolve: getHyphenated }, + begin: { type: DateType }, + end: { type: DateType }, + ended: { type: GraphQLBoolean }, + attributes: { type: new GraphQLList(GraphQLString) }, + // attributeValues: {}, + ...fieldWithID('type') + }) +}) + +export default Relation diff --git a/src/types/release-event.js b/src/types/release-event.js index 53a518b..9cb4512 100644 --- a/src/types/release-event.js +++ b/src/types/release-event.js @@ -1,6 +1,6 @@ import { GraphQLObjectType } from 'graphql/type' -import DateType from './date' -import AreaType from './area' +import { DateType } from './scalars' +import Area from './area' export default new GraphQLObjectType({ name: 'ReleaseEvent', @@ -9,7 +9,7 @@ export default new GraphQLObjectType({ 'particular label, catalog number, barcode, and what release format ' + 'was used.', fields: () => ({ - area: { type: AreaType }, + area: { type: Area }, date: { type: DateType } }) }) diff --git a/src/types/release-group.js b/src/types/release-group.js index 5c466af..7cfeba9 100644 --- a/src/types/release-group.js +++ b/src/types/release-group.js @@ -1,38 +1,38 @@ +import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql/type' +import Entity from './entity' +import { DateType } from './scalars' 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' + id, + title, + disambiguation, + artistCredit, + artists, + releases, + relations, + getHyphenated, + fieldWithID, + createPageType +} from './helpers' -export default new GraphQLObjectType({ +const ReleaseGroup = 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.', + interfaces: () => [Entity], fields: () => ({ - id: { type: new GraphQLNonNull(MBID) }, - title: { type: GraphQLString }, - disambiguation: { type: GraphQLString }, + id, + title, + disambiguation, + artistCredit, 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 } - } - } + artists, + releases, + relations }) }) + +export const ReleaseGroupPage = createPageType(ReleaseGroup) +export default ReleaseGroup diff --git a/src/types/release.js b/src/types/release.js index 9b1677c..68026a8 100644 --- a/src/types/release.js +++ b/src/types/release.js @@ -1,39 +1,51 @@ +import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql/type' +import Entity from './entity' +import { DateType } from './scalars' +import ReleaseEvent from './release-event' 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' + id, + title, + disambiguation, + artistCredit, + artists, + labels, + recordings, + releaseGroups, + relations, + getHyphenated, + fieldWithID, + createPageType +} from './helpers' -export default new GraphQLObjectType({ +const Release = 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.', + interfaces: () => [Entity], fields: () => ({ - id: { type: new GraphQLNonNull(MBID) }, - title: { type: GraphQLString }, - artists: { - type: new GraphQLList(ArtistCreditType), - resolve: data => data['artist-credit'] - }, - artistByline: { type: GraphQLString, resolve: getByline }, + id, + title, + disambiguation, + artistCredit, releaseEvents: { - type: new GraphQLList(ReleaseEventType), + type: new GraphQLList(ReleaseEvent), resolve: getHyphenated }, - disambiguation: { type: GraphQLString }, date: { type: DateType }, country: { type: GraphQLString }, barcode: { type: GraphQLString }, ...fieldWithID('status'), ...fieldWithID('packaging'), - quality: { type: GraphQLString } + quality: { type: GraphQLString }, + artists, + labels, + recordings, + releaseGroups, + relations }) }) + +export const ReleasePage = createPageType(Release) +export default Release diff --git a/src/types/scalars.js b/src/types/scalars.js new file mode 100644 index 0000000..bd3d899 --- /dev/null +++ b/src/types/scalars.js @@ -0,0 +1,188 @@ +import { Kind } from 'graphql/language' +import { GraphQLScalarType } from 'graphql/type' + +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + +function validateMBID (value) { + if (typeof value === 'string' && uuid.test(value)) { + return value + } + throw new TypeError(`Malformed MBID: ${value}`) +} + +function validatePositive (value) { + if (value >= 0) { + return value + } + throw new TypeError(`Expected positive value: ${value}`) +} + +/* +scalar Date +*/ +export const DateType = new GraphQLScalarType({ + name: 'Date', + description: + 'Year, month (optional), and day (optional) in YYYY-MM-DD format.', + serialize: value => value, + parseValue: value => value, + parseLiteral (ast) { + if (ast.kind === Kind.STRING) { + return ast.value + } + return null + } +}) + +/* +scalar Degrees +*/ +export const Degrees = new GraphQLScalarType({ + name: 'Degrees', + description: 'Decimal degrees, used for latitude and longitude.', + serialize: value => value, + parseValue: value => value, + parseLiteral (ast) { + if (ast.kind === Kind.STRING) { + return ast.value + } + return null + } +}) + +/* +scalar Duration +*/ +export const Duration = new GraphQLScalarType({ + name: 'Duration', + description: 'A length of time, in milliseconds.', + serialize: validatePositive, + parseValue: validatePositive, + parseLiteral (ast) { + if (ast.kind === Kind.INT) { + return validatePositive(parseInt(ast.value, 10)) + } + return null + } +}) + +/* +scalar IPI +*/ +export const IPI = new GraphQLScalarType({ + name: 'IPI', + description: + 'An IPI (interested party information) code is an identifying number ' + + 'assigned by the CISAC database for musical rights management.', + serialize: value => value, + parseValue: value => value, + parseLiteral (ast) { + if (ast.kind === Kind.STRING) { + return ast.value + } + return null + } +}) + +/* +scalar ISNI +*/ +export const ISNI = new GraphQLScalarType({ + name: 'ISNI', + description: + 'The International Standard Name Identifier (ISNI) is an ISO standard ' + + 'for uniquely identifying the public identities of contributors to ' + + 'media content.', + serialize: value => value, + parseValue: value => value, + parseLiteral (ast) { + if (ast.kind === Kind.STRING) { + return ast.value + } + return null + } +}) + +/* +scalar ISWC +*/ +export const ISWC = new GraphQLScalarType({ + name: 'ISWC', + description: + 'The International Standard Musical Work Code (ISWC) is an ISO standard ' + + 'similar to ISBNs for identifying musical works / compositions.', + serialize: value => value, + parseValue: value => value, + parseLiteral (ast) { + if (ast.kind === Kind.STRING) { + return ast.value + } + return null + } +}) + +/* +scalar Locale +*/ +export const Locale = new GraphQLScalarType({ + name: 'Locale', + description: 'Language code, optionally with country and encoding.', + serialize: value => value, + parseValue: value => value, + parseLiteral (ast) { + if (ast.kind === Kind.STRING) { + return ast.value + } + return null + } +}) + +/* +scalar Time +*/ +export const Time = new GraphQLScalarType({ + name: 'Time', + description: 'A time of day, in 24-hour hh:mm notation.', + serialize: value => value, + parseValue: value => value, + parseLiteral (ast) { + if (ast.kind === Kind.STRING) { + return ast.value + } + return null + } +}) + +/* +scalar URLString +*/ +export const URLString = new GraphQLScalarType({ + name: 'URLString', + description: 'Description', + serialize: value => value, + parseValue: value => value, + parseLiteral (ast) { + if (ast.kind === Kind.STRING) { + return ast.value + } + return null + } +}) + +/* +scalar MBID +*/ +export const MBID = new GraphQLScalarType({ + name: 'MBID', + description: + 'The `MBID` scalar represents MusicBrainz identifiers, which are ' + + '36-character UUIDs.', + serialize: validateMBID, + parseValue: validateMBID, + parseLiteral (ast) { + if (ast.kind === Kind.STRING) { + return validateMBID(ast.value) + } + return null + } +}) diff --git a/src/types/series.js b/src/types/series.js new file mode 100644 index 0000000..e69de29 diff --git a/src/types/url.js b/src/types/url.js new file mode 100644 index 0000000..bc9cff4 --- /dev/null +++ b/src/types/url.js @@ -0,0 +1,21 @@ +import { GraphQLObjectType, GraphQLNonNull } from 'graphql/type' +import Entity from './entity' +import { URLString } from './scalars' +import { id, relations, createPageType } from './helpers' + +const URL = new GraphQLObjectType({ + name: 'URL', + description: + 'A URL pointing to a resource external to MusicBrainz, i.e. an official ' + + 'homepage, a site where music can be acquired, an entry in another ' + + 'database, etc.', + interfaces: () => [Entity], + fields: () => ({ + id, + resource: { type: new GraphQLNonNull(URLString) }, + relations + }) +}) + +export const URLPage = createPageType(URL) +export default URL diff --git a/src/types/work.js b/src/types/work.js index ebfb65c..e56506c 100644 --- a/src/types/work.js +++ b/src/types/work.js @@ -1,23 +1,32 @@ +import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql/type' +import Entity from './entity' import { - GraphQLObjectType, - GraphQLNonNull, - GraphQLString, - GraphQLList -} from 'graphql/type' -import MBID from './mbid' -import { fieldWithID } from './helpers' + id, + title, + disambiguation, + artists, + relations, + fieldWithID, + createPageType +} from './helpers' -export default new GraphQLObjectType({ +const Work = new GraphQLObjectType({ name: 'Work', description: 'A distinct intellectual or artistic creation, which can be expressed in ' + 'the form of one or more audio recordings', + interfaces: () => [Entity], fields: () => ({ - id: { type: new GraphQLNonNull(MBID) }, - title: { type: GraphQLString }, - disambiguation: { type: GraphQLString }, + id, + title, + disambiguation, iswcs: { type: new GraphQLList(GraphQLString) }, language: { type: GraphQLString }, - ...fieldWithID('type') + ...fieldWithID('type'), + artists, + relations }) }) + +export const WorkPage = createPageType(Work) +export default Work diff --git a/src/util.js b/src/util.js index 96d2b3e..0b847e6 100644 --- a/src/util.js +++ b/src/util.js @@ -1,7 +1,35 @@ +import util from 'util' + export function getFields (info) { - const selections = info.fieldASTs[0].selectionSet.selections + if (info.kind !== 'Field') { + info = info.fieldASTs[0] + } + const selections = info.selectionSet.selections return selections.reduce((fields, selection) => { fields[selection.name.value] = selection return fields }, {}) } + +export function prettyPrint (obj, { depth = 5, + colors = true, + breakLength = 120 } = {}) { + console.log(util.inspect(obj, { depth, colors, breakLength })) +} + +export function toFilteredArray (obj) { + return (Array.isArray(obj) ? obj : [obj]).filter(x => x) +} + +export function extendIncludes (includes, moreIncludes) { + includes = toFilteredArray(includes) + moreIncludes = toFilteredArray(moreIncludes) + const seen = {} + return includes.concat(moreIncludes).filter(x => { + if (seen[x]) { + return false + } + seen[x] = true + return true + }) +}