# GraphBrainz Codebase ## Configuration System GraphBrainz uses environment variables for all configuration. ### Core Configuration | Variable | Type | Default | Purpose | |----------|------|---------|---------| | NODE_ENV | string | development | Environment mode | | PORT | number | 3000 | Server port | | GRAPHBRAINZ_PATH | string | / | GraphQL endpoint path | | GRAPHBRAINZ_CORS_ORIGIN | string/boolean | false | CORS origin (false, *, or URL) | | GRAPHBRAINZ_GRAPHIQL | boolean | true (dev) | Enable GraphiQL interface | | GRAPHBRAINZ_EXTENSIONS | string | - | Comma-separated extension list | ### Cache Configuration | Variable | Type | Default | Purpose | |----------|------|---------|---------| | GRAPHBRAINZ_CACHE_SIZE | number | 8192 | LRU cache max items | | GRAPHBRAINZ_CACHE_TTL | number | 86400000 | Cache TTL in milliseconds (1 day) | ### MusicBrainz Configuration | Variable | Type | Default | Purpose | |----------|------|---------|---------| | MUSICBRAINZ_BASE_URL | string | http://musicbrainz.org/ws/2/ | MusicBrainz API endpoint | ### Extension Configuration #### Cover Art Archive | Variable | Type | Default | Purpose | |----------|------|---------|---------| | COVERART_CACHE_SIZE | number | 8192 | LRU cache max items | | COVERART_CACHE_TTL | number | 86400000 | Cache TTL in milliseconds | #### fanart.tv | Variable | Type | Default | Purpose | |----------|------|---------|---------| | FANART_API_KEY | string | - | API authentication (required) | | FANART_CACHE_SIZE | number | 8192 | LRU cache max items | | FANART_CACHE_TTL | number | 86400000 | Cache TTL in milliseconds | #### MediaWiki | Variable | Type | Default | Purpose | |----------|------|---------|---------| | MEDIAWIKI_CACHE_SIZE | number | 8192 | LRU cache max items | | MEDIAWIKI_CACHE_TTL | number | 86400000 | Cache TTL in milliseconds | #### TheAudioDB | Variable | Type | Default | Purpose | |----------|------|---------|---------| | THEAUDIODB_API_KEY | string | - | API authentication (required) | | THEAUDIODB_CACHE_SIZE | number | 8192 | LRU cache max items | | THEAUDIODB_CACHE_TTL | number | 86400000 | Cache TTL in milliseconds | ### Configuration Loading **File**: `src/config.js` ```javascript import dotenv from 'dotenv'; dotenv.config(); export default { port: parseInt(process.env.PORT, 10) || 3000, path: process.env.GRAPHBRAINZ_PATH || '/', corsOrigin: process.env.GRAPHBRAINZ_CORS_ORIGIN === 'false' ? false : process.env.GRAPHBRAINZ_CORS_ORIGIN || false, graphiql: process.env.GRAPHBRAINZ_GRAPHIQL === 'true' || process.env.NODE_ENV === 'development', extensions: process.env.GRAPHBRAINZ_EXTENSIONS ? process.env.GRAPHBRAINZ_EXTENSIONS.split(',') : [], cache: { size: parseInt(process.env.GRAPHBRAINZ_CACHE_SIZE, 10) || 8192, ttl: parseInt(process.env.GRAPHBRAINZ_CACHE_TTL, 10) || 86400000 }, musicbrainz: { baseURL: process.env.MUSICBRAINZ_BASE_URL || 'http://musicbrainz.org/ws/2/' } }; ``` ## Logging System GraphBrainz uses the `debug` package for namespace-based logging. ### Debug Namespaces | Namespace | Purpose | Location | |-----------|---------|----------| | graphbrainz:schema | Schema construction | src/schema.js | | graphbrainz:context | Context creation | src/context.js | | graphbrainz:loaders | DataLoader operations | src/loaders/*.js | | graphbrainz:rate-limit | Rate limiter activity | src/rate-limit.js | | graphbrainz:api/client | HTTP requests | src/client.js | | graphbrainz:extensions:coverart | Cover Art Archive | src/extensions/cover-art-archive/ | | graphbrainz:extensions:fanart | fanart.tv | src/extensions/fanart/ | | graphbrainz:extensions:mediawiki | MediaWiki | src/extensions/mediawiki/ | | graphbrainz:extensions:theaudiodb | TheAudioDB | src/extensions/theaudiodb/ | ### Enabling Debug Logging **All Namespaces**: ```bash DEBUG=graphbrainz:* node cli.js ``` **Specific Namespace**: ```bash DEBUG=graphbrainz:api/client node cli.js ``` **Multiple Namespaces**: ```bash DEBUG=graphbrainz:schema,graphbrainz:loaders node cli.js ``` **Exclude Namespaces**: ```bash DEBUG=graphbrainz:*,-graphbrainz:api/client node cli.js ``` ### Debug Output Format ``` graphbrainz:api/client GET http://musicbrainz.org/ws/2/artist/5b11f4ce-a62d-471e-81fc-a69a8278c7da +0ms graphbrainz:loaders Artist loader: batching 3 requests +5ms graphbrainz:rate-limit Acquired token (4 remaining) +10ms graphbrainz:extensions:fanart GET http://webservice.fanart.tv/v3/music/5b11f4ce-a62d-471e-81fc-a69a8278c7da +150ms ``` ### Implementation **File**: `src/client.js` ```javascript import debug from 'debug'; const log = debug('graphbrainz:api/client'); class Client { async get(url, options) { log(`GET ${url}`); const response = await this.client.get(url, options); log(`Response: ${response.statusCode}`); return response; } } ``` ## Error Handling GraphBrainz implements custom error classes for different failure modes. ### Error Class Hierarchy ``` Error (built-in) ├── GraphBrainzError (base) │ ├── MusicBrainzError │ ├── CoverArtArchiveError │ ├── FanArtError │ ├── MediaWikiError │ └── TheAudioDBError └── ValidationError ``` ### Custom Error Classes **File**: `src/errors.js` ```javascript import ExtendableError from 'es6-error'; export class GraphBrainzError extends ExtendableError { constructor(message, statusCode) { super(message); this.statusCode = statusCode; } } export class MusicBrainzError extends GraphBrainzError { constructor(message, statusCode) { super(message, statusCode); this.name = 'MusicBrainzError'; } } export class FanArtError extends GraphBrainzError { constructor(message, statusCode) { super(message, statusCode); this.name = 'FanArtError'; } } export class TheAudioDBError extends GraphBrainzError { constructor(message, statusCode) { super(message, statusCode); this.name = 'TheAudioDBError'; } } export class CoverArtArchiveError extends GraphBrainzError { constructor(message, statusCode) { super(message, statusCode); this.name = 'CoverArtArchiveError'; } } export class ValidationError extends GraphBrainzError { constructor(message) { super(message, 400); this.name = 'ValidationError'; } } ``` ### Error Handling in Resolvers ```javascript async function resolveArtist(parent, args, context) { try { return await context.loaders.artist.load(args.mbid); } catch (error) { if (error.statusCode === 404) { return null; // Artist not found } throw new MusicBrainzError( `Failed to fetch artist: ${error.message}`, error.statusCode ); } } ``` ### Scalar Validation Errors **File**: `src/scalars.js` ```javascript import { GraphQLScalarType } from 'graphql'; import { ValidationError } from './errors.js'; export const MBID = new GraphQLScalarType({ name: 'MBID', description: 'MusicBrainz ID (UUID format)', serialize(value) { return value; }, parseValue(value) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) { throw new ValidationError(`Invalid MBID format: ${value}`); } return value; }, parseLiteral(ast) { if (ast.kind !== 'StringValue') { throw new ValidationError('MBID must be a string'); } return this.parseValue(ast.value); } }); ``` ### GraphQL Error Formatting **File**: `src/index.js` ```javascript import { formatError } from 'graphql'; function customFormatError(error) { const formatted = formatError(error); // Include stack trace in development only if (process.env.NODE_ENV === 'development') { formatted.stack = error.stack; } // Add custom error code if (error.originalError) { formatted.extensions = { ...formatted.extensions, code: error.originalError.name, statusCode: error.originalError.statusCode }; } return formatted; } export const middleware = (options) => { return expressGraphQL({ schema, context, graphiql: options.graphiql, customFormatErrorFn: customFormatError }); }; ``` ### Error Response Format **Development**: ```json { "errors": [ { "message": "Failed to fetch artist: Network error", "locations": [{ "line": 2, "column": 3 }], "path": ["lookup", "artist"], "extensions": { "code": "MusicBrainzError", "statusCode": 503 }, "stack": "MusicBrainzError: Failed to fetch artist: Network error\n at resolveArtist (src/resolvers/artist.js:15:11)\n ..." } ], "data": null } ``` **Production**: ```json { "errors": [ { "message": "Failed to fetch artist: Network error", "locations": [{ "line": 2, "column": 3 }], "path": ["lookup", "artist"], "extensions": { "code": "MusicBrainzError", "statusCode": 503 } } ], "data": null } ``` ## Testing Infrastructure GraphBrainz uses AVA test framework with ava-nock for HTTP mocking. ### Test Framework | Tool | Purpose | Version | |------|---------|---------| | AVA | Test runner | Latest | | ava-nock | HTTP mocking | Latest | | c8 | Code coverage | Latest | ### Test Configuration **File**: `package.json` ```json { "ava": { "files": [ "test/**/*.test.js" ], "timeout": "30s", "verbose": true, "require": [ "dotenv/config" ] } } ``` ### HTTP Mocking with ava-nock ava-nock provides three modes: | Mode | Purpose | Behavior | |------|---------|----------| | play | Replay fixtures | Use cached HTTP responses | | record | Record fixtures | Make real HTTP requests, save responses | | cache | Hybrid | Use cache if available, record if missing | **Configuration**: ```javascript import test from 'ava'; import nock from 'ava-nock'; test.before(() => { nock.setupTests({ mode: 'play', // or 'record', 'cache' fixtures: 'test/fixtures' }); }); ``` ### Test Fixtures **Location**: `test/fixtures/*.nock` **Format**: JSON files containing HTTP request/response pairs **Example**: `test/fixtures/artist-lookup.nock` ```json [ { "scope": "http://musicbrainz.org:80", "method": "GET", "path": "/ws/2/artist/5b11f4ce-a62d-471e-81fc-a69a8278c7da?fmt=json", "status": 200, "response": { "id": "5b11f4ce-a62d-471e-81fc-a69a8278c7da", "name": "Radiohead", "sort-name": "Radiohead", "type": "Group", "country": "GB" } } ] ``` ### Test Suite Structure **File**: `test/schema.test.js` (1475+ lines) ```javascript import test from 'ava'; import { graphql } from 'graphql'; import { schema, context } from '../src/index.js'; test('lookup artist by MBID', async t => { const query = ` { lookup { artist(mbid: "5b11f4ce-a62d-471e-81fc-a69a8278c7da") { name country } } } `; const result = await graphql({ schema, source: query, contextValue: context }); t.is(result.errors, undefined); t.is(result.data.lookup.artist.name, 'Radiohead'); t.is(result.data.lookup.artist.country, 'GB'); }); test('browse releases by artist', async t => { const query = ` { browse { releases(artist: "5b11f4ce-a62d-471e-81fc-a69a8278c7da", first: 5) { edges { node { title } } totalCount } } } `; const result = await graphql({ schema, source: query, contextValue: context }); t.is(result.errors, undefined); t.true(result.data.browse.releases.edges.length > 0); t.true(result.data.browse.releases.totalCount > 0); }); test('search artists', async t => { const query = ` { search { artists(query: "artist:Radiohead", first: 5) { edges { node { name score } } } } } `; const result = await graphql({ schema, source: query, contextValue: context }); t.is(result.errors, undefined); t.true(result.data.search.artists.edges.length > 0); t.is(result.data.search.artists.edges[0].node.name, 'Radiohead'); }); ``` ### Extension Tests **File**: `test/extensions.test.js` ```javascript import test from 'ava'; import { graphql } from 'graphql'; import { schema, context } from '../src/index.js'; test('Cover Art Archive extension', async t => { const query = ` { lookup { release(mbid: "f0c8b1e5-c3b6-46c0-9641-25fd3c00e56a") { title coverArtArchive { front images { image thumbnails { large } } } } } } `; const result = await graphql({ schema, source: query, contextValue: context }); t.is(result.errors, undefined); t.true(result.data.lookup.release.coverArtArchive.front); t.true(result.data.lookup.release.coverArtArchive.images.length > 0); }); ``` ### Test Separation GraphBrainz separates tests into two categories: | Test File | Purpose | Lines | |-----------|---------|-------| | test/base-schema.test.js | Core schema without extensions | ~800 | | test/extended-schema.test.js | Schema with all extensions | ~675 | ### Coverage Configuration **File**: `package.json` ```json { "scripts": { "test": "c8 ava", "coverage": "c8 report --reporter=text-lcov > coverage/lcov.info" }, "c8": { "include": [ "src/**/*.js" ], "exclude": [ "test/**/*.js" ], "reporter": [ "text", "lcov", "html" ], "all": true } } ``` ### Coverage Reporting **Services**: - Codecov: https://codecov.io/gh/exogen/graphbrainz - Coveralls: https://coveralls.io/github/exogen/graphbrainz **Upload**: ```bash npm run coverage npx codecov npx coveralls < coverage/lcov.info ``` ## File Structure ``` graphbrainz/ ├── cli.js # CLI entry point ├── package.json # NPM package configuration ├── schema.json # Schema introspection JSON ├── schema.graphql # Schema SDL ├── Procfile # Heroku process definition ├── .travis.yml # Travis CI configuration ├── .env.example # Example environment variables ├── src/ │ ├── index.js # Main module exports │ ├── schema.js # Schema construction │ ├── context.js # Context factory │ ├── config.js # Configuration loading │ ├── client.js # Base HTTP client │ ├── rate-limit.js # Rate limiter implementation │ ├── errors.js # Custom error classes │ ├── scalars.js # Custom scalar types │ ├── types/ # Entity type definitions │ │ ├── area.js │ │ ├── artist.js │ │ ├── collection.js │ │ ├── disc.js │ │ ├── event.js │ │ ├── instrument.js │ │ ├── label.js │ │ ├── place.js │ │ ├── recording.js │ │ ├── release.js │ │ ├── release-group.js │ │ ├── series.js │ │ ├── tag.js │ │ ├── track.js │ │ ├── url.js │ │ ├── work.js │ │ └── relationships.js │ ├── resolvers/ # Resolver implementations │ │ ├── query.js │ │ └── subquery.js │ ├── loaders/ # DataLoader batch functions │ │ └── musicbrainz.js │ └── extensions/ # Built-in extensions │ ├── cover-art-archive/ │ │ ├── index.js │ │ ├── client.js │ │ └── schema.js │ ├── fanart/ │ │ ├── index.js │ │ ├── client.js │ │ └── schema.js │ ├── mediawiki/ │ │ ├── index.js │ │ ├── client.js │ │ └── schema.js │ └── theaudiodb/ │ ├── index.js │ ├── client.js │ └── schema.js ├── test/ │ ├── base-schema.test.js # Core schema tests (~800 lines) │ ├── extended-schema.test.js # Extension tests (~675 lines) │ └── fixtures/ # HTTP mock fixtures │ ├── artist-lookup.nock │ ├── release-browse.nock │ ├── artist-search.nock │ └── ... ├── scripts/ │ ├── deploy.sh # Heroku deployment script │ ├── generate-readme-toc.js # README table of contents │ ├── generate-schema-docs.js # Schema documentation │ ├── generate-type-docs.js # Type documentation │ └── generate-extension-docs.js # Extension documentation ├── docs/ # Generated documentation │ ├── schema.md │ ├── types.md │ └── extensions.md └── coverage/ # Code coverage reports ├── lcov.info └── index.html ``` ## Code Metrics | Metric | Value | |--------|-------| | Total Lines | ~5000 | | Entity Types | 17 | | Type Definitions | ~2000 lines | | Test Suite | 1475+ lines | | Extensions | 4 built-in | | Dependencies | 10 core | ## No Metrics/APM GraphBrainz does not include: - Prometheus metrics - StatsD integration - APM (Application Performance Monitoring) - Health check endpoints - Readiness probes - Liveness probes These would need to be added for production observability. ## No Structured Logging GraphBrainz uses `debug` package for logging, which is: - Namespace-based (good) - Opt-in via DEBUG env var (good) - Plain text output (not structured) - No log levels (only on/off per namespace) - No log aggregation support For production, consider migrating to structured logging: ```javascript import pino from 'pino'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', formatters: { level: (label) => ({ level: label }) } }); logger.info({ mbid: '...', duration: 150 }, 'Artist lookup completed'); ```