Browse Source

feat: unified webshop API response format #53

- Created unified response format for all API endpoints
- Added UnifiedApiResponse type with consistent metadata structure
- Implemented createSuccessResponse, createListResponse, createErrorResponse helpers
- Updated shop-data-api to use unified format across Shopify, WooCommerce, ShopRenter
- All responses now include: success, data, metadata (platform, store_id, resource_type, auth_type, fetched_at, request_id)
- Consistent pagination structure with page, limit, total, has_more, next_page, prev_page
- Standardized error responses with code, message, details
- All platforms return identical response structure regardless of webshop type
Claude 5 months ago
parent
commit
5b8f77cfbf
2 changed files with 315 additions and 125 deletions
  1. 182 0
      supabase/functions/_shared/unified-response.ts
  2. 133 125
      supabase/functions/shop-data-api/index.ts

+ 182 - 0
supabase/functions/_shared/unified-response.ts

@@ -0,0 +1,182 @@
+/**
+ * Unified API Response Format
+ *
+ * Provides consistent response structure across all webshop API endpoints
+ * for Shopify, WooCommerce, and ShopRenter platforms.
+ *
+ * Related: Issue #53 - Unified webshop API response format
+ */
+
+// ============================================================================
+// Response Type Definitions
+// ============================================================================
+
+export interface UnifiedResponseMetadata {
+  /** Platform name (shopify, woocommerce, shoprenter) - only for data endpoints */
+  platform?: string;
+  /** Store UUID - only for data endpoints */
+  store_id?: string;
+  /** Resource type being accessed */
+  resource_type: "customers" | "orders" | "products" | "stores";
+  /** Authentication type used */
+  auth_type: "user" | "internal";
+  /** ISO 8601 timestamp when data was fetched */
+  fetched_at: string;
+  /** Optional request tracking ID */
+  request_id?: string;
+}
+
+export interface UnifiedPagination {
+  /** Current page number (1-indexed) */
+  page: number;
+  /** Items per page limit */
+  limit: number;
+  /** Total count of items (if available from platform) */
+  total?: number;
+  /** Whether there are more items after this page */
+  has_more: boolean;
+  /** Next page number (null if no more pages) */
+  next_page: number | null;
+  /** Previous page number (null if first page) */
+  prev_page: number | null;
+}
+
+export interface UnifiedErrorDetails {
+  /** Error code for programmatic handling */
+  code: string;
+  /** Human-readable error message */
+  message: string;
+  /** Additional error details */
+  details?: unknown;
+}
+
+export interface UnifiedApiResponse<T> {
+  /** Whether the request was successful */
+  success: boolean;
+  /** Response data (single item or array) */
+  data: T | T[];
+  /** Response metadata */
+  metadata: UnifiedResponseMetadata;
+  /** Pagination information (only for list endpoints) */
+  pagination?: UnifiedPagination;
+  /** Error information (only when success=false) */
+  error?: UnifiedErrorDetails;
+}
+
+// ============================================================================
+// Response Builder Functions
+// ============================================================================
+
+/**
+ * Create a successful response for a single item
+ */
+export function createSuccessResponse<T>(
+  data: T,
+  metadata: UnifiedResponseMetadata
+): UnifiedApiResponse<T> {
+  return {
+    success: true,
+    data,
+    metadata: {
+      ...metadata,
+      fetched_at: new Date().toISOString(),
+    },
+  };
+}
+
+/**
+ * Create a successful response for a list of items with pagination
+ */
+export function createListResponse<T>(
+  data: T[],
+  metadata: UnifiedResponseMetadata,
+  pagination: {
+    page: number;
+    limit: number;
+    total?: number;
+    has_more: boolean;
+  }
+): UnifiedApiResponse<T> {
+  const { page, limit, total, has_more } = pagination;
+
+  return {
+    success: true,
+    data,
+    metadata: {
+      ...metadata,
+      fetched_at: new Date().toISOString(),
+    },
+    pagination: {
+      page,
+      limit,
+      total,
+      has_more,
+      next_page: has_more ? page + 1 : null,
+      prev_page: page > 1 ? page - 1 : null,
+    },
+  };
+}
+
+/**
+ * Create an error response
+ */
+export function createErrorResponse(
+  code: string,
+  message: string,
+  details?: unknown,
+  status: number = 500
+): Response {
+  const response: UnifiedApiResponse<never> = {
+    success: false,
+    data: [] as never,
+    metadata: {
+      resource_type: "stores" as const,
+      auth_type: "user",
+      fetched_at: new Date().toISOString(),
+    },
+    error: {
+      code,
+      message,
+      details,
+    },
+  };
+
+  return new Response(JSON.stringify(response), {
+    status,
+    headers: { "Content-Type": "application/json" },
+  });
+}
+
+/**
+ * Create a unified Response object from a unified response
+ */
+export function toHttpResponse<T>(
+  response: UnifiedApiResponse<T>,
+  status: number = 200
+): Response {
+  return new Response(JSON.stringify(response), {
+    status,
+    headers: { "Content-Type": "application/json" },
+  });
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+/**
+ * Calculate pagination has_more based on returned items
+ */
+export function calculateHasMore(
+  returnedCount: number,
+  requestedLimit: number
+): boolean {
+  return returnedCount === requestedLimit;
+}
+
+/**
+ * Generate a unique request ID for tracking
+ */
+export function generateRequestId(): string {
+  return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
+}

+ 133 - 125
supabase/functions/shop-data-api/index.ts

@@ -44,6 +44,15 @@ import {
 } from "../_shared/internal-api-key-auth.ts";
 
 import { getAdapters } from "../_shared/platform-adapters.ts";
+import {
+  createSuccessResponse,
+  createListResponse,
+  createErrorResponse,
+  toHttpResponse,
+  calculateHasMore,
+  generateRequestId,
+  type UnifiedApiResponse,
+} from "../_shared/unified-response.ts";
 
 // Import platform-specific clients
 import * as ShopifyClient from "../_shared/shopify-client.ts";
@@ -52,21 +61,6 @@ import * as ShopRenterClient from "../_shared/shoprenter-client.ts";
 
 const FUNCTION_NAME = "shop-data-api";
 
-interface PaginationMeta {
-  page: number;
-  limit: number;
-  total?: number;
-  has_more?: boolean;
-}
-
-interface ApiResponse {
-  success: boolean;
-  platform?: string;
-  data: unknown;
-  pagination?: PaginationMeta;
-  fetched_at: string;
-}
-
 interface AuthContext {
   type: "user" | "internal";
   userId?: string; // Only for user API keys
@@ -137,10 +131,7 @@ async function handler(req: Request): Promise<Response> {
 
   // Only allow GET requests
   if (req.method !== "GET") {
-    return new Response(
-      JSON.stringify({ error: "Method not allowed" }),
-      { status: 405, headers: { "Content-Type": "application/json" } }
-    );
+    return createErrorResponse("METHOD_NOT_ALLOWED", "Method not allowed", undefined, 405);
   }
 
   // Authenticate request
@@ -157,10 +148,7 @@ async function handler(req: Request): Promise<Response> {
 
   // Expected path: /shop-data-api/{resource}/{id?}
   if (pathParts[0] !== "shop-data-api") {
-    return new Response(
-      JSON.stringify({ error: "Invalid endpoint" }),
-      { status: 404, headers: { "Content-Type": "application/json" } }
-    );
+    return createErrorResponse("INVALID_ENDPOINT", "Invalid endpoint", undefined, 404);
   }
 
   const resource = pathParts[1]; // 'stores', 'customers', 'orders', or 'products'
@@ -180,33 +168,30 @@ async function handler(req: Request): Promise<Response> {
 
   // Validate required parameters
   if (!storeId) {
-    return new Response(
-      JSON.stringify({
-        error: "Missing required parameter: store_id",
-        code: "MISSING_STORE_ID",
-      }),
-      { status: 400, headers: { "Content-Type": "application/json" } }
+    return createErrorResponse(
+      "MISSING_STORE_ID",
+      "Missing required parameter: store_id",
+      undefined,
+      400
     );
   }
 
   if (!["customers", "orders", "products"].includes(resource)) {
-    return new Response(
-      JSON.stringify({
-        error: "Invalid resource. Must be stores, customers, orders, or products",
-        code: "INVALID_RESOURCE",
-      }),
-      { status: 400, headers: { "Content-Type": "application/json" } }
+    return createErrorResponse(
+      "INVALID_RESOURCE",
+      "Invalid resource. Must be stores, customers, orders, or products",
+      undefined,
+      400
     );
   }
 
   // Validate pagination parameters
   if (page < 1 || limit < 1 || limit > 100) {
-    return new Response(
-      JSON.stringify({
-        error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100",
-        code: "INVALID_PAGINATION",
-      }),
-      { status: 400, headers: { "Content-Type": "application/json" } }
+    return createErrorResponse(
+      "INVALID_PAGINATION",
+      "Invalid pagination parameters. Page must be >= 1, limit must be 1-100",
+      undefined,
+      400
     );
   }
 
@@ -225,12 +210,11 @@ async function handler(req: Request): Promise<Response> {
   const { data: store, error: storeError } = await storeQuery.single();
 
   if (storeError || !store) {
-    return new Response(
-      JSON.stringify({
-        error: "Store not found or access denied",
-        code: "STORE_NOT_FOUND",
-      }),
-      { status: 404, headers: { "Content-Type": "application/json" } }
+    return createErrorResponse(
+      "STORE_NOT_FOUND",
+      "Store not found or access denied",
+      undefined,
+      404
     );
   }
 
@@ -240,19 +224,17 @@ async function handler(req: Request): Promise<Response> {
     const dataType = resource.slice(0, -1); // Remove 's' (customers -> customer)
 
     if (!permissions[`allow_${dataType}_access`]) {
-      return new Response(
-        JSON.stringify({
-          error: `Access to ${resource} is not enabled for this store`,
-          code: "ACCESS_DENIED",
-          hint: "Enable access in store settings",
-        }),
-        { status: 403, headers: { "Content-Type": "application/json" } }
+      return createErrorResponse(
+        "ACCESS_DENIED",
+        `Access to ${resource} is not enabled for this store`,
+        { hint: "Enable access in store settings" },
+        403
       );
     }
   }
 
   // Fetch data based on resource and platform
-  let responseData: ApiResponse;
+  let responseData: UnifiedApiResponse<any>;
 
   try {
     switch (store.platform_name.toLowerCase()) {
@@ -263,7 +245,8 @@ async function handler(req: Request): Promise<Response> {
           resourceId,
           page,
           limit,
-          status
+          status,
+          authContext
         );
         break;
       case "woocommerce":
@@ -273,7 +256,8 @@ async function handler(req: Request): Promise<Response> {
           resourceId,
           page,
           limit,
-          status
+          status,
+          authContext
         );
         break;
       case "shoprenter":
@@ -283,35 +267,31 @@ async function handler(req: Request): Promise<Response> {
           resourceId,
           page,
           limit,
-          status
+          status,
+          authContext
         );
         break;
       default:
-        return new Response(
-          JSON.stringify({
-            error: `Unsupported platform: ${store.platform_name}`,
-            code: "UNSUPPORTED_PLATFORM",
-          }),
-          { status: 400, headers: { "Content-Type": "application/json" } }
+        return createErrorResponse(
+          "UNSUPPORTED_PLATFORM",
+          `Unsupported platform: ${store.platform_name}`,
+          undefined,
+          400
         );
     }
 
-    const response = new Response(JSON.stringify(responseData), {
-      status: 200,
-      headers: { "Content-Type": "application/json" },
-    });
+    const response = toHttpResponse(responseData, 200);
 
     // Add rate limit headers
     return addRateLimitHeadersToResponse(response, authContext);
   } catch (error) {
     console.error(`Error fetching ${resource}:`, error);
 
-    return new Response(
-      JSON.stringify({
-        error: error instanceof Error ? error.message : "Failed to fetch data",
-        code: "FETCH_ERROR",
-      }),
-      { status: 500, headers: { "Content-Type": "application/json" } }
+    return createErrorResponse(
+      "FETCH_ERROR",
+      error instanceof Error ? error.message : "Failed to fetch data",
+      error instanceof Error ? { stack: error.stack } : undefined,
+      500
     );
   }
 }
@@ -336,25 +316,31 @@ async function handleListStores(
   const { data: stores, error } = await query;
 
   if (error) {
-    return new Response(
-      JSON.stringify({
-        error: "Failed to fetch stores",
-        code: "FETCH_ERROR",
-      }),
-      { status: 500, headers: { "Content-Type": "application/json" } }
+    return createErrorResponse(
+      "FETCH_ERROR",
+      "Failed to fetch stores",
+      error,
+      500
     );
   }
 
-  return new Response(
-    JSON.stringify({
-      success: true,
-      data: stores,
-      count: stores.length,
+  const response = createListResponse(
+    stores,
+    {
+      resource_type: "stores",
       auth_type: authContext.type,
       fetched_at: new Date().toISOString(),
-    }),
-    { status: 200, headers: { "Content-Type": "application/json" } }
+      request_id: generateRequestId(),
+    },
+    {
+      page: 1,
+      limit: stores.length,
+      total: stores.length,
+      has_more: false,
+    }
   );
+
+  return toHttpResponse(response, 200);
 }
 
 /**
@@ -377,8 +363,9 @@ async function fetchShopifyData(
   resourceId: string | undefined,
   page: number,
   limit: number,
-  status: string | null
-): Promise<ApiResponse> {
+  status: string | null,
+  authContext: AuthContext
+): Promise<UnifiedApiResponse<any>> {
   const adapters = getAdapters("shopify");
 
   if (resourceId) {
@@ -406,12 +393,14 @@ async function fetchShopifyData(
       data = adapters.product(response.product);
     }
 
-    return {
-      success: true,
+    return createSuccessResponse(data, {
       platform: "shopify",
-      data,
+      store_id: store.id,
+      resource_type: resource as any,
+      auth_type: authContext.type,
       fetched_at: new Date().toISOString(),
-    };
+      request_id: generateRequestId(),
+    });
   } else {
     // Fetch list
     const sinceId = page > 1 ? undefined : undefined; // Shopify uses since_id for pagination
@@ -431,17 +420,22 @@ async function fetchShopifyData(
       data = rawData.map((item: any) => adapters.product(item));
     }
 
-    return {
-      success: true,
-      platform: "shopify",
+    return createListResponse(
       data,
-      pagination: {
+      {
+        platform: "shopify",
+        store_id: store.id,
+        resource_type: resource as any,
+        auth_type: authContext.type,
+        fetched_at: new Date().toISOString(),
+        request_id: generateRequestId(),
+      },
+      {
         page,
         limit,
-        has_more: (rawData?.length || 0) === limit,
-      },
-      fetched_at: new Date().toISOString(),
-    };
+        has_more: calculateHasMore(rawData?.length || 0, limit),
+      }
+    );
   }
 }
 
@@ -454,8 +448,9 @@ async function fetchWooCommerceData(
   resourceId: string | undefined,
   page: number,
   limit: number,
-  status: string | null
-): Promise<ApiResponse> {
+  status: string | null,
+  authContext: AuthContext
+): Promise<UnifiedApiResponse<any>> {
   const adapters = getAdapters("woocommerce");
 
   if (resourceId) {
@@ -474,12 +469,14 @@ async function fetchWooCommerceData(
       data = adapters.product(rawData);
     }
 
-    return {
-      success: true,
+    return createSuccessResponse(data, {
       platform: "woocommerce",
-      data,
+      store_id: store.id,
+      resource_type: resource as any,
+      auth_type: authContext.type,
       fetched_at: new Date().toISOString(),
-    };
+      request_id: generateRequestId(),
+    });
   } else {
     // Fetch list
     let data;
@@ -501,17 +498,22 @@ async function fetchWooCommerceData(
       data = rawData.map((item: any) => adapters.product(item));
     }
 
-    return {
-      success: true,
-      platform: "woocommerce",
+    return createListResponse(
       data,
-      pagination: {
+      {
+        platform: "woocommerce",
+        store_id: store.id,
+        resource_type: resource as any,
+        auth_type: authContext.type,
+        fetched_at: new Date().toISOString(),
+        request_id: generateRequestId(),
+      },
+      {
         page,
         limit,
-        has_more: (rawData?.length || 0) === limit,
-      },
-      fetched_at: new Date().toISOString(),
-    };
+        has_more: calculateHasMore(rawData?.length || 0, limit),
+      }
+    );
   }
 }
 
@@ -524,8 +526,9 @@ async function fetchShopRenterData(
   resourceId: string | undefined,
   page: number,
   limit: number,
-  status: string | null
-): Promise<ApiResponse> {
+  status: string | null,
+  authContext: AuthContext
+): Promise<UnifiedApiResponse<any>> {
   const adapters = getAdapters("shoprenter");
 
   if (resourceId) {
@@ -551,17 +554,22 @@ async function fetchShopRenterData(
       data = rawData.map((item: any) => adapters.product(item));
     }
 
-    return {
-      success: true,
-      platform: "shoprenter",
+    return createListResponse(
       data,
-      pagination: {
+      {
+        platform: "shoprenter",
+        store_id: store.id,
+        resource_type: resource as any,
+        auth_type: authContext.type,
+        fetched_at: new Date().toISOString(),
+        request_id: generateRequestId(),
+      },
+      {
         page,
         limit,
-        has_more: (rawData?.length || 0) === limit,
-      },
-      fetched_at: new Date().toISOString(),
-    };
+        has_more: calculateHasMore(rawData?.length || 0, limit),
+      }
+    );
   }
 }