mcpField
S
shopify-app-api-patterns

shopify-app-api-patterns

github.com/tamiror6开发3874 次安装38 周前更新
代码仓库

关于 shopify-app-api-patterns

为 Shopify Remix 应用开发前端与后端交互功能的技能,涵盖认证数据加载、修改操作、会话处理和 API 路由。

主要功能

  1. 01使用 Remix loader 获取数据
  2. 02通过 action 提交表单修改
  3. 03用 useFetcher 执行客户端更新
  4. 04处理认证路由与会话检查
  5. 05为扩展和 webhook 创建 API 路由

适用场景

  1. 01添加获取 Shopify 或数据库数据的页面
  2. 02构建提交后端修改的表单
  3. 03处理认证会话和权限范围检查
  4. 04为扩展或外部服务提供路由

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/tamiror6/shopify-app-skills --skill shopify-app-api-patterns

安装后在 Claude Code 中输入/shopify-app-api-patterns确认已加载。

详细介绍

Shopify 应用 API 模式

在 Shopify Remix 应用中构建与应用后端通信的前端功能时,请使用此技能。

适用场景

  • 添加从 Shopify 或数据库获取数据的新页面
  • 创建提交数据的表单(变更操作)
  • 使用 useFetcher 执行客户端数据操作
  • 在路由中处理经过身份验证的会话
  • 为应用扩展或外部服务构建 API

架构概览

┌─────────────────────────────────────────────────────────────┐
│                    Shopify Admin                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │              Your App (iframe)                       │    │
│  │                                                      │    │
│  │  ┌──────────────┐       ┌──────────────────────┐   │    │
│  │  │   Frontend   │ ───── │   Remix Backend      │   │    │
│  │  │   (React)    │       │   (loaders/actions)  │   │    │
│  │  └──────────────┘       └──────────────────────┘   │    │
│  │                                    │                │    │
│  └────────────────────────────────────│────────────────┘    │
│                                       │                      │
└───────────────────────────────────────│──────────────────────┘
                                        │
                           ┌────────────┴────────────┐
                           │                         │
                    ┌──────▼──────┐          ┌──────▼──────┐
                    │   Prisma    │          │  Shopify    │
                    │   (your DB) │          │  Admin API  │
                    └─────────────┘          └─────────────┘

使用 Loader 获取数据

基本 Loader 模式
// app/routes/app.dashboard.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { authenticate } from "../shopify.server";
import db from "../db.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  // Authenticate and get admin API access
  const { session, admin } = await authenticate.admin(request);
  
  // Fetch from Shopify
  const shopResponse = await admin.graphql(`
    query { shop { name myshopifyDomain } }
  `);
  const { data: shopData } = await shopResponse.json();
  
  // Fetch from your database
  const settings = await db.appSettings.findUnique({
    where: { shop: session.shop }
  });
  
  return json({
    shop: shopData.shop,
    settings
  });
};

export default function Dashboard() {
  const { shop, settings } = useLoaderData<typeof loader>();
  
  return (
    <Page title={`Dashboard - ${shop.name}`}>
      {/* Your UI */}
    </Page>
  );
}
带 URL 参数的 Loader
// app/routes/app.campaigns.$id.tsx
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
  const { session } = await authenticate.admin(request);
  const { id } = params;
  
  const campaign = await db.campaign.findFirst({
    where: { 
      id,
      shop: session.shop 
    }
  });
  
  if (!campaign) {
    throw new Response("Not found", { status: 404 });
  }
  
  return json({ campaign });
};

使用 Action 变更数据

表单提交模式
// app/routes/app.settings.tsx
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { authenticate } from "../shopify.server";

export const action = async ({ request }: ActionFunctionArgs) => {
  const { session } = await authenticate.admin(request);
  const formData = await request.formData();
  
  const intent = formData.get("intent");
  
  if (intent === "updateSettings") {
    const enabled = formData.get("enabled") === "true";
    const message = formData.get("message") as string;
    
    await db.appSettings.upsert({
      where: { shop: session.shop },
      create: { shop: session.shop, enabled, message },
      update: { enabled, message }
    });
    
    return json({ success: true });
  }
  
  return json({ error: "Unknown action" }, { status: 400 });
};

export default function Settings() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";
  
  return (
    <Form method="post">
      <input type="hidden" name="intent" value="updateSettings" />
      {/* Form fields */}
      <Button submit loading={isSubmitting}>
        Save
      </Button>
    </Form>
  );
}

使用 useFetcher 在客户端获取数据

对于不应触发页面导航的操作(行内更新、切换等):

import { useFetcher } from "@remix-run/react";

function CampaignRow({ campaign }) {
  const fetcher = useFetcher();
  const isUpdating = fetcher.state !== "idle";
  
  const toggleStatus = () => {
    fetcher.submit(
      { 
        intent: "toggleStatus",
        campaignId: campaign.id,
        enabled: String(!campaign.enabled)
      },
      { method: "post", action: "/app/campaigns" }
    );
  };
  
  return (
    <ResourceItem id={campaign.id}>
      <Text>{campaign.name}</Text>
      <Button 
        onClick={toggleStatus} 
        loading={isUpdating}
      >
        {campaign.enabled ? "Disable" : "Enable"}
      </Button>
    </ResourceItem>
  );
}

面向扩展/外部服务的 API 路由

经过身份验证的 API 端点
// app/routes/api.widget-config.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  // For app proxy requests or authenticated API calls
  const { session } = await authenticate.admin(request);
  
  const config = await db.widgetConfig.findUnique({
    where: { shop: session.shop }
  });
  
  return json(config);
};

export const action = async ({ request }: ActionFunctionArgs) => {
  const { session } = await authenticate.admin(request);
  const body = await request.json();
  
  // Whitelist allowed fields - never pass raw body to database
  const { theme, position, welcomeMessage } = body;
  
  const updated = await db.widgetConfig.update({
    where: { shop: session.shop },
    data: { theme, position, welcomeMessage }
  });
  
  return json(updated);
};
公共 API(Webhook、回调)
// app/routes/webhooks.tsx
import { type ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";

export const action = async ({ request }: ActionFunctionArgs) => {
  const { topic, shop, payload } = await authenticate.webhook(request);
  
  switch (topic) {
    case "ORDERS_CREATE":
      await handleOrderCreated(shop, payload);
      break;
    case "APP_UNINSTALLED":
      await handleAppUninstalled(shop);
      break;
  }
  
  return new Response();
};

会话处理

在任意路由中获取会话
// Session is available after authenticate.admin()
const { session, admin } = await authenticate.admin(request);

// session contains:
// - session.shop: "store.myshopify.com"
// - session.accessToken: OAuth token
// - session.scope: granted scopes
检查作用域
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { session } = await authenticate.admin(request);
  
  const hasOrdersScope = session.scope?.includes("read_orders");
  
  if (!hasOrdersScope) {
    // Redirect to re-auth or show error
    throw new Response("Missing required scope", { status: 403 });
  }
  
  // Continue...
};

文件结构约定

app/
├── routes/
│   ├── app._index.tsx          # /app (dashboard)
│   ├── app.settings.tsx        # /app/settings
│   ├── app.campaigns._index.tsx # /app/campaigns (list)
│   ├── app.campaigns.$id.tsx   # /app/campaigns/:id (detail)
│   ├── app.campaigns.new.tsx   # /app/campaigns/new (create)
│   ├── api.widget-config.tsx   # /api/widget-config
│   └── webhooks.tsx            # /webhooks
├── components/
│   └── ...
├── shopify.server.ts           # Shopify app config
└── db.server.ts                # Prisma client

最佳实践

  1. 始终进行身份验证 - 对所有应用路由使用 authenticate.admin(request)
  2. 验证所有权 - 按 ID 获取数据时,始终使用 session.shop 进行筛选
  3. 使用 Action 执行变更操作 - 不要在 Loader 中变更数据
  4. 处理加载状态 - 使用 navigation.state 或 fetcher.state
  5. 返回正确的 HTTP 状态码 - 未找到时返回 404,请求错误时返回 400
  6. 为数据添加类型 - 使用 useLoaderData<typeof loader>() 确保类型安全
  7. 验证并清理输入 - 切勿信任用户输入;验证格式并将允许的字段列入白名单
  8. 避免批量赋值 - 切勿将原始请求正文直接传递给数据库;明确选择允许的字段

参考资料

评论

支持 Markdown