yonderbox-contentful-mongodb-abstract-schema
Version:
Imports Contentful space to MongoDB and generates an abstract schema
520 lines (459 loc) • 19.2 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(/-/g,':')}`)
const changeCase = require('change-case')
const path = require('path')
const inflection = require('inflection' )
const glob = require('glob')
const options = {}
const args =require('args')
const spawn = require('child_process').spawn
const _ =require('lodash')
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('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)
if (flags.configFilename) {
try {
const config = require(path.resolve(flags.configFilename))
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 GraphqlMongodbAdapter = require('yonderbox-graphql-mongodb-adapter')
const adapterOptions = {
url: flags.mongodbUrl,
options: {
keepAlive: 1,
connectTimeoutMS: 30000,
},
db: changeCase.snakeCase(flags.schemaName),
schema: flags.schemaPath,
root: changeCase.pascalCase(flags.schemaName),
limit:0,
writeGraphQLfile: flags.writeGraphQLfile,
}
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 cmd = spawn('contentful', [
'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',
])
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(mongodb,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':
result='object'
break
}
return result
}
return getStream('contentTypes',contentfulFile).pipe(es.mapSync(function (data) {
for (let cType of data) {
const collectionName = inflection.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,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(inflection.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].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 => inflection.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)
}
}
}
}))
}
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(mongodb,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 = inflection.pluralize(changeCase.snakeCase(entry.sys.contentType.sys.id))
const set = unsys(unlanguage(entry.fields))
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
mongodb.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(mongodb,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
mongodb.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(mongodb) {
if (flags.skipCreateIndexes) return Promise.resolve()
const promises = []
for (let i in schema.indexes) {
const {collection,spec, options} = schema.indexes[i]
promises.push(mongodb.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 mongodb = await GraphqlMongodbAdapter.direct(adapterOptions,changeCase.snakeCase(flags.schemaName))
await mongodb.update('meta', {_id:'0'},{$set:{filename:path.basename(contentfulFile)}},{ upsert: true })
const meta = await mongodb.query('meta',{_id:'0'})
debug('meta %y',meta)
const fetchSchemaPipe = fetchSchema(mongodb,contentfulFile)
fetchSchemaPipe.on('end', async () => {
const orderedCollections = {}
Object.keys(schema.collections).sort().forEach(function(key) {
orderedCollections[key] = schema.collections[key]
})
schema.collections = orderedCollections
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 mongodb.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 mongodb.createCollection(collection).catch((e) => {/* IGNORE ERRORS */ })
if (createResult) {
debug('New collection %y has been created',collection)
}
}
}
fetchLocales(contentfulFile).then( () => {
fetchEntries(mongodb,contentfulFile).then( () => {
fetchAssets(mongodb,contentfulFile).then( () => {
createIndexes(mongodb).then( () => {
mongodb.stop(() => {
debug('report %y',report)
debug('done')
})
})
})
})
})
})
} else {
debug('File not found: %s',)
}
})
}
main()