diff --git a/connector-registry/shopify/_meta/README.md b/connector-registry/shopify/_meta/README.md new file mode 100644 index 00000000..2c29865f --- /dev/null +++ b/connector-registry/shopify/_meta/README.md @@ -0,0 +1,6 @@ +# Shopify (Registry) + +This is the top-level registry entry for `shopify`. + +- Versions live under `shopify/{version}` +- See author implementations under `shopify/{version}/{author}` diff --git a/connector-registry/shopify/_meta/connector.json b/connector-registry/shopify/_meta/connector.json new file mode 100644 index 00000000..c575f801 --- /dev/null +++ b/connector-registry/shopify/_meta/connector.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schemas.connector-factory.dev/connector-root.schema.json", + "identifier": "shopify", + "name": "Shopify", + "category": "api", + "tags": ["ecommerce", "retail", "sales"], + "description": "Shopify is a leading e-commerce platform for online stores and retail point-of-sale systems", + "homepage": "https://www.shopify.com" +} diff --git a/connector-registry/shopify/v1/514-labs/_meta/CHANGELOG.md b/connector-registry/shopify/v1/514-labs/_meta/CHANGELOG.md new file mode 100644 index 00000000..3d77df53 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/_meta/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this connector will be documented in this file. + +## 0.1.0 - Initial Implementation + +- Initial scaffold for `shopify` (TypeScript) connector +- Support for Shopify Admin API authentication +- Cursor-based pagination support +- Core resources: products, orders, customers +- GraphQL and REST API support diff --git a/connector-registry/shopify/v1/514-labs/_meta/LICENSE b/connector-registry/shopify/v1/514-labs/_meta/LICENSE new file mode 100644 index 00000000..c872170b --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/_meta/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 514-labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/connector-registry/shopify/v1/514-labs/_meta/README.md b/connector-registry/shopify/v1/514-labs/_meta/README.md new file mode 100644 index 00000000..7e8e0b1d --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/_meta/README.md @@ -0,0 +1,10 @@ +# Shopify Connector (by 514-labs) + +This directory contains language-agnostic metadata and documentation for the `shopify` connector. + +- Name: `shopify` +- Author: `514-labs` +- Category: `api` +- Languages: `typescript` + +See `_meta/connector.json` for connector metadata and implementation folders for language-specific code. diff --git a/connector-registry/shopify/v1/514-labs/_meta/connector.json b/connector-registry/shopify/v1/514-labs/_meta/connector.json new file mode 100644 index 00000000..28befdf7 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/_meta/connector.json @@ -0,0 +1,22 @@ +{ + "name": "shopify", + "author": "514-labs", + "version": "v1", + "languages": [ + "typescript" + ], + "category": "api", + "capabilities": { + "extract": true, + "transform": false, + "load": false + }, + "source": { + "type": "api", + "spec": "https://shopify.dev/docs/api/admin-rest", + "homepage": "https://www.shopify.com" + }, + "tags": ["ecommerce", "retail", "sales", "inventory"], + "maintainers": [], + "issues": { "typescript": { "default": "" } } +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/_meta/connector.json b/connector-registry/shopify/v1/514-labs/typescript/_meta/connector.json new file mode 100644 index 00000000..9da6d3f2 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/_meta/connector.json @@ -0,0 +1,8 @@ +{ + "identifier": "shopify", + "name": "shopify", + "author": "514-labs", + "version": "v1", + "language": "typescript", + "implementations": ["default"] +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/.env.example b/connector-registry/shopify/v1/514-labs/typescript/default/.env.example new file mode 100644 index 00000000..0d3b8e47 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/.env.example @@ -0,0 +1,4 @@ +# Shopify API credentials +SHOPIFY_SHOP_NAME=your-shop-name +SHOPIFY_ACCESS_TOKEN=your-admin-api-access-token +SHOPIFY_API_VERSION=2024-10 diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/.gitignore b/connector-registry/shopify/v1/514-labs/typescript/default/.gitignore new file mode 100644 index 00000000..deed335b --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/README.md b/connector-registry/shopify/v1/514-labs/typescript/default/README.md new file mode 100644 index 00000000..2a55c345 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/README.md @@ -0,0 +1,37 @@ +# Shopify (TypeScript) + +TypeScript implementation for `shopify` by `514-labs`. + +## Quick Start + +```typescript +import { createConnector } from '@workspace/connector-shopify' + +const connector = createConnector() +connector.init({ + shopName: 'your-shop-name', + accessToken: process.env.SHOPIFY_ACCESS_TOKEN!, + apiVersion: '2024-10', +}) + +// Fetch products +for await (const page of connector.products.list({ pageSize: 50 })) { + console.log(`Fetched ${page.length} products`) +} +``` + +## Authentication + +Shopify uses custom access tokens for authentication. You'll need: +- A Shopify store name (e.g., `your-shop-name`) +- An Admin API access token + +See [Shopify Admin API documentation](https://shopify.dev/docs/api/admin-rest) for details on obtaining access tokens. + +## Available Resources + +- `products` - Product management +- `orders` - Order management +- `customers` - Customer management + +See `schemas/index.json` for machine-readable definitions and accompanying Markdown docs. diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/docs/configuration.md b/connector-registry/shopify/v1/514-labs/typescript/default/docs/configuration.md new file mode 100644 index 00000000..eebef7da --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/docs/configuration.md @@ -0,0 +1,65 @@ +# Configuration + +## Required Configuration + +- **shopName**: Your Shopify store name (without `.myshopify.com`) + - Example: If your store URL is `https://my-store.myshopify.com`, use `my-store` +- **accessToken**: Your Shopify Admin API access token + - Obtain from: Shopify Admin → Apps → Develop apps → Create an app → Install → Admin API access token + +## Optional Configuration + +- **apiVersion**: Shopify API version to use (default: `2024-10`) + - Format: `YYYY-MM` (e.g., `2024-10`, `2024-07`) + - See [Shopify API versioning](https://shopify.dev/docs/api/usage/versioning) +- **logging**: Request/response logging configuration + - `enabled`: Enable logging (default: `false`) + - `level`: Log level - `debug`, `info`, `warn`, `error` (default: `info`) + - `includeQueryParams`: Include query parameters in logs (default: `false`) + - `includeHeaders`: Include headers in logs (default: `false`) + - `includeBody`: Include request/response bodies in logs (default: `false`) + - `logger`: Custom logger function (default: `console.log`) +- **metrics**: Metrics collection configuration + - `enabled`: Enable metrics collection (default: `false`) + +## Authentication + +This connector uses Shopify's Admin API access token authentication. The token is sent via the `X-Shopify-Access-Token` header. + +### Obtaining an Access Token + +1. Go to your Shopify Admin +2. Navigate to Apps → Develop apps +3. Click "Create an app" +4. Give your app a name and configure the scopes you need +5. Click "Install app" +6. Copy the Admin API access token + +### Required Scopes + +Depending on which resources you want to access, configure the following scopes: + +- `read_products` - For products resource +- `read_orders` - For orders resource +- `read_customers` - For customers resource + +## Example + +```typescript +import { createConnector } from '@workspace/connector-shopify' + +const connector = createConnector() +connector.init({ + shopName: 'my-store', + accessToken: 'shpat_xxxxxxxxxxxx', + apiVersion: '2024-10', + logging: { + enabled: true, + level: 'info', + includeQueryParams: true, + }, + metrics: { + enabled: true, + }, +}) +``` diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/docs/getting-started.md b/connector-registry/shopify/v1/514-labs/typescript/default/docs/getting-started.md new file mode 100644 index 00000000..c1c2ae9e --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/docs/getting-started.md @@ -0,0 +1,78 @@ +# Getting Started + +## Installation + +```bash +npm install @workspace/connector-shopify +# or +pnpm add @workspace/connector-shopify +``` + +## Basic Usage + +```typescript +import { createConnector } from '@workspace/connector-shopify' + +// Initialize the connector +const connector = createConnector() +connector.init({ + shopName: 'your-shop-name', + accessToken: process.env.SHOPIFY_ACCESS_TOKEN!, + apiVersion: '2024-10', +}) + +// Fetch products +for await (const page of connector.products.list({ pageSize: 50 })) { + for (const product of page) { + console.log(`Product: ${product.title}`) + } +} + +// Get a single product +const product = await connector.products.get(123456789) +console.log(product) + +// Fetch orders +for await (const page of connector.orders.list({ status: 'open' })) { + for (const order of page) { + console.log(`Order ${order.name}: ${order.email}`) + } +} + +// Search customers +const customers = await connector.customers.search({ + query: 'email:john@example.com' +}) +console.log(customers) +``` + +## Authentication + +See [Configuration](./configuration.md) for details on obtaining and using Shopify API credentials. + +## Available Resources + +- **Products** - List, get, and count products +- **Orders** - List, get, and count orders +- **Customers** - List, get, search, and count customers + +## Pagination + +All list methods support cursor-based pagination with the following parameters: + +- `pageSize`: Number of items per page (default: 50, max: 250) +- `maxItems`: Maximum total items to fetch across all pages + +Example: +```typescript +// Fetch 100 products, 50 at a time +for await (const page of connector.products.list({ pageSize: 50, maxItems: 100 })) { + console.log(`Page size: ${page.length}`) +} +``` + +## Rate Limiting + +Shopify enforces rate limits on API requests. The connector does not automatically handle rate limiting, so you may want to implement retry logic or use the built-in retry configuration from `@connector-factory/core`. + +See [Shopify Rate Limits](https://shopify.dev/docs/api/usage/rate-limits) for more information. diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/docs/limits.md b/connector-registry/shopify/v1/514-labs/typescript/default/docs/limits.md new file mode 100644 index 00000000..685fbc4e --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/docs/limits.md @@ -0,0 +1,70 @@ +# Limits + +## Rate Limits + +Shopify implements rate limiting for API requests to ensure fair usage and system stability. + +### REST Admin API Rate Limits + +- **Standard Plan**: 2 requests per second +- **Shopify Plus**: 4 requests per second (can be increased) +- **GraphQL Admin API**: 1000 points per second (cost-based) + +### Leaky Bucket Algorithm + +Shopify uses a "leaky bucket" algorithm for rate limiting: +- Each app gets a bucket that can hold a certain number of requests +- The bucket "leaks" at a constant rate (requests per second) +- When the bucket is full, additional requests are throttled with a 429 status code + +### Response Headers + +Shopify includes rate limit information in response headers: +- `X-Shopify-Shop-Api-Call-Limit`: Current usage / Total limit (e.g., "32/40") +- `Retry-After`: Time in seconds to wait before retrying (on 429 responses) + +### Handling Rate Limits + +The connector doesn't automatically retry on rate limit errors. To handle rate limits: + +1. **Monitor response headers** to track your usage +2. **Implement retry logic** with exponential backoff +3. **Use the core retry configuration**: + +```typescript +connector.initialize({ + shopName: 'your-shop', + accessToken: 'token', + retry: { + maxAttempts: 3, + initialDelayMs: 1000, + maxDelayMs: 10000, + backoffMultiplier: 2, + respectRetryAfter: true, + }, +}) +``` + +### Best Practices + +- Batch requests when possible +- Use cursor-based pagination efficiently +- Monitor the `X-Shopify-Shop-Api-Call-Limit` header +- Implement exponential backoff for retries +- Consider using webhooks instead of polling + +## Data Limits + +### Query Parameters + +- **limit**: Maximum 250 items per page +- **fields**: Limit returned fields to reduce response size + +### Bulk Operations + +For large data exports, consider using Shopify's Bulk Operations API instead of paginating through REST endpoints. + +## References + +- [Shopify API Rate Limits](https://shopify.dev/docs/api/usage/rate-limits) +- [Bulk Operations](https://shopify.dev/docs/api/usage/bulk-operations/queries) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/examples/basic-usage.ts b/connector-registry/shopify/v1/514-labs/typescript/default/examples/basic-usage.ts new file mode 100644 index 00000000..f93345cb --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/examples/basic-usage.ts @@ -0,0 +1,62 @@ +import { createConnector } from '../src' + +async function main() { + const connector = createConnector() + + connector.init({ + shopName: process.env.SHOPIFY_SHOP_NAME || 'your-shop-name', + accessToken: process.env.SHOPIFY_ACCESS_TOKEN || 'your-access-token', + apiVersion: process.env.SHOPIFY_API_VERSION || '2024-10', + logging: { + enabled: true, + level: 'info', + }, + }) + + console.log('Shopify Connector initialized') + + // Example: List products + console.log('\n--- Listing Products ---') + let productCount = 0 + for await (const page of connector.products.list({ pageSize: 10, maxItems: 20 })) { + console.log(`Fetched ${page.length} products`) + for (const product of page) { + console.log(` - ${product.title} (${product.id})`) + productCount++ + } + } + console.log(`Total products fetched: ${productCount}`) + + // Example: Get a single product (uncomment and add a valid ID) + // const product = await connector.products.get(123456789) + // console.log('\n--- Single Product ---') + // console.log(product) + + // Example: List orders + console.log('\n--- Listing Orders ---') + let orderCount = 0 + for await (const page of connector.orders.list({ pageSize: 5, maxItems: 10 })) { + console.log(`Fetched ${page.length} orders`) + for (const order of page) { + console.log(` - Order ${order.name}: ${order.email}`) + orderCount++ + } + } + console.log(`Total orders fetched: ${orderCount}`) + + // Example: List customers + console.log('\n--- Listing Customers ---') + let customerCount = 0 + for await (const page of connector.customers.list({ pageSize: 5, maxItems: 10 })) { + console.log(`Fetched ${page.length} customers`) + for (const customer of page) { + console.log(` - ${customer.first_name} ${customer.last_name}: ${customer.email}`) + customerCount++ + } + } + console.log(`Total customers fetched: ${customerCount}`) + + console.log('\nDone!') +} + +main().catch(console.error) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/jest.config.cjs b/connector-registry/shopify/v1/514-labs/typescript/default/jest.config.cjs new file mode 100644 index 00000000..8f0c8364 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/jest.config.cjs @@ -0,0 +1,4 @@ +module.exports = { + testEnvironment: 'node', + transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }] }, +}; diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/package.json b/connector-registry/shopify/v1/514-labs/typescript/default/package.json new file mode 100644 index 00000000..c1f34f09 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/package.json @@ -0,0 +1,25 @@ +{ + "name": "@workspace/connector-shopify", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "jest --runInBand" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "@connector-factory/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.4.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.30", + "nock": "^13.5.0" + } +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/schemas/index.json b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/index.json new file mode 100644 index 00000000..90bdf304 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/index.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://schemas.connector-factory.dev/schema-index.schema.json", + "version": "0.1.0", + "datasets": [ + { + "name": "products", + "stage": "raw", + "kind": "endpoints", + "path": "raw/json/products.schema.json", + "doc": "raw/json/products.md" + }, + { + "name": "orders", + "stage": "raw", + "kind": "endpoints", + "path": "raw/json/orders.schema.json", + "doc": "raw/json/orders.md" + }, + { + "name": "customers", + "stage": "raw", + "kind": "endpoints", + "path": "raw/json/customers.schema.json", + "doc": "raw/json/customers.md" + } + ] +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/customers.md b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/customers.md new file mode 100644 index 00000000..29b43a7d --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/customers.md @@ -0,0 +1,69 @@ +# Customers + +Customers represent people who have purchased from or created an account in a Shopify store. + +## API Endpoint + +``` +GET /admin/api/2024-10/customers.json +``` + +## Common Queries + +### List all customers +```typescript +for await (const page of connector.customers.list()) { + // Process customers +} +``` + +### Search for a customer by email +```typescript +const customers = await connector.customers.search({ + query: 'email:john@example.com' +}) +``` + +### Search for customers by name +```typescript +const customers = await connector.customers.search({ + query: 'first_name:John last_name:Doe' +}) +``` + +### Get customers created after a date +```typescript +for await (const page of connector.customers.list({ + created_at_min: '2024-01-01T00:00:00Z' +})) { + // Process recent customers +} +``` + +## Key Fields + +- **id**: Unique identifier +- **email**: Customer email address +- **first_name**: Customer first name +- **last_name**: Customer last name +- **accepts_marketing**: Marketing consent status +- **verified_email**: Email verification status +- **addresses**: Customer addresses + +## Search Query Syntax + +The search endpoint supports a powerful query syntax: + +- `email:john@example.com` - Search by email +- `first_name:John` - Search by first name +- `last_name:Doe` - Search by last name +- `phone:555-1234` - Search by phone +- `state:enabled` - Filter by account state +- `tag:VIP` - Filter by tag + +Multiple criteria can be combined with spaces (AND logic). + +## References + +- [Shopify Customer API](https://shopify.dev/docs/api/admin-rest/2024-10/resources/customer) +- [Customer Search Query Syntax](https://shopify.dev/docs/api/admin-rest/2024-10/resources/customer#get-customers-search) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/customers.schema.json b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/customers.schema.json new file mode 100644 index 00000000..ae4ac122 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/customers.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Shopify Customer", + "description": "A customer in a Shopify store", + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Unique identifier for the customer" + }, + "email": { + "type": "string", + "description": "The customer's email address" + }, + "first_name": { + "type": "string", + "description": "The customer's first name" + }, + "last_name": { + "type": "string", + "description": "The customer's last name" + }, + "phone": { + "type": ["string", "null"], + "description": "The customer's phone number" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the customer was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the customer was last modified" + }, + "accepts_marketing": { + "type": "boolean", + "description": "Whether the customer has consented to receive marketing material via email" + }, + "verified_email": { + "type": "boolean", + "description": "Whether the customer has verified their email address" + }, + "state": { + "type": "string", + "description": "The state of the customer's account (enabled, disabled, invited, declined)" + }, + "tags": { + "type": "string", + "description": "Tags that the shop owner has attached to the customer" + }, + "addresses": { + "type": "array", + "description": "A list of addresses for the customer", + "items": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "address1": { "type": "string" }, + "city": { "type": "string" }, + "province": { "type": ["string", "null"] }, + "country": { "type": "string" }, + "zip": { "type": "string" }, + "phone": { "type": ["string", "null"] } + } + } + } + }, + "required": ["id", "email"] +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/orders.md b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/orders.md new file mode 100644 index 00000000..40ff5e48 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/orders.md @@ -0,0 +1,59 @@ +# Orders + +Orders represent customer purchases in a Shopify store. Each order contains line items, customer information, and fulfillment details. + +## API Endpoint + +``` +GET /admin/api/2024-10/orders.json +``` + +## Common Queries + +### List all open orders +```typescript +for await (const page of connector.orders.list({ status: 'open' })) { + // Process orders +} +``` + +### Filter by financial status +```typescript +for await (const page of connector.orders.list({ + financial_status: 'paid' +})) { + // Process paid orders +} +``` + +### Filter by fulfillment status +```typescript +for await (const page of connector.orders.list({ + fulfillment_status: 'unfulfilled' +})) { + // Process unfulfilled orders +} +``` + +### Get recent orders +```typescript +for await (const page of connector.orders.list({ + created_at_min: '2024-01-01T00:00:00Z' +})) { + // Process recent orders +} +``` + +## Key Fields + +- **id**: Unique identifier +- **name**: Order number (e.g., #1001) +- **email**: Customer email +- **financial_status**: Payment status +- **fulfillment_status**: Shipping status +- **line_items**: Products in the order +- **total_price**: Total order value + +## References + +- [Shopify Order API](https://shopify.dev/docs/api/admin-rest/2024-10/resources/order) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/orders.schema.json b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/orders.schema.json new file mode 100644 index 00000000..77e87fc1 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/orders.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Shopify Order", + "description": "An order in a Shopify store", + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Unique identifier for the order" + }, + "name": { + "type": "string", + "description": "The order name (e.g., #1001)" + }, + "email": { + "type": "string", + "description": "The customer's email address" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the order was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the order was last modified" + }, + "financial_status": { + "type": "string", + "enum": ["authorized", "pending", "paid", "partially_paid", "refunded", "voided", "partially_refunded", "unpaid"], + "description": "The status of payments associated with the order" + }, + "fulfillment_status": { + "type": ["string", "null"], + "enum": ["shipped", "partial", "unshipped", "unfulfilled", null], + "description": "The order's status in terms of fulfilled line items" + }, + "currency": { + "type": "string", + "description": "The three-letter code (ISO 4217 format) for the shop currency" + }, + "total_price": { + "type": "string", + "description": "The total price of the order" + }, + "subtotal_price": { + "type": "string", + "description": "The price of the order before shipping and taxes" + }, + "total_tax": { + "type": "string", + "description": "The sum of all the taxes applied to the order" + }, + "line_items": { + "type": "array", + "description": "A list of line item objects", + "items": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "title": { "type": "string" }, + "quantity": { "type": "number" }, + "price": { "type": "string" }, + "sku": { "type": "string" }, + "product_id": { "type": ["number", "null"] }, + "variant_id": { "type": ["number", "null"] } + } + } + } + }, + "required": ["id", "name", "email"] +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/products.md b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/products.md new file mode 100644 index 00000000..cef6c0f6 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/products.md @@ -0,0 +1,54 @@ +# Products + +Products are the goods and services that merchants sell. Each product can have multiple variants (e.g., different sizes or colors). + +## API Endpoint + +``` +GET /admin/api/2024-10/products.json +``` + +## Common Queries + +### List all active products +```typescript +for await (const page of connector.products.list({ status: 'active' })) { + // Process products +} +``` + +### Filter by vendor +```typescript +for await (const page of connector.products.list({ vendor: 'Nike' })) { + // Process products +} +``` + +### Filter by product type +```typescript +for await (const page of connector.products.list({ product_type: 'Shoes' })) { + // Process products +} +``` + +### Get products updated since a date +```typescript +for await (const page of connector.products.list({ + updated_at_min: '2024-01-01T00:00:00Z' +})) { + // Process products +} +``` + +## Key Fields + +- **id**: Unique identifier +- **title**: Product name +- **handle**: URL-friendly unique identifier +- **status**: Product status (active, archived, draft) +- **variants**: Array of product variants with prices and inventory +- **images**: Product images + +## References + +- [Shopify Product API](https://shopify.dev/docs/api/admin-rest/2024-10/resources/product) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/products.schema.json b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/products.schema.json new file mode 100644 index 00000000..b3044d9e --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/schemas/raw/json/products.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Shopify Product", + "description": "A product in a Shopify store", + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Unique identifier for the product" + }, + "title": { + "type": "string", + "description": "The name of the product" + }, + "body_html": { + "type": ["string", "null"], + "description": "The description of the product, complete with HTML markup" + }, + "vendor": { + "type": "string", + "description": "The name of the product's vendor" + }, + "product_type": { + "type": "string", + "description": "The categorization that a product can be tagged with" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the product was created" + }, + "handle": { + "type": "string", + "description": "A unique human-friendly string for the product" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the product was last modified" + }, + "published_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "The date and time when the product was published" + }, + "status": { + "type": "string", + "enum": ["active", "archived", "draft"], + "description": "The status of the product" + }, + "tags": { + "type": "string", + "description": "A string of comma-separated tags used for filtering and search" + }, + "variants": { + "type": "array", + "description": "An array of product variants", + "items": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "product_id": { "type": "number" }, + "title": { "type": "string" }, + "price": { "type": "string" }, + "sku": { "type": "string" }, + "inventory_quantity": { "type": "number" } + } + } + }, + "images": { + "type": "array", + "description": "A list of product images", + "items": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "product_id": { "type": "number" }, + "src": { "type": "string" }, + "alt": { "type": ["string", "null"] } + } + } + } + }, + "required": ["id", "title", "handle"] +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/scripts/postinstall.sh b/connector-registry/shopify/v1/514-labs/typescript/default/scripts/postinstall.sh new file mode 100755 index 00000000..076b92d2 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/scripts/postinstall.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This script runs after your package is installed (pnpm/npm postinstall lifecycle). +# It is intentionally minimal and documented so you can customize it for your distribution needs. +# +# Common use cases: +# - Copy shared core sources into this package for single-file distribution +# - Prune dev-only files from the published artifact +# - Flatten directories for easier consumption +# +# By default, this script does nothing. Uncomment and adapt as needed. + +main() { + echo "postinstall: no-op (customize scripts/postinstall.sh as needed)" + + # Example: flatten src into the package root + # if [ -d src ]; then + # shopt -s dotglob + # mv src/* . || true + # shopt -u dotglob + # rm -rf src + # fi +} + +main "$@" diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/src/client/connector.ts b/connector-registry/shopify/v1/514-labs/typescript/default/src/client/connector.ts new file mode 100644 index 00000000..cb6d0dea --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/src/client/connector.ts @@ -0,0 +1,104 @@ +import { ApiConnectorBase } from '@connector-factory/core' +import type { ConnectorConfig as CoreConfig, Hook } from '@connector-factory/core' +import { createLoggingHooks } from '../observability/logging-hooks' +import { createMetricsHooks, InMemoryMetricsSink } from '../observability/metrics-hooks' +import { createResource as createProductsResource } from '../resources/products' +import { createResource as createOrdersResource } from '../resources/orders' +import { createResource as createCustomersResource } from '../resources/customers' + +export type ShopifyConnectorConfig = { + shopName: string + accessToken: string + apiVersion?: string + logging?: { + enabled?: boolean + level?: 'debug' | 'info' | 'warn' | 'error' + includeQueryParams?: boolean + includeHeaders?: boolean + includeBody?: boolean + logger?: (level: string, event: Record) => void + } + metrics?: { + enabled?: boolean + } +} + +export class Connector extends ApiConnectorBase { + init(userConfig: ShopifyConnectorConfig) { + const apiVersion = userConfig.apiVersion || '2024-10' + const baseUrl = `https://${userConfig.shopName}.myshopify.com/admin/api/${apiVersion}` + + const coreConfig: CoreConfig = { + baseUrl, + userAgent: 'shopify-connector', + defaultHeaders: { + 'X-Shopify-Access-Token': userConfig.accessToken, + 'Content-Type': 'application/json', + }, + auth: { type: 'bearer', bearer: { token: '' } }, // Dummy auth since we use headers + } + + super.initialize(coreConfig, (cfg) => cfg) + + const cfg = (this as any).config as CoreConfig & ShopifyConnectorConfig + const existing = cfg.hooks ?? {} as Partial> + + if (userConfig.logging?.enabled) { + const logging = createLoggingHooks({ + level: userConfig.logging?.level, + includeQueryParams: userConfig.logging?.includeQueryParams, + includeHeaders: userConfig.logging?.includeHeaders, + includeBody: userConfig.logging?.includeBody, + logger: userConfig.logging?.logger as any, + }) + cfg.hooks = { + beforeRequest: [...(existing.beforeRequest ?? []), ...(logging.beforeRequest ?? [])], + afterResponse: [...(existing.afterResponse ?? []), ...(logging.afterResponse ?? [])], + onError: [...(existing.onError ?? []), ...(logging.onError ?? [])], + onRetry: [...(existing.onRetry ?? []), ...(logging.onRetry ?? [])], + } + } + + if (userConfig.metrics?.enabled) { + const sink = new InMemoryMetricsSink() + const metrics = createMetricsHooks(sink) + const curr = cfg.hooks ?? {} + cfg.hooks = { + beforeRequest: [...(curr.beforeRequest ?? []), ...(metrics.beforeRequest ?? [])], + afterResponse: [...(curr.afterResponse ?? []), ...(metrics.afterResponse ?? [])], + onError: [...(curr.onError ?? []), ...(metrics.onError ?? [])], + onRetry: [...(curr.onRetry ?? []), ...(metrics.onRetry ?? [])], + } + ;(this as any)._metricsSink = sink + } + + return this + } + + private get sendLite() { + return async (args: any) => { + const response = await (this as any).request(args) + // Attach headers to response for pagination + return { + ...response, + headers: (response as any).headers || {}, + } + } + } + + get products() { + return createProductsResource(this.sendLite as any) + } + + get orders() { + return createOrdersResource(this.sendLite as any) + } + + get customers() { + return createCustomersResource(this.sendLite as any) + } +} + +export function createConnector() { + return new Connector() +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/src/generated/.gitkeep b/connector-registry/shopify/v1/514-labs/typescript/default/src/generated/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/src/index.ts b/connector-registry/shopify/v1/514-labs/typescript/default/src/index.ts new file mode 100644 index 00000000..90afc37d --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/src/index.ts @@ -0,0 +1,2 @@ +export * from './client/connector' +export * from './resources' diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/src/lib/paginate.ts b/connector-registry/shopify/v1/514-labs/typescript/default/src/lib/paginate.ts new file mode 100644 index 00000000..d19f6156 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/src/lib/paginate.ts @@ -0,0 +1,84 @@ +export type HttpResponseEnvelope = { data: T } + +export type SendFn = (args: { + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + path: string; + query?: Record; + headers?: Record; + body?: unknown; + operation?: string; +}) => Promise> + +/** + * Shopify uses cursor-based pagination with Link headers. + * The API returns pagination info in the Link header with rel="next" and rel="previous". + */ +export async function* paginateCursor(params: { + send: SendFn; + path: string; + query?: Record; + pageSize?: number; + maxItems?: number; + extractItems: (res: any) => T[]; +}): AsyncGenerator> { + const { send, path, query = {}, pageSize = 50, maxItems, extractItems } = params + + let currentPath = path + let currentQuery = { ...query, limit: pageSize } + let totalFetched = 0 + + while (true) { + const remaining = maxItems !== undefined ? maxItems - totalFetched : undefined + if (remaining !== undefined && remaining <= 0) break + + const response = await send({ + method: 'GET', + path: currentPath, + query: currentQuery, + }) + + const items = extractItems(response.data) + if (items.length === 0) break + + const itemsToYield = remaining !== undefined && items.length > remaining + ? items.slice(0, remaining) + : items + + totalFetched += itemsToYield.length + yield itemsToYield + + // Extract next page URL from Link header if available + // Shopify returns: Link: ; rel="next" + const linkHeader = (response as any).headers?.link + if (!linkHeader) break + + const nextLink = parseLinkHeader(linkHeader, 'next') + if (!nextLink) break + + // Extract page_info from the next link + const pageInfo = extractPageInfo(nextLink) + if (!pageInfo) break + + currentQuery = { ...query, limit: pageSize, page_info: pageInfo } as any + } +} + +function parseLinkHeader(header: string, rel: string): string | undefined { + const links = header.split(',') + for (const link of links) { + const match = link.match(/<([^>]+)>;\s*rel="([^"]+)"/) + if (match && match[2] === rel) { + return match[1] + } + } + return undefined +} + +function extractPageInfo(url: string): string | undefined { + try { + const urlObj = new URL(url) + return urlObj.searchParams.get('page_info') || undefined + } catch { + return undefined + } +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/src/observability/logging-hooks.ts b/connector-registry/shopify/v1/514-labs/typescript/default/src/observability/logging-hooks.ts new file mode 100644 index 00000000..c85b7f84 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/src/observability/logging-hooks.ts @@ -0,0 +1,107 @@ +import type { Hook, HookContext } from '@connector-factory/core' + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' +export type LoggingOptions = { + level?: LogLevel + logger?: (level: LogLevel, event: Record) => void + includeQueryParams?: boolean + includeHeaders?: boolean + includeBody?: boolean +} + +const levelOrder: Record = { debug: 10, info: 20, warn: 30, error: 40 } +const shouldLog = (cfg: LoggingOptions, level: LogLevel) => + levelOrder[level] >= levelOrder[cfg.level ?? 'info'] + +function safeUrl(raw?: string, includeQuery = false): { url?: string; query?: Record } { + if (!raw) return {} + try { + const isPathOnly = raw.startsWith('/') + const u = new URL(isPathOnly ? `https://dummy${raw}` : raw) + if (!includeQuery) { + return { url: isPathOnly ? u.pathname : u.toString() } + } + const query: Record = {} + u.searchParams.forEach((v, k) => { + query[k] = v + }) + return { url: isPathOnly ? `${u.pathname}${u.search}` : u.toString(), query } + } catch { + return { url: raw } + } +} + +export function createLoggingHooks( + opts: LoggingOptions = {} +): Partial<{ beforeRequest: Hook[]; afterResponse: Hook[]; onError: Hook[]; onRetry: Hook[] }> { + const logger = + opts.logger ?? ((level: LogLevel, event: Record) => { console.log(level, event) }) + + const beforeRequest: Hook = { + name: 'logging:beforeRequest', + execute: (ctx: HookContext) => { + if (ctx.type !== 'beforeRequest' || !shouldLog(opts, 'info')) return + const rawUrl = ctx.request.url || ctx.request.path + const { url, query } = safeUrl(rawUrl, !!opts.includeQueryParams) + const evt: Record = { + event: 'http_request', + operation: ctx.operation ?? ctx.request.operation, + method: ctx.request.method, + url, + } + if (opts.includeQueryParams && query) evt.query = query + if (opts.includeHeaders) evt.headers = ctx.request.headers + if (opts.includeBody && ctx.request.body !== undefined) evt.body = ctx.request.body + logger('info', evt) + }, + } + + const afterResponse: Hook = { + name: 'logging:afterResponse', + execute: (ctx: HookContext) => { + if (ctx.type !== 'afterResponse' || !shouldLog(opts, 'info')) return + const rawUrl = ctx.request.url || ctx.request.path + const { url } = safeUrl(rawUrl, !!opts.includeQueryParams) + const evt: Record = { + event: 'http_response', + operation: ctx.operation ?? ctx.request.operation, + method: ctx.request.method, + url, + status: ctx.response.status, + durationMs: ctx.response.meta?.durationMs, + retryCount: ctx.response.meta?.retryCount, + } + if (opts.includeHeaders) (evt as any).headers = (ctx.response as any).headers + if (opts.includeBody) (evt as any).body = ctx.response.data + logger('info', evt) + }, + } + + const onError: Hook = { + name: 'logging:onError', + execute: (ctx: HookContext) => { + if (ctx.type !== 'onError' || !shouldLog(opts, 'error')) return + logger('error', { + event: 'http_error', + operation: ctx.operation, + message: (ctx.error as any)?.message, + code: (ctx.error as any)?.code, + statusCode: (ctx.error as any)?.statusCode, + }) + }, + } + + const onRetry: Hook = { + name: 'logging:onRetry', + execute: (ctx: HookContext) => { + if (ctx.type !== 'onRetry' || !shouldLog(opts, 'debug')) return + logger('debug', { + event: 'http_retry', + operation: ctx.metadata.operation ?? ctx.operation, + attempt: ctx.metadata.attempt, + }) + }, + } + + return { beforeRequest: [beforeRequest], afterResponse: [afterResponse], onError: [onError], onRetry: [onRetry] } +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/src/observability/metrics-hooks.ts b/connector-registry/shopify/v1/514-labs/typescript/default/src/observability/metrics-hooks.ts new file mode 100644 index 00000000..d8200c65 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/src/observability/metrics-hooks.ts @@ -0,0 +1,54 @@ +import type { Hook, HookContext } from '@connector-factory/core' + +export type MetricsEvent = + | { type: 'request'; operation?: string } + | { type: 'response'; operation?: string; status?: number; durationMs?: number } + | { type: 'error'; operation?: string; code?: string } + | { type: 'retry'; operation?: string; attempt?: number } + +export interface MetricsSink { record: (event: MetricsEvent) => void } +export class InMemoryMetricsSink implements MetricsSink { + public events: MetricsEvent[] = [] + record(e: MetricsEvent) { this.events.push(e) } +} + +export function createMetricsHooks(sink: MetricsSink) { + const beforeRequest: Hook = { + name: 'metrics:beforeRequest', + execute: (ctx: HookContext) => { + if (ctx.type !== 'beforeRequest') return + sink.record({ type: 'request', operation: ctx.operation ?? ctx.request?.operation }) + }, + } + + const afterResponse: Hook = { + name: 'metrics:afterResponse', + execute: (ctx: HookContext) => { + if (ctx.type !== 'afterResponse') return + sink.record({ + type: 'response', + operation: ctx.operation ?? ctx.request?.operation, + status: ctx.response?.status, + durationMs: ctx.response?.meta?.durationMs, + }) + }, + } + + const onError: Hook = { + name: 'metrics:onError', + execute: (ctx: HookContext) => { + if (ctx.type !== 'onError') return + sink.record({ type: 'error', operation: ctx.operation, code: (ctx.error as any)?.code }) + }, + } + + const onRetry: Hook = { + name: 'metrics:onRetry', + execute: (ctx: HookContext) => { + if (ctx.type !== 'onRetry') return + sink.record({ type: 'retry', operation: ctx.metadata?.operation ?? ctx.operation, attempt: ctx.metadata?.attempt }) + }, + } + + return { beforeRequest: [beforeRequest], afterResponse: [afterResponse], onError: [onError], onRetry: [onRetry] } +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/customers.ts b/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/customers.ts new file mode 100644 index 00000000..02639d1b --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/customers.ts @@ -0,0 +1,112 @@ +import { paginateCursor } from '../lib/paginate' +import type { SendFn } from '../lib/paginate' + +export interface Customer { + id: number + email: string + accepts_marketing: boolean + created_at: string + updated_at: string + first_name: string + last_name: string + state: string + note: string | null + verified_email: boolean + multipass_identifier: string | null + tax_exempt: boolean + phone: string | null + email_marketing_consent: EmailMarketingConsent | null + sms_marketing_consent: SmsMarketingConsent | null + tags: string + currency: string + accepts_marketing_updated_at: string + marketing_opt_in_level: string | null + tax_exemptions: string[] + admin_graphql_api_id: string + default_address?: Address + addresses?: Address[] +} + +export interface EmailMarketingConsent { + state: string + opt_in_level: string + consent_updated_at: string | null +} + +export interface SmsMarketingConsent { + state: string + opt_in_level: string + consent_updated_at: string | null + consent_collected_from: string +} + +export interface Address { + id?: number + customer_id?: number + first_name: string | null + last_name: string | null + company: string | null + address1: string + address2: string | null + city: string + province: string | null + country: string + zip: string + phone: string | null + name: string + province_code: string | null + country_code: string + country_name: string + default: boolean +} + +export interface ListCustomersParams { + ids?: string + limit?: number + since_id?: number + created_at_min?: string + created_at_max?: string + updated_at_min?: string + updated_at_max?: string + fields?: string +} + +export const createResource = (send: SendFn) => ({ + async *list(params?: ListCustomersParams & { pageSize?: number; maxItems?: number }) { + const { pageSize, maxItems, ...filters } = params ?? {} + + yield* paginateCursor({ + send, + path: '/customers.json', + query: filters, + pageSize: pageSize ?? 50, + maxItems, + extractItems: (res: any) => res.customers || [], + }) + }, + + async get(id: number | string): Promise { + const response = await send<{ customer: Customer }>({ + method: 'GET', + path: `/customers/${id}.json`, + }) + return response.data.customer + }, + + async count(): Promise { + const response = await send<{ count: number }>({ + method: 'GET', + path: '/customers/count.json', + }) + return response.data.count + }, + + async search(params: { query: string; limit?: number; fields?: string }): Promise { + const response = await send<{ customers: Customer[] }>({ + method: 'GET', + path: '/customers/search.json', + query: params, + }) + return response.data.customers + }, +}) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/index.ts b/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/index.ts new file mode 100644 index 00000000..52ab583d --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/index.ts @@ -0,0 +1,3 @@ +export { createResource as createProductsResource, Product, ProductVariant, ProductOption, ProductImage, ListProductsParams } from './products' +export { createResource as createOrdersResource, Order, PriceSet, Money, DiscountCode, LineItem, TaxLine, ListOrdersParams } from './orders' +export { createResource as createCustomersResource, Customer, EmailMarketingConsent, SmsMarketingConsent, Address, ListCustomersParams } from './customers' diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/orders.ts b/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/orders.ts new file mode 100644 index 00000000..024c2a07 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/orders.ts @@ -0,0 +1,190 @@ +import { paginateCursor } from '../lib/paginate' +import type { SendFn } from '../lib/paginate' + +export interface Order { + id: number + admin_graphql_api_id: string + app_id: number | null + browser_ip: string | null + buyer_accepts_marketing: boolean + cancel_reason: string | null + cancelled_at: string | null + cart_token: string | null + checkout_id: number | null + checkout_token: string | null + client_details: any | null + closed_at: string | null + company: any | null + confirmation_number: string | null + confirmed: boolean + contact_email: string | null + created_at: string + currency: string + current_subtotal_price: string + current_subtotal_price_set: PriceSet + current_total_additional_fees_set: any | null + current_total_discounts: string + current_total_discounts_set: PriceSet + current_total_duties_set: any | null + current_total_price: string + current_total_price_set: PriceSet + current_total_tax: string + current_total_tax_set: PriceSet + customer_locale: string | null + device_id: number | null + discount_codes: DiscountCode[] + email: string + estimated_taxes: boolean + financial_status: string + fulfillment_status: string | null + landing_site: string | null + landing_site_ref: string | null + line_items: LineItem[] + location_id: number | null + merchant_of_record_app_id: number | null + name: string + note: string | null + note_attributes: any[] + number: number + order_number: number + order_status_url: string + original_total_additional_fees_set: any | null + original_total_duties_set: any | null + payment_gateway_names: string[] + phone: string | null + po_number: string | null + presentment_currency: string + processed_at: string + reference: string | null + referring_site: string | null + source_identifier: string | null + source_name: string + source_url: string | null + subtotal_price: string + subtotal_price_set: PriceSet + tags: string + tax_exempt: boolean + tax_lines: TaxLine[] + taxes_included: boolean + test: boolean + token: string + total_discounts: string + total_discounts_set: PriceSet + total_line_items_price: string + total_line_items_price_set: PriceSet + total_outstanding: string + total_price: string + total_price_set: PriceSet + total_shipping_price_set: PriceSet + total_tax: string + total_tax_set: PriceSet + total_tip_received: string + total_weight: number + updated_at: string + user_id: number | null +} + +export interface PriceSet { + shop_money: Money + presentment_money: Money +} + +export interface Money { + amount: string + currency_code: string +} + +export interface DiscountCode { + code: string + amount: string + type: string +} + +export interface LineItem { + id: number + admin_graphql_api_id: string + attributed_staffs: any[] + current_quantity: number + fulfillable_quantity: number + fulfillment_service: string + fulfillment_status: string | null + gift_card: boolean + grams: number + name: string + price: string + price_set: PriceSet + product_exists: boolean + product_id: number | null + properties: any[] + quantity: number + requires_shipping: boolean + sku: string + taxable: boolean + title: string + total_discount: string + total_discount_set: PriceSet + variant_id: number | null + variant_inventory_management: string | null + variant_title: string | null + vendor: string | null + tax_lines: TaxLine[] + duties: any[] + discount_allocations: any[] +} + +export interface TaxLine { + channel_liable: boolean + price: string + price_set: PriceSet + rate: number + title: string +} + +export interface ListOrdersParams { + ids?: string + limit?: number + since_id?: number + created_at_min?: string + created_at_max?: string + updated_at_min?: string + updated_at_max?: string + processed_at_min?: string + processed_at_max?: string + attribution_app_id?: number + status?: 'open' | 'closed' | 'cancelled' | 'any' + financial_status?: 'authorized' | 'pending' | 'paid' | 'partially_paid' | 'refunded' | 'voided' | 'partially_refunded' | 'unpaid' | 'any' + fulfillment_status?: 'shipped' | 'partial' | 'unshipped' | 'any' | 'unfulfilled' + fields?: string +} + +export const createResource = (send: SendFn) => ({ + async *list(params?: ListOrdersParams & { pageSize?: number; maxItems?: number }) { + const { pageSize, maxItems, ...filters } = params ?? {} + + yield* paginateCursor({ + send, + path: '/orders.json', + query: filters, + pageSize: pageSize ?? 50, + maxItems, + extractItems: (res: any) => res.orders || [], + }) + }, + + async get(id: number | string): Promise { + const response = await send<{ order: Order }>({ + method: 'GET', + path: `/orders/${id}.json`, + }) + return response.data.order + }, + + async count(params?: Pick): Promise { + const response = await send<{ count: number }>({ + method: 'GET', + path: '/orders/count.json', + query: params, + }) + return response.data.count + }, +}) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/products.ts b/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/products.ts new file mode 100644 index 00000000..d4b64055 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/src/resources/products.ts @@ -0,0 +1,127 @@ +import { paginateCursor } from '../lib/paginate' +import type { SendFn } from '../lib/paginate' + +export interface Product { + id: number + title: string + body_html: string | null + vendor: string + product_type: string + created_at: string + handle: string + updated_at: string + published_at: string | null + template_suffix: string | null + published_scope: string + tags: string + status: string + admin_graphql_api_id: string + variants: ProductVariant[] + options: ProductOption[] + images: ProductImage[] + image: ProductImage | null +} + +export interface ProductVariant { + id: number + product_id: number + title: string + price: string + sku: string + position: number + inventory_policy: string + compare_at_price: string | null + fulfillment_service: string + inventory_management: string | null + option1: string | null + option2: string | null + option3: string | null + created_at: string + updated_at: string + taxable: boolean + barcode: string | null + grams: number + image_id: number | null + weight: number + weight_unit: string + inventory_item_id: number + inventory_quantity: number + old_inventory_quantity: number + requires_shipping: boolean + admin_graphql_api_id: string +} + +export interface ProductOption { + id: number + product_id: number + name: string + position: number + values: string[] +} + +export interface ProductImage { + id: number + product_id: number + position: number + created_at: string + updated_at: string + alt: string | null + width: number + height: number + src: string + variant_ids: number[] + admin_graphql_api_id: string +} + +export interface ListProductsParams { + ids?: string + limit?: number + since_id?: number + title?: string + vendor?: string + handle?: string + product_type?: string + status?: 'active' | 'archived' | 'draft' + collection_id?: number + created_at_min?: string + created_at_max?: string + updated_at_min?: string + updated_at_max?: string + published_at_min?: string + published_at_max?: string + published_status?: 'published' | 'unpublished' | 'any' + fields?: string + presentment_currencies?: string +} + +export const createResource = (send: SendFn) => ({ + async *list(params?: ListProductsParams & { pageSize?: number; maxItems?: number }) { + const { pageSize, maxItems, ...filters } = params ?? {} + + yield* paginateCursor({ + send, + path: '/products.json', + query: filters, + pageSize: pageSize ?? 50, + maxItems, + extractItems: (res: any) => res.products || [], + }) + }, + + async get(id: number | string): Promise { + const response = await send<{ product: Product }>({ + method: 'GET', + path: `/products/${id}.json`, + }) + return response.data.product + }, + + async count(params?: Pick): Promise { + const response = await send<{ count: number }>({ + method: 'GET', + path: '/products/count.json', + query: params, + }) + return response.data.count + }, +}) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/tests/customers.test.ts b/connector-registry/shopify/v1/514-labs/typescript/default/tests/customers.test.ts new file mode 100644 index 00000000..6d51ba63 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/tests/customers.test.ts @@ -0,0 +1,80 @@ +/* eslint-env jest */ +// @ts-nocheck +import nock from 'nock' +import { createConnector } from '../src' + +describe('Customers Resource', () => { + const SHOP_NAME = 'test-shop' + const BASE_URL = `https://${SHOP_NAME}.myshopify.com/admin/api/2024-10` + + afterEach(() => { + nock.cleanAll() + }) + + it('lists customers', async () => { + const customers = [ + { id: 1, email: 'customer1@example.com', first_name: 'John', last_name: 'Doe' }, + { id: 2, email: 'customer2@example.com', first_name: 'Jane', last_name: 'Smith' }, + ] + + nock(BASE_URL) + .get('/customers.json') + .query({ limit: 50 }) + .reply(200, { customers }) + + const conn = createConnector() + conn.init({ + shopName: SHOP_NAME, + accessToken: 'test-token', + apiVersion: '2024-10', + }) + + const pages = [] + for await (const page of conn.customers.list()) { + pages.push(page) + } + + expect(pages.length).toBe(1) + expect(pages[0].length).toBe(2) + expect(pages[0][0].email).toBe('customer1@example.com') + }) + + it('gets a single customer', async () => { + nock(BASE_URL) + .get('/customers/789.json') + .reply(200, { + customer: { id: 789, email: 'test@example.com', first_name: 'Test', last_name: 'User' } + }) + + const conn = createConnector() + conn.init({ + shopName: SHOP_NAME, + accessToken: 'test-token', + apiVersion: '2024-10', + }) + + const customer = await conn.customers.get(789) + expect(customer.id).toBe(789) + expect(customer.email).toBe('test@example.com') + }) + + it('searches customers', async () => { + nock(BASE_URL) + .get('/customers/search.json') + .query({ query: 'email:john@example.com' }) + .reply(200, { + customers: [{ id: 1, email: 'john@example.com', first_name: 'John' }] + }) + + const conn = createConnector() + conn.init({ + shopName: SHOP_NAME, + accessToken: 'test-token', + apiVersion: '2024-10', + }) + + const customers = await conn.customers.search({ query: 'email:john@example.com' }) + expect(customers.length).toBe(1) + expect(customers[0].email).toBe('john@example.com') + }) +}) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/tests/orders.test.ts b/connector-registry/shopify/v1/514-labs/typescript/default/tests/orders.test.ts new file mode 100644 index 00000000..8c6bf01f --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/tests/orders.test.ts @@ -0,0 +1,60 @@ +/* eslint-env jest */ +// @ts-nocheck +import nock from 'nock' +import { createConnector } from '../src' + +describe('Orders Resource', () => { + const SHOP_NAME = 'test-shop' + const BASE_URL = `https://${SHOP_NAME}.myshopify.com/admin/api/2024-10` + + afterEach(() => { + nock.cleanAll() + }) + + it('lists orders', async () => { + const orders = [ + { id: 1, name: '#1001', email: 'customer1@example.com' }, + { id: 2, name: '#1002', email: 'customer2@example.com' }, + ] + + nock(BASE_URL) + .get('/orders.json') + .query({ limit: 50 }) + .reply(200, { orders }) + + const conn = createConnector() + conn.init({ + shopName: SHOP_NAME, + accessToken: 'test-token', + apiVersion: '2024-10', + }) + + const pages = [] + for await (const page of conn.orders.list()) { + pages.push(page) + } + + expect(pages.length).toBe(1) + expect(pages[0].length).toBe(2) + expect(pages[0][0].name).toBe('#1001') + }) + + it('gets a single order', async () => { + nock(BASE_URL) + .get('/orders/456.json') + .reply(200, { + order: { id: 456, name: '#1003', email: 'test@example.com' } + }) + + const conn = createConnector() + conn.init({ + shopName: SHOP_NAME, + accessToken: 'test-token', + apiVersion: '2024-10', + }) + + const order = await conn.orders.get(456) + expect(order.id).toBe(456) + expect(order.name).toBe('#1003') + }) +}) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/tests/products.test.ts b/connector-registry/shopify/v1/514-labs/typescript/default/tests/products.test.ts new file mode 100644 index 00000000..9f410803 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/tests/products.test.ts @@ -0,0 +1,100 @@ +/* eslint-env jest */ +// @ts-nocheck +import nock from 'nock' +import { createConnector } from '../src' + +describe('Products Resource', () => { + const SHOP_NAME = 'test-shop' + const BASE_URL = `https://${SHOP_NAME}.myshopify.com/admin/api/2024-10` + + afterEach(() => { + nock.cleanAll() + }) + + it('lists products with pagination', async () => { + const products1 = [ + { id: 1, title: 'Product 1', handle: 'product-1' }, + { id: 2, title: 'Product 2', handle: 'product-2' }, + ] + const products2 = [ + { id: 3, title: 'Product 3', handle: 'product-3' }, + ] + + nock(BASE_URL) + .get('/products.json') + .query({ limit: 2 }) + .reply(200, { products: products1 }, { + 'Link': `<${BASE_URL}/products.json?page_info=page2>; rel="next"` + }) + + nock(BASE_URL) + .get('/products.json') + .query({ limit: 2, page_info: 'page2' }) + .reply(200, { products: products2 }) + + const conn = createConnector() + conn.init({ + shopName: SHOP_NAME, + accessToken: 'test-token', + apiVersion: '2024-10', + }) + + const pages = [] + for await (const page of conn.products.list({ pageSize: 2 })) { + pages.push(page) + } + + expect(pages.length).toBe(2) + expect(pages[0].length).toBe(2) + expect(pages[1].length).toBe(1) + expect(pages[0][0].title).toBe('Product 1') + expect(pages[1][0].title).toBe('Product 3') + }) + + it('gets a single product', async () => { + nock(BASE_URL) + .get('/products/123.json') + .reply(200, { + product: { id: 123, title: 'Test Product', handle: 'test-product' } + }) + + const conn = createConnector() + conn.init({ + shopName: SHOP_NAME, + accessToken: 'test-token', + apiVersion: '2024-10', + }) + + const product = await conn.products.get(123) + expect(product.id).toBe(123) + expect(product.title).toBe('Test Product') + }) + + it('respects maxItems', async () => { + const products = Array.from({ length: 5 }, (_, i) => ({ + id: i + 1, + title: `Product ${i + 1}`, + handle: `product-${i + 1}`, + })) + + nock(BASE_URL) + .get('/products.json') + .query({ limit: 50 }) + .reply(200, { products }) + + const conn = createConnector() + conn.init({ + shopName: SHOP_NAME, + accessToken: 'test-token', + apiVersion: '2024-10', + }) + + const pages = [] + for await (const page of conn.products.list({ maxItems: 3 })) { + pages.push(page) + } + + expect(pages.length).toBe(1) + expect(pages[0].length).toBe(3) + }) +}) diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/tsconfig.json b/connector-registry/shopify/v1/514-labs/typescript/default/tsconfig.json new file mode 100644 index 00000000..920e22a7 --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "declaration": true, + "outDir": "dist", + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "types": ["node", "jest"], + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "examples", "tests"] +} diff --git a/connector-registry/shopify/v1/514-labs/typescript/default/tsconfig.test.json b/connector-registry/shopify/v1/514-labs/typescript/default/tsconfig.test.json new file mode 100644 index 00000000..2c9aa02f --- /dev/null +++ b/connector-registry/shopify/v1/514-labs/typescript/default/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": ".temp-test", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "src", + "tests" + ] +} diff --git a/connector-registry/shopify/v1/_meta/version.json b/connector-registry/shopify/v1/_meta/version.json new file mode 100644 index 00000000..f1f2dcaa --- /dev/null +++ b/connector-registry/shopify/v1/_meta/version.json @@ -0,0 +1,7 @@ +{ + "name": "shopify", + "version": "v1", + "status": "beta", + "releasedAt": "", + "notes": "Shopify Admin API v1 connector with support for products, orders, customers, and more" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e983559..2a198a73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,6 +290,31 @@ importers: specifier: ^5.6.3 version: 5.8.3 + connector-registry/shopify/v1/514-labs/typescript/default: + dependencies: + '@connector-factory/core': + specifier: workspace:* + version: link:../../../../../../packages/core + devDependencies: + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^20.11.30 + version: 20.16.13 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.16.13)(ts-node@10.9.2(@swc/core@1.10.15(@swc/helpers@0.5.15))(@types/node@20.16.13)(typescript@5.8.3)) + nock: + specifier: ^13.5.0 + version: 13.5.6 + ts-jest: + specifier: ^29.1.2 + version: 29.4.4(@babel/core@7.24.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.16.13)(ts-node@10.9.2(@swc/core@1.10.15(@swc/helpers@0.5.15))(@types/node@20.16.13)(typescript@5.8.3)))(typescript@5.8.3) + typescript: + specifier: ^5.4.0 + version: 5.8.3 + examples/quickstart: dependencies: dotenv: