Adds plugin for Contentful synchronization (#75)

Adds plugin for Contentful

Will create product and product variants in Contentful, when created in Medusa

Closes #47
This commit is contained in:
Oliver Windall Juhl authored and GitHub committed 2020-07-02 09:41:57 +02:00
1 parent a7951ca29c
commit d56ccba2b1
20 files changed
+7662 -109

No files matched your search

@@ -0,0 +1,13 @@
{
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-instanceof",
"@babel/plugin-transform-classes"
],
"presets": ["@babel/preset-env"],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
}
}
}
@@ -0,0 +1,9 @@
{
"plugins": ["prettier"],
"extends": ["prettier"],
"rules": {
"prettier/prettier": "error",
"semi": "error",
"no-unused-expressions": "true"
}
}
@@ -0,0 +1,15 @@
/lib
node_modules
.DS_store
.env*
/*.js
!index.js
yarn.lock
/dist
/api
/services
/models
/subscribers
@@ -0,0 +1,9 @@
/lib
node_modules
.DS_store
.env*
/*.js
!index.js
yarn.lock
@@ -0,0 +1,7 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}
@@ -0,0 +1 @@
// noop
@@ -0,0 +1,44 @@
{
"name": "medusa-plugin-contentful",
"version": "1.0.0",
"description": "Contentful plugin for Medusa Commerce",
"main": "index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/medusa-plugin-contentful"
},
"author": "Oliver Juhl",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@babel/cli": "^7.7.5",
"@babel/core": "^7.7.5",
"@babel/node": "^7.7.4",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-transform-instanceof": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.5",
"@babel/register": "^7.7.4",
"@babel/runtime": "^7.9.6",
"client-sessions": "^0.8.0",
"cross-env": "^5.2.1",
"eslint": "^6.8.0",
"jest": "^25.5.2"
},
"scripts": {
"build": "babel src -d dist",
"prepare": "cross-env NODE_ENV=production npm run build",
"watch": "babel -w src --out-dir . --ignore **/__tests__",
"test": "jest"
},
"dependencies": {
"@babel/plugin-transform-classes": "^7.9.5",
"body-parser": "^1.19.0",
"contentful-management": "^5.27.1",
"express": "^4.17.1",
"medusa-core-utils": "^0.3.0",
"medusa-interfaces": "^0.3.0",
"medusa-test-utils": "^0.3.0",
"redis": "^3.0.2"
}
}
@@ -0,0 +1,10 @@
import { Router } from "express"
import hooks from "./routes/hooks"
export default (container) => {
const app = Router()
hooks(app)
return app
}
@@ -0,0 +1 @@
export default (fn) => (...args) => fn(...args).catch(args[2])
@@ -0,0 +1,5 @@
import { default as wrap } from "./await-middleware"
export default {
wrap,
}
@@ -0,0 +1,25 @@
export default async (req, res) => {
try {
const contentfulService = req.scope.resolve("contentfulService")
const contentfulType = req.body.contentType.sys.id
let updated = {}
switch (contentfulType) {
case "product":
updated = await contentfulService.sendContentfulProductToAdmin(req.body)
break
case "productVariant":
updated = await contentfulService.sendContentfulProductVariantToAdmin(
req.body
)
break
default:
break
}
res.status(200).send(updated)
} catch (error) {
res.status(400).send(`Webhook error: ${error.message}`)
}
}
@@ -0,0 +1,13 @@
import { Router } from "express"
import bodyParser from "body-parser"
import middlewares from "../../middlewares"
const route = Router()
export default (app) => {
app.use("/hooks", route)
route.post("/contentful", middlewares.wrap(require("./contentful").default))
return app
}
@@ -0,0 +1,254 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { createClient } from "contentful-management"
import redis from "redis"
class ContentfulService extends BaseService {
constructor(
{ productService, productVariantService, eventBusService },
options
) {
super()
this.productService_ = productService
this.productVariantService_ = productVariantService
this.eventBus_ = eventBusService
this.options_ = options
this.contentful_ = createClient({
accessToken: options.access_token,
})
this.redis_ = redis.createClient()
}
async getIgnoreIds_(type) {
return new Promise((resolve, reject) => {
this.redis_.get(`${type}_ignore_ids`, (err, reply) => {
if (err) {
return reject(err)
}
if (reply) {
return reject("Missing key")
}
return resolve(JSON.parse(reply))
})
})
}
async getContentfulEnvironment_() {
try {
const space = await this.contentful_.getSpace(this.options_.space_id)
const environment = await space.getEnvironment(this.options_.environment)
return environment
} catch (error) {
throw error
}
}
async getVariantEntries_(variantEntryIds) {
if (!variantEntryIds) {
return []
}
try {
const environment = await this.getContentfulEnvironment_()
return Promise.all(variantEntryIds.map((v) => environment.getEntry(v)))
} catch (error) {
throw error
}
}
async getVariantLinks_(variantEntries) {
if (!variantEntries) {
return []
}
return variantEntries.map((v) => ({
sys: {
type: "Link",
linkType: "Entry",
id: v.sys.id,
},
}))
}
async createProductInContentful(product) {
try {
const environment = await this.getContentfulEnvironment_()
const variantEntries = await this.getVariantEntries_(product.variants)
return environment.createEntryWithId("product", product._id, {
fields: {
title: {
"en-US": product.title,
},
variants: {
"en-US": this.getVariantLinks_(variantEntries),
},
objectId: {
"en-US": product._id,
},
},
})
} catch (error) {
throw error
}
}
async createProductVariantInContentful(variant) {
try {
const environment = await this.getContentfulEnvironment_()
return environment.createEntryWithId("productVariant", variant._id, {
fields: {
title: {
"en-US": variant.title,
},
sku: {
"en-US": variant.sku,
},
prices: {
"en-US": variant.prices,
},
objectId: {
"en-US": variant._id,
},
},
})
} catch (error) {
throw error
}
}
async updateProductInContentful(product) {
try {
const environment = await this.getContentfulEnvironment_()
// check if product exists
let productEntry = undefined
try {
productEntry = await environment.getEntry(product._id)
} catch (error) {
return this.createProductInContentful(product)
}
const variantEntries = await this.getVariantEntries_(product.variants)
productEntry.fields = _.assignIn(productEntry.fields, {
title: {
"en-US": product.title,
},
variants: {
"en-US": this.getVariantLinks_(variantEntries),
},
})
await productEntry.update()
const publishedEntry = await productEntry.publish()
const ignoreIds = await this.getIgnoreIds_("product")
if (ignoreIds.includes(publishedEntry.sys.id)) {
ignoreIds.filter((id) => id !== publishedEntry.sys.id)
} else {
this.eventBus_.emit("product.updated", publishedEntry)
}
return publishedEntry
} catch (error) {
throw error
}
}
async updateProductVariantInContentful(variant) {
try {
const environment = await this.getContentfulEnvironment_()
// check if product exists
let variantEntry = undefined
variantEntry = await environment.getEntry(variant._id)
// if not, we create a new one
if (!variantEntry) {
return this.createProductVariantInContentful(variant)
}
variantEntry.fields = _.assignIn(variantEntry.fields, {
title: {
"en-US": variant.title,
},
sku: {
"en-US": variant.sku,
},
prices: {
"en-US": variant.prices,
},
objectId: {
"en-US": variant._id,
},
})
await variantEntry.update()
const publishedEntry = await variantEntry.publish()
const ignoreIds = await this.getIgnoreIds_("product_variant")
if (ignoreIds.includes(publishedEntry.sys.id)) {
ignoreIds.filter((id) => id !== publishedEntry.sys.id)
} else {
this.eventBus_.emit("product-variant.updated", publishedEntry)
}
return publishedEntry
} catch (error) {
throw error
}
}
async sendContentfulProductToAdmin(product) {
try {
const environment = await this.getContentfulEnvironment_()
const productEntry = await environment.getEntry(product.sys.id)
const ignoreIds = await this.getIgnoreIds_("product")
ignoreIds.push(product.sys.id)
this.redis_.set("product_ignore_ids", JSON.stringify(ignoreIds))
const updatedProduct = await this.productService_.update(
productEntry.objectId,
{
title: productEntry.fields.title["en-US"],
variants: productEntry.fields.variants["en-US"],
}
)
return updatedProduct
} catch (error) {
throw error
}
}
async sendContentfulProductVariantToAdmin(variant) {
try {
const environment = await this.getContentfulEnvironment_()
const variantEntry = await environment.getEntry(variant.sys.id)
const ignoreIds = await this.getIgnoreIds_("product_variant")
ignoreIds.push(variant.sys.id)
this.redis_.set("product_variant_ignore_ids", JSON.stringify(ignoreIds))
const updatedVariant = await this.variantService_.update(
variantEntry.objectId,
{
title: variantEntry.fields.title["en-US"],
sku: variantEntry.fields.sku["en-US"],
prices: variantEntry.fields.prices["en-US"],
}
)
return updatedVariant
} catch (error) {
throw error
}
}
}
export default ContentfulService
@@ -0,0 +1,20 @@
class ContentfulSubscriber {
constructor({ contentfulService, eventBusService }) {
this.contentfulService_ = contentfulService
this.eventBus_ = eventBusService
this.eventBus_.subscribe("product-variant.updated", async (data) => {
await this.contentfulService_.updateProductVariantInContentful(data)
})
this.eventBus_.subscribe("product.updated", async (data) => {
await this.contentfulService_.updateProductInContentful(data)
})
this.eventBus_.subscribe("product.created", async (data) => {
await this.contentfulService_.createProductVariantInContentful(data)
})
}
}
export default ContentfulSubscriber