@yonderbox/graphql-contentful-mongodb-abstract-schema
Version:
Imports Contentful space to MongoDB and generates an abstract schema
601 lines (528 loc) • 23.4 kB
JavaScript
const pkg = require( './package.json' )
const yves = require( 'yves' )
yves.debugger( 'Y', { stream: null, sortKeys: true, hideFunctions: true, singleLineMax: 0, obfuscates: [ /key/i, /token/i ] } )
const debug = yves.debugger( `${pkg.name.replace( /^@/, '' ).replace( /[/-]+/g, ':' )}` )
const changeCase = require( 'change-case' )
const path = require( 'path' )
const glob = require( 'glob' )
const options = {}
const args = require( 'args' )
const spawn = require( 'child_process' ).spawn
const _ = require( 'lodash' )
process.on( 'unhandledRejection', ( reason, promise ) => {
debug( 'Unhandled Rejection, reason: %s', reason.toString() )
} )
args
.option( 'schema-name', 'Path to schema file', 'abstract' )
.option( 'data-dir', 'The directory which holds the contentful backups', 'data' )
.option( 'spaces-dir', 'The directory which holds the space schemas (abstract & GraphQL)', 'spaces' )
.option( 'contentful-file', 'Specific Contentful file to use as source' )
.option( 'schema-path', 'Path to schema file' )
.option( 'skip-backup', 'Skip fetching Contentful backup', false )
.option( 'skip-entries', 'Skip importing Contentful entries', false )
.option( 'skip-assets', 'Skip importing Contentful assets', false )
.option( 'skip-locales', 'Skip importing Contentful locales', false )
.option( 'skip-drop-collections', 'Skip drop MongoDB collections', false )
.option( 'skip-create-collections', 'Skip create MongoDB collections', false )
.option( 'skip-create-indexes', 'Skip create MongoDB indexes', false )
.option( 'skip-schema', 'Skip create schema file', false )
.option( 'verbose', 'Verbose output' )
.option( 'management-token', 'Contentful management API token' )
.option( 'space-id', 'ID of Space with source data' )
.option( 'environment-id', 'ID of Environment with source data', 'master' )
.option( 'mongodb-url', 'ID of Environment with source data' )
.option( 'config-filename', 'Config filename can optionally point to a JSON file, containing camelCased variables like the options here' )
let flags = args.parse( process.argv )
let config
if ( flags.configFilename ) {
try {
const filename = path.resolve( flags.configFilename )
config = require( filename )
config.name = path.basename( filename, '.js' )
const setup = {}
if ( config ) {
if ( config.url ) setup.mongodbUrl = config.url
if ( config.root ) setup.schemaName = config.root
if ( config.schema ) setup.schemaPath = config.schema
if ( config.writeGraphQLfile ) setup.writeGraphQLfile = config.writeGraphQLfile
}
if ( Object.keys( setup ).length ) {
debug( 'setup %y', setup )
debug( 'flags %y', flags )
flags = Object.assign( {}, setup, flags )
}
} catch( e ) {
debug( 'error %y', e )
}
}
if ( !flags.schemaPath ) flags.schemaPath = `${flags.schemaName}.schema.json`
debug( 'flags %y', flags )
const JSONStream = require( 'JSONStream' )
const fs = require( 'fs-extra' )
const es = require( 'event-stream' )
const Adapter = require( '@yonderbox/graphql-adapter' )
const adapterOptions = Object.assign( {}, config, {
adapter: 'mongodb',
url: flags.mongodbUrl,
options: {
// keepAlive: true,
connectTimeoutMS: 30000,
directConnection: true,
},
db: changeCase.snakeCase( flags.schemaName ),
schema: flags.schemaPath,
root: changeCase.pascalCase( flags.schemaName ),
limit: 0,
writeGraphQLfile: flags.writeGraphQLfile,
} )
debug( 'adapterOptions %y', adapterOptions )
const default_contentful_language = 'nl-NL'
const default_mongodb_language = 'none'
let language = null
const langauges = {}
const objectHash = require( 'object-hash' )
// # contentTypes
// # editorInterfaces
// # entries
// # assets
// # locales
function fetchBackup( callback ) {
if ( !flags.skipBackup && flags.managementToken && flags.spaceId ) {
const dirPath = `${flags.dataDir}/${changeCase.snakeCase( flags.schemaName )}`
fs.ensureDirSync( dirPath )
const options = [
'space',
'export',
'--management-Token',
flags.managementToken,
'--space-id',
flags.spaceId,
'--environment-id',
flags.environmentId,
'--export-dir',
dirPath,
'--skip-webhooks',
'--skip-roles',
// '--include-drafts',
// '--include-archived',
]
/* debug('contentful: %y',options)*/
const cmd = spawn( 'contentful', options )
cmd.stdout.on( 'data', function ( data ) {
if ( flags.verbose ) {
console.log( 'stdout: ' + data.toString() )
} else {
process.stdout.write( data.toString() )
}
} )
cmd.stderr.on( 'data', function ( data ) {
if ( flags.verbose ) {
console.log( 'stderr: ' + data.toString() )
}
} )
cmd.on( 'exit', function ( code ) {
console.log( 'child process exited with code ' + code.toString() )
callback && callback()
} )
} else {
callback && callback()
}
}
function getStream( rootName, contentfulFile ) {
if ( contentfulFile ) {
const stream = fs.createReadStream( contentfulFile, { encoding: 'utf8' } )
const parser = JSONStream.parse( rootName/*['contentTypes', {recurse: true}, ['sys','fields']]*/ )
return stream.pipe( parser )
}
}
const schema = {
collections: {
assets: {
type: 'collection',
name: 'assets',
schema: {
_id: { type: 'string', required: true },
title: { type: 'string', required: true },
description: { type: 'string', required: true },
file: {
type: 'object',
required: true,
schema: {
url: { type: 'string', required: true },
details: {
type: 'object',
required: false,
schema: {
size: { type: 'integer', required: false },
image: {
type: 'object',
required: false,
schema: {
width: { type: 'integer', required: false },
height: { type: 'integer', required: false },
},
},
filename: { type: 'string', required: false },
contentType: { type: 'string', required: false },
},
},
title: { type: 'string', required: false },
},
},
},
},
},
indexes: {}
}
const report = {}
function fetchSchema( adapter, contentfulFile ) {
function ContentfulType( type ) {
let result = 'string'
switch ( type ) {
case 'Symbol': // fall-through
case 'Link': // fall-through
case 'Text':
result = 'string'
break
case 'Integer':
result = 'integer'
break
case 'Number':
result = 'float'
break
case 'Date':
result = 'date'
break
case 'Boolean':
result = 'boolean'
break
case 'Array':
result = 'array'
break
case 'Location': // fall-through
case 'Object': // fall-through
case 'YonderBoxGraphQLTypeObject':
result = 'object'
break
}
return result
}
return getStream( 'contentTypes', contentfulFile ).pipe( es.mapSync( function ( data ) {
for ( let cType of data ) {
const collectionName = adapter.pluralize( changeCase.snakeCase( cType.name ) )
schema.collections[ collectionName ] = { type: 'collection', name: collectionName }
// if (cType.displayField) schema.collections[collectionName].displayField = cType.displayField
if ( cType.description ) schema.collections[ collectionName ].description = cType.description
schema.collections[ collectionName ].schema = {}
schema.collections[ collectionName ].schema[ '_id' ] = { type: 'string', required: true }
schema.collections[ collectionName ].schema[ 'meta' ] = { type: 'object', required: false, kind: 'meta', schema: {
createdAt: { type: 'date', required: false },
updatedAt: { type: 'date', required: false },
createdBy: { type: 'string', required: false },
updatedBy: { type: 'string', required: false },
version: { type: 'integer', required: false },
firstPublishedAt: { type: 'date', required: false },
publishedAt: { type: 'date', required: false },
publishedBy: { type: 'string', required: false },
publishedVersion: { type: 'integer', required: false },
publishedCounter: { type: 'integer', required: false },
} }
for ( let field of cType.fields ) {
if ( !field.disabled && !field.ommited ) {
schema.collections[ collectionName ].schema[ field.id ] = {}
const type = ContentfulType( field.type )
schema.collections[ collectionName ].schema[ field.id ].type = type
if ( field.validations && field.validations.length ) {
// debug('validation for %s.%s = %y',collectionName,field.id, field.validations)
for ( let v of field.validations ) {
if ( v.unique ) {
const oldIndex = schema.indexes[ `${collectionName}_${field.id}` ]
const newIndex = { collection: collectionName, spec: { [ field.id ]: 1 }, options: { unique: true } }
if ( oldIndex && objectHash( oldIndex ) != objectHash( newIndex ) ) {
debug( 'Conflicting index specs for %s.%s old: %y new: %y', collectionName, field.id, oldIndex, newIndex )
}
schema.indexes[ `${collectionName}_${field.id}` ] = newIndex
}
}
}
if ( field.type == 'Array' ) {
schema.collections[ collectionName ].schema[ field.id ].member = { type: ContentfulType( field.items.type ) }
if ( field.items.type == 'Link' && ( field.items.linkType == 'Entry' || field.items.linkType == 'Asset' ) ) {
let references = []
if ( field.items.linkType == 'Asset' ) references.push( 'assets' )
if ( field.items.validations ) {
for ( let v of field.items.validations ) {
if ( v.linkContentType ) references = [ ...references, ...v.linkContentType.map( c => changeCase.snakeCase( adapter.pluralize( c ) ) ) ]
if ( v.in ) {
schema.collections[ collectionName ].schema[ field.id ].member.options = v.in
}
}
}
if ( references && references.length ) {
references.forEach( reference => {
const oldIndex = schema.indexes[ `${collectionName}_${field.id}` ]
const newIndex = { collection: collectionName, spec: { [ field.id ]: 1 }, options: {} }
if ( oldIndex && objectHash( oldIndex ) != objectHash( newIndex ) ) {
debug( 'Conflicting index specs for %s.%s old: %y new: %y', collectionName, field.id, oldIndex, newIndex )
}
schema.indexes[ `${collectionName}_${field.id}` ] = newIndex
} )
if ( references.length == 1 ) {
schema.collections[ collectionName ].schema[ field.id ].member.reference = references[ 0 ]
// schema.collections[collectionName].schema[field.id].member.reverse = `${schema.collections[collectionName].options.plural}_reverse`
} else {
schema.collections[ collectionName ].schema[ field.id ].member.references = references
}
}
}
} else if ( field.type == 'Link' && ( field.linkType == 'Entry' || field.linkType == 'Asset' ) ) {
let references = []
if ( field.linkType == 'Asset' ) references.push( 'assets' )
if ( field.validations ) {
for ( let v of field.validations ) {
if ( v.linkContentType ) references = [ ...references, ...v.linkContentType.map( c => adapter.pluralize( c ) ) ]
}
}
if ( references && references.length ) {
references.forEach( reference => {
const oldIndex = schema.indexes[ `${collectionName}_${field.id}` ]
const newIndex = { collection: collectionName, spec: { [ field.id ]: 1 }, options: {} }
if ( oldIndex && objectHash( oldIndex ) != objectHash( newIndex ) ) {
debug( 'Conflicting index specs for %s.%s old: %y new: %y', collectionName, field.id, oldIndex, newIndex )
}
schema.indexes[ `${collectionName}_${field.id}` ] = newIndex
} )
if ( references.length == 1 ) {
schema.collections[ collectionName ].schema[ field.id ].reference = references[ 0 ]
} else {
schema.collections[ collectionName ].schema[ field.id ].references = references
}
}
}
schema.collections[ collectionName ].schema[ field.id ].required = field.required
if ( schema.collections[ collectionName ].schema[ field.id ].type == 'string' &&
!schema.collections[ collectionName ].schema[ field.id ].reference &&
!schema.collections[ collectionName ].schema[ field.id ].references ) {
if ( !schema.indexes[ `${collectionName}` ] ) schema.indexes[ `${collectionName}` ] = { collection: collectionName, spec: {}, options: { default_language: default_mongodb_language } }
schema.indexes[ `${collectionName}` ].spec[ field.id ] = 'text'
}
} else {
debug( 'Disabled or Ommited: %s.%s', cType.name, field.id )
}
if ( field && field.validations ) {
for ( let v of field.validations ) {
if ( v.in ) {
schema.collections[ collectionName ].schema[ field.id ].options = v.in
}
}
}
}
}
} ) )
}
function unlanguage( obj ) {
if ( typeof( obj ) == 'object' ) {
let keys = Object.keys( obj )
if ( keys && /*language &&*/ keys.indexOf( language || default_contentful_language ) >= 0 ) {
return obj[ language || default_contentful_language ]
}
for ( let key of keys ) {
obj[ key ] = unlanguage( obj[ key ] )
}
}
return obj
}
function unsys( obj ) {
if ( typeof( obj ) == 'object' ) {
let keys = Object.keys( obj )
if ( keys && keys.length == 1 && keys[ 0 ] == 'sys' && obj.sys.type == 'Link' && ( obj.sys.linkType == 'Entry' || obj.sys.linkType == 'Asset' ) && obj.sys.id ) {
return obj.sys.id
}
for ( let key of keys ) {
obj[ key ] = unsys( obj[ key ] )
}
}
return obj
}
function fetchLocales( contentfulFile ) {
if ( flags.skipLocales ) return Promise.resolve()
return new Promise( ( resolve ) => {
return getStream( 'locales', contentfulFile ).pipe( es.mapSync( ( data ) => {
// debug('locales: %y',data)
for ( let lang of data ) {
debug( 'Language %y = %y', lang.code, lang.name )
if ( !language || lang.default ) language = lang.code
langauges[ lang.code ] = { code: lang.code, name: lang.name }
}
} ) ).on( 'end', () => {
resolve()
} )
} )
}
function fetchEntries( adapter, contentfulFile ) {
if ( flags.skipEntries ) return Promise.resolve()
return new Promise( ( resolve ) => {
getStream( 'entries', contentfulFile ).pipe( es.mapSync( ( data ) => {
for ( let entry of data ) {
const collection = adapter.pluralize( changeCase.snakeCase( entry.sys.contentType.sys.id ) )
let set = unsys( unlanguage( entry.fields ) )
if ( adapterOptions.refactor ) {
for ( let refactor of adapterOptions.refactor ) {
if ( !refactor.collection || refactor.collection == collection ) {
if ( refactor.field && refactor.field.old && refactor.field.new ) {
const newObj = {}
for ( let okey in set ) {
newObj[ ( okey == refactor.field.old ) ? refactor.field.new : okey ] = set[ okey ]
}
set = newObj
}
}
}
}
set.meta = {}
if ( _.get( entry, 'sys.createdAt' ) ) set.meta.createdAt = new Date( entry.sys.createdAt )
if ( _.get( entry, 'sys.updatedAt' ) ) set.meta.updatedAt = new Date( entry.sys.updatedAt )
if ( _.get( entry, 'sys.createdBy.sys.id' ) ) set.meta.createdBy = entry.sys.createdBy.sys.id
if ( _.get( entry, 'sys.updatedBy.sys.id' ) ) set.meta.updatedBy = entry.sys.updatedBy.sys.id
if ( _.get( entry, 'sys.version' ) ) set.meta.version = entry.sys.version
if ( _.get( entry, 'sys.firstPublishedAt' ) ) set.meta.firstPublishedAt = new Date( entry.sys.firstPublishedAt )
if ( _.get( entry, 'sys.publishedAt' ) ) set.meta.publishedAt = new Date( entry.sys.publishedAt )
if ( _.get( entry, 'sys.publishedBy.sys.id' ) ) set.meta.publishedBy = entry.sys.publishedBy.sys.id
if ( _.get( entry, 'sys.publishedVersion' ) ) set.meta.publishedVersion = entry.sys.publishedVersion
if ( _.get( entry, 'sys.publishedCounter' ) ) set.meta.publishedCounter = entry.sys.publishedCounter
const _id = entry.sys.id
if ( !report[ collection ] ) report[ collection ] = 0
adapter.update( collection, { _id }, { $set: set }, { upsert: true } ).then( () => {
debug( 'Entry %y: %s', collection, _id )
report[ collection ]++
} ).catch( e => {
debug( 'catch %y', e )
} )
}
} ) ).on( 'end', () => {
resolve()
} )
} )
}
function fetchAssets( adapter, contentfulFile ) {
if ( flags.skipAssets ) return Promise.resolve()
return new Promise( ( resolve ) => {
return getStream( 'assets', contentfulFile ).pipe( es.mapSync( ( data ) => {
for ( let asset of data ) {
const collection = 'assets'
const set = unsys( unlanguage( asset.fields ) )
const _id = asset.sys.id
if ( !report[ collection ] ) report[ collection ] = 0
adapter.update( collection, { _id }, { $set: set }, { upsert: true } ).then( () => {
debug( 'Entry %y: %s', collection, _id )
report[ collection ]++
} ).catch( e => {
debug( 'catch %y', e )
} )
}
} ) ).on( 'end', () => {
resolve()
} )
} )
}
function createIndexes( adapter ) {
if ( flags.skipCreateIndexes ) return Promise.resolve()
const promises = []
for ( let i in schema.indexes ) {
const { collection, spec, options } = schema.indexes[ i ]
promises.push( adapter.createIndex( collection, spec, options ).then( () => debug( 'Created Index on %s -> %y', collection, spec, options ) ) )
}
return Promise.all( promises )
}
function main() {
fetchBackup( async () => {
const contentfulFiles = flags.dataDir && glob.sync( path.join( flags.dataDir, changeCase.snakeCase( flags.schemaName ), 'contentful' ) + '*.json', options ).filter( f => f.indexOf( 'error-log' ) < 0 )
const contentfulFile = flags.contentfulFile || ( contentfulFiles && contentfulFiles.length ? contentfulFiles[ contentfulFiles.length - 1 ] : null )
debug( 'Contentful %y', contentfulFile )
if ( contentfulFile ) {
const adapter = await Adapter.direct( { ...adapterOptions, name: changeCase.snakeCase( flags.schemaName ) } )
await adapter.update( 'meta', { _id: '0' }, { $set: { filename: path.basename( contentfulFile ) } }, { upsert: true } )
const meta = await adapter.query( 'meta', { _id: '0' } )
debug( 'meta %y', meta )
const fetchSchemaPipe = fetchSchema( adapter, contentfulFile )
fetchSchemaPipe.on( 'end', async () => {
const orderedCollections = {}
if ( adapterOptions.refactor ) {
for ( let refactor of adapterOptions.refactor ) {
for ( let collection in schema.collections ) {
if ( !refactor.collection || refactor.collection == collection ) {
if ( refactor.field && refactor.field.old && refactor.field.new ) {
const newObj = {}
for ( let okey in schema.collections[ collection ].schema ) {
newObj[ ( okey == refactor.field.old ) ? refactor.field.new : okey ] = schema.collections[ collection ].schema[ okey ]
}
schema.collections[ collection ].schema = newObj
}
}
}
}
}
/* apply the extend setting in options */
if ( adapterOptions.extend ) {
for ( let ckey in adapterOptions.extend ) {
for ( let fkey in adapterOptions.extend[ ckey ].schema ) {
if ( schema.collections[ ckey ] && schema.collections[ ckey ].schema[ fkey ] ) {
for ( let ekey in adapterOptions.extend[ ckey ].schema[ fkey ] ) {
if ( !Object.prototype.hasOwnProperty.call( schema.collections[ ckey ].schema[ fkey ], ekey ) ) {
schema.collections[ ckey ].schema[ fkey ][ ekey ] = adapterOptions.extend[ ckey ].schema[ fkey ][ ekey ]
}
}
if ( schema.collections[ ckey ].schema[ fkey ].breakdown && schema.collections[ ckey ].schema[ fkey ].options ) {
schema.collections[ ckey ].breakdown = fkey
}
}
}
}
}
Object.keys( schema.collections ).sort().forEach( function( key ) {
orderedCollections[ key ] = schema.collections[ key ]
} )
schema.collections = orderedCollections
if ( !flags.skipSchema ) {
if ( flags.spacesDir ) fs.ensureDirSync( flags.spacesDir )
//${flags.spacesDir?(`${flags.spacesDir}/`):''}
fs.writeFileSync( `${adapterOptions.schema}`, JSON.stringify( schema, null, 2 ) )
debug( 'Schema %y has been written.', path.resolve( adapterOptions.schema ) )
}
if ( !flags.skipDropCollections ) {
for ( let collection of Object.keys( schema.collections ) ) {
const dropResult = await adapter.dropCollection( collection ).catch( ( e ) => {/* IGNORE ERRORS */ } )
if ( dropResult ) {
debug( 'Existing collection %y has been dropped', collection )
}
}
}
if ( !flags.skipCreateCollections ) {
for ( let collection of Object.keys( schema.collections ) ) {
report[ collection ] = 0
const createResult = await adapter.createCollection( collection ).catch( ( e ) => {/* IGNORE ERRORS */ } )
if ( createResult ) {
debug( 'New collection %y has been created', collection )
}
}
}
fetchLocales( contentfulFile ).then( () => {
fetchEntries( adapter, contentfulFile ).then( () => {
fetchAssets( adapter, contentfulFile ).then( () => {
createIndexes( adapter ).then( () => {
adapter.stop( () => {
debug( 'report %y', report )
debug( 'done' )
} )
} )
} )
} )
} )
} )
} else {
debug( 'File not found: %s', )
}
} )
}
main()