Skip to content

Commit e6e4149

Browse files
committed
skill specific to api client development concerns for scapi
1 parent 7433962 commit e6e4149

3 files changed

Lines changed: 405 additions & 1 deletion

File tree

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
---
2+
name: api-client-development
3+
description: Creating API clients with OpenAPI specs, authentication, and OAuth scopes for SCAPI and similar APIs
4+
---
5+
6+
# API Client Development
7+
8+
This skill covers creating typed API clients using OpenAPI specifications, with proper authentication and OAuth scope handling. It builds on the patterns in [SDK Module Development](../sdk-module-development/SKILL.md).
9+
10+
## Overview
11+
12+
API clients in this project use:
13+
- **openapi-fetch**: Type-safe HTTP client generated from OpenAPI specs
14+
- **openapi-typescript**: Generates TypeScript types from OpenAPI specs
15+
- **Middleware pattern**: Auth and logging injected via openapi-fetch middleware
16+
17+
## Creating a New API Client
18+
19+
### 1. Add the OpenAPI Spec
20+
21+
Place the spec in `packages/b2c-tooling-sdk/specs/`:
22+
23+
```
24+
specs/
25+
├── custom-apis-v1.yaml # YAML or JSON
26+
├── slas-admin-v1.yaml
27+
└── ods-api-v1.json
28+
```
29+
30+
### 2. Update Type Generation Script
31+
32+
In `packages/b2c-tooling-sdk/package.json`, add to the generate script:
33+
34+
```json
35+
{
36+
"scripts": {
37+
"generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/newapi-v1.yaml -o src/clients/newapi.generated.ts"
38+
}
39+
}
40+
```
41+
42+
Run generation:
43+
44+
```bash
45+
pnpm --filter @salesforce/b2c-tooling-sdk run generate:types
46+
```
47+
48+
### 3. Create the Client Module
49+
50+
```typescript
51+
// src/clients/newapi.ts
52+
import createClient, {type Client} from 'openapi-fetch';
53+
import type {AuthStrategy} from '../auth/types.js';
54+
import type {paths, components} from './newapi.generated.js';
55+
import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js';
56+
57+
// Re-export generated types for consumers
58+
export type {paths, components};
59+
60+
// Client type alias
61+
export type NewApiClient = Client<paths>;
62+
63+
// Config interface
64+
export interface NewApiClientConfig {
65+
hostname: string;
66+
// Add API-specific config here
67+
}
68+
69+
// Factory function
70+
export function createNewApiClient(
71+
config: NewApiClientConfig,
72+
auth: AuthStrategy
73+
): NewApiClient {
74+
const client = createClient<paths>({
75+
baseUrl: `https://${config.hostname}/api/v1`,
76+
});
77+
78+
// Middleware order: auth first (runs last), logging last (sees complete request)
79+
client.use(createAuthMiddleware(auth));
80+
client.use(createLoggingMiddleware('NEWAPI'));
81+
82+
return client;
83+
}
84+
```
85+
86+
### 4. Export from Clients Barrel
87+
88+
```typescript
89+
// src/clients/index.ts
90+
export {createNewApiClient, type NewApiClient, type NewApiClientConfig} from './newapi.js';
91+
export type {paths as NewApiPaths, components as NewApiComponents} from './newapi.js';
92+
```
93+
94+
---
95+
96+
## SCAPI Client Pattern (OAuth Scope Injection)
97+
98+
SCAPI APIs require specific OAuth scopes. Instead of requiring CLI commands to manage scopes, **encapsulate scope logic in the client factory**.
99+
100+
### The Problem
101+
102+
Without encapsulation, CLI commands leak auth implementation details:
103+
104+
```typescript
105+
// BAD: CLI command manages scopes
106+
class MyCommand extends OAuthCommand {
107+
protected override loadConfiguration(): ResolvedConfig {
108+
const config = super.loadConfiguration();
109+
config.scopes = ['sfcc.custom-apis', `SALESFORCE_COMMERCE_API:${tenantId}`];
110+
return config;
111+
}
112+
}
113+
```
114+
115+
### The Solution
116+
117+
Use `OAuthStrategy.withAdditionalScopes()` in the client factory:
118+
119+
```typescript
120+
// GOOD: Client encapsulates scope requirements
121+
import {OAuthStrategy} from '../auth/oauth.js';
122+
import type {AuthStrategy} from '../auth/types.js';
123+
124+
/** Default OAuth scopes required for this API */
125+
export const MY_API_DEFAULT_SCOPES = ['sfcc.my-api'];
126+
127+
export interface MyApiClientConfig {
128+
shortCode: string;
129+
tenantId: string; // Required for tenant-specific scope
130+
scopes?: string[]; // Optional override
131+
}
132+
133+
export function createMyApiClient(
134+
config: MyApiClientConfig,
135+
auth: AuthStrategy
136+
): MyApiClient {
137+
const client = createClient<paths>({
138+
baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/my-api/v1`,
139+
});
140+
141+
// Build required scopes: domain scope + tenant-specific scope
142+
const requiredScopes = config.scopes ?? [
143+
...MY_API_DEFAULT_SCOPES,
144+
buildTenantScope(config.tenantId),
145+
];
146+
147+
// If OAuth strategy, add required scopes; otherwise use as-is (e.g., for testing)
148+
const scopedAuth = auth instanceof OAuthStrategy
149+
? auth.withAdditionalScopes(requiredScopes)
150+
: auth;
151+
152+
client.use(createAuthMiddleware(scopedAuth));
153+
client.use(createLoggingMiddleware('MY-API'));
154+
155+
return client;
156+
}
157+
```
158+
159+
This pattern:
160+
1. Keeps scope knowledge in the SDK, not the CLI
161+
2. Allows scope override for special cases via `config.scopes`
162+
3. Works with non-OAuth auth strategies (for testing/mocking)
163+
4. CLI commands just pass the auth strategy through unchanged
164+
165+
---
166+
167+
## SCAPI Tenant ID Utilities
168+
169+
SCAPI APIs use an `organizationId` path parameter with the `f_ecom_` prefix, but OAuth scopes use the raw tenant ID. Use these utilities:
170+
171+
```typescript
172+
// From @salesforce/b2c-tooling-sdk (or clients/custom-apis.ts)
173+
import {toOrganizationId, toTenantId, buildTenantScope} from '@salesforce/b2c-tooling-sdk';
174+
175+
// Convert tenant ID to organization ID (adds f_ecom_ prefix)
176+
toOrganizationId('zzxy_prd') // Returns 'f_ecom_zzxy_prd'
177+
toOrganizationId('f_ecom_zzxy_prd') // Returns 'f_ecom_zzxy_prd' (unchanged)
178+
179+
// Extract raw tenant ID (strips f_ecom_ prefix)
180+
toTenantId('f_ecom_zzxy_prd') // Returns 'zzxy_prd'
181+
toTenantId('zzxy_prd') // Returns 'zzxy_prd' (unchanged)
182+
183+
// Build tenant-specific OAuth scope
184+
buildTenantScope('zzxy_prd') // Returns 'SALESFORCE_COMMERCE_API:zzxy_prd'
185+
buildTenantScope('f_ecom_zzxy_prd') // Returns 'SALESFORCE_COMMERCE_API:zzxy_prd'
186+
```
187+
188+
### Constants
189+
190+
```typescript
191+
/** Prefix required for SCAPI organizationId path parameter */
192+
export const ORGANIZATION_ID_PREFIX = 'f_ecom_';
193+
194+
/** Prefix for tenant-specific SCAPI OAuth scopes */
195+
export const SCAPI_TENANT_SCOPE_PREFIX = 'SALESFORCE_COMMERCE_API:';
196+
```
197+
198+
---
199+
200+
## OAuthStrategy.withAdditionalScopes()
201+
202+
The `OAuthStrategy` class has a method for scope injection:
203+
204+
```typescript
205+
// Creates a new OAuthStrategy with merged scopes
206+
const scopedAuth = auth.withAdditionalScopes(['sfcc.custom-apis', 'SALESFORCE_COMMERCE_API:zzxy_prd']);
207+
```
208+
209+
Key behaviors:
210+
- Returns a **new** `OAuthStrategy` instance (immutable pattern)
211+
- Merges scopes with deduplication (uses `Set`)
212+
- The new strategy shares token cache with the original (keyed by clientId)
213+
- If cached token doesn't have required scopes, it re-authenticates
214+
215+
---
216+
217+
## Complete SCAPI Client Example
218+
219+
Reference implementation: `packages/b2c-tooling-sdk/src/clients/custom-apis.ts`
220+
221+
```typescript
222+
/*
223+
* Copyright (c) 2025, Salesforce, Inc.
224+
* SPDX-License-Identifier: Apache-2
225+
*/
226+
import createClient, {type Client} from 'openapi-fetch';
227+
import type {AuthStrategy} from '../auth/types.js';
228+
import {OAuthStrategy} from '../auth/oauth.js';
229+
import type {paths, components} from './custom-apis.generated.js';
230+
import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js';
231+
232+
export type {paths, components};
233+
export type CustomApisClient = Client<paths>;
234+
235+
/** Default OAuth scopes required for Custom APIs */
236+
export const CUSTOM_APIS_DEFAULT_SCOPES = ['sfcc.custom-apis'];
237+
238+
export interface CustomApisClientConfig {
239+
shortCode: string;
240+
tenantId: string;
241+
scopes?: string[];
242+
}
243+
244+
export function createCustomApisClient(
245+
config: CustomApisClientConfig,
246+
auth: AuthStrategy
247+
): CustomApisClient {
248+
const client = createClient<paths>({
249+
baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/dx/custom-apis/v1`,
250+
});
251+
252+
// Build required scopes: domain scope + tenant-specific scope
253+
const requiredScopes = config.scopes ?? [
254+
...CUSTOM_APIS_DEFAULT_SCOPES,
255+
buildTenantScope(config.tenantId),
256+
];
257+
258+
// If OAuth strategy, add required scopes; otherwise use as-is
259+
const scopedAuth = auth instanceof OAuthStrategy
260+
? auth.withAdditionalScopes(requiredScopes)
261+
: auth;
262+
263+
client.use(createAuthMiddleware(scopedAuth));
264+
client.use(createLoggingMiddleware('CUSTOM-APIS'));
265+
266+
return client;
267+
}
268+
269+
// Tenant ID utilities
270+
export const ORGANIZATION_ID_PREFIX = 'f_ecom_';
271+
export const SCAPI_TENANT_SCOPE_PREFIX = 'SALESFORCE_COMMERCE_API:';
272+
273+
export function toOrganizationId(tenantId: string): string {
274+
return tenantId.startsWith(ORGANIZATION_ID_PREFIX)
275+
? tenantId
276+
: `${ORGANIZATION_ID_PREFIX}${tenantId}`;
277+
}
278+
279+
export function toTenantId(value: string): string {
280+
return value.startsWith(ORGANIZATION_ID_PREFIX)
281+
? value.slice(ORGANIZATION_ID_PREFIX.length)
282+
: value;
283+
}
284+
285+
export function buildTenantScope(tenantId: string): string {
286+
return `${SCAPI_TENANT_SCOPE_PREFIX}${toTenantId(tenantId)}`;
287+
}
288+
```
289+
290+
---
291+
292+
## CLI Command Integration
293+
294+
With scope encapsulation in the client, CLI commands become simple:
295+
296+
```typescript
297+
// packages/b2c-cli/src/commands/scapi/custom/status.ts
298+
import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli';
299+
import {createCustomApisClient, toOrganizationId} from '@salesforce/b2c-tooling-sdk';
300+
301+
export default class ScapiCustomStatus extends OAuthCommand<typeof ScapiCustomStatus> {
302+
static flags = {
303+
...OAuthCommand.baseFlags,
304+
'tenant-id': Flags.string({
305+
description: 'Organization/tenant ID',
306+
env: 'SFCC_TENANT_ID',
307+
required: true,
308+
}),
309+
};
310+
311+
async run() {
312+
this.requireOAuthCredentials();
313+
314+
const {'tenant-id': tenantId} = this.flags;
315+
const {shortCode} = this.resolvedConfig;
316+
317+
// Auth strategy from base class - no scope configuration needed!
318+
const oauthStrategy = this.getOAuthStrategy();
319+
320+
// Client handles scope injection internally
321+
const client = createCustomApisClient({shortCode, tenantId}, oauthStrategy);
322+
323+
const {data, error} = await client.GET('/organizations/{organizationId}/endpoints', {
324+
params: {
325+
path: {organizationId: toOrganizationId(tenantId)},
326+
},
327+
});
328+
329+
// Handle response...
330+
}
331+
}
332+
```
333+
334+
---
335+
336+
## Testing API Clients
337+
338+
Use MSW (Mock Service Worker) to mock API responses:
339+
340+
```typescript
341+
import {http, HttpResponse} from 'msw';
342+
import {setupServer} from 'msw/node';
343+
import {createCustomApisClient} from '@salesforce/b2c-tooling-sdk';
344+
345+
const mockAuth: AuthStrategy = {
346+
async fetch(url, init) {
347+
return fetch(url, init);
348+
},
349+
async getAuthorizationHeader() {
350+
return 'Bearer mock-token';
351+
},
352+
};
353+
354+
const server = setupServer(
355+
http.get('https://test.api.commercecloud.salesforce.com/dx/custom-apis/v1/organizations/*/endpoints', () => {
356+
return HttpResponse.json({
357+
data: [{apiName: 'test', status: 'active'}],
358+
total: 1,
359+
limit: 10,
360+
});
361+
})
362+
);
363+
364+
beforeAll(() => server.listen());
365+
afterAll(() => server.close());
366+
367+
it('fetches endpoints', async () => {
368+
const client = createCustomApisClient(
369+
{shortCode: 'test', tenantId: 'zzxy_prd'},
370+
mockAuth
371+
);
372+
373+
const {data} = await client.GET('/organizations/{organizationId}/endpoints', {
374+
params: {path: {organizationId: 'f_ecom_zzxy_prd'}},
375+
});
376+
377+
expect(data?.data).toHaveLength(1);
378+
});
379+
```
380+
381+
---
382+
383+
## Checklist: New SCAPI Client
384+
385+
1. Add OpenAPI spec to `specs/`
386+
2. Update `generate:types` script in `package.json`
387+
3. Run `pnpm --filter @salesforce/b2c-tooling-sdk run generate:types`
388+
4. Create client module with:
389+
- Config interface including `tenantId`
390+
- Default scopes constant
391+
- Factory function with scope injection pattern
392+
- Tenant ID utilities (or import from existing)
393+
5. Export from `src/clients/index.ts`
394+
6. Add to main `src/index.ts` if needed
395+
7. Write tests with MSW mocks
396+
8. Build: `pnpm --filter @salesforce/b2c-tooling-sdk run build`
397+
9. Test: `pnpm --filter @salesforce/b2c-tooling-sdk run test`

0 commit comments

Comments
 (0)