Skip to content

Commit b26ebeb

Browse files
Add API Browser with Swagger UI for VS Code extension (#244)
* Add `slas token` command for retrieving SLAS shopper access tokens Supports public (PKCE) and private (client_credentials) client flows, guest and registered customer authentication, and auto-discovery of public SLAS clients via the admin API. * api browser wip * Fix API Browser: CORS proxy, auth, parameter prefill, and UX improvements - Proxy API requests through extension host to avoid CORS in webview - Fix OAuth2 preauthorize payload format (lock icons now turn green) - Fix parameter prefill for $ref schemas (replace schema instead of adding sibling) - Pre-fill both organizationId and siteId from config - Acquire token before rendering and embed via onComplete callback - Extract required scopes from spec and include tenant scope for Admin APIs - Add scopes option to CreateOAuthOptions in SDK for scope-aware token requests - Expand custom_properties when fetching SCAPI schemas - Defer tree view loading until user clicks "Load APIs" - Remove transparent background overrides to fix Swagger UI code block readability - Align SLAS token logging with Auth module conventions ([SLAS REQ/RESP] format) * Add changeset for SDK scopes option and SLAS logging * Include b2c-vs-extension in changeset --------- Co-authored-by: amit-kumar8-sf <amit.kumar.sf1408@gmail.com>
1 parent 50acc01 commit b26ebeb

13 files changed

Lines changed: 1179 additions & 1400 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@salesforce/b2c-tooling-sdk': minor
3+
'b2c-vs-extension': minor
4+
---
5+
6+
Add API Browser with Swagger UI for interactive SCAPI exploration. Proxy requests through extension host to avoid CORS, pre-fill parameters and auth tokens, and expand custom properties in schemas.

packages/b2c-tooling-sdk/src/config/resolved-config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,14 @@ export class ResolvedConfigImpl implements ResolvedB2CConfig {
7373
if (!this.hasOAuthConfig()) {
7474
throw new Error('OAuth requires clientId');
7575
}
76+
const configScopes = this.values.scopes ?? [];
77+
const additionalScopes = options?.scopes ?? [];
78+
const mergedScopes =
79+
additionalScopes.length > 0 ? [...new Set([...configScopes, ...additionalScopes])] : configScopes;
7680
const credentials: AuthCredentials = {
7781
clientId: this.values.clientId,
7882
clientSecret: this.values.clientSecret,
79-
scopes: this.values.scopes,
83+
scopes: mergedScopes.length > 0 ? mergedScopes : undefined,
8084
accountManagerHost: this.values.accountManagerHost,
8185
};
8286
return resolveAuthStrategy(credentials, {allowedMethods: options?.allowedMethods});

packages/b2c-tooling-sdk/src/config/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ export interface ConfigSource {
306306
export interface CreateOAuthOptions {
307307
/** Allowed OAuth methods (default: ['client-credentials', 'implicit']) */
308308
allowedMethods?: AuthMethod[];
309+
/** Additional OAuth scopes to request beyond those in config */
310+
scopes?: string[];
309311
}
310312

311313
/**

packages/b2c-tooling-sdk/src/slas/token.ts

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,40 @@ async function checkResponse(response: Response, context: string): Promise<void>
5757
throw new Error(`SLAS ${context} failed (HTTP ${response.status})${detail}`);
5858
}
5959

60+
/**
61+
* Helper to collect response headers as a plain object for logging.
62+
*/
63+
function serializeHeaders(response: Response): Record<string, string> {
64+
const headers: Record<string, string> = {};
65+
response.headers.forEach((value, key) => {
66+
headers[key] = value;
67+
});
68+
return headers;
69+
}
70+
71+
/**
72+
* Performs a logged fetch request following the Auth logging conventions.
73+
* Returns the response (caller must check status).
74+
*/
75+
async function loggedFetch(url: string, init: RequestInit): Promise<{response: Response; duration: number}> {
76+
const logger = getLogger();
77+
const method = init.method ?? 'GET';
78+
79+
logger.debug({method, url}, `[SLAS REQ] ${method} ${url}`);
80+
logger.trace({method, url, headers: init.headers, body: init.body?.toString()}, `[SLAS REQ BODY] ${method} ${url}`);
81+
82+
const startTime = Date.now();
83+
const response = await fetch(url, init);
84+
const duration = Date.now() - startTime;
85+
86+
logger.debug(
87+
{method, url, status: response.status, duration},
88+
`[SLAS RESP] ${method} ${url} ${response.status} ${duration}ms`,
89+
);
90+
91+
return {response, duration};
92+
}
93+
6094
/**
6195
* Retrieves a guest shopper access token from SLAS.
6296
*
@@ -73,7 +107,7 @@ export async function getGuestToken(config: SlasTokenConfig): Promise<SlasTokenR
73107
return getPrivateClientGuestToken(config);
74108
}
75109

76-
logger.debug('Using public client PKCE guest flow');
110+
logger.debug({clientId: config.slasClientId}, '[SLAS] Using public client PKCE guest flow');
77111

78112
const baseUrl = buildBaseUrl(config.shortCode, config.organizationId);
79113
const verifier = generateCodeVerifier();
@@ -89,11 +123,12 @@ export async function getGuestToken(config: SlasTokenConfig): Promise<SlasTokenR
89123
});
90124

91125
const authorizeUrl = `${baseUrl}/oauth2/authorize?${authorizeParams.toString()}`;
92-
logger.debug({url: authorizeUrl}, 'SLAS authorize request');
93126

94-
const authorizeResponse = await fetch(authorizeUrl, {redirect: 'manual'});
127+
const {response: authorizeResponse} = await loggedFetch(authorizeUrl, {redirect: 'manual'});
95128

96129
if (authorizeResponse.status !== 303) {
130+
const respHeaders = serializeHeaders(authorizeResponse);
131+
logger.trace({headers: respHeaders}, `[SLAS RESP BODY] GET ${authorizeUrl}`);
97132
await checkResponse(authorizeResponse, 'authorize');
98133
throw new Error(`Expected 303 redirect from SLAS authorize, got ${authorizeResponse.status}`);
99134
}
@@ -104,7 +139,7 @@ export async function getGuestToken(config: SlasTokenConfig): Promise<SlasTokenR
104139
}
105140

106141
const {code, usid} = parseRedirectCode(location);
107-
logger.debug({usid}, 'Got authorization code from SLAS');
142+
logger.debug({usid}, '[SLAS] Got authorization code');
108143

109144
// Step 2: Exchange code for token
110145
const tokenBody = new URLSearchParams({
@@ -117,22 +152,27 @@ export async function getGuestToken(config: SlasTokenConfig): Promise<SlasTokenR
117152
usid,
118153
});
119154

120-
const tokenResponse = await fetch(`${baseUrl}/oauth2/token`, {
155+
const tokenUrl = `${baseUrl}/oauth2/token`;
156+
const {response: tokenResponse} = await loggedFetch(tokenUrl, {
121157
method: 'POST',
122158
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
123159
body: tokenBody,
124160
});
125161

126162
await checkResponse(tokenResponse, 'token exchange (authorization_code_pkce)');
127-
return (await tokenResponse.json()) as SlasTokenResponse;
163+
const data = (await tokenResponse.json()) as SlasTokenResponse;
164+
const respHeaders = serializeHeaders(tokenResponse);
165+
logger.trace({headers: respHeaders, body: data}, `[SLAS RESP BODY] POST ${tokenUrl}`);
166+
167+
return data;
128168
}
129169

130170
/**
131171
* Private client guest token via client_credentials grant.
132172
*/
133173
async function getPrivateClientGuestToken(config: SlasTokenConfig): Promise<SlasTokenResponse> {
134174
const logger = getLogger();
135-
logger.debug('Using private client client_credentials guest flow');
175+
logger.debug({clientId: config.slasClientId}, '[SLAS] Using private client client_credentials guest flow');
136176

137177
const baseUrl = buildBaseUrl(config.shortCode, config.organizationId);
138178
const basicAuth = Buffer.from(`${config.slasClientId}:${config.slasClientSecret}`).toString('base64');
@@ -142,7 +182,8 @@ async function getPrivateClientGuestToken(config: SlasTokenConfig): Promise<Slas
142182
channel_id: config.siteId,
143183
});
144184

145-
const tokenResponse = await fetch(`${baseUrl}/oauth2/token`, {
185+
const tokenUrl = `${baseUrl}/oauth2/token`;
186+
const {response: tokenResponse} = await loggedFetch(tokenUrl, {
146187
method: 'POST',
147188
headers: {
148189
'Content-Type': 'application/x-www-form-urlencoded',
@@ -152,7 +193,11 @@ async function getPrivateClientGuestToken(config: SlasTokenConfig): Promise<Slas
152193
});
153194

154195
await checkResponse(tokenResponse, 'token (client_credentials)');
155-
return (await tokenResponse.json()) as SlasTokenResponse;
196+
const data = (await tokenResponse.json()) as SlasTokenResponse;
197+
const respHeaders = serializeHeaders(tokenResponse);
198+
logger.trace({headers: respHeaders, body: data}, `[SLAS RESP BODY] POST ${tokenUrl}`);
199+
200+
return data;
156201
}
157202

158203
/**
@@ -172,7 +217,7 @@ export async function getRegisteredToken(config: SlasRegisteredLoginConfig): Pro
172217
const baseUrl = buildBaseUrl(config.shortCode, config.organizationId);
173218
const isPrivate = Boolean(config.slasClientSecret);
174219

175-
logger.debug({isPrivate}, 'Using registered customer login flow');
220+
logger.debug({clientId: config.slasClientId, isPrivate}, '[SLAS] Using registered customer login flow');
176221

177222
const verifier = generateCodeVerifier();
178223
const challenge = generateCodeChallenge(verifier);
@@ -187,7 +232,8 @@ export async function getRegisteredToken(config: SlasRegisteredLoginConfig): Pro
187232
redirect_uri: config.redirectUri,
188233
});
189234

190-
const loginResponse = await fetch(`${baseUrl}/oauth2/login`, {
235+
const loginUrl = `${baseUrl}/oauth2/login`;
236+
const {response: loginResponse} = await loggedFetch(loginUrl, {
191237
method: 'POST',
192238
headers: {
193239
'Content-Type': 'application/x-www-form-urlencoded',
@@ -198,6 +244,8 @@ export async function getRegisteredToken(config: SlasRegisteredLoginConfig): Pro
198244
});
199245

200246
if (loginResponse.status !== 303) {
247+
const respHeaders = serializeHeaders(loginResponse);
248+
logger.trace({headers: respHeaders}, `[SLAS RESP BODY] POST ${loginUrl}`);
201249
await checkResponse(loginResponse, 'login');
202250
throw new Error(`Expected 303 redirect from SLAS login, got ${loginResponse.status}`);
203251
}
@@ -208,9 +256,11 @@ export async function getRegisteredToken(config: SlasRegisteredLoginConfig): Pro
208256
}
209257

210258
const {code, usid} = parseRedirectCode(location);
211-
logger.debug({usid}, 'Got authorization code from SLAS login');
259+
logger.debug({usid}, '[SLAS] Got authorization code from login');
212260

213261
// Step 2: Exchange code for token
262+
const tokenUrl = `${baseUrl}/oauth2/token`;
263+
214264
if (isPrivate) {
215265
const basicAuth = Buffer.from(`${config.slasClientId}:${config.slasClientSecret}`).toString('base64');
216266
const tokenBody = new URLSearchParams({
@@ -221,7 +271,7 @@ export async function getRegisteredToken(config: SlasRegisteredLoginConfig): Pro
221271
usid,
222272
});
223273

224-
const tokenResponse = await fetch(`${baseUrl}/oauth2/token`, {
274+
const {response: tokenResponse} = await loggedFetch(tokenUrl, {
225275
method: 'POST',
226276
headers: {
227277
'Content-Type': 'application/x-www-form-urlencoded',
@@ -231,7 +281,11 @@ export async function getRegisteredToken(config: SlasRegisteredLoginConfig): Pro
231281
});
232282

233283
await checkResponse(tokenResponse, 'token exchange (authorization_code)');
234-
return (await tokenResponse.json()) as SlasTokenResponse;
284+
const data = (await tokenResponse.json()) as SlasTokenResponse;
285+
const respHeaders = serializeHeaders(tokenResponse);
286+
logger.trace({headers: respHeaders, body: data}, `[SLAS RESP BODY] POST ${tokenUrl}`);
287+
288+
return data;
235289
}
236290

237291
// Public client — use PKCE
@@ -245,12 +299,16 @@ export async function getRegisteredToken(config: SlasRegisteredLoginConfig): Pro
245299
usid,
246300
});
247301

248-
const tokenResponse = await fetch(`${baseUrl}/oauth2/token`, {
302+
const {response: tokenResponse} = await loggedFetch(tokenUrl, {
249303
method: 'POST',
250304
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
251305
body: tokenBody,
252306
});
253307

254308
await checkResponse(tokenResponse, 'token exchange (authorization_code_pkce)');
255-
return (await tokenResponse.json()) as SlasTokenResponse;
309+
const data = (await tokenResponse.json()) as SlasTokenResponse;
310+
const respHeaders = serializeHeaders(tokenResponse);
311+
logger.trace({headers: respHeaders, body: data}, `[SLAS RESP BODY] POST ${tokenUrl}`);
312+
313+
return data;
256314
}

packages/b2c-vs-extension/package.json

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"onCommand:b2c-dx.openUI",
2626
"onCommand:b2c-dx.promptAgent",
2727
"onCommand:b2c-dx.listWebDav",
28-
"onCommand:b2c-dx.scapiExplorer",
28+
"onView:b2cApiBrowser",
2929
"onView:b2cSandboxExplorer",
3030
"onCommand:b2c-dx.scaffold.generate"
3131
],
@@ -59,6 +59,11 @@
5959
"default": true,
6060
"description": "Enable scaffold generation commands."
6161
},
62+
"b2c-dx.features.apiBrowser": {
63+
"type": "boolean",
64+
"default": true,
65+
"description": "Enable the API Browser for exploring SCAPI schemas."
66+
},
6267
"b2c-dx.logLevel": {
6368
"type": "string",
6469
"default": "info",
@@ -114,6 +119,12 @@
114119
"name": "Libraries",
115120
"icon": "media/b2c-icon.svg",
116121
"contextualTitle": "B2C-DX"
122+
},
123+
{
124+
"id": "b2cApiBrowser",
125+
"name": "API Browser",
126+
"icon": "media/b2c-icon.svg",
127+
"contextualTitle": "B2C-DX"
117128
}
118129
],
119130
"b2c-dx-sandboxes": [
@@ -133,6 +144,10 @@
133144
"view": "b2cContentExplorer",
134145
"contents": "No content libraries configured.\n\nSet \"contentLibrary\" in dw.json or add a library manually.\n\n[Add Library](command:b2c-dx.content.addLibrary)"
135146
},
147+
{
148+
"view": "b2cApiBrowser",
149+
"contents": "Browse SCAPI OpenAPI schemas for your Commerce Cloud instance.\n\nRequires OAuth credentials (clientId, clientSecret) and shortCode in dw.json.\n\n[Load APIs](command:b2c-dx.apiBrowser.refresh)"
150+
},
136151
{
137152
"view": "b2cSandboxExplorer",
138153
"contents": "No sandbox realms configured.\n\nSet \"realm\" in dw.json or add a realm manually.\n\n[Add Realm](command:b2c-dx.sandbox.addRealm)"
@@ -155,9 +170,15 @@
155170
"category": "B2C DX"
156171
},
157172
{
158-
"command": "b2c-dx.scapiExplorer",
159-
"title": "SCAPI API Explorer",
160-
"category": "B2C DX"
173+
"command": "b2c-dx.apiBrowser.refresh",
174+
"title": "Refresh",
175+
"icon": "$(refresh)",
176+
"category": "B2C DX - API Browser"
177+
},
178+
{
179+
"command": "b2c-dx.apiBrowser.openSwagger",
180+
"title": "Open API Documentation",
181+
"category": "B2C DX - API Browser"
161182
},
162183
{
163184
"command": "b2c-dx.sandbox.refresh",
@@ -387,6 +408,11 @@
387408
"when": "view == b2cContentExplorer && b2cContentFilterActive",
388409
"group": "navigation"
389410
},
411+
{
412+
"command": "b2c-dx.apiBrowser.refresh",
413+
"when": "view == b2cApiBrowser",
414+
"group": "navigation"
415+
},
390416
{
391417
"command": "b2c-dx.sandbox.refresh",
392418
"when": "view == b2cSandboxExplorer",
@@ -532,6 +558,10 @@
532558
}
533559
],
534560
"commandPalette": [
561+
{
562+
"command": "b2c-dx.apiBrowser.openSwagger",
563+
"when": "false"
564+
},
535565
{
536566
"command": "b2c-dx.sandbox.removeRealm",
537567
"when": "false"
@@ -639,6 +669,7 @@
639669
"@vscode/test-electron": "^2.5.2",
640670
"@vscode/vsce": "^3.7.1",
641671
"esbuild": "^0.24.0",
672+
"swagger-ui-dist": "^5.18.0",
642673
"eslint": "catalog:",
643674
"eslint-config-prettier": "catalog:",
644675
"eslint-plugin-header": "catalog:",

packages/b2c-vs-extension/scripts/esbuild-bundle.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ function inlineSdkPackageJson() {
7272
fs.writeFileSync(outPath, str, 'utf8');
7373
}
7474

75+
/** Copy Swagger UI assets to dist/swagger-ui/ for the API Browser webview. */
76+
function copySwaggerUiAssets() {
77+
// Resolve the swagger-ui-dist package location (pnpm may hoist it)
78+
const swaggerUiIndex = fileURLToPath(import.meta.resolve('swagger-ui-dist'));
79+
const swaggerUiDist = path.dirname(swaggerUiIndex);
80+
const outDir = path.join(pkgRoot, 'dist', 'swagger-ui');
81+
fs.mkdirSync(outDir, {recursive: true});
82+
83+
const files = ['swagger-ui-bundle.js', 'swagger-ui-standalone-preset.js', 'swagger-ui.css'];
84+
for (const file of files) {
85+
fs.copyFileSync(path.join(swaggerUiDist, file), path.join(outDir, file));
86+
}
87+
console.log(`[swagger-ui] Copied ${files.length} assets to dist/swagger-ui/`);
88+
}
89+
7590
const watchMode = process.argv.includes('--watch');
7691

7792
const buildOptions = {
@@ -103,6 +118,7 @@ if (watchMode) {
103118

104119
inlineSdkPackageJson();
105120
copySdkScaffolds();
121+
copySwaggerUiAssets();
106122

107123
if (result.metafile && process.env.ANALYZE_BUNDLE) {
108124
const metaPath = path.join(pkgRoot, 'dist', 'meta.json');

0 commit comments

Comments
 (0)