Focuses on API authentication, access scopes, and permission management.
Hi all,
I'm attempting to get a simple test route in my server/server.js connected to a component in my pages/ directory.
server.js:
import "@babel/polyfill";
import dotenv from "dotenv";
import "isomorphic-fetch";
import createShopifyAuth, { verifyRequest } from "@shopify/koa-shopify-auth";
import Shopify, { ApiVersion } from "@shopify/shopify-api";
import Koa from "koa";
import next from "next";
import Router from "koa-router";
dotenv.config();
const port = parseInt(process.env.PORT, 10) || 8081;
const dev = process.env.NODE_ENV !== "production";
const app = next({
dev,
});
const handle = app.getRequestHandler();
Shopify.Context.initialize({
API_KEY: process.env.SHOPIFY_API_KEY,
API_SECRET_KEY: process.env.SHOPIFY_API_SECRET,
SCOPES: process.env.SCOPES.split(","),
HOST_NAME: process.env.HOST.replace(/https:\/\//, ""),
API_VERSION: ApiVersion.October20,
IS_EMBEDDED_APP: true,
// This should be replaced with your preferred storage strategy
SESSION_STORAGE: new Shopify.Session.MemorySessionStorage(),
});
// Storing the currently active shops in memory will force them to re-login when your server restarts. You should
// persist this object in your app.
const ACTIVE_SHOPIFY_SHOPS = {};
app.prepare().then(async () => {
const server = new Koa();
const router = new Router();
server.keys = [Shopify.Context.API_SECRET_KEY];
server.use(
createShopifyAuth({
async afterAuth(ctx) {
// Access token and shop available in ctx.state.shopify
const { shop, accessToken, scope } = ctx.state.shopify;
const host = ctx.query.host;
ACTIVE_SHOPIFY_SHOPS[shop] = scope;
const response = await Shopify.Webhooks.Registry.register({
shop,
accessToken,
path: "/webhooks",
topic: "APP_UNINSTALLED",
webhookHandler: async (topic, shop, body) =>
delete ACTIVE_SHOPIFY_SHOPS[shop],
});
if (!response.success) {
console.log(
`Failed to register APP_UNINSTALLED webhook: ${response.result}`
);
}
// Redirect to app with shop parameter upon auth
ctx.redirect(`/?shop=${shop}&host=${host}`);
},
})
);
const handleRequest = async (ctx) => {
await handle(ctx.req, ctx.res);
ctx.respond = false;
ctx.res.statusCode = 200;
};
router.post("/webhooks", async (ctx) => {
try {
await Shopify.Webhooks.Registry.process(ctx.req, ctx.res);
console.log(`Webhook processed, returned status code 200`);
} catch (error) {
console.log(`Failed to process webhook: ${error}`);
}
});
// THIS IS THE ROUTE I WANT TO CONNECT TO
router.get(
"/init_fauna",
// verifyRequest({ returnHeader: true }),
async (ctx, next) => {
ctx.status = 201;
ctx.body = "Woohoo";
}
);
router.post(
"/graphql",
verifyRequest({ returnHeader: true }),
async (ctx, next) => {
await Shopify.Utils.graphqlProxy(ctx.req, ctx.res);
}
);
router.get("(/_next/static/.*)", handleRequest); // Static content is clear
router.get("/_next/webpack-hmr", handleRequest); // Webpack content is clear
router.get("(.*)", async (ctx) => {
const shop = ctx.query.shop;
// This shop hasn't been seen yet, go through OAuth to create a session
if (ACTIVE_SHOPIFY_SHOPS[shop] === undefined) {
ctx.redirect(`/auth?shop=${shop}`);
} else {
await handleRequest(ctx);
}
});
server.use(router.allowedMethods());
server.use(router.routes());
server.listen(port, () => {
console.log(`> Ready on http://localhost:${port}`);
});
});
index.js (Index component):
import React, { useState } from 'react';
import { Page, Layout, EmptyState} from "@shopify/polaris";
import { ResourcePicker, TitleBar, useAppBridge, getSessionToken } from '@shopify/app-bridge-react';
import store from 'store-js';
import ResourceListWithProducts from './components/ResourceList';
const img = 'https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg';
export const Index = () => {
const app = useAppBridge();
const [open, setOpen] = useState(false);
const emptyState = !store.get('ids');
const handleSelection = (resources) => {
const idsFromResources = resources.selection.map((product) => product.id);
setOpen(false);
store.set('ids', idsFromResources);
};
const testFauna = async () => {
const res = await fetch("/api/fauna_init");
const json = await res.json();
console.log(json);
};
return (
<Page>
<TitleBar
primaryAction={{
content: 'Select products',
onAction: () => setOpen(true),
}}
secondaryActions={[
{
content: 'Test Fauna',
onAction: testFauna
}
]}
/>
<ResourcePicker
resourceType="Product"
showVariants={false}
open={open}
onSelection={(resources) => handleSelection(resources)}
onCancel={() => setOpen(false)}
/>
{emptyState ? ( // Controls the layout of your app's empty state
<Layout>
<EmptyState
heading="Discount your products temporarily"
action={{
content: 'Select products',
onAction: () => setOpen(true),
}}
image={img}
>
<p>Select products to change their price temporarily.</p>
</EmptyState>
</Layout>
) : (
// Uses the new resource list that retrieves products by IDs
<ResourceListWithProducts />
)}
</Page>
);
}
export default Index;
However when I look at what's happening in Chrome's network inspector I see this:
Note how it's attempting to reach auth?shop=undefined.
I have followed the basic tutorials trying to get things setup (October, 2021 versions), but I haven't came across anything that talks about connecting your frontend to custom backend routes, which I'm assuming you need to do to interface with an external database, such as in my case FaunaDB.
If someone could point me in the right direction I would appreciate it immensely, I've been at this for hours and have made little progress.
Solved! Go to the solution
This is an accepted solution.
Hey @saricden. How are you?
My name is Olavo and I'm a developer from Shopify. Thank you for reaching out!
I was able to reproduce your issue here and it seems that the catch-all route is handling these requests. Because it doesn't have the `shop` query param, it redirects to the `/auth?shop=undefined` route.
As @tewe suggested, the simplest solution is to declare your route as `/api/init_fauna`. That will allow you to make the fetch request. Alternatively, to get this working with authentication, I'd suggest using Next.js' API routes and passing along the session token with the request.
Let me walk you through how to do that:
1. Create a new file `pages/api/init_fauna.js` containing:
export default function handler(req, res) {
res.status(201).json('Woohoo')
}
2. On the `server/server.js` file, update the catch-all route to look for the shop on the query param and on the session:
router.get("(.*)", async (ctx) => {
const shop =
ctx.query.shop ||
(await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res))?.shop; // This retrieves the shop from the session
// This shop hasn't been seen yet, go through OAuth to create a session
if (ACTIVE_SHOPIFY_SHOPS[shop] === undefined) {
ctx.redirect(`/auth?shop=${shop}`);
} else {
await handleRequest(ctx);
}
});
3. On your `pages/index.js` file, you should pass the session token with the request. You can do this by leveraging the `authenticatedFetch` utility:
// ... more imports
import { authenticatedFetch } from "@shopify/app-bridge-utils";
import { ResourcePicker, TitleBar, useAppBridge } from '@shopify/app-bridge-react';
export const Index = () => {
const app = useAppBridge();
// ... more logic
const testFauna = async () => {
const authFetch = authenticatedFetch(app);
const res = await authFetch("/api/init_fauna");
const json = await res.json();
console.log(json);
};
return (/* Your JSX */);
}
I hope this helps. Please let me know if you have any more questions.
To learn more visit the Shopify Help Center or the Community Blog.
Hi @saricden ,
if I see it correctly you have to name the route in the server.js `
/api/fauna_init
Then it should work.
Regards
Thomas
This is an accepted solution.
Hey @saricden. How are you?
My name is Olavo and I'm a developer from Shopify. Thank you for reaching out!
I was able to reproduce your issue here and it seems that the catch-all route is handling these requests. Because it doesn't have the `shop` query param, it redirects to the `/auth?shop=undefined` route.
As @tewe suggested, the simplest solution is to declare your route as `/api/init_fauna`. That will allow you to make the fetch request. Alternatively, to get this working with authentication, I'd suggest using Next.js' API routes and passing along the session token with the request.
Let me walk you through how to do that:
1. Create a new file `pages/api/init_fauna.js` containing:
export default function handler(req, res) {
res.status(201).json('Woohoo')
}
2. On the `server/server.js` file, update the catch-all route to look for the shop on the query param and on the session:
router.get("(.*)", async (ctx) => {
const shop =
ctx.query.shop ||
(await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res))?.shop; // This retrieves the shop from the session
// This shop hasn't been seen yet, go through OAuth to create a session
if (ACTIVE_SHOPIFY_SHOPS[shop] === undefined) {
ctx.redirect(`/auth?shop=${shop}`);
} else {
await handleRequest(ctx);
}
});
3. On your `pages/index.js` file, you should pass the session token with the request. You can do this by leveraging the `authenticatedFetch` utility:
// ... more imports
import { authenticatedFetch } from "@shopify/app-bridge-utils";
import { ResourcePicker, TitleBar, useAppBridge } from '@shopify/app-bridge-react';
export const Index = () => {
const app = useAppBridge();
// ... more logic
const testFauna = async () => {
const authFetch = authenticatedFetch(app);
const res = await authFetch("/api/init_fauna");
const json = await res.json();
console.log(json);
};
return (/* Your JSX */);
}
I hope this helps. Please let me know if you have any more questions.
To learn more visit the Shopify Help Center or the Community Blog.
Fantastic thank you @olavoasantos that worked for me.
Just a followup question, is it safe to store private keys in ENV variables and pass them to Next API routes in this fashion?
(I'm looking to communicate with a database).
Thanks!
Dealing with env variables and secrets is always delicate. So it's always good to be extra careful. Next.js has good control over env variables and forces you to explicitly opt into making env variables public (take a look here).
So, to answer your question. Yes, it's ok to use env variables. But remember never to commit `.env` files containing your secrets. Going down this path, it's usually a good practice to commit a `.env.example` which contains the keys with no values. Then, on your production server, you rename the`.env.example` to `.env` and fill in the secrets.
I hope this helped 😃
To learn more visit the Shopify Help Center or the Community Blog.
One followup question @olavoasantos - do you know how I can send the authenticated shop data to my API route?
I'm just looking to associate the store name or ID with records in a database now.
Thanks!
Hey @saricden.
Going back to my original response, I suggested that you use the `authenticatedFetch`. What this will do is attach the current session token as a header to your request (if you check the network request, it should contain an `authorization` header with a `Bearer {TOKEN}`). On your API, you can then use the `Shopify.Utils.loadCurrentSession` function to decode that token and get information about the shop.
Breaking it down, you'll do:
1. On your `pages/index.js` file (or in whatever component you'll do this), you should pass the session token with the request. You can do this by leveraging the `authenticatedFetch` utility:
// ... more imports
import { authenticatedFetch } from "@shopify/app-bridge-utils";
import { ResourcePicker, TitleBar, useAppBridge } from '@shopify/app-bridge-react';
export const Index = () => {
const app = useAppBridge();
// ... more logic
const testFauna = async () => {
const authFetch = authenticatedFetch(app);
const res = await authFetch("/api/init_fauna");
const json = await res.json();
console.log(json);
};
return (/* Your JSX */);
}
2. On your `pages/api/init_fauna.js` (or in whatever handler you'll need this), you can decode the session token using the `loadCurrentSession` utility:
import Shopify from "@shopify/shopify-api";
export default async function handler(req, res) {
const session = await Shopify.Utils.loadCurrentSession(req, res);
// ... do your magic ...
res.status(201).json(('Woohoo'))
}
Looking at your `session` object, it should have the following shape:
interface Session {
id: string;
shop: string;
state: string;
scope: string;
expires?: Date;
isOnline?: boolean;
accessToken?: string;
onlineAccessInfo?:
expires_in: number;
associated_user_scope: string;
associated_user: {
id: number;
first_name: string;
last_name: string;
email: string;
email_verified: boolean;
account_owner: boolean;
locale: string;
collaborator: boolean;
};
};
}
You should be able to get what you need from this. Also make sure the `session` object is not `undefined`. If there's no session, you can throw an exception and/or try to re-authenticate the user.
To learn more visit the Shopify Help Center or the Community Blog.
Awesome, thanks a bunch!
@olavoasantos I followed your process, but I am getting this error:
Request URL: https://9f71-2402-e280-3e11-3ed-55b8-c3c5-52af-d96e.ngrok.io/api/init_fauna
Request Method: GET
Status Code: 500
Remote Address: [2600:1f16:d83:1202::6e:2]:443
Referrer Policy: strict-origin-when-cross-origin
Hi @PrateekGoyal . How are you?
Could you share some code snippets of what you did? And how did you get that error you showed? Was it a call using the authenticated fetch or did you run a direct request?
To learn more visit the Shopify Help Center or the Community Blog.
when i try to call function in server.js i also face this issue (https://8be3-182-176-104-93.ngrok.io/auth?shop=undefined) i am sharing my server.js file
import "@babel/polyfill";
import dotenv from "dotenv";
import "isomorphic-fetch";
import createShopifyAuth, { verifyRequest } from "@shopify/koa-shopify-auth";
import Shopify, { ApiVersion } from "@shopify/shopify-api";
import Koa from "koa";
import next from "next";
import Router from "koa-router";
dotenv.config();
const port = parseInt(process.env.PORT, 10) || 8081;
const dev = process.env.NODE_ENV !== "production";
const getProducts = require('../server/products/products.js');
const app = next({
dev,
});
const handle = app.getRequestHandler();
Shopify.Context.initialize({
API_KEY: process.env.SHOPIFY_API_KEY,
API_SECRET_KEY: process.env.SHOPIFY_API_SECRET,
SCOPES: process.env.SCOPES.split(","),
HOST_NAME: process.env.HOST.replace(/https:\/\/|\/$/g, ""),
API_VERSION: ApiVersion.October20,
IS_EMBEDDED_APP: true,
// This should be replaced with your preferred storage strategy
SESSION_STORAGE: new Shopify.Session.MemorySessionStorage(),
});
// Storing the currently active shops in memory will force them to re-login when your server restarts. You should
// persist this object in your app.
const ACTIVE_SHOPIFY_SHOPS = {};
app.prepare().then(async () => {
const server = new Koa();
const router = new Router();
server.keys = [Shopify.Context.API_SECRET_KEY];
server.use(
createShopifyAuth({
async afterAuth(ctx) {
// Access token and shop available in ctx.state.shopify
const { shop, accessToken, scope } = ctx.state.shopify;
const host = ctx.query.host;
ACTIVE_SHOPIFY_SHOPS[shop] = scope;
const response = await Shopify.Webhooks.Registry.register({
shop,
accessToken,
path: "/webhooks",
topic: "APP_UNINSTALLED",
webhookHandler: async (topic, shop, body) =>
delete ACTIVE_SHOPIFY_SHOPS[shop],
});
if (!response.success) {
console.log(
`Failed to register APP_UNINSTALLED webhook: ${response.result}`
);
}
// Redirect to app with shop parameter upon auth
ctx.redirect(`/?shop=${shop}&host=${host}`);
},
})
);
const handleRequest = async (ctx) => {
await handle(ctx.req, ctx.res);
ctx.respond = false;
ctx.res.statusCode = 200;
};
router.post("/webhooks", async (ctx) => {
try {
await Shopify.Webhooks.Registry.process(ctx.req, ctx.res);
console.log(`Webhook processed, returned status code 200`);
} catch (error) {
console.log(`Failed to process webhook: ${error}`);
}
});
router.post(
"/graphql",
verifyRequest({ returnHeader: true }),
async (ctx, next) => {
await Shopify.Utils.graphqlProxy(ctx.req, ctx.res);
}
);
router.get("(/_next/static/.*)", handleRequest); // Static content is clear
router.get("/_next/webpack-hmr", handleRequest); // Webpack content is clear
router.get("(.*)", async (ctx) => {
const shop = ctx.query.shop;
// This shop hasn't been seen yet, go through OAuth to create a session
if (ACTIVE_SHOPIFY_SHOPS[shop] === undefined) {
ctx.redirect(`/auth?shop=${shop}` );
} else {
await handleRequest(ctx);
}
});
//=====================get products====================//
router.get("/getProducts", verifyRequest(), async(ctx,res) => {
const { shop, accessToken } = ctx.session;
const response = await getProducts(accessToken, shop);
ctx.body = response.data;
ctx.res.statusCode = 200
});
//=====================get products end====================//
//=====================script tags====================//
router.get("/scripttag", verifyRequest({ returnHeader: true }), async(ctx, res) => {
const { shop, accessToken } = ctx.session;
const url = `https://${shop}/admin/api/2022-01/script_tags.json`;
const src='https://example.com/example.js';
const shopifyHeader = (token)=>({
"Content-Type": "application/json",
"X-Shopify-Access-Token":token,
});
const scriptTagBody = JSON.stringify({
script_tag: {
event: 'onload',
src,
}
});
await axios.post(url,scriptTagBody,{headers:shopifyHeader(accessToken)});
});
//=====================end script tags====================//
server.use(router.allowedMethods());
server.use(router.routes());
server.listen(port, () => {
console.log(`> Ready on http://localhost:${port}`);
});
});