feat: gatsby source medusa (#924)

This commit is contained in:
Kasper Fabricius Kristensen
2021-12-14 20:56:31 +01:00
committed by GitHub
parent d0d8dd7bf6
commit b8ff364276
18 changed files with 12013 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"presets": [["babel-preset-gatsby-package"]],
"overrides": [
{
"test": [],
"presets": [
["babel-preset-gatsby-package", { "browser": true, "esm": true }]
]
}
]
}

View File

@@ -0,0 +1,10 @@
node_modules
.DS_Store
*.js
*.js.map
*.d.ts
!/types/*.d.ts
!jest.config.js

View File

@@ -0,0 +1,6 @@
src
.prettierrc
.env
.babelrc.js
.eslintrc
.gitignore

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Medusa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,72 @@
<p align="center">
<a href="https://www.medusa-commerce.com">
<img alt="Medusa" src="https://user-images.githubusercontent.com/7554214/129161578-19b83dc8-fac5-4520-bd48-53cba676edd2.png" width="100" />
</a>
</p>
<h1 align="center">
gatsby-source-medusa
</h1>
<p align="center">
Medusa is an open-source headless commerce engine that enables developers to create amazing digital commerce experiences. This is a Gatsby source plugin for building websites using Medusa as a data source.
</p>
<p align="center">
<a href="https://github.com/medusajs/medusa/blob/master/LICENSE">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="Medusa is released under the MIT license." />
</a>
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
</a>
<a href="https://discord.gg/xpCwq3Kfn8">
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
</a>
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
</a>
</p>
## Note
This plugin is still under development. Please report any issues or suggestions on the GitHub issues page.
## Quickstart
This takes you through the minimal steps to see your Medusa data in your Gatsby site's GraphiQL explorer.
### 1. Installation
Install the source plugin to your Gatsby project using your favorite package manager.
```shell
npm install gatsby-source-medusa
```
```shell
yarn add gatsby-source-medusa
```
### 2. Configuration
Add the plugin to your `gatsby-config.js`:
```js:title=gatsby-config.js
require("dotenv").config()
module.exports = {
plugins: [
{
resolve: "gatsby-source-medusa",
options: {
storeUrl: process.env.MEDUSA_URL,
authToken: process.env.MEDUSA_AUTH_TOKEN //This is optional
},
},
...,
],
}
```
The plugin accepts two options `storeUrl` and `authToken`. The `storeUrl` option is required and should point to the server where your Medusa instance is hosted (this could be `localhost:9000` in development). The `authToken` option is optional, and if you add it the plugin will also source orders from your store.
## You should now be ready to begin querying your data
You should now be able to view your stores `MedusaProducts`, `MedusaRegions`, `MedusaCollections`, and `MedusaOrders` (if enabled) in your Gatsby site's GraphiQL explorer.

View File

@@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`helper functions should return return a new node from processNode 1`] = `
Array [
Object {
"children": Array [],
"description": "A test product",
"id": "prod_test_1234",
"internal": Object {
"content": "{\\"id\\":\\"prod_test_1234\\",\\"title\\":\\"Test Shirt\\",\\"description\\":\\"A test product\\",\\"unit_price\\":2500}",
"contentDigest": "digest_string",
"type": "MedusaProduct",
},
"parent": null,
"title": "Test Shirt",
"unit_price": 2500,
},
]
`;

View File

@@ -0,0 +1,20 @@
const { processNode } = require("../src/process-node.ts");
describe("helper functions", () => {
it("should return return a new node from processNode", () => {
const fieldName = "product";
const node = {
id: "prod_test_1234",
title: "Test Shirt",
description: "A test product",
unit_price: 2500,
};
const createContentDigest = jest.fn(() => "digest_string");
const processNodeResult = processNode(node, fieldName, createContentDigest);
expect(createContentDigest).toBeCalled();
expect(processNodeResult).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,46 @@
{
"name": "gatsby-source-medusa",
"version": "0.0.44",
"description": "Gatsby source plugin for building websites using Medusa Commerce as a data source",
"scripts": {
"test": "jest --watchAll",
"watch": "tsc-watch --outDir .",
"build": "tsc --outDir ."
},
"keywords": [
"gatsby",
"gatsby-plugin",
"gatsby-source",
"gatsby-source-medusa",
"medusa",
"medusa-commerce"
],
"author": "Kasper Kristensen <kasper@medusa-commerce.com> (https://www.medusa-commerce.com/)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa.git",
"directory": "packages/gatsby-source-medusa"
},
"bugs": {
"url": "https://github.com/medusajs/medusa/issues"
},
"dependencies": {
"axios": "^0.24.0",
"babel-preset-gatsby-package": "^2.0.0",
"gatsby-core-utils": "^3.3.0",
"gatsby-plugin-image": "^2.3.0",
"gatsby-source-filesystem": "^4.3.0"
},
"devDependencies": {
"@types/jest": "^27.0.1",
"babel-plugin-polyfill-corejs2": "^0.3.0",
"gatsby": "^4.1.0",
"jest": "^27.0.6",
"tsc-watch": "^4.5.0",
"typescript": "^4.5.2"
},
"peerDependencies": {
"gatsby": "^4.1.0"
}
}

View File

@@ -0,0 +1,128 @@
import axios, { AxiosPromise, AxiosRequestConfig } from "axios"
import { Reporter } from "gatsby-cli/lib/reporter/reporter"
function medusaRequest(
storeURL: string,
path = "",
headers = {}
): AxiosPromise {
const options: AxiosRequestConfig = {
method: "GET",
withCredentials: true,
url: path,
headers: headers,
}
const client = axios.create({ baseURL: storeURL })
return client(options)
}
export const createClient = (
options: MedusaPluginOptions,
reporter: Reporter
): any => {
const { storeUrl, authToken } = options
/**
*
* @param {string} date used fetch products updated since the specified date
* @return {Promise<any[]>}
*/
async function products(date?: string): Promise<any[]> {
let products: any[] = []
let offset = 0
let count = 1
do {
await medusaRequest(storeUrl, `/store/products?offset=${offset}`)
.then(({ data }) => {
products = [...products, ...data.products]
count = data.count
offset = data.products.length
})
.catch((error) => {
reporter.error(
`"The following error status was produced while attempting to fetch products: ${error}`
)
return []
})
} while (products.length < count)
return products
}
/**
*
* @param {string} date used fetch regions updated since the specified date
* @return {Promise<any[]>}
*/
async function regions(date?: string): Promise<any[]> {
const regions = await medusaRequest(storeUrl, `/store/regions`)
.then(({ data }) => {
return data.regions
})
.catch((error) => {
console.warn(`
"The following error status was produced while attempting to fetch regions: ${error}
`)
return []
})
return regions
}
/**
*
* @param {string} date used fetch regions updated since the specified date
* @return {Promise<any[]>}
*/
async function orders(date?: string): Promise<any[]> {
const orders = await medusaRequest(storeUrl, `/admin/orders`, {
Authorization: `Bearer ${authToken}`,
})
.then(({ data }) => {
return data.orders
})
.catch((error) => {
console.warn(`
The following error status was produced while attempting to fetch orders: ${error}. \n
Make sure that the auth token you provided is valid.
`)
return []
})
return orders
}
/**
*
* @param {string} date used fetch regions updated since the specified date
* @return {Promise<any[]>}
*/
async function collections(date?: string): Promise<any[]> {
let collections: any[] = []
let offset = 0
let count = 1
do {
await medusaRequest(storeUrl, `/store/collections?offset=${offset}`)
.then(({ data }) => {
collections = [...collections, ...data.collections]
count = data.count
offset = data.collections.length
})
.catch((error) => {
reporter.error(
`"The following error status was produced while attempting to fetch products: ${error}`
)
return []
})
} while (collections.length < count)
return collections
}
return {
products,
collections,
regions,
orders,
}
}

View File

@@ -0,0 +1,227 @@
import {
CreateResolversArgs,
GatsbyCache,
Node,
PluginOptionsSchemaArgs,
Reporter,
SourceNodesArgs,
Store,
} from "gatsby"
import { createRemoteFileNode } from "gatsby-source-filesystem"
import { makeSourceFromOperation } from "./make-source-from-operation"
import { createOperations } from "./operations"
export function pluginOptionsSchema({ Joi }: PluginOptionsSchemaArgs): any {
return Joi.object({
storeUrl: Joi.string().required(),
apiKey: Joi.string().optional(),
})
}
async function sourceAllNodes(
gatsbyApi: SourceNodesArgs,
pluginOptions: MedusaPluginOptions
): Promise<void> {
const {
createProductsOperation,
createRegionsOperation,
createOrdersOperation,
createCollectionsOperation,
} = createOperations(pluginOptions, gatsbyApi)
const operations = [
createProductsOperation,
createRegionsOperation,
createCollectionsOperation,
]
// if auth token is provided then source orders
if (pluginOptions.apiKey) {
operations.push(createOrdersOperation)
}
const sourceFromOperation = makeSourceFromOperation(gatsbyApi)
for (const op of operations) {
await sourceFromOperation(op)
}
}
const medusaNodeTypes = [
"MedusaRegions",
"MedusaProducts",
"MedusaOrders",
"MedusaCollections",
]
async function sourceUpdatedNodes(
gatsbyApi: SourceNodesArgs,
pluginOptions: MedusaPluginOptions
): Promise<void> {
const {
incrementalProductsOperation,
incrementalRegionsOperation,
incrementalOrdersOperation,
incrementalCollectionsOperation,
} = createOperations(pluginOptions, gatsbyApi)
const lastBuildTime = new Date(
gatsbyApi.store.getState().status.plugins?.[`gatsby-source-medusa`]?.[
`lastBuildTime`
]
)
for (const nodeType of medusaNodeTypes) {
gatsbyApi
.getNodesByType(nodeType)
.forEach((node) => gatsbyApi.actions.touchNode(node))
}
const operations = [
incrementalProductsOperation(lastBuildTime),
incrementalRegionsOperation(lastBuildTime),
incrementalCollectionsOperation(lastBuildTime),
]
if (pluginOptions.apiKey) {
operations.push(incrementalOrdersOperation(lastBuildTime))
}
const sourceFromOperation = makeSourceFromOperation(gatsbyApi)
for (const op of operations) {
await sourceFromOperation(op)
}
}
export async function sourceNodes(
gatsbyApi: SourceNodesArgs,
pluginOptions: MedusaPluginOptions
): Promise<void> {
const pluginStatus =
gatsbyApi.store.getState().status.plugins?.[`gatsby-source-medusa`]
const lastBuildTime = pluginStatus?.[`lastBuildTime`]
if (lastBuildTime !== undefined) {
gatsbyApi.reporter.info(
`Cache is warm, but incremental builds are currently not supported. Running a clean build.`
)
await sourceAllNodes(gatsbyApi, pluginOptions)
} else {
gatsbyApi.reporter.info(`Cache is cold, running a clean build.`)
await sourceAllNodes(gatsbyApi, pluginOptions)
}
gatsbyApi.reporter.info(`Finished sourcing nodes, caching last build time`)
gatsbyApi.actions.setPluginStatus(
pluginStatus !== undefined
? {
...pluginStatus,
[`lastBuildTime`]: Date.now(),
}
: {
[`lastBuildTime`]: Date.now(),
}
)
}
export function createResolvers({ createResolvers }: CreateResolversArgs): any {
const resolvers = {
MedusaProducts: {
images: {
type: ["MedusaImages"],
resolve: async (
source: any,
_args: any,
context: any,
_info: any
): Promise<any> => {
const { entries } = await context.nodeModel.findAll({
query: {
filter: { parent: { id: { eq: source.id } } },
},
type: "MedusaImages",
})
return entries
},
},
},
}
createResolvers(resolvers)
}
export async function createSchemaCustomization({
actions: { createTypes },
}: {
actions: { createTypes: any }
schema: any
}): Promise<void> {
createTypes(`
type MedusaProducts implements Node {
thumbnail: File @link(from: "fields.localThumbnail")
}
type MedusaImages implements Node {
image: File @link(from: "fields.localImage")
}
`)
}
export async function onCreateNode({
actions: { createNode, createNodeField },
cache,
createNodeId,
node,
store,
reporter,
}: {
actions: { createNode: any; createNodeField: any }
cache: GatsbyCache
createNodeId: any
node: Node
store: Store
reporter: Reporter
}): Promise<void> {
if (node.internal.type === `MedusaProducts`) {
if (node.thumbnail !== null) {
const thumbnailNode: Node | null = await createRemoteFileNode({
url: `${node.thumbnail}`,
parentNodeId: node.id,
createNode,
createNodeId,
cache,
store,
reporter,
})
if (thumbnailNode) {
createNodeField({
node,
name: `localThumbnail`,
value: thumbnailNode.id,
})
}
}
}
if (node.internal.type === `MedusaImages`) {
const imageNode: Node | null = await createRemoteFileNode({
url: `${node.url}`,
parentNodeId: node.id,
createNode,
createNodeId,
cache,
store,
reporter,
})
if (imageNode) {
createNodeField({
node,
name: `localImage`,
value: imageNode.id,
})
}
}
}

View File

@@ -0,0 +1,25 @@
import { SourceNodesArgs } from "gatsby"
import { processNode } from "./process-node"
export function makeSourceFromOperation(gatsbyApi: SourceNodesArgs) {
return async function sourceFromOperation(
op: IMedusaOperation
): Promise<void> {
const { reporter, actions } = gatsbyApi
reporter.info(`Initiating operation query ${op.name}`)
const nodes = await op.execute()
nodes.map((rawNode) => {
const nodeArr = processNode(
rawNode,
op.name,
gatsbyApi.createContentDigest
)
nodeArr.forEach((node) => {
actions.createNode(node)
})
})
}
}

View File

@@ -0,0 +1,34 @@
import { SourceNodesArgs } from "gatsby"
import { createClient } from "./client"
export function createOperations(
options: MedusaPluginOptions,
{ reporter }: SourceNodesArgs
): IOperations {
const client = createClient(options, reporter)
function createOperation(
name: "products" | "collections" | "regions" | "orders",
queryString?: string
): IMedusaOperation {
return {
execute: (): Promise<any[]> => client[name](queryString),
name: name,
}
}
return {
createProductsOperation: createOperation("products"),
createCollectionsOperation: createOperation("collections"),
createRegionsOperation: createOperation("regions"),
createOrdersOperation: createOperation("orders"),
incrementalProductsOperation: (date: Date): any =>
createOperation("products", date.toISOString()),
incrementalCollectionsOperation: (date: Date): any =>
createOperation("collections", date.toISOString()),
incrementalRegionsOperation: (date: Date): any =>
createOperation("regions", date.toISOString()),
incrementalOrdersOperation: (date: Date): any =>
createOperation("orders", date.toISOString()),
}
}

View File

@@ -0,0 +1,49 @@
import { capitalize } from "./utils/capitalize"
export const processNode = (
node: any,
fieldName: string,
createContentDigest: (this: void, input: string | object) => string
): any[] => {
const nodeId: string = node.id
const nodeContent = JSON.stringify(node)
const nodeContentDigest = createContentDigest(nodeContent)
let images = []
if (fieldName === "products") {
if (node.images?.length) {
images = node.images.map((image: any) => {
const nodeImageContentDigest = createContentDigest(image.id)
const nodeImageContent = JSON.stringify(image)
const imageData = Object.assign({}, image, {
id: image.id,
parent: nodeId,
children: [],
internal: {
type: "MedusaImages",
content: nodeImageContent,
contentDigest: nodeImageContentDigest,
},
})
return imageData
})
}
delete node.images
}
const nodeData = Object.assign({}, node, {
id: nodeId,
parent: null,
children: [],
internal: {
type: `Medusa${capitalize(fieldName)}`,
content: nodeContent,
contentDigest: nodeContentDigest,
},
})
return [nodeData, ...images]
}

View File

@@ -0,0 +1,3 @@
export function capitalize(s: string): string {
return s[0].toUpperCase() + s.slice(1)
}

View File

@@ -0,0 +1,12 @@
export const formatUri = (uri: string): string => {
let url
try {
url = new URL(uri)
} catch (_) {
const formatted = /[\w||\d].*/.exec(uri)?.[0]
return `https://${formatted}`
}
return url.href
}

View File

@@ -0,0 +1,19 @@
{
"include": ["src/**/*.ts", "types"],
"exclude": ["node_modules"],
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"module": "commonjs",
"removeComments": false,
"preserveConstEnums": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"target": "es2017",
"declaration": true,
"lib": ["es2017", "dom", "esnext.asynciterable"]
}
}

View File

@@ -0,0 +1,29 @@
interface MedusaPluginOptions {
storeUrl: string
apiKey: string
}
interface MedusaProductImage {
url: string
metadata: Record<string, unknown> | null
id: string
created_at: string
updated_at: string
deleted_at: string | null
}
interface IMedusaOperation {
execute: () => Promise<any[]>
name: string
}
interface IOperations {
createProductsOperation: IMedusaOperation
createCollectionsOperation: IMedusaOperation
createRegionsOperation: IMedusaOperation
createOrdersOperation: IMedusaOperation
incrementalProductsOperation: (date: Date) => IMedusaOperation
incrementalCollectionsOperation: (date: Date) => IMedusaOperation
incrementalRegionsOperation: (date: Date) => IMedusaOperation
incrementalOrdersOperation: (date: Date) => IMedusaOperation
}

File diff suppressed because it is too large Load Diff