mcpField
S
shopify-app-api-patterns

shopify-app-api-patterns

github.com/tamiror6開発3874 回インストール38 週間前に更新
リポジトリ

shopify-app-api-patterns について

Shopify Remix アプリで、フロントエンドからバックエンドと連携する機能を構築するためのスキルです。認証付きデータ取得、変更処理、セッション管理、API ルートを扱います。

主な機能

  1. 01Remix loader によるデータ取得
  2. 02action によるフォーム変更処理
  3. 03useFetcher によるクライアント更新
  4. 04認証ルートとセッション確認
  5. 05拡張機能や webhook 向け API ルート

活用シーン

  1. 01Shopify やデータベースのデータを取得するページの追加
  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. マスアサインメントを避ける - リクエスト本文をそのままデータベースに渡さず、許可するフィールドを明示的に選択します

参考資料

コメント