Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/api-browser-custom-api-org-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'b2c-vs-extension': patch
---

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.
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,18 @@ export class ApiSchemaTreeItem extends vscode.TreeItem {
this.description = schema.apiVersion;
this.contextValue = 'apiSchema';

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

if (schema.status === 'deprecated') {
Expand Down
101 changes: 99 additions & 2 deletions packages/b2c-vs-extension/src/api-browser/swagger-webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,42 @@ function extractRequiredScopes(spec: Record<string, unknown>): string[] {
return Array.from(scopes);
}

function detectApiType(spec: Record<string, unknown>, schema: SchemaEntry): ApiType {
const SHOPPER_SECURITY_SCHEMES = new Set(['ShopperToken', 'ShopperTokenTaob']);
const ADMIN_SECURITY_SCHEMES = new Set(['AmOAuth2', 'BearerToken']);

const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head'] as const;

/**
* Collect security scheme names used by any operation in the spec, falling
* back to the spec's global `security` array if no operation declares one.
*/
function collectSecuritySchemeNames(spec: Record<string, unknown>): Set<string> {
const names = new Set<string>();
const collect = (security: unknown): void => {
if (!Array.isArray(security)) return;
for (const req of security) {
if (req && typeof req === 'object') {
for (const key of Object.keys(req as Record<string, unknown>)) {
names.add(key);
}
}
}
};

const paths = spec.paths as Record<string, Record<string, unknown>> | undefined;
if (paths) {
for (const pathItem of Object.values(paths)) {
for (const method of HTTP_METHODS) {
const op = pathItem[method] as Record<string, unknown> | undefined;
if (op?.security) collect(op.security);
}
}
}
if (names.size === 0) collect(spec.security);
return names;
}

export function detectApiType(spec: Record<string, unknown>, schema: SchemaEntry): ApiType {
const info = spec.info as Record<string, unknown> | undefined;
if (info) {
const xApiType = info['x-api-type'] ?? info['x-apiType'];
Expand All @@ -114,7 +149,62 @@ function detectApiType(spec: Record<string, unknown>, schema: SchemaEntry): ApiT
if (xApiType.toLowerCase().includes('admin')) return 'Admin';
}
}
return schema.apiFamily.startsWith('shopper') ? 'Shopper' : 'Admin';

// Detect from declared security schemes — works for standard SCAPI (where
// shopper-named APIs may live under non-shopper families) and for Custom
// APIs (which can be Shopper or Admin depending on the scheme they declare).
const schemeNames = collectSecuritySchemeNames(spec);
let shopperHits = 0;
let adminHits = 0;
for (const name of schemeNames) {
if (SHOPPER_SECURITY_SCHEMES.has(name)) shopperHits++;
else if (ADMIN_SECURITY_SCHEMES.has(name)) adminHits++;
}
if (shopperHits > 0 && adminHits === 0) return 'Shopper';
if (adminHits > 0 && shopperHits === 0) return 'Admin';
if (shopperHits > 0 && adminHits > 0) {
return schema.apiName.startsWith('shopper-') ? 'Shopper' : 'Admin';
}

if (schema.apiName.startsWith('shopper-')) return 'Shopper';
if (schema.apiFamily === 'shopper') return 'Shopper';
return 'Admin';
}

/**
* Custom API specs only describe the developer-authored portion of each path;
* the platform injects `/organizations/{organizationId}` between the base URL
* and the path at runtime. Rewrite the spec so Swagger UI shows the runtime
* path and "Try it out" calls the correct URL.
*/
export function injectCustomApiOrgPathPrefix(spec: Record<string, unknown>): void {
const paths = spec.paths as Record<string, Record<string, unknown>> | undefined;
if (!paths) return;

const orgParam = (): Record<string, unknown> => ({
name: 'organizationId',
in: 'path',
required: true,
schema: {type: 'string'},
});

const rewritten: Record<string, unknown> = {};
for (const [originalPath, pathItem] of Object.entries(paths)) {
const normalized = originalPath.startsWith('/') ? originalPath : `/${originalPath}`;
const newKey = `/organizations/{organizationId}${normalized}`;

if (pathItem && typeof pathItem === 'object') {
const item = pathItem as Record<string, unknown>;
const existing = Array.isArray(item.parameters) ? [...(item.parameters as unknown[])] : [];
const hasOrg = existing.some(
(p) => p && typeof p === 'object' && (p as {name?: unknown}).name === 'organizationId',
);
if (!hasOrg) existing.push(orgParam());
item.parameters = existing;
}
rewritten[newKey] = pathItem;
}
spec.paths = rewritten;
}

/**
Expand Down Expand Up @@ -242,6 +332,13 @@ export class SwaggerWebviewManager implements vscode.Disposable {
await resolveExternalRefs(spec, authHeader, this.log);
}

// Custom APIs author paths relative to their resource; the platform routes
// them under `/organizations/{organizationId}/...`. Add that prefix back so
// the browser displays — and "Try it out" calls — the correct URL.
if (schema.apiFamily === 'custom') {
injectCustomApiOrgPathPrefix(spec);
}

// Derive organizationId and pre-fill it in the spec
const tenantId = deriveTenantId(config.values.hostname);
const organizationId = tenantId ? toOrganizationId(tenantId) : '';
Expand Down
133 changes: 133 additions & 0 deletions packages/b2c-vs-extension/src/test/api-browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/

import * as assert from 'assert';
import type {SchemaEntry} from '../api-browser/api-browser-tree-provider.js';
import {detectApiType, injectCustomApiOrgPathPrefix} from '../api-browser/swagger-webview.js';

function entry(apiFamily: string, apiName: string): SchemaEntry {
return {apiFamily, apiName, apiVersion: 'v1'};
}

function specWithGlobalSecurity(schemes: Record<string, string[]>[]): Record<string, unknown> {
return {security: schemes, paths: {}};
}

function specWithOpSecurity(perOp: Record<string, string[]>[]): Record<string, unknown> {
return {
paths: {
'/foo': {
get: {security: perOp, responses: {'200': {description: 'ok'}}},
},
},
};
}

suite('detectApiType', () => {
test('returns Shopper for ShopperToken-only spec', () => {
const spec = specWithGlobalSecurity([{ShopperToken: ['c_loyalty']}]);
assert.strictEqual(detectApiType(spec, entry('custom', 'loyalty')), 'Shopper');
});

test('returns Admin for AmOAuth2-only spec', () => {
const spec = specWithGlobalSecurity([{AmOAuth2: ['c_agentforce']}]);
assert.strictEqual(detectApiType(spec, entry('custom', 'agentforce')), 'Admin');
});

test('returns Admin for BearerToken (SLAS Admin API)', () => {
const spec = specWithOpSecurity([{BearerToken: []}]);
assert.strictEqual(detectApiType(spec, entry('shopper', 'auth-admin')), 'Admin');
});

test('Shopper for shopper-named spec mixing AmOAuth2 + ShopperToken', () => {
const spec = specWithOpSecurity([{ShopperToken: []}, {AmOAuth2: []}]);
assert.strictEqual(detectApiType(spec, entry('checkout', 'shopper-baskets')), 'Shopper');
});

test('Admin for non-shopper-named spec mixing AmOAuth2 + ShopperToken', () => {
const spec = specWithOpSecurity([{AmOAuth2: []}, {ShopperToken: []}]);
assert.strictEqual(detectApiType(spec, entry('checkout', 'orders')), 'Admin');
});

test('per-op security takes precedence over global', () => {
const spec: Record<string, unknown> = {
security: [{AmOAuth2: ['c_admin']}],
paths: {
'/foo': {get: {security: [{ShopperToken: ['c_x']}], responses: {'200': {description: 'ok'}}}},
},
};
assert.strictEqual(detectApiType(spec, entry('custom', 'thing')), 'Shopper');
});

test('falls back to apiName/family heuristic when no recognized scheme', () => {
const spec = specWithGlobalSecurity([{UnknownScheme: []}]);
assert.strictEqual(detectApiType(spec, entry('product', 'shopper-products')), 'Shopper');
assert.strictEqual(detectApiType(spec, entry('product', 'products')), 'Admin');
assert.strictEqual(detectApiType(spec, entry('shopper', 'auth')), 'Shopper');
});

test('respects info.x-api-type when present', () => {
const spec: Record<string, unknown> = {
info: {'x-api-type': 'Shopper'},
security: [{AmOAuth2: []}],
paths: {},
};
assert.strictEqual(detectApiType(spec, entry('custom', 'override')), 'Shopper');
});
});

suite('injectCustomApiOrgPathPrefix', () => {
test('rewrites path keys with /organizations/{organizationId} prefix', () => {
const spec: Record<string, unknown> = {
paths: {
'/customers/{customerId}/loyalty': {get: {responses: {'200': {description: 'ok'}}}},
'/groups/{ids}': {get: {responses: {'200': {description: 'ok'}}}},
},
};
injectCustomApiOrgPathPrefix(spec);
const paths = spec.paths as Record<string, unknown>;
assert.deepStrictEqual(Object.keys(paths).sort(), [
'/organizations/{organizationId}/customers/{customerId}/loyalty',
'/organizations/{organizationId}/groups/{ids}',
]);
});

test('adds organizationId path parameter when missing', () => {
const spec: Record<string, unknown> = {
paths: {'/foo': {get: {responses: {'200': {description: 'ok'}}}}},
};
injectCustomApiOrgPathPrefix(spec);
const item = (spec.paths as Record<string, Record<string, unknown>>)['/organizations/{organizationId}/foo'];
const params = item.parameters as Array<Record<string, unknown>>;
const orgParam = params.find((p) => p.name === 'organizationId');
assert.ok(orgParam, 'organizationId parameter should be added');
assert.strictEqual(orgParam.in, 'path');
assert.strictEqual(orgParam.required, true);
});

test('does not duplicate organizationId parameter when already present', () => {
const existing = {name: 'organizationId', in: 'path', required: true, schema: {type: 'string'}};
const spec: Record<string, unknown> = {
paths: {
'/foo': {
parameters: [existing],
get: {responses: {'200': {description: 'ok'}}},
},
},
};
injectCustomApiOrgPathPrefix(spec);
const item = (spec.paths as Record<string, Record<string, unknown>>)['/organizations/{organizationId}/foo'];
const params = item.parameters as Array<Record<string, unknown>>;
const orgParams = params.filter((p) => p.name === 'organizationId');
assert.strictEqual(orgParams.length, 1);
});

test('is a no-op when spec has no paths', () => {
const spec: Record<string, unknown> = {};
injectCustomApiOrgPathPrefix(spec);
assert.strictEqual(spec.paths, undefined);
});
});
Loading