visit
Disclaimer: This article was written by one of WunderGraph’s writers, Prithwish Nath, and has been reposted with permission.
Ugh, this wouldn’t be a problem if I could just have a separate ‘backend’ for each of my frontends! Perhaps one such API per frontend, tailor made for it, and maintained by the team working on that frontend so they have full control over how they consume data internally.*
The Products pages, using the Products microservice for their data.
To build our backend as microservices, each an independent API, with its own OpenAPI V3 specification. This is NOT a tutorial about microservices, so to keep things simple for this tutorial, we’ll limit our backend to 2 microservices — Products, and Orders — and mock the downstream data calls they make, rather than interfacing with an actual database.
import express, { Application, Request, Response } from "express";
import { products } from "./mockdata/tutorialData";
const app: Application = express();
const PORT: number = 3001;
// Endpoint to get all products
app.get("/products", (req: Request, res: Response) => {
res.json(products);
});
// Endpoint to get product by ID
app.get("/products/:id", (req: Request, res: Response) => {
const product = products.find((p) => p.id === parseInt(req.params.id));
if (!product) {
res.status(404).send("Product not found");
} else {
res.json(product);
}
});
// Start server
app.listen(PORT, () => {
console.log(`Product catalog service started on port ${PORT}`);
});
import express, { Application, Request, Response } from "express";
import { orders, Order } from "./mockdata/tutorialData";
const app: Application = express();
const PORT: number = 3002;
app.use(express.json());
// Endpoint to get all orders
app.get("/orders", (req: Request, res: Response) => {
res.json(orders);
});
// Endpoint to get order by ID
app.get("/orders/:id", (req: Request, res: Response) => {
const order: Order | undefined = orders.find(
(o) => o.id === parseInt(req.params.id)
);
if (!order) {
res.status(404).send("Order not found");
} else {
res.json(order);
}
});
// Endpoint to update order status
app.put("/orders/:id", (req: Request, res: Response) => {
const order: Order | undefined = orders.find(
(o) => o.id === parseInt(req.params.id)
);
if (!order) {
res.status(404).send("Order not found");
} else {
order.status = req.body.status;
res.json(order);
}
});
// Start server
app.listen(PORT, () => {
console.log(`Order tracking service started on port ${PORT}`);
});
💡 An is a human-readable description of your RESTful API. This is just a JSON or YAML file describing the servers an API uses, its authentication methods, what each endpoint does, the format for the params/request body each needs, and the schema for the response each returns.
Finally, to keep things simple for this tutorial, we’re using mock eCommerce data from the Fake Store API for them rather than making database connections ourselves. If you need the same data I’m using,.
WunderGraph’s create-wundergraph-app
CLI is the best way to set up both our BFF server and the Next.js app in one go, so let’s do just that. Just make sure you have the latest Node.js LTS installed, first.
npx create-wundergraph-app my-project -E nextjs
Then, cd into the project directory, and
npm install && npm start
If you see a WunderGraph splash page pop up in your browser (at localhost:3000
) with data from an example query, you’re good to go!
Our data dependencies are the two microservices we just built, as Express.js REST APIs. For this. So add them to wundergraph.config.ts
, pointing them at the OpenAPI spec JSONs, and including them in the configureWunderGraphApplication dependency array.
// products catalog microservice
const products = introspect.openApi({
apiNamespace: 'products',
source: {
kind: 'file',
filePath: '../backend/products-service-openAPI.json', // this is the OpenAPI specification.
},
});
// orders microservice
const orders = introspect.openApi({
apiNamespace: 'orders',
source: {
kind: 'file',
filePath: '../backend/orders-service-openAPI.json', // this is the OpenAPI specification.
},
});
// configureWunderGraph emits the configuration
configureWunderGraphApplication({
apis: [products, orders],
…
})
Once you hit save, the WunderNode will introspect the Products and Orders microservices via their OpenAPI spec, and generate their models. If you want, check these generated types/interfaces at ./components/generated/models.ts. You’ll be able to see the exact shapes of all your data.
WunderGraph’s GraphQL Operations are just .graphql
files within ./.wundergraph/operations
.
query AllProducts {
products: products_getProducts {
id
name
price
description
}
}
query AllOrders {
orders: orders_getOrders {
id
items {
productId
quantity
}
status
deliveryDate
shippingAddress {
name
address
city
state
zip
}
}
}
With TypeScript Operations
TypeScript Operations are just .ts files within ./.wundergraph/operations
, use file-based routing similar to NextJS, and are namespaced. So here’s what our operations will look like :
import { createOperation } from "../../generated/wundergraph.factory";
export default createOperation.query({
handler: async () => {
const response = await fetch("//localhost:3001/products");
const productsData = await response.json();
return response.ok
? { success: true, products: productsData }
: { success: false, products: [] };
},
});
import { createOperation } from "../../generated/wundergraph.factory";
export default createOperation.query({
handler: async () => {
const response = await fetch("//localhost:3002/orders");
const ordersResponse = await response.json();
return response.ok
? { success: true, orders: ordersResponse }
: { success: false, orders: [] };
},
});
These TypeScript Operations have one advantage over GraphQL — they are entirely in server-land — meaning you can natively async/await any Promise within them to fetch, modify, expand, and otherwise return completely custom data, from any data source you want.
import { createOperation, z } from "../../generated/wundergraph.factory";
export default createOperation.query({
input: z.object({
productID: z.number(),
}),
handler: async ({ input }) => {
const response = await fetch(
`//localhost:3001/products/${input.productID}`
);
const productResponse = await response.json();
return response.ok
? { success: true, product: productResponse }
: { success: false, product: {} };
},
});
import { createOperation, z } from "../../generated/wundergraph.factory";
export default createOperation.query({
input: z.object({
orderID: z.number(),
}),
handler: async ({ input }) => {
const response = await fetch(
`//localhost:3002/orders/${input.orderID}`
);
const orderData = await response.json();
return response.ok
? { success: true, order: orderData }
: { success: false, order: {} };
},
});
Note how we’re using as a built-in JSON schema validation tool.
Essentially, our frontend is just two pages — one to show all the orders, and another to show the Product Catalog. You can lay this out in any way you like; I’m just using a really basic Sidebar/Active Tab pattern to switch between two display components — <ProductList>
and <OrderList>
— selectively rendering each.
import { NextPage } from "next";
import React, { useState } from "react";
/* WG stuff */
import { useQuery, withWunderGraph } from "../components/generated/nextjs";
/* my components */
import ProductList from "../components/ProductList";
import OrderList from "../components/OrderList";
import Sidebar from "../components/Sidebar";
/* my types*/
import { Product } from "types/Product";
import { Order } from "types/Order";
const Home: NextPage = () => {
// add more tabs as and when you require them
const [activeTab, setActiveTab] = useState<"Product Catalog" | "Orders">(
"Product Catalog"
);
const handleTabClick = (tab: "Product Catalog" | "Orders") => {
setActiveTab(tab);
};
// Using WunderGraph's auto-generated data fetching hooks
const { data: productsData } = useQuery({
operationName: "products/getAll",
});
// …and again.
const { data: ordersData } = useQuery({
operationName: "orders/getAll",
});
return (
<div className="flex">
<Sidebar activeTab={activeTab} handleTabClick={handleTabClick} />
<main className="flex-grow p-8">
{activeTab === "Product Catalog" ? (
<ProductList products={productsData?.products as Product[]} />
) : activeTab === "Orders" ? (
<OrderList orders={ordersData?.orders as Order[]} />
) : (
// account for any other tab, if present. Placeholder for now.
<div className="text-white"> Under Construction</div>
)}
</main>
</div>
);
};
export default withWunderGraph(Home);
import { Product } from "../types/Product";
import Link from "next/link";
type Props = {
product: Product;
};
const ProductCard: React.FC<Props> = ({ product }) => {
return (
<Link
href={{
pathname: `/products/${product.id}`,
}}
>
<div className="h-64 bg-white transform transition-transform duration-50 hover:translate-y-1 rounded-lg shadow-lg p-4 cursor-pointer relative">
<h2 className="text-lg font-medium border-b-2 h-16 line-clamp-2">
{product.name}
</h2>
<p className="text-gray-500 line-clamp-4 my-2">{product.description}</p>
<p
className="text-lg font-bold text-gray-800 p-4 "
style={{ position: "absolute", bottom: 0, left: 0 }}
>
${product.price.toFixed(2)}
</p>
</div>
</Link>
);
};
export default ProductCard;
import { Product } from "../types/Product";
import ProductCard from "./ProductCard";
type Props = {
products: Product[];
};
const ProductList: React.FC<Props> = ({ products }) => {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{products?.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
};
export default ProductList;
import { Order } from "../types/Order";
import Link from "next/link";
type Props = {
order: Order;
};
const OrderCard: React.FC<Props> = ({ order }) => {
return (
<Link
href={{
pathname: `/orders/${order.id}`,
}}
>
<div className="bg-white transform transition-transform duration-50 hover:translate-x-2 rounded-lg shadow-lg p-4 mb-4 cursor-pointer h-60">
<h2 className="text-lg font-medium">Order #{order.id}</h2>
<p className="text-lg font-semibold my-2 text-gray-500">
{order.status}
</p>
<ul className="border-y mb-2">
{order.items.map((item) => (
<li key={item.productId}>
{item.quantity} x Product ID {item.productId}
</li>
))}
</ul>
<p className="text-gray-500">
Shipping Address: {order.shippingAddress.address},{" "}
{order.shippingAddress.city}, {order.shippingAddress.state}{" "}
{order.shippingAddress.zip}
</p>
<p className="text-gray-500">Delivery Date: {order.deliveryDate}</p>
</div>
</Link>
);
};
export default OrderCard;
import { Order } from "../types/Order";
import OrderCard from "./OrderCard";
type Props = {
orders: Order[];
};
const OrderList: React.FC<Props> = ({ orders }) => {
return (
<div>
{orders.map((order) => (
<OrderCard key={order.id} order={order} />
))}
</div>
);
};
export default OrderList;
type SidebarProps = {
activeTab: "Product Catalog" | "Orders";
handleTabClick: (tab: "Product Catalog" | "Orders") => void;
};
const Sidebar: React.FC<SidebarProps> = ({ activeTab, handleTabClick }) => {
return (
<aside className="lg:w-1/8 bg-gradient-to-b from-gray-700 via-gray-900 to-black p-8 flex flex-col justify-between h-screen">
<div>
<h2 className="text-xl font-bold mb-4 text-slate-400 tracking-wider font-mono">
Navigation
</h2>
<nav>
<ul className="tracking-tight text-zinc-200">
<li
className={`${
activeTab === "Product Catalog" ? "font-bold text-white " : ""
} mb-4 cursor-pointer hover:underline`}
onClick={() => handleTabClick("Product Catalog")}
>
Product Catalog
</li>
<li
className={`${
activeTab === "Orders" ? "font-bold text-white " : ""
} mb-4 cursor-pointer hover:underline`}
onClick={() => handleTabClick("Orders")}
>
Orders
</li>
</ul>
</nav>
</div>
</aside>
);
};
export default Sidebar;
import { useRouter } from "next/router";
import { useQuery, withWunderGraph } from "../../components/generated/nextjs";
import BackButton from "components/BackButton";
const Order = () => {
const router = useRouter();
const { productID } = router.query;
const { data: productData } = useQuery({
operationName: "products/getByID",
input: {
productID: parseInt(productID as string),
},
});
const product = productData?.product;
if (!product) {
return (
<div className="w-screen h-screen flex items-center justify-center">
<BackButton />
<div className="max-w-4xl bg-white rounded-lg shadow-lg overflow-hidden">
<div className="p-8 flex flex-col justify-between items-center">
<h2 className="text-xl font-bold"> Product not found!</h2>
</div>
</div>
</div>
);
}
return (
<div className="w-screen h-screen flex items-center justify-center">
<BackButton />
<div className="max-w-4xl bg-white rounded-lg shadow-lg overflow-hidden">
<img
className="w-full h-56 object-cover object-center"
src="//dummyimage.com/720x400"
alt={product.name}
/>
<div className="p-6">
<h3 className="text-lg font-semibold text-gray-800">
{product.name}
</h3>
<p className="mt-2 text-gray-600 text-sm">{product.description}</p>
<div className="mt-4 flex items-center justify-between">
<span className="text-lg font-bold text-gray-800">
${product.price.toFixed(2)}
</span>
<button className="px-3 py-1 bg-gray-800 text-white font-semibold rounded">
Add to Cart
</button>
</div>
</div>
</div>
</div>
);
};
export default withWunderGraph(Order);
import { useRouter } from "next/router";
import { useQuery, withWunderGraph } from "../../components/generated/nextjs";
import BackButton from "components/BackButton";
type ItemInOrder = {
productId: number;
quantity: number;
};
const Order = () => {
const router = useRouter();
const { orderID } = router.query;
const { data: orderData } = useQuery({
operationName: "orders/getByID",
input: {
orderID: parseInt(orderID as string),
},
});
const order = orderData?.order;
if (!order) {
return (
<div className="">
<BackButton />
<div className="min-h-screen flex flex-col items-center justify-center text-white">
<div className="bg-white text-sm md:text-lg text-black p-6 md:p-12 rounded-lg mt-12">
<div className="flex flex-col justify-between items-center">
<h2 className="text-xl font-bold"> Order not found!</h2>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex">
<BackButton />
<div className="min-h-screen w-screen flex items-center justify-center text-white">
<div className="bg-white text-sm md:text-lg text-black p-6 md:p-12 rounded-lg mt-12 shadow-xl">
<div className="flex flex-col justify-between items-center">
<h2 className="text-xl font-bold">Order #{order.id}</h2>
<span
className={`${
order.status === "Processing"
? "text-yellow-500"
: order.status === "Shipped"
? "text-green-500"
: "text-red-500"
} font-bold`}
>
{order.status}
</span>
</div>
<div className="mt-4">
<h3 className="text-lg font-bold">Items</h3>
<ul className="list-disc list-inside">
{order.items.map((item: ItemInOrder) => (
<li key={item.productId}>
{item.quantity} x Product #{item.productId}
</li>
))}
</ul>
</div>
<div className="mt-4">
<h3 className="text-lg font-bold">Shipping Address</h3>
<p>{order.shippingAddress.name}</p>
<p>{order.shippingAddress.address}</p>
<p>
{order.shippingAddress.city}, {order.shippingAddress.state}{" "}
{order.shippingAddress.zip}
</p>
</div>
<div className="mt-4">
<h3 className="text-lg font-bold">Delivery Date</h3>
<p>{new Date(order.deliveryDate).toDateString()}</p>
</div>
</div>
</div>
</div>
);
};
export default withWunderGraph(Order);
The Orders pages, using the Orders microservice for their data.
Also published