Skip to content

Commit 637df9e

Browse files
authored
fix(vs-extension): correct API Browser paths and type detection for Custom and shopper APIs (#458)
Custom APIs now display endpoint paths with the required `/organizations/{organizationId}/...` prefix. Shopper/Admin classification is now derived from the spec's declared security schemes rather than the API family name, so shopper-named APIs in non-shopper families (e.g. product/shopper-products, checkout/shopper-baskets) and Custom APIs both auto-fetch the correct token type. Fixes #453 W-22726280
1 parent 0363dca commit 637df9e

4 files changed

Lines changed: 249 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'b2c-vs-extension': patch
3+
---
4+
5+
Fix VS Code API Browser handling of Custom APIs and shopper-named system APIs. Custom APIs now show endpoint paths with the required `/organizations/{organizationId}/...` prefix, and the Shopper/Admin classification is now derived from the spec's declared security schemes (ShopperToken / AmOAuth2 / BearerToken) rather than the API family name — fixing token selection for shopper-named APIs that live under non-shopper families (e.g. `product/shopper-products`, `checkout/shopper-baskets`) and for Custom APIs which can be either type. Resolves #453.

packages/b2c-vs-extension/src/api-browser/api-browser-tree-provider.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,18 @@ export class ApiSchemaTreeItem extends vscode.TreeItem {
3838
this.description = schema.apiVersion;
3939
this.contextValue = 'apiSchema';
4040

41-
const apiType = schema.apiFamily.startsWith('shopper') ? 'Shopper' : 'Admin';
41+
// Tooltip type is best-effort — the authoritative classification happens
42+
// when the spec is loaded (see detectApiType in swagger-webview.ts) since
43+
// it depends on declared security schemes. For Custom APIs we can't know
44+
// without the spec, so just label them as such here.
45+
let apiType: string;
46+
if (schema.apiFamily === 'custom') {
47+
apiType = 'Custom';
48+
} else if (schema.apiName.startsWith('shopper-') || schema.apiFamily === 'shopper') {
49+
apiType = 'Shopper';
50+
} else {
51+
apiType = 'Admin';
52+
}
4253
this.tooltip = `${schema.apiName} ${schema.apiVersion} (${apiType})`;
4354

4455
if (schema.status === 'deprecated') {

packages/b2c-vs-extension/src/api-browser/swagger-webview.ts

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,42 @@ function extractRequiredScopes(spec: Record<string, unknown>): string[] {
105105
return Array.from(scopes);
106106
}
107107

108-
function detectApiType(spec: Record<string, unknown>, schema: SchemaEntry): ApiType {
108+
const SHOPPER_SECURITY_SCHEMES = new Set(['ShopperToken', 'ShopperTokenTaob']);
109+
const ADMIN_SECURITY_SCHEMES = new Set(['AmOAuth2', 'BearerToken']);
110+
111+
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head'] as const;
112+
113+
/**
114+
* Collect security scheme names used by any operation in the spec, falling
115+
* back to the spec's global `security` array if no operation declares one.
116+
*/
117+
function collectSecuritySchemeNames(spec: Record<string, unknown>): Set<string> {
118+
const names = new Set<string>();
119+
const collect = (security: unknown): void => {
120+
if (!Array.isArray(security)) return;
121+
for (const req of security) {
122+
if (req && typeof req === 'object') {
123+
for (const key of Object.keys(req as Record<string, unknown>)) {
124+
names.add(key);
125+
}
126+
}
127+
}
128+
};
129+
130+
const paths = spec.paths as Record<string, Record<string, unknown>> | undefined;
131+
if (paths) {
132+
for (const pathItem of Object.values(paths)) {
133+
for (const method of HTTP_METHODS) {
134+
const op = pathItem[method] as Record<string, unknown> | undefined;
135+
if (op?.security) collect(op.security);
136+
}
137+
}
138+
}
139+
if (names.size === 0) collect(spec.security);
140+
return names;
141+
}
142+
143+
export function detectApiType(spec: Record<string, unknown>, schema: SchemaEntry): ApiType {
109144
const info = spec.info as Record<string, unknown> | undefined;
110145
if (info) {
111146
const xApiType = info['x-api-type'] ?? info['x-apiType'];
@@ -114,7 +149,62 @@ function detectApiType(spec: Record<string, unknown>, schema: SchemaEntry): ApiT
114149
if (xApiType.toLowerCase().includes('admin')) return 'Admin';
115150
}
116151
}
117-
return schema.apiFamily.startsWith('shopper') ? 'Shopper' : 'Admin';
152+
153+
// Detect from declared security schemes — works for standard SCAPI (where
154+
// shopper-named APIs may live under non-shopper families) and for Custom
155+
// APIs (which can be Shopper or Admin depending on the scheme they declare).
156+
const schemeNames = collectSecuritySchemeNames(spec);
157+
let shopperHits = 0;
158+
let adminHits = 0;
159+
for (const name of schemeNames) {
160+
if (SHOPPER_SECURITY_SCHEMES.has(name)) shopperHits++;
161+
else if (ADMIN_SECURITY_SCHEMES.has(name)) adminHits++;
162+
}
163+
if (shopperHits > 0 && adminHits === 0) return 'Shopper';
164+
if (adminHits > 0 && shopperHits === 0) return 'Admin';
165+
if (shopperHits > 0 && adminHits > 0) {
166+
return schema.apiName.startsWith('shopper-') ? 'Shopper' : 'Admin';
167+
}
168+
169+
if (schema.apiName.startsWith('shopper-')) return 'Shopper';
170+
if (schema.apiFamily === 'shopper') return 'Shopper';
171+
return 'Admin';
172+
}
173+
174+
/**
175+
* Custom API specs only describe the developer-authored portion of each path;
176+
* the platform injects `/organizations/{organizationId}` between the base URL
177+
* and the path at runtime. Rewrite the spec so Swagger UI shows the runtime
178+
* path and "Try it out" calls the correct URL.
179+
*/
180+
export function injectCustomApiOrgPathPrefix(spec: Record<string, unknown>): void {
181+
const paths = spec.paths as Record<string, Record<string, unknown>> | undefined;
182+
if (!paths) return;
183+
184+
const orgParam = (): Record<string, unknown> => ({
185+
name: 'organizationId',
186+
in: 'path',
187+
required: true,
188+
schema: {type: 'string'},
189+
});
190+
191+
const rewritten: Record<string, unknown> = {};
192+
for (const [originalPath, pathItem] of Object.entries(paths)) {
193+
const normalized = originalPath.startsWith('/') ? originalPath : `/${originalPath}`;
194+
const newKey = `/organizations/{organizationId}${normalized}`;
195+
196+
if (pathItem && typeof pathItem === 'object') {
197+
const item = pathItem as Record<string, unknown>;
198+
const existing = Array.isArray(item.parameters) ? [...(item.parameters as unknown[])] : [];
199+
const hasOrg = existing.some(
200+
(p) => p && typeof p === 'object' && (p as {name?: unknown}).name === 'organizationId',
201+
);
202+
if (!hasOrg) existing.push(orgParam());
203+
item.parameters = existing;
204+
}
205+
rewritten[newKey] = pathItem;
206+
}
207+
spec.paths = rewritten;
118208
}
119209

120210
/**
@@ -250,6 +340,13 @@ export class SwaggerWebviewManager implements vscode.Disposable {
250340
await resolveExternalRefs(spec, authHeader, this.log);
251341
}
252342

343+
// Custom APIs author paths relative to their resource; the platform routes
344+
// them under `/organizations/{organizationId}/...`. Add that prefix back so
345+
// the browser displays — and "Try it out" calls — the correct URL.
346+
if (schema.apiFamily === 'custom') {
347+
injectCustomApiOrgPathPrefix(spec);
348+
}
349+
253350
// Derive organizationId and pre-fill it in the spec
254351
const tenantId = deriveTenantId(config.values.hostname);
255352
const organizationId = tenantId ? toOrganizationId(tenantId) : '';
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
import * as assert from 'assert';
8+
import type {SchemaEntry} from '../api-browser/api-browser-tree-provider.js';
9+
import {detectApiType, injectCustomApiOrgPathPrefix} from '../api-browser/swagger-webview.js';
10+
11+
function entry(apiFamily: string, apiName: string): SchemaEntry {
12+
return {apiFamily, apiName, apiVersion: 'v1'};
13+
}
14+
15+
function specWithGlobalSecurity(schemes: Record<string, string[]>[]): Record<string, unknown> {
16+
return {security: schemes, paths: {}};
17+
}
18+
19+
function specWithOpSecurity(perOp: Record<string, string[]>[]): Record<string, unknown> {
20+
return {
21+
paths: {
22+
'/foo': {
23+
get: {security: perOp, responses: {'200': {description: 'ok'}}},
24+
},
25+
},
26+
};
27+
}
28+
29+
suite('detectApiType', () => {
30+
test('returns Shopper for ShopperToken-only spec', () => {
31+
const spec = specWithGlobalSecurity([{ShopperToken: ['c_loyalty']}]);
32+
assert.strictEqual(detectApiType(spec, entry('custom', 'loyalty')), 'Shopper');
33+
});
34+
35+
test('returns Admin for AmOAuth2-only spec', () => {
36+
const spec = specWithGlobalSecurity([{AmOAuth2: ['c_agentforce']}]);
37+
assert.strictEqual(detectApiType(spec, entry('custom', 'agentforce')), 'Admin');
38+
});
39+
40+
test('returns Admin for BearerToken (SLAS Admin API)', () => {
41+
const spec = specWithOpSecurity([{BearerToken: []}]);
42+
assert.strictEqual(detectApiType(spec, entry('shopper', 'auth-admin')), 'Admin');
43+
});
44+
45+
test('Shopper for shopper-named spec mixing AmOAuth2 + ShopperToken', () => {
46+
const spec = specWithOpSecurity([{ShopperToken: []}, {AmOAuth2: []}]);
47+
assert.strictEqual(detectApiType(spec, entry('checkout', 'shopper-baskets')), 'Shopper');
48+
});
49+
50+
test('Admin for non-shopper-named spec mixing AmOAuth2 + ShopperToken', () => {
51+
const spec = specWithOpSecurity([{AmOAuth2: []}, {ShopperToken: []}]);
52+
assert.strictEqual(detectApiType(spec, entry('checkout', 'orders')), 'Admin');
53+
});
54+
55+
test('per-op security takes precedence over global', () => {
56+
const spec: Record<string, unknown> = {
57+
security: [{AmOAuth2: ['c_admin']}],
58+
paths: {
59+
'/foo': {get: {security: [{ShopperToken: ['c_x']}], responses: {'200': {description: 'ok'}}}},
60+
},
61+
};
62+
assert.strictEqual(detectApiType(spec, entry('custom', 'thing')), 'Shopper');
63+
});
64+
65+
test('falls back to apiName/family heuristic when no recognized scheme', () => {
66+
const spec = specWithGlobalSecurity([{UnknownScheme: []}]);
67+
assert.strictEqual(detectApiType(spec, entry('product', 'shopper-products')), 'Shopper');
68+
assert.strictEqual(detectApiType(spec, entry('product', 'products')), 'Admin');
69+
assert.strictEqual(detectApiType(spec, entry('shopper', 'auth')), 'Shopper');
70+
});
71+
72+
test('respects info.x-api-type when present', () => {
73+
const spec: Record<string, unknown> = {
74+
info: {'x-api-type': 'Shopper'},
75+
security: [{AmOAuth2: []}],
76+
paths: {},
77+
};
78+
assert.strictEqual(detectApiType(spec, entry('custom', 'override')), 'Shopper');
79+
});
80+
});
81+
82+
suite('injectCustomApiOrgPathPrefix', () => {
83+
test('rewrites path keys with /organizations/{organizationId} prefix', () => {
84+
const spec: Record<string, unknown> = {
85+
paths: {
86+
'/customers/{customerId}/loyalty': {get: {responses: {'200': {description: 'ok'}}}},
87+
'/groups/{ids}': {get: {responses: {'200': {description: 'ok'}}}},
88+
},
89+
};
90+
injectCustomApiOrgPathPrefix(spec);
91+
const paths = spec.paths as Record<string, unknown>;
92+
assert.deepStrictEqual(Object.keys(paths).sort(), [
93+
'/organizations/{organizationId}/customers/{customerId}/loyalty',
94+
'/organizations/{organizationId}/groups/{ids}',
95+
]);
96+
});
97+
98+
test('adds organizationId path parameter when missing', () => {
99+
const spec: Record<string, unknown> = {
100+
paths: {'/foo': {get: {responses: {'200': {description: 'ok'}}}}},
101+
};
102+
injectCustomApiOrgPathPrefix(spec);
103+
const item = (spec.paths as Record<string, Record<string, unknown>>)['/organizations/{organizationId}/foo'];
104+
const params = item.parameters as Array<Record<string, unknown>>;
105+
const orgParam = params.find((p) => p.name === 'organizationId');
106+
assert.ok(orgParam, 'organizationId parameter should be added');
107+
assert.strictEqual(orgParam.in, 'path');
108+
assert.strictEqual(orgParam.required, true);
109+
});
110+
111+
test('does not duplicate organizationId parameter when already present', () => {
112+
const existing = {name: 'organizationId', in: 'path', required: true, schema: {type: 'string'}};
113+
const spec: Record<string, unknown> = {
114+
paths: {
115+
'/foo': {
116+
parameters: [existing],
117+
get: {responses: {'200': {description: 'ok'}}},
118+
},
119+
},
120+
};
121+
injectCustomApiOrgPathPrefix(spec);
122+
const item = (spec.paths as Record<string, Record<string, unknown>>)['/organizations/{organizationId}/foo'];
123+
const params = item.parameters as Array<Record<string, unknown>>;
124+
const orgParams = params.filter((p) => p.name === 'organizationId');
125+
assert.strictEqual(orgParams.length, 1);
126+
});
127+
128+
test('is a no-op when spec has no paths', () => {
129+
const spec: Record<string, unknown> = {};
130+
injectCustomApiOrgPathPrefix(spec);
131+
assert.strictEqual(spec.paths, undefined);
132+
});
133+
});

0 commit comments

Comments
 (0)