@thorprovider/adapters 4.2.0 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +19 -0
- package/dist/index.d.mts +90 -2
- package/dist/index.d.ts +90 -2
- package/dist/index.js +1066 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1064 -0
- package/dist/index.mjs.map +1 -1
- package/docs/capabilities-system.md +30 -0
- package/docs/providers-overview.md +14 -0
- package/docs/services-overview.md +16 -0
- package/package.json +10 -6
- package/quick-reference.md +1 -0
- package/src/factory/index.ts +4 -0
- package/src/index.ts +3 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/stelorder/client.test-helpers.ts +3 -0
- package/src/providers/stelorder/client.test.ts +58 -0
- package/src/providers/stelorder/client.ts +326 -0
- package/src/providers/stelorder/index.test.ts +48 -0
- package/src/providers/stelorder/index.ts +685 -0
- package/src/providers/stelorder/live-smoke.test.ts +54 -0
- package/src/providers/stelorder/transforms.test.ts +103 -0
- package/src/providers/stelorder/transforms.ts +631 -0
- package/src/types/capabilities.types.ts +51 -0
- package/vitest.config.ts +13 -0
- package/vitest.setup.ts +6 -0
- package/.turbo/turbo-build.log +0 -18
- package/dist/.tsbuildinfo +0 -1
|
@@ -141,3 +141,33 @@ Capabilities are calculated once during provider initialization. Accessing `comm
|
|
|
141
141
|
|
|
142
142
|
- [Providers Overview](providers-overview.md)
|
|
143
143
|
- [Services Overview](services-overview.md)
|
|
144
|
+
|
|
145
|
+
## Per-Provider Capability Matrix
|
|
146
|
+
|
|
147
|
+
### Stel Order (ERP Adapter)
|
|
148
|
+
|
|
149
|
+
| Capability | Value | Notes |
|
|
150
|
+
|------------|-------|-------|
|
|
151
|
+
| `fetchOrders` | ✅ true | Read sales orders via `/salesOrders` |
|
|
152
|
+
| `listPaymentProviders` | ✅ true | List STEL payment options via `/paymentOptions` |
|
|
153
|
+
| `manageAddresses` | ✅ true | Via client main-address (PUT /clients) |
|
|
154
|
+
| `updateProfile` | ❌ false | No customer self-service (ERP admin only) |
|
|
155
|
+
| `updateEmail` | ❌ false | — |
|
|
156
|
+
| `changePasswordDirect` | ❌ false | No auth system |
|
|
157
|
+
| `changePasswordViaEmail` | ❌ false | No auth system |
|
|
158
|
+
| `deleteAccount` | ❌ false | — |
|
|
159
|
+
| `uploadAvatar` | ❌ false | — |
|
|
160
|
+
| `avatarViaMetadata` | ❌ false | — |
|
|
161
|
+
| `customAttributes` | ❌ false | — |
|
|
162
|
+
| `cancelOrder` | ❌ false | ERP orders are read-only from adapter |
|
|
163
|
+
| `defaultAddress` | ❌ false | — |
|
|
164
|
+
| `applyDiscounts` | ❌ false | No cart/checkout |
|
|
165
|
+
| `shippingCalculation` | ❌ false | No cart/checkout |
|
|
166
|
+
| `guestCheckout` | ❌ false | No cart/checkout |
|
|
167
|
+
| `savedPaymentMethods` | ❌ false | — |
|
|
168
|
+
| `regionSpecificPayments` | ❌ false | Single-company ERP |
|
|
169
|
+
| `wishlist` | ❌ false | — |
|
|
170
|
+
| `advancedSearch` | ❌ false | Client-side text filter only |
|
|
171
|
+
| `facetedFilters` | ❌ false | — |
|
|
172
|
+
| `productReviews` | ❌ false | — |
|
|
173
|
+
| `loyaltyProgram` | ❌ false | — |
|
|
@@ -42,6 +42,18 @@ Our primary integration, highly optimized for headless performance.
|
|
|
42
42
|
**Status:** 🚧 In Development (Phase 1)
|
|
43
43
|
Focusing on Storefront API integration for product discovery and cart.
|
|
44
44
|
|
|
45
|
+
### Stel Order
|
|
46
|
+
**Status:** ✅ ERP Adapter (v1)
|
|
47
|
+
Single-company ERP integration for catalog, categories, inventory visibility, customers (STEL clients), sales orders, and payment/delivery option listing.
|
|
48
|
+
|
|
49
|
+
**Scope & Limitations:**
|
|
50
|
+
- **Supported:** Products, categories (aliased as collections), warehouses/inventory, customers, orders, payment methods, delivery options, storefront context
|
|
51
|
+
- **Unsupported:** Cart, checkout, customer auth/profile self-service, discount codes, fulfillment orchestration, recommendations, advanced search
|
|
52
|
+
- **Rate limits:** 60 req/min + low daily limits — designed to minimize API round-trips
|
|
53
|
+
- **Single-company:** No multi-tenant/storefront channel assumptions; `requiresStorefront: false`
|
|
54
|
+
- **No variants:** STEL products are single-SKU; the adapter creates one synthetic variant per product
|
|
55
|
+
- **Client-side search:** STEL has no fulltext search; `getProducts({ query })` filters in-memory after fetch
|
|
56
|
+
|
|
45
57
|
### WooCommerce
|
|
46
58
|
**Status:** 🗓️ Planned
|
|
47
59
|
Targeting the REST API for hybrid headless setups.
|
|
@@ -62,9 +74,11 @@ graph TD
|
|
|
62
74
|
B --> C[Medusa Provider]
|
|
63
75
|
B --> D[Shopify Provider]
|
|
64
76
|
B --> E[Mock Provider]
|
|
77
|
+
B --> F2[Stel Order Provider]
|
|
65
78
|
C --> F[Unified Commerce Interface]
|
|
66
79
|
D --> F
|
|
67
80
|
E --> F
|
|
81
|
+
F2 --> F
|
|
68
82
|
```
|
|
69
83
|
|
|
70
84
|
## Design Decisions
|
|
@@ -63,6 +63,22 @@ Handles customer account management and secure sessions.
|
|
|
63
63
|
- Profile and address management
|
|
64
64
|
- Metadata extensions
|
|
65
65
|
|
|
66
|
+
### 5. ERP Adapters (Stel Order)
|
|
67
|
+
Not all providers are full storefronts. ERP adapters like **Stel Order** expose a subset of services:
|
|
68
|
+
|
|
69
|
+
| Service | Stel Order Support |
|
|
70
|
+
|---------|-------------------|
|
|
71
|
+
| Products & Categories | ✅ Read-only catalog with inventory visibility |
|
|
72
|
+
| Collections | ✅ Aliased from categories |
|
|
73
|
+
| Cart & Checkout | ❌ Not supported (ERP has no cart concept) |
|
|
74
|
+
| Authentication | ❌ Not supported (API-key auth only, no customer login) |
|
|
75
|
+
| Customers (CRM) | ✅ Read + update via STEL clients endpoint |
|
|
76
|
+
| Orders | ✅ Read sales orders; no create/cancel from adapter |
|
|
77
|
+
| Payment Methods | ✅ List STEL payment options |
|
|
78
|
+
| Shipping/Delivery | ✅ List STEL delivery options |
|
|
79
|
+
|
|
80
|
+
Unsupported methods throw `ProviderAPIError` (501) with a clear message explaining the ERP limitation.
|
|
81
|
+
|
|
66
82
|
## Service Interactions
|
|
67
83
|
|
|
68
84
|
```mermaid
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thorprovider/adapters",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.1",
|
|
4
4
|
"description": "Framework-agnostic commerce adapters for Thor Commerce ecosystem - Multi-platform support with unified API",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org",
|
|
@@ -11,16 +11,18 @@
|
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
13
13
|
"types": "./dist/index.d.ts",
|
|
14
|
-
"development": "./
|
|
15
|
-
"import": "./dist/index.
|
|
16
|
-
"require": "./dist/index.js"
|
|
14
|
+
"development": "./dist/index.mjs",
|
|
15
|
+
"import": "./dist/index.mjs",
|
|
16
|
+
"require": "./dist/index.js",
|
|
17
|
+
"default": "./dist/index.mjs"
|
|
17
18
|
}
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
21
|
"build": "tsup",
|
|
21
22
|
"dev": "tsup --watch",
|
|
22
23
|
"type-check": "tsc --noEmit",
|
|
23
|
-
"test": "
|
|
24
|
+
"test": "vitest run --config vitest.config.ts",
|
|
25
|
+
"test:live": "vitest run --config vitest.config.ts src/providers/stelorder/live-smoke.test.ts",
|
|
24
26
|
"generate:agent-index": "node ../../scripts/generate-package-agents-index.cjs packages/adapters @thorprovider/adapters"
|
|
25
27
|
},
|
|
26
28
|
"dependencies": {
|
|
@@ -31,8 +33,10 @@
|
|
|
31
33
|
"jose": "^6.2.2"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|
|
36
|
+
"msw": "^2.12.3",
|
|
34
37
|
"tsup": "^8.0.0",
|
|
35
|
-
"typescript": "^5.0.0"
|
|
38
|
+
"typescript": "^5.0.0",
|
|
39
|
+
"vitest": "^3.2.4"
|
|
36
40
|
},
|
|
37
41
|
"repository": {
|
|
38
42
|
"type": "git",
|
package/quick-reference.md
CHANGED
|
@@ -57,6 +57,7 @@ Framework-agnostic business logic SDK for e-commerce operations. Provides unifie
|
|
|
57
57
|
```
|
|
58
58
|
createProvider() → Provider instance
|
|
59
59
|
├── MedusaProvider (complete implementation)
|
|
60
|
+
├── StelOrderProvider (ERP: catalog, CRM, orders — no cart/checkout/auth)
|
|
60
61
|
├── ShopifyProvider (planned)
|
|
61
62
|
└── Custom providers (extensible)
|
|
62
63
|
```
|
package/src/factory/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { BigCommerceProvider, type BigCommerceProviderConfig } from '../provider
|
|
|
17
17
|
import { WooCommerceProvider, type WooCommerceProviderConfig } from '../providers/woocommerce';
|
|
18
18
|
import { SpreeProvider, type SpreeProviderConfig } from '../providers/spree';
|
|
19
19
|
import { MagentoProvider, type MagentoProviderConfig } from '../providers/magento';
|
|
20
|
+
import { StelOrderProvider, type StelOrderProviderConfig } from '../providers/stelorder';
|
|
20
21
|
import { MockProvider, type MockProviderConfig } from '../providers/mock';
|
|
21
22
|
import { ProviderNotFoundError, ConfigurationError } from '../core/errors';
|
|
22
23
|
import type { ProviderTypeString } from '@thorprovider/types';
|
|
@@ -33,6 +34,7 @@ export type ProviderType = ProviderTypeString;
|
|
|
33
34
|
export type ProviderFactoryConfig =
|
|
34
35
|
| { provider: 'mock'; config: MockProviderConfig }
|
|
35
36
|
| { provider: 'medusa'; config: MedusaProviderConfig }
|
|
37
|
+
| { provider: 'stelorder'; config: StelOrderProviderConfig }
|
|
36
38
|
| { provider: 'shopify'; config: ShopifyProviderConfig }
|
|
37
39
|
| { provider: 'bigcommerce'; config: BigCommerceProviderConfig }
|
|
38
40
|
| { provider: 'woocommerce'; config: WooCommerceProviderConfig }
|
|
@@ -79,6 +81,8 @@ export function createProvider(options: ProviderFactoryConfig | RuntimeProviderC
|
|
|
79
81
|
return new MockProvider(config);
|
|
80
82
|
case 'medusa':
|
|
81
83
|
return new MedusaProvider(config);
|
|
84
|
+
case 'stelorder':
|
|
85
|
+
return new StelOrderProvider(config);
|
|
82
86
|
case 'shopify':
|
|
83
87
|
return new ShopifyProvider(config);
|
|
84
88
|
case 'bigcommerce':
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export {
|
|
|
40
40
|
export {
|
|
41
41
|
MOCK_CAPABILITIES,
|
|
42
42
|
MEDUSA_CAPABILITIES,
|
|
43
|
+
STELORDER_CAPABILITIES,
|
|
43
44
|
SHOPIFY_CAPABILITIES,
|
|
44
45
|
BIGCOMMERCE_CAPABILITIES,
|
|
45
46
|
WOOCOMMERCE_CAPABILITIES,
|
|
@@ -193,6 +194,8 @@ export {
|
|
|
193
194
|
export {
|
|
194
195
|
MedusaProvider,
|
|
195
196
|
type MedusaProviderConfig,
|
|
197
|
+
StelOrderProvider,
|
|
198
|
+
type StelOrderProviderConfig,
|
|
196
199
|
MockProvider,
|
|
197
200
|
type MockProviderConfig,
|
|
198
201
|
ShopifyProvider,
|
package/src/providers/index.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
export { MedusaProvider } from './medusa';
|
|
7
7
|
export type { MedusaProviderConfig } from './medusa';
|
|
8
8
|
|
|
9
|
+
export { StelOrderProvider } from './stelorder';
|
|
10
|
+
export type { StelOrderProviderConfig } from './stelorder';
|
|
11
|
+
|
|
9
12
|
export { ShopifyProvider } from './shopify';
|
|
10
13
|
export type { ShopifyProviderConfig } from './shopify';
|
|
11
14
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { http, HttpResponse } from 'msw'
|
|
3
|
+
import { server } from './client.test-helpers'
|
|
4
|
+
import { StelOrderClient } from './client'
|
|
5
|
+
|
|
6
|
+
describe('StelOrderClient', () => {
|
|
7
|
+
it('normalizes base url and sends APIKEY header', async () => {
|
|
8
|
+
server.use(
|
|
9
|
+
http.get('https://stel.example.com/app/products', ({ request }) => {
|
|
10
|
+
expect(request.headers.get('APIKEY')).toBe('secret')
|
|
11
|
+
expect(request.headers.get('accept')).toBe('application/json')
|
|
12
|
+
return HttpResponse.json([{ id: 1, name: 'P1' }])
|
|
13
|
+
}),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const client = new StelOrderClient({
|
|
17
|
+
baseUrl: 'https://stel.example.com/api/openapi.json',
|
|
18
|
+
apiKey: 'secret',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const products = await client.list<{ id: number; name: string }>('products')
|
|
22
|
+
expect(products).toEqual([{ id: 1, name: 'P1' }])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('supports create update and delete', async () => {
|
|
26
|
+
server.use(
|
|
27
|
+
http.post('https://stel.example.com/app/clients', async ({ request }) => {
|
|
28
|
+
expect(await request.json()).toEqual({ email: 'a@b.com' })
|
|
29
|
+
return HttpResponse.json({ id: 1, email: 'a@b.com' })
|
|
30
|
+
}),
|
|
31
|
+
http.put('https://stel.example.com/app/clients/1', async ({ request }) => {
|
|
32
|
+
expect(await request.json()).toEqual({ email: 'c@d.com' })
|
|
33
|
+
return HttpResponse.json({ id: 1, email: 'c@d.com' })
|
|
34
|
+
}),
|
|
35
|
+
http.delete('https://stel.example.com/app/clients/1', () => HttpResponse.text('', { status: 204 })),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const client = new StelOrderClient({ baseUrl: 'https://stel.example.com', apiKey: 'secret' })
|
|
39
|
+
await expect(client.create('clients', { email: 'a@b.com' })).resolves.toEqual({ id: 1, email: 'a@b.com' })
|
|
40
|
+
await expect(client.update('clients', 1, { email: 'c@d.com' })).resolves.toEqual({ id: 1, email: 'c@d.com' })
|
|
41
|
+
await expect(client.remove('clients', 1)).resolves.toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('throws provider api error on non-ok responses', async () => {
|
|
45
|
+
server.use(
|
|
46
|
+
http.get('https://stel.example.com/app/products/1', () =>
|
|
47
|
+
HttpResponse.json({ message: 'Not found' }, { status: 404 }),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const client = new StelOrderClient({ baseUrl: 'https://stel.example.com', apiKey: 'secret' })
|
|
52
|
+
|
|
53
|
+
await expect(client.getOne('products', 1)).rejects.toMatchObject({
|
|
54
|
+
name: 'ProviderAPIError',
|
|
55
|
+
status: 404,
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { ProviderConfig } from '../../types/provider';
|
|
2
|
+
import { ConfigurationError, ProviderAPIError, extractErrorMessage } from '../../core/errors';
|
|
3
|
+
import { fetchWithRetry, type RetryConfig } from '../../core/fetch-utils';
|
|
4
|
+
import { createLogger, type Logger } from '../../core/logger';
|
|
5
|
+
import { normalizeStelCollection, normalizeStelEntity } from './transforms';
|
|
6
|
+
|
|
7
|
+
type FetchImplementation = (url: string, init?: RequestInit) => Promise<Response>;
|
|
8
|
+
type HttpMethod = 'DELETE' | 'GET' | 'POST' | 'PUT';
|
|
9
|
+
|
|
10
|
+
export type StelQueryValue =
|
|
11
|
+
| string
|
|
12
|
+
| number
|
|
13
|
+
| boolean
|
|
14
|
+
| null
|
|
15
|
+
| undefined
|
|
16
|
+
| Array<string | number | boolean>;
|
|
17
|
+
|
|
18
|
+
export type StelQueryParams = Record<string, StelQueryValue>;
|
|
19
|
+
|
|
20
|
+
export interface StelOrderProviderConfig extends ProviderConfig {
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
publishableKey?: string;
|
|
24
|
+
currencyCode?: string;
|
|
25
|
+
companyName?: string;
|
|
26
|
+
storefrontId?: string;
|
|
27
|
+
retry?: RetryConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type RequiredRetryConfig = Required<RetryConfig>;
|
|
31
|
+
|
|
32
|
+
const DEFAULT_RETRY_CONFIG: RequiredRetryConfig = {
|
|
33
|
+
maxRetries: 2,
|
|
34
|
+
initialDelayMs: 300,
|
|
35
|
+
backoffMultiplier: 2,
|
|
36
|
+
maxDelayMs: 5000,
|
|
37
|
+
timeoutMs: 10000,
|
|
38
|
+
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function sleep(ms: number): Promise<void> {
|
|
42
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function calculateDelay(attempt: number, retryConfig: RequiredRetryConfig): number {
|
|
46
|
+
const delay = retryConfig.initialDelayMs * Math.pow(retryConfig.backoffMultiplier, attempt);
|
|
47
|
+
return Math.min(delay, retryConfig.maxDelayMs);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isRetryableError(error: unknown, retryConfig: RequiredRetryConfig): boolean {
|
|
51
|
+
if (error instanceof ProviderAPIError) {
|
|
52
|
+
return typeof error.status === 'number' && retryConfig.retryableStatusCodes.includes(error.status);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!(error instanceof Error)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const message = error.message.toLowerCase();
|
|
60
|
+
return (
|
|
61
|
+
error.name === 'AbortError' ||
|
|
62
|
+
error.name === 'TimeoutError' ||
|
|
63
|
+
message.includes('fetch failed') ||
|
|
64
|
+
message.includes('timeout') ||
|
|
65
|
+
message.includes('econnrefused') ||
|
|
66
|
+
message.includes('etimedout')
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createRetryingFetch(
|
|
71
|
+
fetchImplementation: FetchImplementation,
|
|
72
|
+
retryConfig: RequiredRetryConfig,
|
|
73
|
+
logger: Logger,
|
|
74
|
+
): FetchImplementation {
|
|
75
|
+
return async (url: string, init?: RequestInit): Promise<Response> => {
|
|
76
|
+
let lastError: unknown = new Error('Unknown STEL Order request error');
|
|
77
|
+
|
|
78
|
+
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt += 1) {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timeoutId = setTimeout(() => controller.abort(), retryConfig.timeoutMs);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetchImplementation(url, {
|
|
84
|
+
...init,
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
clearTimeout(timeoutId);
|
|
89
|
+
|
|
90
|
+
if (!response.ok && retryConfig.retryableStatusCodes.includes(response.status)) {
|
|
91
|
+
throw new ProviderAPIError(
|
|
92
|
+
`STEL Order API error: ${response.status} ${response.statusText}`,
|
|
93
|
+
'stelorder',
|
|
94
|
+
response.status,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return response;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
clearTimeout(timeoutId);
|
|
101
|
+
lastError = error;
|
|
102
|
+
|
|
103
|
+
if (attempt >= retryConfig.maxRetries || !isRetryableError(error, retryConfig)) {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const delay = calculateDelay(attempt, retryConfig);
|
|
108
|
+
logger.warn('STEL request failed, retrying.', {
|
|
109
|
+
url,
|
|
110
|
+
attempt: attempt + 1,
|
|
111
|
+
maxAttempts: retryConfig.maxRetries + 1,
|
|
112
|
+
nextRetryIn: delay,
|
|
113
|
+
error: extractErrorMessage(error),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await sleep(delay);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw lastError;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function parseErrorResponse(response: Response): Promise<string> {
|
|
125
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
if (contentType.includes('application/json')) {
|
|
129
|
+
const body = (await response.json()) as unknown;
|
|
130
|
+
const message = extractErrorMessage(body);
|
|
131
|
+
if (message && message !== 'An unknown error occurred') {
|
|
132
|
+
return message;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Ignore body parsing issues and fall back to text/status.
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const text = await response.text();
|
|
141
|
+
if (text.trim()) {
|
|
142
|
+
return text.trim();
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Ignore body parsing issues and fall back to status.
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return `STEL Order API error: ${response.status} ${response.statusText}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function normalizeStelApiBaseUrl(baseUrl: string): string {
|
|
152
|
+
const trimmed = baseUrl.trim();
|
|
153
|
+
|
|
154
|
+
if (!trimmed) {
|
|
155
|
+
throw new ConfigurationError('Stel Order baseUrl is required.');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const normalized = trimmed
|
|
159
|
+
.replace(/\/api\/openapi\.json$/i, '')
|
|
160
|
+
.replace(/\/+$/, '');
|
|
161
|
+
|
|
162
|
+
return normalized.endsWith('/app') ? normalized : `${normalized}/app`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface StelRequestOptions {
|
|
166
|
+
method: HttpMethod;
|
|
167
|
+
path: string;
|
|
168
|
+
query?: StelQueryParams;
|
|
169
|
+
body?: unknown;
|
|
170
|
+
headers?: HeadersInit;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class StelOrderClient {
|
|
174
|
+
readonly apiBaseUrl: string;
|
|
175
|
+
|
|
176
|
+
private readonly apiKey: string;
|
|
177
|
+
private readonly logger: Logger;
|
|
178
|
+
private readonly retryConfig: RequiredRetryConfig;
|
|
179
|
+
private readonly fetchImplementation?: FetchImplementation;
|
|
180
|
+
|
|
181
|
+
constructor(config: StelOrderProviderConfig) {
|
|
182
|
+
const apiKey = config.apiKey ?? config.publishableKey;
|
|
183
|
+
|
|
184
|
+
if (!apiKey) {
|
|
185
|
+
throw new ConfigurationError(
|
|
186
|
+
'Stel Order API key is required. Pass `apiKey` or `publishableKey` to createProvider().',
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
this.apiBaseUrl = normalizeStelApiBaseUrl(config.baseUrl);
|
|
191
|
+
this.apiKey = apiKey;
|
|
192
|
+
this.logger = createLogger('StelOrderClient', config.debug);
|
|
193
|
+
this.retryConfig = {
|
|
194
|
+
...DEFAULT_RETRY_CONFIG,
|
|
195
|
+
...config.retry,
|
|
196
|
+
};
|
|
197
|
+
this.fetchImplementation = config.customFetch
|
|
198
|
+
? createRetryingFetch(config.customFetch, this.retryConfig, this.logger)
|
|
199
|
+
: undefined;
|
|
200
|
+
|
|
201
|
+
this.logger.info('Initializing Stel Order client', {
|
|
202
|
+
apiBaseUrl: this.apiBaseUrl,
|
|
203
|
+
retryConfig: this.retryConfig,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async list<T>(resource: string, query?: StelQueryParams): Promise<T[]> {
|
|
208
|
+
const payload = await this.request<T | T[]>({
|
|
209
|
+
method: 'GET',
|
|
210
|
+
path: resource,
|
|
211
|
+
query,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return normalizeStelCollection(payload);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async getOne<T>(resource: string, id: string | number, query?: StelQueryParams): Promise<T | undefined> {
|
|
218
|
+
const payload = await this.request<T | T[]>({
|
|
219
|
+
method: 'GET',
|
|
220
|
+
path: `${resource}/${id}`,
|
|
221
|
+
query,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return normalizeStelEntity(payload);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async create<TResponse, TBody = unknown>(resource: string, body: TBody): Promise<TResponse> {
|
|
228
|
+
return this.request<TResponse>({
|
|
229
|
+
method: 'POST',
|
|
230
|
+
path: resource,
|
|
231
|
+
body,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async update<TResponse, TBody = unknown>(resource: string, id: string | number, body: TBody): Promise<TResponse> {
|
|
236
|
+
return this.request<TResponse>({
|
|
237
|
+
method: 'PUT',
|
|
238
|
+
path: `${resource}/${id}`,
|
|
239
|
+
body,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async remove(resource: string, id: string | number): Promise<void> {
|
|
244
|
+
await this.request<void>({
|
|
245
|
+
method: 'DELETE',
|
|
246
|
+
path: `${resource}/${id}`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private buildUrl(path: string, query?: StelQueryParams): string {
|
|
251
|
+
const normalizedPath = path.replace(/^\/+/, '');
|
|
252
|
+
const url = new URL(`${this.apiBaseUrl}/${normalizedPath}`);
|
|
253
|
+
|
|
254
|
+
if (!query) {
|
|
255
|
+
return url.toString();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const [key, value] of Object.entries(query)) {
|
|
259
|
+
if (value === undefined || value === null || value === '') {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (Array.isArray(value)) {
|
|
264
|
+
url.searchParams.set(key, value.map(String).join(','));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
url.searchParams.set(key, String(value));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return url.toString();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private buildHeaders(headers?: HeadersInit, hasBody: boolean = false): Headers {
|
|
275
|
+
const normalizedHeaders = new Headers(headers);
|
|
276
|
+
|
|
277
|
+
normalizedHeaders.set('Accept', 'application/json');
|
|
278
|
+
normalizedHeaders.set('APIKEY', this.apiKey);
|
|
279
|
+
|
|
280
|
+
if (hasBody && !normalizedHeaders.has('Content-Type')) {
|
|
281
|
+
normalizedHeaders.set('Content-Type', 'application/json');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return normalizedHeaders;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async executeFetch(url: string, init: RequestInit): Promise<Response> {
|
|
288
|
+
if (this.fetchImplementation) {
|
|
289
|
+
return this.fetchImplementation(url, init);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return fetchWithRetry(url, init, this.retryConfig);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async request<TResponse>(options: StelRequestOptions): Promise<TResponse> {
|
|
296
|
+
const url = this.buildUrl(options.path, options.query);
|
|
297
|
+
const hasBody = options.body !== undefined;
|
|
298
|
+
|
|
299
|
+
this.logger.debug('STEL request', {
|
|
300
|
+
method: options.method,
|
|
301
|
+
url,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const response = await this.executeFetch(url, {
|
|
305
|
+
method: options.method,
|
|
306
|
+
headers: this.buildHeaders(options.headers, hasBody),
|
|
307
|
+
body: hasBody ? JSON.stringify(options.body) : undefined,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (!response.ok) {
|
|
311
|
+
const message = await parseErrorResponse(response);
|
|
312
|
+
throw new ProviderAPIError(message, 'stelorder', response.status);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (response.status === 204) {
|
|
316
|
+
return undefined as TResponse;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
320
|
+
if (contentType.includes('application/json')) {
|
|
321
|
+
return (await response.json()) as TResponse;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return (await response.text()) as TResponse;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { http, HttpResponse } from 'msw'
|
|
3
|
+
import { server } from './client.test-helpers'
|
|
4
|
+
import { StelOrderProvider } from './index'
|
|
5
|
+
|
|
6
|
+
describe('StelOrderProvider', () => {
|
|
7
|
+
it('returns storefront context and supports read methods', async () => {
|
|
8
|
+
server.use(
|
|
9
|
+
http.get('https://stel.example.com/app/products', () =>
|
|
10
|
+
HttpResponse.json([{ id: 10, name: 'Test Product', 'full-reference': 'TP-10', 'sales-price': 10 }]),
|
|
11
|
+
),
|
|
12
|
+
http.get('https://stel.example.com/app/productCategories', () =>
|
|
13
|
+
HttpResponse.json([{ id: 2, name: 'Hardware', slug: 'hardware' }]),
|
|
14
|
+
),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const provider = new StelOrderProvider({
|
|
18
|
+
baseUrl: 'https://stel.example.com',
|
|
19
|
+
apiKey: 'secret',
|
|
20
|
+
companyName: 'Test Co',
|
|
21
|
+
storefrontId: 'stel-1',
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const context = await provider.getStorefrontContext()
|
|
25
|
+
expect(context.platformType).toBe('stelorder')
|
|
26
|
+
expect(context.name).toBe('Test Co')
|
|
27
|
+
|
|
28
|
+
await expect(provider.getProducts()).resolves.toHaveLength(1)
|
|
29
|
+
await expect(provider.getCategories()).resolves.toHaveLength(1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('throws clear errors for unsupported workflows', async () => {
|
|
33
|
+
const provider = new StelOrderProvider({
|
|
34
|
+
baseUrl: 'https://stel.example.com',
|
|
35
|
+
apiKey: 'secret',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await expect(provider.createCart()).rejects.toMatchObject({
|
|
39
|
+
name: 'ProviderAPIError',
|
|
40
|
+
status: 501,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
await expect(provider.completeCheckout('cart-1')).rejects.toMatchObject({
|
|
44
|
+
name: 'ProviderAPIError',
|
|
45
|
+
status: 501,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
})
|