diff --git a/.changeset/api-browser-custom-api-org-prefix.md b/.changeset/api-browser-custom-api-org-prefix.md new file mode 100644 index 00000000..5bd716d4 --- /dev/null +++ b/.changeset/api-browser-custom-api-org-prefix.md @@ -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. diff --git a/packages/b2c-vs-extension/src/api-browser/api-browser-tree-provider.ts b/packages/b2c-vs-extension/src/api-browser/api-browser-tree-provider.ts index 0f303252..21a1c315 100644 --- a/packages/b2c-vs-extension/src/api-browser/api-browser-tree-provider.ts +++ b/packages/b2c-vs-extension/src/api-browser/api-browser-tree-provider.ts @@ -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') { diff --git a/packages/b2c-vs-extension/src/api-browser/swagger-webview.ts b/packages/b2c-vs-extension/src/api-browser/swagger-webview.ts index f2e9ed19..9745c6cf 100644 --- a/packages/b2c-vs-extension/src/api-browser/swagger-webview.ts +++ b/packages/b2c-vs-extension/src/api-browser/swagger-webview.ts @@ -105,7 +105,42 @@ function extractRequiredScopes(spec: Record): string[] { return Array.from(scopes); } -function detectApiType(spec: Record, 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): Set { + const names = new Set(); + 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)) { + names.add(key); + } + } + } + }; + + const paths = spec.paths as Record> | undefined; + if (paths) { + for (const pathItem of Object.values(paths)) { + for (const method of HTTP_METHODS) { + const op = pathItem[method] as Record | undefined; + if (op?.security) collect(op.security); + } + } + } + if (names.size === 0) collect(spec.security); + return names; +} + +export function detectApiType(spec: Record, schema: SchemaEntry): ApiType { const info = spec.info as Record | undefined; if (info) { const xApiType = info['x-api-type'] ?? info['x-apiType']; @@ -114,7 +149,62 @@ function detectApiType(spec: Record, 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): void { + const paths = spec.paths as Record> | undefined; + if (!paths) return; + + const orgParam = (): Record => ({ + name: 'organizationId', + in: 'path', + required: true, + schema: {type: 'string'}, + }); + + const rewritten: Record = {}; + 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; + 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; } /** @@ -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) : ''; diff --git a/packages/b2c-vs-extension/src/test/api-browser.test.ts b/packages/b2c-vs-extension/src/test/api-browser.test.ts new file mode 100644 index 00000000..b99c3a6f --- /dev/null +++ b/packages/b2c-vs-extension/src/test/api-browser.test.ts @@ -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[]): Record { + return {security: schemes, paths: {}}; +} + +function specWithOpSecurity(perOp: Record[]): Record { + 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 = { + 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 = { + 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 = { + paths: { + '/customers/{customerId}/loyalty': {get: {responses: {'200': {description: 'ok'}}}}, + '/groups/{ids}': {get: {responses: {'200': {description: 'ok'}}}}, + }, + }; + injectCustomApiOrgPathPrefix(spec); + const paths = spec.paths as Record; + 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 = { + paths: {'/foo': {get: {responses: {'200': {description: 'ok'}}}}}, + }; + injectCustomApiOrgPathPrefix(spec); + const item = (spec.paths as Record>)['/organizations/{organizationId}/foo']; + const params = item.parameters as Array>; + 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 = { + paths: { + '/foo': { + parameters: [existing], + get: {responses: {'200': {description: 'ok'}}}, + }, + }, + }; + injectCustomApiOrgPathPrefix(spec); + const item = (spec.paths as Record>)['/organizations/{organizationId}/foo']; + const params = item.parameters as Array>; + 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 = {}; + injectCustomApiOrgPathPrefix(spec); + assert.strictEqual(spec.paths, undefined); + }); +});