Relay-compliant API

This commit is contained in:
Brian Beck 2016-11-25 17:38:32 -08:00
parent 116775eaca
commit 1eeaa83eef
38 changed files with 11329 additions and 5527 deletions

1246
README.md

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,12 @@
"version": "1.0.0",
"description": "",
"main": "lib/index.js",
"files": [
"lib",
"scripts",
"Procfile",
"schema.json"
],
"engines": {
"node": "^4.3.0",
"npm": "^3.10.5"
@ -17,11 +23,14 @@
"lint": "standard --verbose | snazzy",
"prepublish": "npm run clean && npm run check && npm run build",
"print-schema": "babel-node scripts/print-schema.js",
"print-schema:json": "babel-node scripts/print-schema.js --json",
"print-schema:json": "npm run print-schema -- --json",
"print-schema:md": "printf '```graphql\\n%s\\n```' \"$(npm run -s print-schema)\"",
"start": "node lib/index.js",
"start:dev": "nodemon --exec babel-node src/index.js",
"test": "mocha --compilers js:babel-register",
"update-schema": "npm run -s print-schema:json > schema.json"
"update-schema": "npm run update-schema:json && npm run update-schema:md",
"update-schema:json": "npm run -s print-schema:json > schema.json",
"update-schema:md": "printf '# GraphQL Schema\\n\\n%s' \"$(npm run -s print-schema:md)\" > schema.md"
},
"keywords": [],
"homepage": "https://github.com/exogen/graphbrainz",
@ -37,29 +46,33 @@
"license": "MIT",
"dependencies": {
"chalk": "^1.1.3",
"compression": "^1.6.2",
"dashify": "^0.2.2",
"dataloader": "^1.2.0",
"es6-error": "^3.0.1",
"debug": "^2.3.3",
"dotenv": "^2.0.0",
"es6-error": "^4.0.0",
"express": "^4.14.0",
"express-graphql": "^0.5.3",
"graphql": "^0.6.2",
"graphql-relay": "^0.4.2",
"express-graphql": "^0.6.1",
"graphql": "^0.8.2",
"graphql-relay": "^0.4.4",
"lru-cache": "^4.0.1",
"pascalcase": "^0.1.1",
"qs": "^6.2.1",
"request": "^2.74.0",
"qs": "^6.3.0",
"request": "^2.79.0",
"retry": "^0.10.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",
"babel-cli": "^6.18.0",
"babel-eslint": "^7.1.1",
"babel-preset-es2015": "^6.18.0",
"babel-preset-stage-2": "^6.18.0",
"babel-register": "^6.18.0",
"chai": "^3.5.0",
"mocha": "^3.0.1",
"nodemon": "^1.10.2",
"snazzy": "^4.0.1",
"standard": "^8.0.0"
"mocha": "^3.2.0",
"nodemon": "^1.11.0",
"snazzy": "^5.0.0",
"standard": "^8.6.0"
},
"standard": {
"parser": "babel-eslint"

File diff suppressed because it is too large Load diff

1473
schema.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,8 @@ import ExtendableError from 'es6-error'
import RateLimit from './rate-limit'
import pkg from '../package.json'
const debug = require('debug')('graphbrainz:api')
// 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.
@ -28,45 +30,39 @@ export class MusicBrainzError extends ExtendableError {
}
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,
// 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: 5,
limitPeriod: 5000,
concurrency: 10,
retries: 10,
// 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
}
this.baseURL = options.baseURL
this.userAgent = options.userAgent
this.timeout = options.timeout
this.limiter = new RateLimit({
limit: options.limit,
period: options.limitPeriod,
concurrency: options.concurrency
})
constructor ({
baseURL = process.env.MUSICBRAINZ_BASE_URL || 'http://musicbrainz.org/ws/2/',
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 = 5,
period = 5000,
concurrency = 10,
retries = 10,
// 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
} = {}) {
this.baseURL = baseURL
this.userAgent = userAgent
this.timeout = timeout
this.limiter = new RateLimit({ limit, period, concurrency })
this.retryOptions = {
retries: options.retries,
minTimeout: options.retryDelayMin,
maxTimeout: options.retryDelayMax,
randomize: options.randomizeRetry
retries,
minTimeout: retryDelayMin,
maxTimeout: retryDelayMax,
randomize: randomizeRetry
}
}
@ -98,8 +94,7 @@ export default class MusicBrainz {
timeout: this.timeout
}
const attempt = `(attempt #${info.currentAttempt})`
console.log('GET:', path, info.currentAttempt > 1 ? attempt : '')
debug(path, info.currentAttempt > 1 ? `(attempt #${info.currentAttempt})` : '')
request(options, (err, response, body) => {
if (err) {

View file

@ -1,24 +1,37 @@
import express from 'express'
import graphqlHTTP from 'express-graphql'
import compression from 'compression'
import MusicBrainz from './api'
import schema from './schema'
import { lookupLoader, browseLoader, searchLoader } from './loaders'
import createLoaders from './loaders'
const app = express()
app.use('/graphql', graphqlHTTP({
schema,
context: { lookupLoader, browseLoader, searchLoader },
pretty: true,
graphiql: true,
formatError: error => ({
message: error.message,
locations: error.locations,
stack: error.stack
})
}))
app.get('/graphiql', (req, res) => {
res.redirect(`/graph${req.url.slice(7)}`)
const formatError = (err) => ({
message: err.message,
locations: err.locations,
stack: err.stack
})
app.listen(process.env.PORT || 3001)
const middleware = ({ client = new MusicBrainz(), ...options } = {}) => {
const DEV = process.env.NODE_ENV !== 'production'
const loaders = createLoaders(client)
return graphqlHTTP({
schema,
context: { client, loaders },
pretty: DEV,
graphiql: DEV,
formatError: DEV ? formatError : undefined,
...options
})
}
export default middleware
if (require.main === module) {
require('dotenv').config({ silent: true })
const app = express()
const port = process.env.PORT || 3000
const route = process.env.GRAPHBRAINZ_PATH || '/'
app.use(compression())
app.use(route, middleware())
app.listen(port)
}

View file

@ -1,48 +1,64 @@
import DataLoader from 'dataloader'
import MusicBrainz from './api'
import LRUCache from 'lru-cache'
import { toPlural } from './types/helpers'
const client = new MusicBrainz()
const debug = require('debug')('graphbrainz:loaders')
export const lookupLoader = new DataLoader(keys => {
return Promise.all(keys.map(key => {
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)
})
export default function createLoaders (client) {
const cache = LRUCache({
max: 8192,
maxAge: 24 * 60 * 60 * 1000, // 1 day.
dispose (key) {
debug(`Removed '${key}' from cache.`)
}
})
cache.delete = cache.del
cache.clear = cache.reset
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
const lookup = new DataLoader(keys => {
return Promise.all(keys.map(key => {
const [ entityType, id, params ] = key
return client.lookup(entityType, id, params).then(entity => {
if (entity) {
entity.entityType = entityType
}
return entity
})
return list
})
}))
}, {
cacheKeyFn: (key) => client.getBrowseURL(...key)
})
}))
}, {
cacheKeyFn: (key) => client.getLookupURL(...key),
cacheMap: cache
})
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
const browse = new DataLoader(keys => {
return Promise.all(keys.map(key => {
const [ entityType, params ] = key
return client.browse(entityType, params).then(list => {
list[toPlural(entityType)].forEach(entity => {
entity.entityType = entityType
})
return list
})
return list
})
}))
}, {
cacheKeyFn: (key) => client.getSearchURL(...key)
})
}))
}, {
cacheKeyFn: (key) => client.getBrowseURL(...key),
cacheMap: cache
})
const search = new DataLoader(keys => {
return Promise.all(keys.map(key => {
const [ entityType, query, params ] = key
return client.search(entityType, query, params).then(list => {
list[toPlural(entityType)].forEach(entity => {
entity.entityType = entityType
})
return list
})
}))
}, {
cacheKeyFn: (key) => client.getSearchURL(...key),
cacheMap: cache
})
return { lookup, browse, search }
}

View file

@ -14,10 +14,34 @@ import {
URLConnection,
WorkConnection
} from '../types'
import { toWords } from '../types/helpers'
const area = {
type: MBID,
description: 'The MBID of an area to which the entity is linked.'
}
const artist = {
type: MBID,
description: 'The MBID of an artist to which the entity is linked.'
}
const recording = {
type: MBID,
description: 'The MBID of a recording to which the entity is linked.'
}
const release = {
type: MBID,
description: 'The MBID of a release to which the entity is linked.'
}
const releaseGroup = {
type: MBID,
description: 'The MBID of a release group to which the entity is linked.'
}
function browseQuery (connectionType, args) {
const typeName = toWords(connectionType.name.slice(0, -10))
return {
type: connectionType,
description: `Browse ${typeName} entities linked to the given arguments.`,
args: {
...forwardConnectionArgs,
...args
@ -28,51 +52,69 @@ function browseQuery (connectionType, args) {
export default new GraphQLObjectType({
name: 'BrowseQuery',
description:
'Browse requests are a direct lookup of all the entities directly linked ' +
'to another entity.',
description: `A query for all MusicBrainz entities directly linked to another
entity.`,
fields: {
artists: browseQuery(ArtistConnection, {
area: { type: MBID },
recording: { type: MBID },
release: { type: MBID },
releaseGroup: { type: MBID },
work: { type: MBID }
area,
recording,
release,
releaseGroup,
work: {
type: MBID,
description: 'The MBID of a work to which the artist is linked.'
}
}),
events: browseQuery(EventConnection, {
area: { type: MBID },
artist: { type: MBID },
place: { type: MBID }
area,
artist,
place: {
type: MBID,
description: 'The MBID of a place to which the event is linked.'
}
}),
labels: browseQuery(LabelConnection, {
area: { type: MBID },
release: { type: MBID }
area,
release
}),
places: browseQuery(PlaceConnection, {
area: { type: MBID }
area
}),
recordings: browseQuery(RecordingConnection, {
artist: { type: MBID },
release: { type: MBID }
artist,
release
}),
releases: browseQuery(ReleaseConnection, {
area: { type: MBID },
artist: { type: MBID },
label: { type: MBID },
track: { type: MBID },
trackArtist: { type: MBID },
recording: { type: MBID },
releaseGroup: { type: MBID }
area,
artist,
label: {
type: MBID,
description: 'The MBID of a label to which the release is linked.'
},
track: {
type: MBID,
description: 'The MBID of a track that is included in the release.'
},
trackArtist: {
type: MBID,
description: `The MBID of an artist that appears on a track in the
release, but is not included in the credits for the release itself.`
},
recording,
releaseGroup
}),
releaseGroups: browseQuery(ReleaseGroupConnection, {
artist: { type: MBID },
release: { type: MBID }
artist,
release
}),
works: browseQuery(WorkConnection, {
artist: { type: MBID }
artist
}),
urls: browseQuery(URLConnection, {
resource: { type: URLString }
resource: {
type: URLString,
description: 'The web address for which to browse URL entities.'
}
})
}
})

View file

@ -1,6 +1,6 @@
import { GraphQLObjectType } from 'graphql'
import { lookupResolver } from '../resolvers'
import { mbid } from '../types/helpers'
import { mbid, toWords } from '../types/helpers'
import {
Area,
Artist,
@ -17,9 +17,10 @@ import {
} from '../types'
function lookupQuery (entity) {
const typeName = toWords(entity.name)
return {
type: entity,
description: `Look up a specific ${entity.name} by its MBID.`,
description: `Look up a specific ${typeName} by its MBID.`,
args: { mbid },
resolve: lookupResolver()
}
@ -27,9 +28,7 @@ function lookupQuery (entity) {
export default new GraphQLObjectType({
name: 'LookupQuery',
description:
'You can perform a lookup of an entity when you have the MBID for that ' +
'entity.',
description: 'A lookup of an individual MusicBrainz entity by its MBID.',
fields: {
area: lookupQuery(Area),
artist: lookupQuery(Artist),

View file

@ -11,10 +11,13 @@ import {
ReleaseGroupConnection,
WorkConnection
} from '../types'
import { toWords } from '../types/helpers'
function searchQuery (connectionType) {
const typeName = toWords(connectionType.name.slice(0, -10))
return {
type: connectionType,
description: `Search for ${typeName} entities matching the given query.`,
args: {
query: { type: new GraphQLNonNull(GraphQLString) },
...forwardConnectionArgs
@ -25,9 +28,7 @@ function searchQuery (connectionType) {
export default new GraphQLObjectType({
name: 'SearchQuery',
description:
'Search queries provide a way to search for MusicBrainz entities using ' +
'Lucene query syntax.',
description: 'A search for MusicBrainz entities using Lucene query syntax.',
fields: {
areas: searchQuery(AreaConnection),
artists: searchQuery(ArtistConnection),

View file

@ -1,16 +1,14 @@
export default class RateLimit {
constructor (options = {}) {
options = {
limit: 1,
period: 1000,
concurrency: options.limit || 1,
defaultPriority: 1,
...options
}
this.limit = options.limit
this.period = options.period
this.defaultPriority = options.defaultPriority
this.concurrency = options.concurrency
constructor ({
limit = 1,
period = 1000,
concurrency = limit || 1,
defaultPriority = 1
} = {}) {
this.limit = limit
this.period = period
this.defaultPriority = defaultPriority
this.concurrency = concurrency
this.queues = []
this.numPending = 0
this.periodStart = null

View file

@ -1,4 +1,4 @@
import { toEntityType } from './types/helpers'
import { toDashed, toSingular } from './types/helpers'
import {
getOffsetWithDefault,
connectionFromArray,
@ -6,18 +6,20 @@ import {
} from 'graphql-relay'
import { getFields, extendIncludes } from './util'
export function includeRelations (params, info) {
export function includeRelationships (params, info) {
let fields = getFields(info)
if (info.fieldName !== 'relations') {
if (fields.relations) {
fields = getFields(fields.relations)
if (info.fieldName !== 'relationships') {
if (fields.relationships) {
fields = getFields(fields.relationships)
} else {
return params
}
}
if (fields) {
const relations = Object.keys(fields)
const includeRels = relations.map(key => `${toEntityType(key)}-rels`)
const relationships = Object.keys(fields)
const includeRels = relationships.map(field => {
return `${toDashed(toSingular(field))}-rels`
})
if (includeRels.length) {
params = {
...params,
@ -40,43 +42,30 @@ export function includeSubqueries (params, info) {
}
export function lookupResolver () {
return (root, { mbid }, { lookupLoader }, info) => {
const entityType = toEntityType(info.fieldName)
const params = includeRelations({}, info)
return lookupLoader.load([entityType, mbid, params])
return (root, { mbid }, { loaders }, info) => {
const entityType = toDashed(info.fieldName)
const params = includeRelationships({}, info)
return loaders.lookup.load([entityType, mbid, params])
}
}
export function browseResolver () {
return (source, { first = 25, after, ...args }, { browseLoader }, info) => {
const pluralName = toEntityType(info.fieldName)
let singularName = pluralName
if (pluralName.endsWith('s')) {
singularName = pluralName.slice(0, -1)
}
const { type, types, status, statuses, ...moreParams } = args
return (source, { first = 25, after, type = [], status = [], ...args }, { loaders }, info) => {
const pluralName = toDashed(info.fieldName)
const singularName = toSingular(pluralName)
let params = {
...moreParams,
type: [],
status: [],
...args,
type,
status,
limit: first,
offset: getOffsetWithDefault(after, 0)
}
params = includeSubqueries(params, info)
params = includeRelations(params, info)
if (type) {
params.type.push(type)
}
if (types) {
params.type.push(...types)
}
if (status) {
params.status.push(status)
}
if (statuses) {
params.status.push(...statuses)
}
return browseLoader.load([singularName, params]).then(list => {
params = includeRelationships(params, info)
const formatValue = value => value.toLowerCase().replace(/ /g, '')
params.type = params.type.map(formatValue)
params.status = params.status.map(formatValue)
return loaders.browse.load([singularName, params]).then(list => {
const {
[pluralName]: arraySlice,
[`${singularName}-offset`]: sliceStart,
@ -89,16 +78,13 @@ export function browseResolver () {
}
export function searchResolver () {
return (source, { first = 25, after, ...args }, { searchLoader }, info) => {
const pluralName = toEntityType(info.fieldName)
let singularName = pluralName
if (pluralName.endsWith('s')) {
singularName = pluralName.slice(0, -1)
}
return (source, { first = 25, after, ...args }, { loaders }, info) => {
const pluralName = toDashed(info.fieldName)
const singularName = toSingular(pluralName)
const { query, ...params } = args
params.limit = first
params.offset = getOffsetWithDefault(after, 0)
return searchLoader.load([singularName, query, params]).then(list => {
return loaders.search.load([singularName, query, params]).then(list => {
const {
[pluralName]: arraySlice,
offset: sliceStart,
@ -110,31 +96,31 @@ export function searchResolver () {
}
}
export function relationResolver () {
export function relationshipResolver () {
return (source, args, context, info) => {
const targetType = toEntityType(info.fieldName).replace('-', '_')
const relations = source.filter(relation => {
if (relation['target-type'] !== targetType) {
const targetType = toDashed(toSingular(info.fieldName)).replace('-', '_')
const relationships = source.filter(rel => {
if (rel['target-type'] !== targetType) {
return false
}
if (args.direction != null && relation.direction !== args.direction) {
if (args.direction != null && rel.direction !== args.direction) {
return false
}
if (args.type != null && relation.type !== args.type) {
if (args.type != null && rel.type !== args.type) {
return false
}
if (args.typeID != null && relation['type-id'] !== args.typeID) {
if (args.typeID != null && rel['type-id'] !== args.typeID) {
return false
}
return true
})
return connectionFromArray(relations, args)
return connectionFromArray(relationships, args)
}
}
export function linkedResolver () {
return (source, args, context, info) => {
const parentEntity = toEntityType(info.parentType.name)
const parentEntity = toDashed(info.parentType.name)
args = { ...args, [parentEntity]: source.id }
return browseResolver()(source, args, context, info)
}

View file

@ -4,12 +4,28 @@ import { nodeField } from './types/node'
export default new GraphQLSchema({
query: new GraphQLObjectType({
name: 'RootQuery',
name: 'Query',
description: `The query root, from which multiple types of MusicBrainz
requests can be made.`,
fields: () => ({
node: nodeField,
lookup: { type: LookupQuery, resolve: () => ({}) },
browse: { type: BrowseQuery, resolve: () => ({}) },
search: { type: SearchQuery, resolve: () => ({}) }
lookup: {
type: LookupQuery,
description: 'Perform a lookup of a MusicBrainz entity by its MBID.',
resolve: () => ({})
},
browse: {
type: BrowseQuery,
description: `Browse all MusicBrainz entities directly linked to another
entity.`,
resolve: () => ({})
},
search: {
type: SearchQuery,
description: `Search for MusicBrainz entities using Lucene query
syntax.`,
resolve: () => ({})
}
})
})
})

View file

@ -3,17 +3,33 @@ import {
GraphQLString,
GraphQLBoolean
} from 'graphql/type'
import { MBID } from './scalars'
import { getHyphenated } from './helpers'
import { name, sortName, fieldWithID } from './helpers'
export default new GraphQLObjectType({
name: 'Alias',
description: `[Aliases](https://musicbrainz.org/doc/Aliases) are variant names
that are mostly used as search help: if a search matches an entitys alias, the
entity will be given as a result even if the actual name wouldnt be. They are
available for artists, labels, and works.`,
fields: () => ({
name: { type: GraphQLString },
sortName: { type: GraphQLString, resolve: getHyphenated },
locale: { type: GraphQLString },
primary: { type: GraphQLBoolean },
type: { type: GraphQLString },
typeID: { type: MBID, resolve: getHyphenated }
name: {
...name,
description: 'The aliased name of the entity.'
},
sortName,
locale: {
type: GraphQLString,
description: `The locale (language and/or country) in which the alias is
used.`
},
primary: {
type: GraphQLBoolean,
description: `Whether this is the main alias for the entity in the
specified locale (this could mean the most recent or the most common).`
},
...fieldWithID('type', {
description: `The type or purpose of the alias whether it is a variant,
search hint, etc.`
})
})
})

View file

@ -17,7 +17,8 @@ import {
const Area = new GraphQLObjectType({
name: 'Area',
description: 'A country, region, city or the like.',
description: `[Areas](https://musicbrainz.org/doc/Area) are geographic regions
or settlements (countries, cities, or the like).`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
@ -27,6 +28,8 @@ const Area = new GraphQLObjectType({
disambiguation,
isoCodes: {
type: new GraphQLList(GraphQLString),
description: `[ISO 3166 codes](https://en.wikipedia.org/wiki/ISO_3166) are
the codes assigned by ISO to countries and subdivisions.`,
resolve: data => data['iso-3166-1-codes']
},
artists,

View file

@ -1,22 +1,36 @@
import { GraphQLObjectType, GraphQLString } from 'graphql/type'
import Artist from './artist'
import { name } from './helpers'
export default new GraphQLObjectType({
name: 'ArtistCredit',
description:
'Artist, variation of artist name and piece of text to join the artist ' +
'name to the next.',
description: `[Artist credits](https://musicbrainz.org/doc/Artist_Credits)
indicate who is the main credited artist (or artists) for releases, release
groups, tracks and recordings, and how they are credited. They consist of
artists, with (optionally) their names as credited in the specific release,
track, etc., and join phrases between them.`,
fields: () => ({
artist: {
type: Artist,
description: `The entity representing the artist referenced in the
credits.`,
resolve: (source) => {
const { artist } = source
artist.entityType = 'artist'
if (artist) {
artist.entityType = 'artist'
}
return artist
}
},
name,
joinPhrase: { type: GraphQLString, resolve: data => data['joinphrase'] }
name: {
type: GraphQLString,
description: `The name of the artist as credited in the specific release,
track, etc.`
},
joinPhrase: {
type: GraphQLString,
description: `Join phrases might include words and/or punctuation to
separate artist names as they appear on the release, track, etc.`,
resolve: data => data['joinphrase']
}
})
})

View file

@ -17,14 +17,16 @@ import {
releases,
releaseGroups,
works,
relations
relationships
} from './helpers'
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.)',
description: `An [artist](https://musicbrainz.org/doc/Artist) is generally a
musician, group of musicians, or other music professional (like a producer or
engineer). Occasionally, it can also be a non-musical person (like a
photographer, an illustrator, or a poet whose writings are set to music), or
even a fictional character.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
@ -34,37 +36,56 @@ const Artist = new GraphQLObjectType({
disambiguation,
aliases: {
type: new GraphQLList(Alias),
resolve: (source, args, { lookupLoader }, info) => {
description: `[Aliases](https://musicbrainz.org/doc/Aliases) are used to
store alternate names or misspellings.`,
resolve: (source, args, { loaders }, 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 loaders.lookup.load([entityType, id, params]).then(entity => {
return entity[key]
})
}
}
},
country: { type: GraphQLString },
area: { type: Area },
country: {
type: GraphQLString,
description: `The country with which an artist is primarily identified. It
is often, but not always, its birth/formation country.`
},
area: {
type: Area,
description: `The area with which an artist is primarily identified. It
is often, but not always, its birth/formation country.`
},
beginArea: {
type: Area,
description: `The area in which an artist began their career (or where
were born, if the artist is a person).`,
resolve: getFallback(['begin-area', 'begin_area'])
},
endArea: {
type: Area,
description: `The area in which an artist ended their career (or where
they died, if the artist is a person).`,
resolve: getFallback(['end-area', 'end_area'])
},
lifeSpan,
...fieldWithID('gender'),
...fieldWithID('type'),
...fieldWithID('gender', {
description: `Whether a person or character identifies as male, female, or
neither. Groups do not have genders.`
}),
...fieldWithID('type', {
description: 'Whether an artist is a person, a group, or something else.'
}),
recordings,
releases,
releaseGroups,
works,
relations
relationships
})
})

View file

@ -1,199 +1,186 @@
import { GraphQLEnumType } from 'graphql/type'
/*
ReleaseStatus {
OFFICIAL
PROMOTION
BOOTLEG
PSEUDORELEASE
}
*/
export const ArtistType = new GraphQLEnumType({
name: 'ArtistType',
values: {
PERSON: {
name: 'Person',
description: 'This indicates an individual person.',
value: 'Person'
},
GROUP: {
name: 'Group',
description: `This indicates a group of people that may or may not have a
distinctive name.`,
value: 'Group'
},
ORCHESTRA: {
name: 'Orchestra',
description: 'This indicates an orchestra (a large instrumental ensemble).',
value: 'Orchestra'
},
CHOIR: {
name: 'Choir',
description: 'This indicates a choir/chorus (a large vocal ensemble).',
value: 'Choir'
},
CHARACTER: {
name: 'Character',
description: 'This indicates an individual fictional character.',
value: 'Character'
},
OTHER: {
name: 'Other',
description: 'An artist which does not fit into the other categories.',
value: 'Other'
}
}
})
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'
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'
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'
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'
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'
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'
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 dont 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'
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 dont 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'
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'
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'
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'
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'
value: 'Spoken Word'
},
INTERVIEW: {
name: 'Interview',
description:
'An interview release contains an interview, generally with an artist.',
value: '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'
value: 'Audiobook'
},
LIVE: {
name: 'Live',
description: 'A release that was recorded live.',
value: 'live'
value: 'Live'
},
REMIX: {
name: 'Remix',
description:
'A release that was (re)mixed from previously released material.',
value: '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'
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 artists 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'
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 artists 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'
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'
value: 'NAT'
}
}
})

View file

@ -14,9 +14,9 @@ import {
const Event = new GraphQLObjectType({
name: 'Event',
description:
'An organized event which people can attend, usually live performances ' +
'like concerts and festivals.',
description: `An [event](https://musicbrainz.org/doc/Event) refers to an
organised event which people can attend, and is relevant to MusicBrainz.
Generally this means live performances, like concerts and festivals.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
@ -24,10 +24,23 @@ const Event = new GraphQLObjectType({
name,
disambiguation,
lifeSpan,
time: { type: Time },
cancelled: { type: GraphQLBoolean },
setlist: { type: GraphQLString },
...fieldWithID('type')
time: {
type: Time,
description: 'The start time of the event.'
},
cancelled: {
type: GraphQLBoolean,
description: 'Whether or not the event took place.'
},
setlist: {
type: GraphQLString,
description: `A list of songs performed, optionally including links to
artists and works. See the [setlist documentation](https://musicbrainz.org/doc/Event/Setlist)
for syntax and examples.`
},
...fieldWithID('type', {
description: 'What kind of event the event is, e.g. concert, festival, etc.'
})
})
})

View file

@ -20,18 +20,34 @@ import { LabelConnection } from './label'
import LifeSpan from './life-span'
import { PlaceConnection } from './place'
import { RecordingConnection } from './recording'
import Relation from './relation'
import { RelationshipConnection } from './relationship'
import { ReleaseConnection } from './release'
import { ReleaseGroupConnection } from './release-group'
import { WorkConnection } from './work'
import {
linkedResolver,
relationResolver,
includeRelations
relationshipResolver,
includeRelationships
} from '../resolvers'
export const toNodeType = pascalCase
export const toEntityType = dashify
export const toPascal = pascalCase
export const toDashed = dashify
export function toPlural (name) {
return name.endsWith('s') ? name : name + 's'
}
export function toSingular (name) {
return name.endsWith('s') && !/series/i.test(name) ? name.slice(0, -1) : name
}
export function toWords (name) {
return toPascal(name).replace(/([^A-Z])?([A-Z]+)/g, (match, tail, head) => {
tail = tail ? tail + ' ' : ''
head = head.length > 1 ? head : head.toLowerCase()
return `${tail}${head}`
})
}
export function fieldWithID (name, config = {}) {
config = {
@ -40,10 +56,12 @@ export function fieldWithID (name, config = {}) {
...config
}
const isPlural = config.type instanceof GraphQLList
const singularName = isPlural && name.endsWith('s') ? name.slice(0, -1) : name
const singularName = isPlural ? toSingular(name) : name
const idName = isPlural ? `${singularName}IDs` : `${name}ID`
const idConfig = {
type: isPlural ? new GraphQLList(MBID) : MBID,
description: `The MBID${isPlural ? 's' : ''} associated with the
value${isPlural ? 's' : ''} of the \`${name}\` field.`,
resolve: getHyphenated
}
return {
@ -74,15 +92,37 @@ export const mbid = {
description: 'The MBID of the entity.',
resolve: source => source.id
}
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 name = {
type: GraphQLString,
description: 'The official name of the entity.'
}
export const sortName = {
type: GraphQLString,
description: `The string to use for the purpose of ordering by name (for
example, by moving articles like the to the end or a persons last name to
the front).`,
resolve: getHyphenated
}
export const title = {
type: GraphQLString,
description: 'The official title of the entity.'
}
export const disambiguation = {
type: GraphQLString,
description: 'A comment used to help distinguish identically named entitites.'
}
export const lifeSpan = {
type: LifeSpan,
description: `The begin and end dates of the entitys existence. Its exact
meaning depends on the type of entity.`,
resolve: getHyphenated
}
function linkedQuery (connectionType, args) {
const typeName = toWords(connectionType.name.slice(0, -10))
return {
type: connectionType,
description: `A list of ${typeName} entities linked to this entity.`,
args: {
...forwardConnectionArgs,
...args
@ -91,43 +131,50 @@ function linkedQuery (connectionType, args) {
}
}
export const relation = {
type: new GraphQLList(Relation),
export const relationship = {
type: RelationshipConnection,
description: 'A list of relationships between these two entity types.',
args: {
...connectionArgs,
direction: { type: GraphQLString },
type: { type: GraphQLString },
typeID: { type: MBID }
direction: {
type: GraphQLString,
description: 'Filter by the relationship direction.'
},
...fieldWithID('type', {
description: 'Filter by the relationship type.'
})
},
resolve: relationResolver()
resolve: relationshipResolver()
}
export const relations = {
export const relationships = {
type: new GraphQLObjectType({
name: 'Relations',
name: 'Relationships',
description: 'Lists of entity relationships for each entity type.',
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
areas: relationship,
artists: relationship,
events: relationship,
instruments: relationship,
labels: relationship,
places: relationship,
recordings: relationship,
releases: relationship,
releaseGroups: relationship,
series: relationship,
urls: relationship,
works: relationship
})
}),
resolve: (source, args, { lookupLoader }, info) => {
description: 'Relationships between this entity and other entitites.',
resolve: (source, args, { loaders }, info) => {
if (source.relations != null) {
return source.relations
}
const entityType = toEntityType(info.parentType.name)
const entityType = toDashed(info.parentType.name)
const id = source.id
const params = includeRelations({}, info)
return lookupLoader.load([entityType, id, params]).then(entity => {
const params = includeRelationships({}, info)
return loaders.lookup.load([entityType, id, params]).then(entity => {
return entity.relations
})
}
@ -135,14 +182,15 @@ export const relations = {
export const artistCredit = {
type: new GraphQLList(ArtistCredit),
resolve: (source, args, { lookupLoader }, info) => {
description: 'The main credited artist(s).',
resolve: (source, args, { loaders }, 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 loaders.lookup.load([entityType, id, params]).then(entity => {
return entity[key]
})
}
@ -154,17 +202,11 @@ export const events = linkedQuery(EventConnection)
export const labels = linkedQuery(LabelConnection)
export const places = linkedQuery(PlaceConnection)
export const recordings = linkedQuery(RecordingConnection)
export const releases = linkedQuery(ReleaseConnection, {
type: { type: ReleaseGroupType },
types: { type: new GraphQLList(ReleaseGroupType) },
status: { type: ReleaseStatus },
statuses: { type: new GraphQLList(ReleaseStatus) }
type: { type: new GraphQLList(ReleaseGroupType) },
status: { type: new GraphQLList(ReleaseStatus) }
})
export const releaseGroups = linkedQuery(ReleaseGroupConnection, {
type: { type: ReleaseGroupType },
types: { type: new GraphQLList(ReleaseGroupType) }
type: { type: new GraphQLList(ReleaseGroupType) }
})
export const works = linkedQuery(WorkConnection)

View file

@ -11,16 +11,25 @@ import {
const Instrument = new GraphQLObjectType({
name: 'Instrument',
description:
'Instruments are devices created or adapted to make musical sounds.',
description: `[Instruments](https://musicbrainz.org/doc/Instrument) are
devices created or adapted to make musical sounds. Instruments are primarily
used in relationships between two other entities.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
mbid,
name,
disambiguation,
description: { type: GraphQLString },
...fieldWithID('type')
description: {
type: GraphQLString,
description: `A brief description of the main characteristics of the
instrument.`
},
...fieldWithID('type', {
description: `The type categorises the instrument by the way the sound is
created, similar to the [Hornbostel-Sachs](https://en.wikipedia.org/wiki/Hornbostel%E2%80%93Sachs)
classification.`
})
})
})

View file

@ -22,7 +22,9 @@ import {
const Label = new GraphQLObjectType({
name: 'Label',
description: 'Labels represent mostly (but not only) imprints.',
description: `[Labels](https://musicbrainz.org/doc/Label) represent mostly
(but not only) imprints. To a lesser extent, a label entity may be created to
represent a record company.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
@ -30,12 +32,29 @@ const Label = new GraphQLObjectType({
name,
sortName,
disambiguation,
country: { type: GraphQLString },
area: { type: Area },
country: {
type: GraphQLString,
description: 'The country of origin for the label.'
},
area: {
type: Area,
description: 'The area in which the label is based.'
},
lifeSpan,
labelCode: { type: GraphQLInt },
ipis: { type: new GraphQLList(IPI) },
...fieldWithID('type'),
labelCode: {
type: GraphQLInt,
description: `The [“LC” code](https://musicbrainz.org/doc/Label/Label_Code)
of the label.`
},
ipis: {
type: new GraphQLList(IPI),
description: `List of IPI (interested party information) codes for the
label.`
},
...fieldWithID('type', {
description: `A type describing the main activity of the label, e.g.
imprint, production, distributor, rights society, etc.`
}),
releases
})
})

View file

@ -3,8 +3,8 @@ import { DateType } from './scalars'
export default new GraphQLObjectType({
name: 'LifeSpan',
description:
'Begin and end date of an entity that may have a finite lifetime.',
description: `Fields indicating the begin and end date of an entitys
lifetime, including whether it has ended (even if the date is unknown).`,
fields: () => ({
begin: { type: DateType },
end: { type: DateType },

View file

@ -1,14 +1,14 @@
import { nodeDefinitions, fromGlobalId } from 'graphql-relay'
import { lookupLoader } from '../loaders'
import { toEntityType } from './helpers'
import { toDashed } from './helpers'
const { nodeInterface, nodeField } = nodeDefinitions(
(globalID) => {
(globalID, { loaders }) => {
const { type, id } = fromGlobalId(globalID)
const entityType = toEntityType(type)
return lookupLoader.load([entityType, id])
const entityType = toDashed(type)
return loaders.lookup.load([entityType, id])
},
(obj) => {
console.log(obj.entityType)
try {
return require(`./${obj.entityType}`).default
} catch (err) {

View file

@ -16,29 +16,48 @@ import {
export const Coordinates = new GraphQLObjectType({
name: 'Coordinates',
description: 'Geographic coordinates with latitude and longitude.',
description: 'Geographic coordinates described with latitude and longitude.',
fields: () => ({
latitude: { type: Degrees },
longitude: { type: Degrees }
latitude: {
type: Degrees,
description: 'The northsouth position of a point on the Earths surface.'
},
longitude: {
type: Degrees,
description: 'The eastwest position of a point on the Earths surface.'
}
})
})
const Place = new GraphQLObjectType({
name: 'Place',
description:
'A venue, studio or other place where music is performed, recorded, ' +
'engineered, etc.',
description: `A [place](https://musicbrainz.org/doc/Place) is a venue, studio
or other place where music is performed, recorded, engineered, etc.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
mbid,
name,
disambiguation,
address: { type: GraphQLString },
area: { type: Area },
coordinates: { type: Coordinates },
address: {
type: GraphQLString,
description: `The address describes the location of the place using the
standard addressing format for the country it is located in.`
},
area: {
type: Area,
description: `The area entity representing the area, such as the city, in
which the place is located.`
},
coordinates: {
type: Coordinates,
description: 'The geographic coordinates of the place.'
},
lifeSpan,
...fieldWithID('type'),
...fieldWithID('type', {
description: `The type categorises the place based on its primary
function.`
}),
events
})
})

View file

@ -10,14 +10,23 @@ import {
artistCredit,
artists,
releases,
relations
relationships
} from './helpers'
const Recording = new GraphQLObjectType({
name: 'Recording',
description:
'Represents a unique mix or edit. Has title, artist credit, duration, ' +
'list of PUIDs and ISRCs.',
description: `A [recording](https://musicbrainz.org/doc/Recording) is an
entity in MusicBrainz which can be linked to tracks on releases. Each track must
always be associated with a single recording, but a recording can be linked to
any number of tracks.
A recording represents distinct audio that has been used to produce at least one
released track through copying or mastering. A recording itself is never
produced solely through copying or mastering.
Generally, the audio represented by a recording corresponds to the audio at a
stage in the production process before any final mastering but after any editing
or mixing.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
@ -25,11 +34,18 @@ const Recording = new GraphQLObjectType({
title,
disambiguation,
artistCredit,
length: { type: GraphQLInt },
video: { type: GraphQLBoolean },
length: {
type: GraphQLInt,
description: `An approximation to the length of the recording, calculated
from the lengths of the tracks using it.`
},
video: {
type: GraphQLBoolean,
description: 'Whether this is a video recording.'
},
artists,
releases,
relations
relationships
})
})

View file

@ -1,43 +0,0 @@
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

83
src/types/relationship.js Normal file
View file

@ -0,0 +1,83 @@
import {
GraphQLObjectType,
GraphQLNonNull,
GraphQLString,
GraphQLList,
GraphQLBoolean
} from 'graphql/type'
import { connectionDefinitions } from 'graphql-relay'
import { DateType } from './scalars'
import Entity from './entity'
import {
getHyphenated,
fieldWithID
} from './helpers'
const Relationship = new GraphQLObjectType({
name: 'Relationship',
description: `[Relationships](https://musicbrainz.org/doc/Relationships) are a
way to represent all the different ways in which entities are connected to each
other and to URLs outside MusicBrainz.`,
fields: () => ({
target: {
type: new GraphQLNonNull(Entity),
description: 'The target entity.',
resolve: source => {
const targetType = source['target-type']
const target = source[targetType]
target.entityType = targetType.replace('_', '-')
return target
}
},
direction: {
type: new GraphQLNonNull(GraphQLString),
description: 'The direction of the relationship.'
},
targetType: {
type: new GraphQLNonNull(GraphQLString),
description: 'The type of entity on the receiving end of the relationship.',
resolve: getHyphenated
},
sourceCredit: {
type: GraphQLString,
description: `How the source entity was actually credited, if different
from its main (performance) name.`,
resolve: getHyphenated
},
targetCredit: {
type: GraphQLString,
description: `How the target entity was actually credited, if different
from its main (performance) name.`,
resolve: getHyphenated
},
begin: {
type: DateType,
description: 'The date on which the relationship became applicable.'
},
end: {
type: DateType,
description: 'The date on which the relationship became no longer applicable.'
},
ended: {
type: GraphQLBoolean,
description: 'Whether the relationship still applies.'
},
attributes: {
type: new GraphQLList(GraphQLString),
description: `Attributes which modify the relationship. There is a [list
of all attributes](https://musicbrainz.org/relationship-attributes), but the
attributes which are available, and how they should be used, depends on the
relationship type.`
},
// There doesn't seem to be any documentation for the `attribute-values`
// field.
// attributeValues: {},
...fieldWithID('type', {
description: 'The type of relationship.'
})
})
})
const { connectionType: RelationshipConnection } = connectionDefinitions({ nodeType: Relationship })
export { RelationshipConnection }
export default Relationship

View file

@ -4,10 +4,8 @@ import Area 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.',
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: Area },
date: { type: DateType }

View file

@ -1,8 +1,9 @@
import { GraphQLObjectType, GraphQLString, GraphQLList } from 'graphql/type'
import { GraphQLObjectType, GraphQLList } from 'graphql/type'
import { connectionDefinitions } from 'graphql-relay'
import Node from './node'
import Entity from './entity'
import { DateType } from './scalars'
import { ReleaseGroupType } from './enums'
import {
id,
mbid,
@ -11,16 +12,21 @@ import {
artistCredit,
artists,
releases,
relations,
relationships,
getHyphenated,
fieldWithID
} from './helpers'
const ReleaseGroup = new GraphQLObjectType({
name: 'ReleaseGroup',
description:
'Represents an abstract "album" (or "single", or "EP") entity. ' +
'Technically its a group of releases, with a specified type.',
description: `A [release group](https://musicbrainz.org/doc/Release_Group) is
used to group several different releases into a single logical entity. Every
release belongs to one, and only one release group.
Both release groups and releases are albums in a general sense, but with an
important difference: a release is something you can buy as media such as a CD
or a vinyl record, while a release group embraces the overall concept of an
album it doesnt matter how many CDs or editions/versions it had.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
@ -28,12 +34,26 @@ const ReleaseGroup = new GraphQLObjectType({
title,
disambiguation,
artistCredit,
firstReleaseDate: { type: DateType, resolve: getHyphenated },
...fieldWithID('primaryType'),
...fieldWithID('secondaryTypes', { type: new GraphQLList(GraphQLString) }),
firstReleaseDate: {
type: DateType,
description: 'The date of the earliest release in the group.',
resolve: getHyphenated
},
...fieldWithID('primaryType', {
type: ReleaseGroupType,
description: `The [type](https://musicbrainz.org/doc/Release_Group/Type)
of a release group describes what kind of releases the release group represents,
e.g. album, single, soundtrack, compilation, etc. A release group can have a
main type and an unspecified number of additional types.`
}),
...fieldWithID('secondaryTypes', {
type: new GraphQLList(ReleaseGroupType),
description: `Additional [types](https://musicbrainz.org/doc/Release_Group/Type)
that apply to this release group.`
}),
artists,
releases,
relations
relationships
})
})

View file

@ -3,6 +3,7 @@ import { connectionDefinitions } from 'graphql-relay'
import Node from './node'
import Entity from './entity'
import { DateType } from './scalars'
import { ReleaseStatus } from './enums'
import ReleaseEvent from './release-event'
import {
id,
@ -14,17 +15,18 @@ import {
labels,
recordings,
releaseGroups,
relations,
relationships,
getHyphenated,
fieldWithID
} from './helpers'
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.',
description: `A [release](https://musicbrainz.org/doc/Release) represents the
unique release (i.e. issuing) of a product on a specific date with specific
release information such as the country, label, barcode, packaging, etc. If you
walk into a store and purchase an album or single, theyre each represented in
MusicBrainz as one release.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
@ -34,19 +36,46 @@ const Release = new GraphQLObjectType({
artistCredit,
releaseEvents: {
type: new GraphQLList(ReleaseEvent),
description: 'The release events for this release.',
resolve: getHyphenated
},
date: { type: DateType },
country: { type: GraphQLString },
barcode: { type: GraphQLString },
...fieldWithID('status'),
...fieldWithID('packaging'),
quality: { type: GraphQLString },
date: {
type: DateType,
description: `The [release date](https://musicbrainz.org/doc/Release/Date)
is the date in which a release was made available through some sort of
distribution mechanism.`
},
country: {
type: GraphQLString,
description: 'The country in which the release was issued.'
},
barcode: {
type: GraphQLString,
description: `The [barcode](https://en.wikipedia.org/wiki/Barcode), if the
release has one. The most common types found on releases are 12-digit
[UPCs](https://en.wikipedia.org/wiki/Universal_Product_Code) and 13-digit
[EANs](https://en.wikipedia.org/wiki/International_Article_Number).`
},
...fieldWithID('status', {
type: ReleaseStatus,
description: 'The status describes how “official” a release is.'
}),
...fieldWithID('packaging', {
description: `The physical packaging that accompanies the release. See
the [list of packaging](https://musicbrainz.org/doc/Release/Packaging) for more
information.`
}),
quality: {
type: GraphQLString,
description: `Data quality indicates how good the data for a release is.
It is not a mark of how good or bad the music itself is for that, use
[ratings](https://musicbrainz.org/doc/Rating_System).`
},
artists,
labels,
recordings,
releaseGroups,
relations
relationships
})
})

View file

@ -17,9 +17,6 @@ function validatePositive (value) {
throw new TypeError(`Expected positive value: ${value}`)
}
/*
scalar Date
*/
export const DateType = new GraphQLScalarType({
name: 'Date',
description:
@ -34,9 +31,6 @@ export const DateType = new GraphQLScalarType({
}
})
/*
scalar Degrees
*/
export const Degrees = new GraphQLScalarType({
name: 'Degrees',
description: 'Decimal degrees, used for latitude and longitude.',
@ -50,9 +44,6 @@ export const Degrees = new GraphQLScalarType({
}
})
/*
scalar Duration
*/
export const Duration = new GraphQLScalarType({
name: 'Duration',
description: 'A length of time, in milliseconds.',
@ -66,14 +57,11 @@ export const Duration = new GraphQLScalarType({
}
})
/*
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.',
description: `An [IPI](https://musicbrainz.org/doc/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) {
@ -84,15 +72,11 @@ export const IPI = new GraphQLScalarType({
}
})
/*
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.',
description: `The [International Standard Name Identifier](https://musicbrainz.org/doc/ISNI)
(ISNI) is an ISO standard for uniquely identifying the public identities of
contributors to media content.`,
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
@ -103,14 +87,11 @@ export const ISNI = new GraphQLScalarType({
}
})
/*
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.',
description: `The [International Standard Musical Work Code](https://musicbrainz.org/doc/ISWC)
(ISWC) is an ISO standard similar to ISBNs for identifying musical works /
compositions.`,
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
@ -121,9 +102,6 @@ export const ISWC = new GraphQLScalarType({
}
})
/*
scalar Locale
*/
export const Locale = new GraphQLScalarType({
name: 'Locale',
description: 'Language code, optionally with country and encoding.',
@ -137,9 +115,6 @@ export const Locale = new GraphQLScalarType({
}
})
/*
scalar Time
*/
export const Time = new GraphQLScalarType({
name: 'Time',
description: 'A time of day, in 24-hour hh:mm notation.',
@ -153,12 +128,9 @@ export const Time = new GraphQLScalarType({
}
})
/*
scalar URLString
*/
export const URLString = new GraphQLScalarType({
name: 'URLString',
description: 'Description',
description: 'A web address.',
serialize: value => value,
parseValue: value => value,
parseLiteral (ast) {
@ -169,14 +141,10 @@ export const URLString = new GraphQLScalarType({
}
})
/*
scalar MBID
*/
export const MBID = new GraphQLScalarType({
name: 'MBID',
description:
'The `MBID` scalar represents MusicBrainz identifiers, which are ' +
'36-character UUIDs.',
description: `The MBID scalar represents MusicBrainz identifiers, which are
36-character UUIDs.`,
serialize: validateMBID,
parseValue: validateMBID,
parseLiteral (ast) {

View file

@ -2,26 +2,23 @@ import { GraphQLObjectType } from 'graphql/type'
import { connectionDefinitions } from 'graphql-relay'
import Node from './node'
import Entity from './entity'
import {
id,
mbid,
name,
disambiguation,
fieldWithID
} from './helpers'
import { id, mbid, name, disambiguation, fieldWithID } from './helpers'
const Series = new GraphQLObjectType({
name: 'Series',
description:
'A series is a sequence of separate release groups, releases, ' +
'recordings, works or events with a common theme.',
description: `A [series](https://musicbrainz.org/doc/Series) is a sequence of
separate release groups, releases, recordings, works or events with a common
theme.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
mbid,
name,
disambiguation,
...fieldWithID('type')
...fieldWithID('type', {
description: `The type primarily describes what type of entity the series
contains.`
})
})
})

View file

@ -3,20 +3,22 @@ import { connectionDefinitions } from 'graphql-relay'
import Node from './node'
import Entity from './entity'
import { URLString } from './scalars'
import { id, mbid, relations } from './helpers'
import { id, mbid, relationships } 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.',
description: `A [URL](https://musicbrainz.org/doc/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: () => [Node, Entity],
fields: () => ({
id,
mbid,
resource: { type: new GraphQLNonNull(URLString) },
relations
resource: {
type: new GraphQLNonNull(URLString),
description: 'The actual URL string.'
},
relationships
})
})

View file

@ -8,26 +8,35 @@ import {
title,
disambiguation,
artists,
relations,
relationships,
fieldWithID
} from './helpers'
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',
description: `A [work](https://musicbrainz.org/doc/Work) is a distinct
intellectual or artistic creation, which can be expressed in the form of one or
more audio recordings.`,
interfaces: () => [Node, Entity],
fields: () => ({
id,
mbid,
title,
disambiguation,
iswcs: { type: new GraphQLList(GraphQLString) },
language: { type: GraphQLString },
...fieldWithID('type'),
iswcs: {
type: new GraphQLList(GraphQLString),
description: `A list of [ISWCs](https://musicbrainz.org/doc/ISWC) assigned
to the work by copyright collecting agencies.`
},
language: {
type: GraphQLString,
description: 'The language in which the work was originally written.'
},
...fieldWithID('type', {
description: 'The type of work.'
}),
artists,
relations
relationships
})
})

View file

@ -2,7 +2,7 @@ import util from 'util'
export function getFields (info) {
if (info.kind !== 'Field') {
info = info.fieldASTs[0]
info = info.fieldNodes[0]
}
const selections = info.selectionSet.selections
return selections.reduce((fields, selection) => {

View file

@ -8,4 +8,11 @@ describe('RateLimit', () => {
expect(limiter.limit).to.equal(1)
expect(limiter.period).to.equal(1000)
})
it('concurrency defaults to limit', () => {
let limiter = new RateLimit()
expect(limiter.concurrency).to.equal(1)
limiter = new RateLimit({ limit: 5 })
expect(limiter.concurrency).to.equal(5)
})
})

3072
yarn.lock Normal file

File diff suppressed because it is too large Load diff