diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ee75b423..8dc394e8 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -37,6 +37,7 @@ const guideSidebar = [ { text: 'MRT Commands', link: '/cli/mrt' }, { text: 'SLAS Commands', link: '/cli/slas' }, { text: 'Custom APIs', link: '/cli/custom-apis' }, + { text: 'SCAPI Schemas', link: '/cli/scapi-schemas' }, { text: 'Auth Commands', link: '/cli/auth' }, { text: 'Logging', link: '/cli/logging' }, ], diff --git a/docs/cli/scapi-schemas.md b/docs/cli/scapi-schemas.md new file mode 100644 index 00000000..52911df4 --- /dev/null +++ b/docs/cli/scapi-schemas.md @@ -0,0 +1,227 @@ +# SCAPI Schemas + +Commands for browsing and retrieving SCAPI (Salesforce Commerce API) schema specifications. + +## Global SCAPI Schemas Flags + +These flags are available on all SCAPI Schemas commands: + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--tenant-id` | `SFCC_TENANT_ID` | (Required) Organization/tenant ID | +| `--short-code` | `SFCC_SHORTCODE` | SCAPI short code | + +## Authentication + +SCAPI Schemas commands require an Account Manager API Client with OAuth credentials. + +### Required Scopes + +The following scopes are automatically requested by the CLI: + +| Scope | Description | +|-------|-------------| +| `sfcc.scapi-schemas` | Access to SCAPI Schemas API | +| `SALESFORCE_COMMERCE_API:` | Tenant-specific access scope | + +### Configuration + +```bash +# Set credentials via environment variables +export SFCC_CLIENT_ID=my-client +export SFCC_CLIENT_SECRET=my-secret +export SFCC_TENANT_ID=zzxy_prd +export SFCC_SHORTCODE=kv7kzm78 + +# Or provide via flags +b2c scapi schemas list --client-id xxx --client-secret xxx --tenant-id zzxy_prd +``` + +--- + +## b2c scapi schemas list + +List available SCAPI schemas with optional filtering. + +### Usage + +```bash +b2c scapi schemas list --tenant-id +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--tenant-id` | (Required) Organization/tenant ID | | +| `--api-family` | Filter by API family (e.g., shopper, admin) | | +| `--api-name` | Filter by API name (e.g., products, orders) | | +| `--api-version` | Filter by API version (e.g., v1) | | +| `--status`, `-s` | Filter by schema status (`current`, `deprecated`) | | +| `--columns`, `-c` | Columns to display (comma-separated) | | +| `--extended`, `-x` | Show all columns including extended fields | `false` | +| `--json` | Output results as JSON | `false` | + +### Available Columns + +Default columns: `apiFamily`, `apiName`, `apiVersion`, `status` + +Extended columns (shown with `--extended`): `schemaVersion`, `link` + +### Examples + +```bash +# List all available SCAPI schemas +b2c scapi schemas list --tenant-id zzxy_prd + +# Filter by API family +b2c scapi schemas list --tenant-id zzxy_prd --api-family shopper + +# Filter by API name +b2c scapi schemas list --tenant-id zzxy_prd --api-name products + +# Filter by status +b2c scapi schemas list --tenant-id zzxy_prd --status current +b2c scapi schemas list --tenant-id zzxy_prd --status deprecated + +# Show extended columns +b2c scapi schemas list --tenant-id zzxy_prd --extended + +# Output as JSON +b2c scapi schemas list --tenant-id zzxy_prd --json +``` + +### Output + +Default table output: + +``` +Found 15 schema(s): + +API Family API Name Version Status +────────────────────────────────────────── +shopper products v1 current +shopper orders v1 current +shopper customers v1 current +admin inventory v1 current +... +``` + +--- + +## b2c scapi schemas get + +Get a specific SCAPI schema with optional selective expansion. + +### Usage + +```bash +b2c scapi schemas get --tenant-id +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `apiFamily` | API family (e.g., shopper, admin) | +| `apiName` | API name (e.g., products, orders) | +| `apiVersion` | API version (e.g., v1) | + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--tenant-id` | (Required) Organization/tenant ID | | +| `--expand-paths` | Paths to fully expand (comma-separated) | | +| `--expand-schemas` | Schema names to fully expand (comma-separated) | | +| `--expand-examples` | Example names to fully expand (comma-separated) | | +| `--expand-custom-properties` | Expand custom properties | `true` | +| `--no-expand-custom-properties` | Do not expand custom properties | | +| `--expand-all` | Return full schema without collapsing | `false` | +| `--list-paths` | List available paths and exit | `false` | +| `--list-schemas` | List available schema names and exit | `false` | +| `--list-examples` | List available example names and exit | `false` | +| `--yaml` | Output as YAML instead of JSON | `false` | +| `--json` | Output wrapped JSON with metadata | `false` | + +### Schema Collapsing + +By default, schemas are output in a collapsed/outline format optimized for context efficiency (ideal for agentic use cases and LLM consumption): + +- **Paths**: Show only HTTP methods available: `{"/products": ["get", "post"]}` +- **Schemas**: Show only schema names: `{"Product": {}, "Order": {}}` +- **Examples**: Show only example names: `{"ProductExample": {}}` + +Use the `--expand-*` flags for selective expansion or `--expand-all` for the full, unmodified schema. + +### Examples + +```bash +# Get collapsed/outline schema (default - context efficient) +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd + +# Get full schema without collapsing +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --expand-all + +# Expand specific paths only +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --expand-paths /products,/products/{id} + +# Expand specific schemas only +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --expand-schemas Product,SearchResult + +# Expand specific examples only +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --expand-examples ProductExample + +# Combine selective expansions +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --expand-paths /products --expand-schemas Product + +# List available paths in the schema +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --list-paths + +# List available schema names +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --list-schemas + +# List available examples +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --list-examples + +# Output as YAML +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --yaml + +# Output wrapped JSON with metadata +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --json + +# Disable custom properties expansion +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --no-expand-custom-properties +``` + +### Output Formats + +**Default (raw JSON to stdout)**: The schema is output directly to stdout as JSON. Use shell redirection to save to a file: + +```bash +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd > schema.json +``` + +**YAML format (`--yaml`)**: Output as YAML for readability: + +```bash +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --yaml > schema.yaml +``` + +**Wrapped JSON (`--json`)**: Output includes metadata wrapper: + +```json +{ + "apiFamily": "shopper", + "apiName": "products", + "apiVersion": "v1", + "schema": { ... } +} +``` + +### Notes + +- The collapsed output significantly reduces context size while preserving structure, making it ideal for AI/LLM consumption +- Use `--list-paths` to discover available paths before using `--expand-paths` +- Use `--list-schemas` to discover available schema names before using `--expand-schemas` +- Custom properties expansion is enabled by default and fetches tenant-specific custom attributes diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 6146e3f9..6ba48bec 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -129,6 +129,9 @@ "subtopics": { "custom": { "description": "Manage Custom API endpoints" + }, + "schemas": { + "description": "Browse and retrieve SCAPI schema specifications" } } } diff --git a/packages/b2c-cli/src/commands/scapi/schemas/get.ts b/packages/b2c-cli/src/commands/scapi/schemas/get.ts new file mode 100644 index 00000000..9a07b249 --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/schemas/get.ts @@ -0,0 +1,384 @@ +/* + * 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 {Args, Flags} from '@oclif/core'; +import { + collapseOpenApiSchema, + getPathKeys, + getSchemaNames, + getExampleNames, + type OpenApiSchemaInput, +} from '@salesforce/b2c-tooling-sdk/operations/scapi-schemas'; +import {ScapiSchemasCommand, formatApiError} from '../../../utils/scapi/schemas.js'; +import {t} from '../../../i18n/index.js'; + +/** + * Response type for the get command when using --json flag. + */ +interface GetOutput { + apiFamily: string; + apiName: string; + apiVersion: string; + schema: Record; +} + +/** + * Command to get a specific SCAPI schema. + * + * By default, outputs collapsed schema for context efficiency (ideal for agentic use). + * Use --expand-* flags to selectively expand specific paths, schemas, or examples. + * Use --expand-all to get the full, unmodified schema. + */ +export default class ScapiSchemasGet extends ScapiSchemasCommand { + static args = { + apiFamily: Args.string({ + description: t('args.apiFamily.description', 'API family (e.g., shopper, admin)'), + required: true, + }), + apiName: Args.string({ + description: t('args.apiName.description', 'API name (e.g., products, orders)'), + required: true, + }), + apiVersion: Args.string({ + description: t('args.apiVersion.description', 'API version (e.g., v1)'), + required: true, + }), + }; + + static description = t( + 'commands.scapi.schemas.get.description', + 'Get a specific SCAPI schema with optional selective expansion', + ); + + static enableJsonFlag = true; + + static examples = [ + // Basic usage + '<%= config.bin %> <%= command.id %> shopper products v1 --tenant-id f_ecom_zzxy_prd', + // Full schema + '<%= config.bin %> <%= command.id %> shopper products v1 --tenant-id f_ecom_zzxy_prd --expand-all', + // Selective expansion + '<%= config.bin %> <%= command.id %> shopper products v1 --tenant-id f_ecom_zzxy_prd --expand-paths /products', + '<%= config.bin %> <%= command.id %> shopper products v1 --tenant-id f_ecom_zzxy_prd --expand-schemas Product,SearchResult', + // List available paths/schemas + '<%= config.bin %> <%= command.id %> shopper products v1 --tenant-id f_ecom_zzxy_prd --list-paths', + '<%= config.bin %> <%= command.id %> shopper products v1 --tenant-id f_ecom_zzxy_prd --list-schemas', + // YAML output + '<%= config.bin %> <%= command.id %> shopper products v1 --tenant-id f_ecom_zzxy_prd --yaml', + // JSON wrapped output + '<%= config.bin %> <%= command.id %> shopper products v1 --tenant-id f_ecom_zzxy_prd --json', + ]; + + static flags = { + ...ScapiSchemasCommand.baseFlags, + + // Selective expansion flags + 'expand-paths': Flags.string({ + description: t( + 'flags.expandPaths.description', + 'Paths to fully expand (comma-separated, e.g., /products,/orders)', + ), + multiple: false, + }), + 'expand-schemas': Flags.string({ + description: t('flags.expandSchemas.description', 'Schema names to fully expand (comma-separated)'), + multiple: false, + }), + 'expand-examples': Flags.string({ + description: t('flags.expandExamples.description', 'Example names to fully expand (comma-separated)'), + multiple: false, + }), + 'expand-custom-properties': Flags.boolean({ + description: t('flags.expandCustomProperties.description', 'Expand custom properties'), + default: true, + allowNo: true, + }), + 'expand-all': Flags.boolean({ + description: t( + 'flags.expandAll.description', + 'Return full schema without collapsing (overrides selective expand)', + ), + default: false, + }), + + // List available items flags + 'list-paths': Flags.boolean({ + description: t('flags.listPaths.description', 'List available paths in the schema and exit'), + default: false, + exclusive: ['list-schemas', 'list-examples'], + }), + 'list-schemas': Flags.boolean({ + description: t('flags.listSchemas.description', 'List available schema names and exit'), + default: false, + exclusive: ['list-paths', 'list-examples'], + }), + 'list-examples': Flags.boolean({ + description: t('flags.listExamples.description', 'List available example names and exit'), + default: false, + exclusive: ['list-paths', 'list-schemas'], + }), + + // Output format flags + yaml: Flags.boolean({ + description: t('flags.yaml.description', 'Output as YAML instead of JSON'), + default: false, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {apiFamily, apiName, apiVersion} = this.args; + const { + 'expand-paths': expandPathsRaw, + 'expand-schemas': expandSchemasRaw, + 'expand-examples': expandExamplesRaw, + 'expand-custom-properties': expandCustomProperties, + 'expand-all': expandAll, + 'list-paths': listPaths, + 'list-schemas': listSchemas, + 'list-examples': listExamples, + yaml: outputYaml, + } = this.flags; + + // Parse comma-separated values + const expandPaths = expandPathsRaw ? expandPathsRaw.split(',').map((p) => p.trim()) : []; + const expandSchemas = expandSchemasRaw ? expandSchemasRaw.split(',').map((s) => s.trim()) : []; + const expandExamples = expandExamplesRaw ? expandExamplesRaw.split(',').map((e) => e.trim()) : []; + + if (!this.jsonEnabled()) { + // Build expansion info for the log message + const expansionInfo = this.getExpansionInfo(expandAll, expandPaths, expandSchemas, expandExamples); + + this.log( + t( + 'commands.scapi.schemas.get.fetching', + 'Fetching {{apiFamily}}/{{apiName}}/{{apiVersion}} schema{{expansionInfo}}...', + { + apiFamily, + apiName, + apiVersion, + expansionInfo, + }, + ), + ); + } + + const client = this.getSchemasClient(); + + const {data, error} = await client.GET( + '/organizations/{organizationId}/schemas/{apiFamily}/{apiName}/{apiVersion}', + { + params: { + path: { + organizationId: this.getOrganizationId(), + apiFamily, + apiName, + apiVersion, + }, + query: expandCustomProperties ? {expand: 'custom_properties'} : undefined, + }, + }, + ); + + if (error) { + this.error( + t('commands.scapi.schemas.get.error', 'Failed to fetch schema: {{message}}', { + message: formatApiError(error), + }), + ); + } + + // Handle list-* flags - just output the available items + if (listPaths || listSchemas || listExamples) { + const fullSchema = data as OpenApiSchemaInput; + + if (listPaths) { + const paths = getPathKeys(fullSchema); + return this.outputList(paths, 'paths'); + } + + if (listSchemas) { + const schemas = getSchemaNames(fullSchema); + return this.outputList(schemas, 'schemas'); + } + + if (listExamples) { + const examples = getExampleNames(fullSchema); + return this.outputList(examples, 'examples'); + } + } + + // Apply collapsing unless --expand-all is set + let schema: Record; + if (expandAll) { + schema = data as Record; + } else { + const fullSchema = data as OpenApiSchemaInput; + schema = collapseOpenApiSchema(fullSchema, { + expandPaths, + expandSchemas, + expandExamples, + }) as Record; + } + + const output: GetOutput = {apiFamily, apiName, apiVersion, schema}; + + // For --json flag, oclif handles serialization (wrapped output) + if (this.jsonEnabled()) { + return output; + } + + // Output to stdout (raw schema) + if (outputYaml) { + process.stdout.write(this.serializeToYaml(schema)); + process.stdout.write('\n'); + } else { + process.stdout.write(JSON.stringify(schema, null, 2)); + process.stdout.write('\n'); + } + + return output; + } + + /** + * Build expansion info string for the log message. + */ + private getExpansionInfo( + expandAll: boolean, + expandPaths: string[], + expandSchemas: string[], + expandExamples: string[], + ): string { + if (expandAll) { + return ' (expand=full)'; + } + + const parts: string[] = []; + + if (expandPaths.length > 0) { + parts.push(`paths: ${expandPaths.join(', ')}`); + } + + if (expandSchemas.length > 0) { + parts.push(`schemas: ${expandSchemas.join(', ')}`); + } + + if (expandExamples.length > 0) { + parts.push(`examples: ${expandExamples.join(', ')}`); + } + + if (parts.length > 0) { + return ` (expanding ${parts.join('; ')})`; + } + + return ' (outline only - use --expand-paths, --expand-schemas, or --expand-all for details)'; + } + + /** + * Output a list of items (paths, schemas, or examples). + */ + private outputList(items: string[], type: string): string[] { + if (this.jsonEnabled()) { + return items; + } + + if (items.length === 0) { + this.log(t('commands.scapi.schemas.get.noItems', 'No {{type}} found.', {type})); + } else { + this.log(t('commands.scapi.schemas.get.itemCount', 'Found {{count}} {{type}}:', {count: items.length, type})); + for (const item of items) { + this.log(` ${item}`); + } + } + + return items; + } + + /** + * Serialize object to YAML format. + * Uses a simple JSON-to-YAML conversion for basic YAML output. + */ + private serializeToYaml(obj: unknown, indent = 0): string { + const indentStr = ' '.repeat(indent); + + if (obj === null || obj === undefined) { + return 'null'; + } + + if (typeof obj === 'string') { + // Quote strings that need it + if (obj.includes('\n') || obj.includes(':') || obj.includes('#') || obj === '') { + return JSON.stringify(obj); + } + return obj; + } + + if (typeof obj === 'number' || typeof obj === 'boolean') { + return String(obj); + } + + if (Array.isArray(obj)) { + if (obj.length === 0) { + return '[]'; + } + + // For arrays of primitives, use inline format + if (obj.every((item) => typeof item !== 'object' || item === null)) { + return `[${obj.map((item) => this.serializeToYaml(item, 0)).join(', ')}]`; + } + + // For arrays of objects, use block format + const lines: string[] = []; + for (const item of obj) { + const serialized = this.serializeToYaml(item, indent + 1); + if (typeof item === 'object' && item !== null && !Array.isArray(item)) { + // Object items - first line starts with -, rest are indented + const objLines = serialized.split('\n'); + lines.push(`${indentStr}- ${objLines[0]}`); + for (let i = 1; i < objLines.length; i++) { + lines.push(`${indentStr} ${objLines[i]}`); + } + } else { + lines.push(`${indentStr}- ${serialized}`); + } + } + return lines.join('\n'); + } + + if (typeof obj === 'object') { + const entries = Object.entries(obj as Record); + if (entries.length === 0) { + return '{}'; + } + + const lines: string[] = []; + for (const [key, value] of entries) { + const serializedValue = this.serializeToYaml(value, indent + 1); + if (typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length > 0) { + // Nested object - put on next line + lines.push(`${indentStr}${key}:`); + const valueLines = serializedValue.split('\n'); + for (const line of valueLines) { + lines.push(`${indentStr} ${line}`); + } + } else if (Array.isArray(value) && value.some((item) => typeof item === 'object')) { + // Array with objects - put on next line + lines.push(`${indentStr}${key}:`); + const valueLines = serializedValue.split('\n'); + for (const line of valueLines) { + lines.push(line); + } + } else { + // Simple value or empty object/array - inline + lines.push(`${indentStr}${key}: ${serializedValue}`); + } + } + return lines.join('\n'); + } + + return String(obj); + } +} diff --git a/packages/b2c-cli/src/commands/scapi/schemas/list.ts b/packages/b2c-cli/src/commands/scapi/schemas/list.ts new file mode 100644 index 00000000..45002592 --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/schemas/list.ts @@ -0,0 +1,183 @@ +/* + * 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 {Flags} from '@oclif/core'; +import {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import type {SchemaListItem} from '@salesforce/b2c-tooling-sdk/clients'; +import {ScapiSchemasCommand, formatApiError} from '../../../utils/scapi/schemas.js'; +import {t} from '../../../i18n/index.js'; + +/** + * Response type for the list command. + */ +interface ListOutput { + schemas: SchemaListItem[]; + total: number; +} + +const COLUMNS: Record> = { + apiFamily: { + header: 'API Family', + get: (s) => s.apiFamily || '-', + }, + apiName: { + header: 'API Name', + get: (s) => s.apiName || '-', + }, + apiVersion: { + header: 'Version', + get: (s) => s.apiVersion || '-', + }, + status: { + header: 'Status', + get: (s) => s.status || '-', + }, + schemaVersion: { + header: 'Schema Ver', + get: (s) => s.schemaVersion || '-', + extended: true, + }, + link: { + header: 'Link', + get: (s) => s.link || '-', + extended: true, + }, +}; + +/** Default columns shown without --extended */ +const DEFAULT_COLUMNS = ['apiFamily', 'apiName', 'apiVersion', 'status']; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to list available SCAPI schemas. + */ +export default class ScapiSchemasList extends ScapiSchemasCommand { + static description = t('commands.scapi.schemas.list.description', 'List available SCAPI schemas'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --api-family shopper', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --api-name products', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --status current', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --extended', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --json', + ]; + + static flags = { + ...ScapiSchemasCommand.baseFlags, + 'api-family': Flags.string({ + description: t('flags.apiFamily.description', 'Filter by API family (e.g., shopper, admin)'), + }), + 'api-name': Flags.string({ + description: t('flags.apiName.description', 'Filter by API name (e.g., products, orders)'), + }), + 'api-version': Flags.string({ + description: t('flags.apiVersion.description', 'Filter by API version (e.g., v1)'), + }), + status: Flags.string({ + char: 's', + description: t('flags.status.description', 'Filter by schema status'), + options: ['current', 'deprecated'], + }), + columns: Flags.string({ + char: 'c', + description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, + }), + extended: Flags.boolean({ + char: 'x', + description: t('flags.extended.description', 'Show all columns including extended fields'), + default: false, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {'api-family': apiFamily, 'api-name': apiName, 'api-version': apiVersion, status} = this.flags; + + if (!this.jsonEnabled()) { + this.log(t('commands.scapi.schemas.list.fetching', 'Fetching SCAPI schemas...')); + } + + const client = this.getSchemasClient(); + + const {data, error} = await client.GET('/organizations/{organizationId}/schemas', { + params: { + path: {organizationId: this.getOrganizationId()}, + query: { + apiFamily: apiFamily || undefined, + apiName: apiName || undefined, + apiVersion: apiVersion || undefined, + status: status as 'current' | 'deprecated' | undefined, + }, + }, + }); + + if (error) { + this.error( + t('commands.scapi.schemas.list.error', 'Failed to fetch SCAPI schemas: {{message}}', { + message: formatApiError(error), + }), + ); + } + + const schemas = data?.data ?? []; + const output: ListOutput = { + schemas, + total: data?.total ?? schemas.length, + }; + + if (this.jsonEnabled()) { + return output; + } + + if (schemas.length === 0) { + this.log(t('commands.scapi.schemas.list.noSchemas', 'No schemas found.')); + return output; + } + + this.log( + t('commands.scapi.schemas.list.count', 'Found {{count}} schema(s):', { + count: schemas.length, + }), + ); + this.log(''); + + const columns = this.getSelectedColumns(); + tableRenderer.render(schemas, columns); + + return output; + } + + /** + * Determines which columns to display based on flags. + */ + private getSelectedColumns(): string[] { + const columnsFlag = this.flags.columns; + const extended = this.flags.extended; + + if (columnsFlag) { + // User specified explicit columns + const requested = columnsFlag.split(',').map((c) => c.trim()); + const valid = tableRenderer.validateColumnKeys(requested); + if (valid.length === 0) { + this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); + return DEFAULT_COLUMNS; + } + return valid; + } + + if (extended) { + // Show all columns + return tableRenderer.getColumnKeys(); + } + + // Default columns (non-extended) + return DEFAULT_COLUMNS; + } +} diff --git a/packages/b2c-cli/src/utils/scapi/schemas.ts b/packages/b2c-cli/src/utils/scapi/schemas.ts new file mode 100644 index 00000000..c661ffb0 --- /dev/null +++ b/packages/b2c-cli/src/utils/scapi/schemas.ts @@ -0,0 +1,59 @@ +/* + * 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 {Command, Flags} from '@oclif/core'; +import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {createScapiSchemasClient, toOrganizationId, type ScapiSchemasClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {t} from '../../i18n/index.js'; + +/** + * Format API error for display. + */ +export function formatApiError(error: unknown): string { + return typeof error === 'object' ? JSON.stringify(error) : String(error); +} + +/** + * Base command for SCAPI Schemas operations. + * Provides common flags and helper methods for interacting with the SCAPI Schemas API. + */ +export abstract class ScapiSchemasCommand extends OAuthCommand { + static baseFlags = { + ...OAuthCommand.baseFlags, + 'tenant-id': Flags.string({ + description: t('flags.tenantId.description', 'Organization/tenant ID'), + env: 'SFCC_TENANT_ID', + required: true, + }), + }; + + /** + * Get the organization ID from the tenant-id flag. + */ + protected getOrganizationId(): string { + const tenantId = (this.flags as Record)['tenant-id']; + return toOrganizationId(tenantId); + } + + /** + * Get the SCAPI Schemas client, ensuring short code is configured. + */ + protected getSchemasClient(): ScapiSchemasClient { + const {shortCode} = this.resolvedConfig; + const tenantId = (this.flags as Record)['tenant-id']; + + if (!shortCode) { + this.error( + t( + 'error.shortCodeRequired', + 'SCAPI short code required. Provide --short-code, set SFCC_SHORTCODE, or configure short-code in dw.json.', + ), + ); + } + + const oauthStrategy = this.getOAuthStrategy(); + return createScapiSchemasClient({shortCode, tenantId}, oauthStrategy); + } +} diff --git a/packages/b2c-cli/test/commands/scapi/schemas/get.test.ts b/packages/b2c-cli/test/commands/scapi/schemas/get.test.ts new file mode 100644 index 00000000..772433fb --- /dev/null +++ b/packages/b2c-cli/test/commands/scapi/schemas/get.test.ts @@ -0,0 +1,48 @@ +/* + * 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 {runCommand} from '@oclif/test'; +import {expect} from 'chai'; + +describe('scapi schemas get', () => { + it('shows help without errors', async () => { + const {error} = await runCommand('scapi schemas get --help'); + expect(error).to.be.undefined; + }); + + it('requires tenant-id flag', async () => { + const {error} = await runCommand('scapi schemas get shopper products v1'); + expect(error).to.not.be.undefined; + expect(error?.message).to.include('tenant-id'); + }); + + it('requires apiFamily argument', async () => { + const {error} = await runCommand('scapi schemas get --tenant-id f_ecom_zzxy_prd'); + expect(error).to.not.be.undefined; + expect(error?.message).to.include('apiFamily'); + }); + + it('shows expand flags in help', async () => { + const {stdout} = await runCommand('scapi schemas get --help'); + expect(stdout).to.include('--expand-paths'); + expect(stdout).to.include('--expand-schemas'); + expect(stdout).to.include('--expand-examples'); + expect(stdout).to.include('--expand-all'); + expect(stdout).to.include('--expand-custom-properties'); + }); + + it('shows list flags in help', async () => { + const {stdout} = await runCommand('scapi schemas get --help'); + expect(stdout).to.include('--list-paths'); + expect(stdout).to.include('--list-schemas'); + expect(stdout).to.include('--list-examples'); + }); + + it('shows output format flags in help', async () => { + const {stdout} = await runCommand('scapi schemas get --help'); + expect(stdout).to.include('--yaml'); + expect(stdout).to.include('--json'); + }); +}); diff --git a/packages/b2c-cli/test/commands/scapi/schemas/list.test.ts b/packages/b2c-cli/test/commands/scapi/schemas/list.test.ts new file mode 100644 index 00000000..55e54209 --- /dev/null +++ b/packages/b2c-cli/test/commands/scapi/schemas/list.test.ts @@ -0,0 +1,36 @@ +/* + * 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 {runCommand} from '@oclif/test'; +import {expect} from 'chai'; + +describe('scapi schemas list', () => { + it('shows help without errors', async () => { + const {error} = await runCommand('scapi schemas list --help'); + expect(error).to.be.undefined; + }); + + it('requires tenant-id flag', async () => { + const {error} = await runCommand('scapi schemas list'); + expect(error).to.not.be.undefined; + expect(error?.message).to.include('tenant-id'); + }); + + it('shows available columns in help', async () => { + const {stdout} = await runCommand('scapi schemas list --help'); + expect(stdout).to.include('apiFamily'); + expect(stdout).to.include('apiName'); + expect(stdout).to.include('apiVersion'); + expect(stdout).to.include('status'); + }); + + it('shows filter flags in help', async () => { + const {stdout} = await runCommand('scapi schemas list --help'); + expect(stdout).to.include('--api-family'); + expect(stdout).to.include('--api-name'); + expect(stdout).to.include('--api-version'); + expect(stdout).to.include('--status'); + }); +}); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 4a171bb4..badd186d 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -113,6 +113,17 @@ "default": "./dist/cjs/operations/docs/index.js" } }, + "./operations/scapi-schemas": { + "development": "./src/operations/scapi-schemas/index.ts", + "import": { + "types": "./dist/esm/operations/scapi-schemas/index.d.ts", + "default": "./dist/esm/operations/scapi-schemas/index.js" + }, + "require": { + "types": "./dist/cjs/operations/scapi-schemas/index.d.ts", + "default": "./dist/cjs/operations/scapi-schemas/index.js" + } + }, "./cli": { "development": "./src/cli/index.ts", "import": { @@ -189,7 +200,7 @@ "data" ], "scripts": { - "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts", + "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts", "build": "pnpm run generate:types && pnpm run build:esm && pnpm run build:cjs", "build:esm": "tsc -p tsconfig.esm.json", "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", diff --git a/packages/b2c-tooling-sdk/specs/scapi-schemas-v1.yaml b/packages/b2c-tooling-sdk/specs/scapi-schemas-v1.yaml new file mode 100644 index 00000000..615c2d5f --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/scapi-schemas-v1.yaml @@ -0,0 +1,341 @@ +openapi: 3.0.3 +info: + x-api-type: Admin + x-api-family: DX + title: SCAPI Schemas + version: 1.0.0 + description: |- + [Download API specification](https://developer.salesforce.com/static/commercecloud/commerce-api/scapi-schemas/scapi-schemas-oas-v1-public.yaml) + + # API Overview + + The SCAPI Schemas API provides access to OpenAPI schema definitions for Salesforce Commerce APIs (SCAPI). This API enables discovery and cataloging of available SCAPI endpoints, schema validation and code generation, version management for migration planning, and integration of schema access into development workflows including CI/CD pipelines and testing automation. + + ## Authentication & Authorization + + The SCAPI Schemas API requires valid authentication and authorization. It uses an Account Manager OAuth 2.0 bearer token for authentication. The necessary scope for accessing the API is: + + * `sfcc.scapi-schemas`: Provides read-only access to schema definitions. + + Access is intended for admin users only. + + ## Use Cases + + ### Schema Discovery + + Developers can use this API to discover available SCAPI schemas, filter by API family (e.g., shopper, admin), and retrieve detailed OpenAPI specifications for code generation or documentation purposes. + + ### Custom Property Expansion + + When retrieving a schema, developers can request expansion of custom properties to see tenant-specific customizations applied to standard SCAPI schemas. +servers: + - url: https://{shortCode}.api.commercecloud.salesforce.com/dx/scapi-schemas/v1 + variables: + shortCode: + default: shortCode +paths: + /organizations/{organizationId}/schemas: + get: + summary: Get a list of available SCAPI schemas. + description: List available SCAPI schema definitions with optional filtering by API family, name, version, or status. + operationId: getSchemas + parameters: + - $ref: '#/components/parameters/organizationId' + - $ref: '#/components/parameters/apiFamily' + - $ref: '#/components/parameters/apiName' + - $ref: '#/components/parameters/apiVersion' + - $ref: '#/components/parameters/status' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/SchemaListResult' + '400': + description: Invalid or malformed filter parameter + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - invalid or missing authentication + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - insufficient permissions + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - AmOAuth2: + - sfcc.scapi-schemas + /organizations/{organizationId}/schemas/{apiFamily}/{apiName}/{apiVersion}: + get: + summary: Get a specific SCAPI schema. + description: Retrieve the detailed OpenAPI schema specification for a specific SCAPI API. + operationId: getSchema + parameters: + - $ref: '#/components/parameters/organizationId' + - $ref: '#/components/parameters/apiFamilyPath' + - $ref: '#/components/parameters/apiNamePath' + - $ref: '#/components/parameters/apiVersionPath' + - $ref: '#/components/parameters/expand' + responses: + '200': + description: Successful operation - returns OpenAPI schema + content: + application/json: + schema: + $ref: '#/components/schemas/OpenApiSchema' + '400': + description: Invalid parameter value + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - invalid or missing authentication + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - insufficient permissions + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Schema not found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - AmOAuth2: + - sfcc.scapi-schemas +components: + securitySchemes: + AmOAuth2: + type: oauth2 + description: AccountManager OAuth 2.0 bearer token Authentication. + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: + sfcc.scapi-schemas: SCAPI Schemas READONLY scope + authorizationCode: + authorizationUrl: https://account.demandware.com/dwsso/oauth2/authorize + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: + sfcc.scapi-schemas: SCAPI Schemas READONLY scope + schemas: + OrganizationId: + description: An identifier for the organization the request is being made by + example: f_ecom_zzxy_prd + type: string + minLength: 1 + maxLength: 32 + SchemaStatus: + type: string + enum: + - current + - deprecated + example: current + Limit: + default: 10 + minimum: 1 + format: int32 + description: Maximum records to retrieve per request, not to exceed the maximum defined. A limit must be at least 1 so at least one record is returned (if any match the criteria). + type: integer + example: 10 + Total: + default: 0 + minimum: 0 + format: int32 + description: The total number of hits that match the search's criteria. This can be greater than the number of results returned as search results are paginated. + type: integer + example: 10 + ResultBase: + description: Schema defining generic list result. Each response schema of a resource requiring a list response should extend this schema. + type: object + required: + - limit + - total + properties: + limit: + maximum: 200 + allOf: + - $ref: '#/components/schemas/Limit' + total: + $ref: '#/components/schemas/Total' + SchemaListFilter: + type: object + properties: + apiFamily: + type: string + example: shopper + apiName: + type: string + example: products + apiVersion: + type: string + example: v1 + status: + $ref: '#/components/schemas/SchemaStatus' + SchemaListItem: + type: object + properties: + schemaVersion: + type: string + description: Semantic version of the schema (e.g., "1.0.0") + example: '1.0.0' + apiFamily: + type: string + description: The API family (e.g., shopper, admin) + example: shopper + apiName: + type: string + description: The API name (e.g., products, orders) + example: products + apiVersion: + type: string + description: The API version (e.g., v1) + example: v1 + status: + $ref: '#/components/schemas/SchemaStatus' + link: + type: string + description: URL to the schema detail endpoint + example: /organizations/f_ecom_zzxy_prd/schemas/shopper/products/v1 + SchemaListResult: + type: object + allOf: + - $ref: '#/components/schemas/ResultBase' + properties: + filter: + $ref: '#/components/schemas/SchemaListFilter' + data: + type: array + items: + $ref: '#/components/schemas/SchemaListItem' + OpenApiSchema: + type: object + description: An OpenAPI 3.0 schema specification + additionalProperties: true + properties: + openapi: + type: string + description: OpenAPI version + example: '3.0.3' + info: + type: object + additionalProperties: true + properties: + title: + type: string + version: + type: string + description: + type: string + paths: + type: object + additionalProperties: true + components: + type: object + additionalProperties: true + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + description: A short, human-readable summary of the problem type. + type: string + maxLength: 256 + example: Bad Request + type: + description: A URI reference that identifies the problem type. + type: string + maxLength: 2048 + example: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/bad-request + detail: + description: A human-readable explanation specific to this occurrence of the problem. + type: string + example: Invalid value for filter parameter. + instance: + description: A URI reference that identifies the specific occurrence of the problem. + type: string + maxLength: 2048 + required: + - title + - type + - detail + parameters: + organizationId: + description: An identifier for the organization the request is being made by + name: organizationId + in: path + required: true + example: f_ecom_zzxy_prd + schema: + $ref: '#/components/schemas/OrganizationId' + apiFamily: + name: apiFamily + in: query + required: false + description: Filter by API family (e.g., shopper, admin) + schema: + type: string + apiName: + name: apiName + in: query + required: false + description: Filter by API name (e.g., products, orders) + schema: + type: string + apiVersion: + name: apiVersion + in: query + required: false + description: Filter by API version (e.g., v1) + schema: + type: string + status: + name: status + in: query + required: false + description: Filter by schema status + schema: + $ref: '#/components/schemas/SchemaStatus' + apiFamilyPath: + name: apiFamily + in: path + required: true + description: The API family (e.g., shopper, admin) + schema: + type: string + apiNamePath: + name: apiName + in: path + required: true + description: The API name (e.g., products, orders) + schema: + type: string + apiVersionPath: + name: apiVersion + in: path + required: true + description: The API version (e.g., v1) + schema: + type: string + expand: + name: expand + in: query + required: false + description: Comma-separated list of sections to expand (e.g., "custom_properties") + schema: + type: string diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 0591330b..591d3615 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -16,6 +16,7 @@ * - {@link SlasClient} - SLAS Admin API for managing tenants and clients * - {@link OdsClient} - On-Demand Sandbox API for managing developer sandboxes * - {@link CustomApisClient} - Custom APIs DX API for retrieving endpoint status + * - {@link ScapiSchemasClient} - SCAPI Schemas API for discovering and retrieving OpenAPI schemas * * ## Usage * @@ -181,3 +182,16 @@ export type { paths as CustomApisPaths, components as CustomApisComponents, } from './custom-apis.js'; + +export {createScapiSchemasClient, SCAPI_SCHEMAS_DEFAULT_SCOPES} from './scapi-schemas.js'; +export type { + ScapiSchemasClient, + ScapiSchemasClientConfig, + ScapiSchemasError, + ScapiSchemasResponse, + SchemaListItem, + SchemaListResult, + OpenApiSchema, + paths as ScapiSchemasPaths, + components as ScapiSchemasComponents, +} from './scapi-schemas.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 7e204c8f..1dfebdec 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -44,7 +44,7 @@ import type {Middleware} from 'openapi-fetch'; /** * Types of HTTP clients that can receive middleware. */ -export type HttpClientType = 'ocapi' | 'slas' | 'ods' | 'mrt' | 'custom-apis' | 'webdav'; +export type HttpClientType = 'ocapi' | 'slas' | 'ods' | 'mrt' | 'custom-apis' | 'scapi-schemas' | 'webdav'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-schemas.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-schemas.generated.ts new file mode 100644 index 00000000..664d2472 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-schemas.generated.ts @@ -0,0 +1,329 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/schemas": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a list of available SCAPI schemas. + * @description List available SCAPI schema definitions with optional filtering by API family, name, version, or status. + */ + get: operations["getSchemas"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/schemas/{apiFamily}/{apiName}/{apiVersion}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a specific SCAPI schema. + * @description Retrieve the detailed OpenAPI schema specification for a specific SCAPI API. + */ + get: operations["getSchema"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + OrganizationId: string; + /** + * @example current + * @enum {string} + */ + SchemaStatus: "current" | "deprecated"; + /** + * Format: int32 + * @description Maximum records to retrieve per request, not to exceed the maximum defined. A limit must be at least 1 so at least one record is returned (if any match the criteria). + * @default 10 + * @example 10 + */ + Limit: number; + /** + * Format: int32 + * @description The total number of hits that match the search's criteria. This can be greater than the number of results returned as search results are paginated. + * @default 0 + * @example 10 + */ + Total: number; + /** @description Schema defining generic list result. Each response schema of a resource requiring a list response should extend this schema. */ + ResultBase: { + limit: components["schemas"]["Limit"]; + total: components["schemas"]["Total"]; + }; + SchemaListFilter: { + /** @example shopper */ + apiFamily?: string; + /** @example products */ + apiName?: string; + /** @example v1 */ + apiVersion?: string; + status?: components["schemas"]["SchemaStatus"]; + }; + SchemaListItem: { + /** + * @description Semantic version of the schema (e.g., "1.0.0") + * @example 1.0.0 + */ + schemaVersion?: string; + /** + * @description The API family (e.g., shopper, admin) + * @example shopper + */ + apiFamily?: string; + /** + * @description The API name (e.g., products, orders) + * @example products + */ + apiName?: string; + /** + * @description The API version (e.g., v1) + * @example v1 + */ + apiVersion?: string; + status?: components["schemas"]["SchemaStatus"]; + /** + * @description URL to the schema detail endpoint + * @example /organizations/f_ecom_zzxy_prd/schemas/shopper/products/v1 + */ + link?: string; + }; + SchemaListResult: { + filter?: components["schemas"]["SchemaListFilter"]; + data?: components["schemas"]["SchemaListItem"][]; + } & components["schemas"]["ResultBase"]; + /** @description An OpenAPI 3.0 schema specification */ + OpenApiSchema: { + /** + * @description OpenAPI version + * @example 3.0.3 + */ + openapi?: string; + info?: { + title?: string; + version?: string; + description?: string; + } & { + [key: string]: unknown; + }; + paths?: { + [key: string]: unknown; + }; + components?: { + [key: string]: unknown; + }; + } & { + [key: string]: unknown; + }; + ErrorResponse: { + /** + * @description A short, human-readable summary of the problem type. + * @example Bad Request + */ + title: string; + /** + * @description A URI reference that identifies the problem type. + * @example https://api.commercecloud.salesforce.com/documentation/error/v1/errors/bad-request + */ + type: string; + /** + * @description A human-readable explanation specific to this occurrence of the problem. + * @example Invalid value for filter parameter. + */ + detail: string; + /** @description A URI reference that identifies the specific occurrence of the problem. */ + instance?: string; + } & { + [key: string]: unknown; + }; + }; + responses: never; + parameters: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["schemas"]["OrganizationId"]; + /** @description Filter by API family (e.g., shopper, admin) */ + apiFamily: string; + /** @description Filter by API name (e.g., products, orders) */ + apiName: string; + /** @description Filter by API version (e.g., v1) */ + apiVersion: string; + /** @description Filter by schema status */ + status: components["schemas"]["SchemaStatus"]; + /** @description The API family (e.g., shopper, admin) */ + apiFamilyPath: string; + /** @description The API name (e.g., products, orders) */ + apiNamePath: string; + /** @description The API version (e.g., v1) */ + apiVersionPath: string; + /** @description Comma-separated list of sections to expand (e.g., "custom_properties") */ + expand: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getSchemas: { + parameters: { + query?: { + /** @description Filter by API family (e.g., shopper, admin) */ + apiFamily?: components["parameters"]["apiFamily"]; + /** @description Filter by API name (e.g., products, orders) */ + apiName?: components["parameters"]["apiName"]; + /** @description Filter by API version (e.g., v1) */ + apiVersion?: components["parameters"]["apiVersion"]; + /** @description Filter by schema status */ + status?: components["parameters"]["status"]; + }; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SchemaListResult"]; + }; + }; + /** @description Invalid or malformed filter parameter */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unauthorized - invalid or missing authentication */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden - insufficient permissions */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getSchema: { + parameters: { + query?: { + /** @description Comma-separated list of sections to expand (e.g., "custom_properties") */ + expand?: components["parameters"]["expand"]; + }; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + /** @description The API family (e.g., shopper, admin) */ + apiFamily: components["parameters"]["apiFamilyPath"]; + /** @description The API name (e.g., products, orders) */ + apiName: components["parameters"]["apiNamePath"]; + /** @description The API version (e.g., v1) */ + apiVersion: components["parameters"]["apiVersionPath"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful operation - returns OpenAPI schema */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OpenApiSchema"]; + }; + }; + /** @description Invalid parameter value */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unauthorized - invalid or missing authentication */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden - insufficient permissions */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Schema not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-schemas.ts b/packages/b2c-tooling-sdk/src/clients/scapi-schemas.ts new file mode 100644 index 00000000..d5165efa --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-schemas.ts @@ -0,0 +1,174 @@ +/* + * 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 + */ +/** + * SCAPI Schemas API client for B2C Commerce. + * + * Provides a fully typed client for SCAPI Schemas API operations using + * openapi-fetch with OAuth authentication middleware. Used for discovering + * and retrieving OpenAPI schema specifications for SCAPI APIs. + * + * @module clients/scapi-schemas + */ +import createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import type {paths, components} from './scapi-schemas.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {toOrganizationId, toTenantId, buildTenantScope} from './custom-apis.js'; + +/** + * Re-export generated types for external use. + */ +export type {paths, components}; + +/** + * Re-export organization/tenant utilities for convenience. + */ +export {toOrganizationId, toTenantId, buildTenantScope}; + +/** + * The typed SCAPI Schemas client - this is the openapi-fetch Client with full type safety. + * + * @see {@link createScapiSchemasClient} for instantiation + */ +export type ScapiSchemasClient = Client; + +/** + * Helper type to extract response data from an operation. + */ +export type ScapiSchemasResponse = T extends {content: {'application/json': infer R}} ? R : never; + +/** + * Standard SCAPI Schemas error response structure. + */ +export type ScapiSchemasError = components['schemas']['ErrorResponse']; + +/** + * Schema list item from the list endpoint. + */ +export type SchemaListItem = components['schemas']['SchemaListItem']; + +/** + * Schema list result from the list endpoint. + */ +export type SchemaListResult = components['schemas']['SchemaListResult']; + +/** + * OpenAPI schema structure returned by the get endpoint. + */ +export type OpenApiSchema = components['schemas']['OpenApiSchema']; + +/** Default OAuth scopes required for SCAPI Schemas (read-only) */ +export const SCAPI_SCHEMAS_DEFAULT_SCOPES = ['sfcc.scapi-schemas']; + +/** + * Configuration for creating a SCAPI Schemas client. + */ +export interface ScapiSchemasClientConfig { + /** + * The short code for the SCAPI instance. + * This is typically a 4-8 character alphanumeric code. + * @example "kv7kzm78" + */ + shortCode: string; + + /** + * The tenant ID (with or without f_ecom_ prefix). + * Used to build the organizationId path parameter and tenant-specific OAuth scope. + * @example "zzxy_prd" or "f_ecom_zzxy_prd" + */ + tenantId: string; + + /** + * Optional scope override. If not provided, defaults to domain scope + * (sfcc.scapi-schemas) plus tenant-specific scope (SALESFORCE_COMMERCE_API:{tenant}). + */ + scopes?: string[]; + + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; +} + +/** + * Creates a typed SCAPI Schemas API client. + * + * Returns the openapi-fetch client directly, with authentication + * handled via middleware. This gives full access to all openapi-fetch + * features with type-safe paths, parameters, and responses. + * + * The client automatically handles OAuth scope requirements: + * - Domain scope: `sfcc.scapi-schemas` (or custom via config.scopes) + * - Tenant scope: `SALESFORCE_COMMERCE_API:{tenantId}` + * + * @param config - SCAPI Schemas client configuration including shortCode and tenantId + * @param auth - Authentication strategy (typically OAuth) + * @returns Typed openapi-fetch client + * + * @example + * // Create SCAPI Schemas client - scopes are handled automatically + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createScapiSchemasClient( + * { shortCode: 'kv7kzm78', tenantId: 'zzxy_prd' }, + * oauthStrategy + * ); + * + * // List available SCAPI schemas + * const { data, error } = await client.GET('/organizations/{organizationId}/schemas', { + * params: { + * path: { organizationId: toOrganizationId('zzxy_prd') } + * } + * }); + * + * // Get a specific schema + * const { data: schema } = await client.GET( + * '/organizations/{organizationId}/schemas/{apiFamily}/{apiName}/{apiVersion}', + * { + * params: { + * path: { + * organizationId: toOrganizationId('zzxy_prd'), + * apiFamily: 'shopper', + * apiName: 'products', + * apiVersion: 'v1' + * }, + * query: { expand: 'custom_properties' } + * } + * } + * ); + */ +export function createScapiSchemasClient(config: ScapiSchemasClientConfig, auth: AuthStrategy): ScapiSchemasClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/dx/scapi-schemas/v1`, + }); + + // Build required scopes: domain scope + tenant-specific scope + const requiredScopes = config.scopes ?? [...SCAPI_SCHEMAS_DEFAULT_SCOPES, buildTenantScope(config.tenantId)]; + + // If OAuth strategy, add required scopes; otherwise use as-is + const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; + + // Core middleware: auth first + client.use(createAuthMiddleware(scopedAuth)); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('scapi-schemas')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) + client.use(createLoggingMiddleware('SCAPI-SCHEMAS')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/operations/scapi-schemas/collapse.ts b/packages/b2c-tooling-sdk/src/operations/scapi-schemas/collapse.ts new file mode 100644 index 00000000..38a02bcd --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/scapi-schemas/collapse.ts @@ -0,0 +1,304 @@ +/* + * 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 + */ + +/** + * OpenAPI schema collapsing utilities for agentic clients. + * + * These utilities implement a three-tier disclosure model for OpenAPI schemas: + * - **Collapsed** (default): Show structure only - paths as method names, schemas as keys + * - **Selective expansion**: Full details for specific items only + * - **Full expansion**: Complete schema without any collapsing + * + * This approach addresses context length concerns when working with large OpenAPI + * schemas in agentic/LLM contexts. + * + * @module operations/scapi-schemas/collapse + */ + +/** + * Options for collapsing an OpenAPI schema. + */ +export interface SchemaCollapseOptions { + /** + * Paths to fully expand (e.g., ["/products", "/orders"]). + * When provided, only these paths will have full operation details. + * Other paths will show only their HTTP method names. + */ + expandPaths?: string[]; + + /** + * Schema names to fully expand (e.g., ["Product", "Order"]). + * When provided, only these schemas will have full definitions. + * Other schemas will be shown as empty objects. + */ + expandSchemas?: string[]; + + /** + * Example names to fully expand (e.g., ["ProductExample"]). + * When provided, only these examples will have full content. + * Other examples will be shown as empty objects. + */ + expandExamples?: string[]; +} + +/** + * Represents an OpenAPI 3.x schema structure. + * This is a simplified type that captures the structure we need for collapsing. + */ +export interface OpenApiSchemaInput { + openapi?: string; + info?: Record; + servers?: unknown[]; + paths?: Record>; + components?: { + schemas?: Record; + examples?: Record; + parameters?: Record; + responses?: Record; + requestBodies?: Record; + headers?: Record; + securitySchemes?: Record; + links?: Record; + callbacks?: Record; + }; + security?: unknown[]; + tags?: unknown[]; + externalDocs?: unknown; + [key: string]: unknown; +} + +/** + * Represents a collapsed path entry. + * When collapsed, a path only shows the HTTP methods it supports. + */ +export type CollapsedPath = string[] | Record; + +/** + * The output schema structure after collapsing. + */ +export interface CollapsedOpenApiSchema { + openapi?: string; + info?: Record; + servers?: unknown[]; + paths?: Record; + components?: { + schemas?: Record; + examples?: Record; + parameters?: Record; + responses?: Record; + requestBodies?: Record; + headers?: Record; + securitySchemes?: Record; + links?: Record; + callbacks?: Record; + }; + security?: unknown[]; + tags?: unknown[]; + externalDocs?: unknown; + [key: string]: unknown; +} + +/** HTTP methods that can appear in OpenAPI paths */ +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const; + +/** + * Collapses an OpenAPI schema for context-efficient representation. + * + * This function implements a three-tier disclosure model: + * + * 1. **No options provided (default):** + * - Paths: `{"/products": ["get", "post"]}` (method names only) + * - Schemas: `{"Product": {}}` (keys only) + * - Examples: `{"ProductExample": {}}` (keys only) + * + * 2. **Selective expansion:** + * - Only specified paths/schemas/examples are fully expanded + * - Everything else remains collapsed + * + * Non-collapsible sections (info, servers, security, tags, etc.) are preserved as-is. + * + * @param schema - The OpenAPI schema to collapse + * @param options - Options controlling what to expand + * @returns The collapsed schema + * + * @example + * // Collapse everything (default behavior) + * const collapsed = collapseOpenApiSchema(fullSchema); + * + * @example + * // Expand only /products path and Product schema + * const collapsed = collapseOpenApiSchema(fullSchema, { + * expandPaths: ['/products'], + * expandSchemas: ['Product'] + * }); + */ +export function collapseOpenApiSchema( + schema: OpenApiSchemaInput, + options: SchemaCollapseOptions = {}, +): CollapsedOpenApiSchema { + const {expandPaths = [], expandSchemas = [], expandExamples = []} = options; + + // Start with a shallow copy + const result: CollapsedOpenApiSchema = {...schema}; + + // Collapse paths + if (schema.paths) { + result.paths = collapsePaths(schema.paths, expandPaths); + } + + // Collapse components + if (schema.components) { + result.components = { + ...schema.components, + }; + + if (schema.components.schemas) { + result.components.schemas = collapseSchemas(schema.components.schemas, expandSchemas); + } + + if (schema.components.examples) { + result.components.examples = collapseExamples(schema.components.examples, expandExamples); + } + } + + return result; +} + +/** + * Collapses path items to show only HTTP method names. + * + * @param paths - The paths object from an OpenAPI schema + * @param expandPaths - Paths to keep fully expanded + * @returns Collapsed paths object + */ +function collapsePaths( + paths: Record>, + expandPaths: string[], +): Record { + const result: Record = {}; + const expandSet = new Set(expandPaths); + + for (const [pathKey, pathItem] of Object.entries(paths)) { + if (expandSet.has(pathKey)) { + // Keep full path item for expanded paths + result[pathKey] = pathItem; + } else { + // Collapse to method names only + const methods = Object.keys(pathItem).filter((key) => + HTTP_METHODS.includes(key as (typeof HTTP_METHODS)[number]), + ); + result[pathKey] = methods; + } + } + + return result; +} + +/** + * Collapses schemas to show only keys with empty objects. + * + * @param schemas - The schemas object from components + * @param expandSchemas - Schema names to keep fully expanded + * @returns Collapsed schemas object + */ +function collapseSchemas(schemas: Record, expandSchemas: string[]): Record { + const result: Record = {}; + const expandSet = new Set(expandSchemas); + + for (const [schemaName, schemaValue] of Object.entries(schemas)) { + if (expandSet.has(schemaName)) { + // Keep full schema for expanded schemas + result[schemaName] = schemaValue; + } else { + // Collapse to empty object + result[schemaName] = {}; + } + } + + return result; +} + +/** + * Collapses examples to show only keys with empty objects. + * + * @param examples - The examples object from components + * @param expandExamples - Example names to keep fully expanded + * @returns Collapsed examples object + */ +function collapseExamples(examples: Record, expandExamples: string[]): Record { + const result: Record = {}; + const expandSet = new Set(expandExamples); + + for (const [exampleName, exampleValue] of Object.entries(examples)) { + if (expandSet.has(exampleName)) { + // Keep full example for expanded examples + result[exampleName] = exampleValue; + } else { + // Collapse to empty object + result[exampleName] = {}; + } + } + + return result; +} + +/** + * Checks if a schema has been collapsed (i.e., is in outline form). + * + * @param schema - The schema to check + * @returns true if paths are collapsed (arrays) or schemas are empty objects + */ +export function isCollapsedSchema(schema: CollapsedOpenApiSchema): boolean { + // Check if any path is collapsed (array of methods instead of full path item) + if (schema.paths) { + for (const pathItem of Object.values(schema.paths)) { + if (Array.isArray(pathItem)) { + return true; + } + } + } + + // Check if any schema is collapsed (empty object) + if (schema.components?.schemas) { + for (const schemaValue of Object.values(schema.components.schemas)) { + if (typeof schemaValue === 'object' && schemaValue !== null && Object.keys(schemaValue).length === 0) { + return true; + } + } + } + + return false; +} + +/** + * Gets the list of available path keys from a schema. + * + * @param schema - The OpenAPI schema + * @returns Array of path keys (e.g., ["/products", "/orders"]) + */ +export function getPathKeys(schema: OpenApiSchemaInput | CollapsedOpenApiSchema): string[] { + return schema.paths ? Object.keys(schema.paths) : []; +} + +/** + * Gets the list of available schema names from a schema. + * + * @param schema - The OpenAPI schema + * @returns Array of schema names (e.g., ["Product", "Order"]) + */ +export function getSchemaNames(schema: OpenApiSchemaInput | CollapsedOpenApiSchema): string[] { + return schema.components?.schemas ? Object.keys(schema.components.schemas) : []; +} + +/** + * Gets the list of available example names from a schema. + * + * @param schema - The OpenAPI schema + * @returns Array of example names + */ +export function getExampleNames(schema: OpenApiSchemaInput | CollapsedOpenApiSchema): string[] { + return schema.components?.examples ? Object.keys(schema.components.examples) : []; +} diff --git a/packages/b2c-tooling-sdk/src/operations/scapi-schemas/index.ts b/packages/b2c-tooling-sdk/src/operations/scapi-schemas/index.ts new file mode 100644 index 00000000..03219744 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/scapi-schemas/index.ts @@ -0,0 +1,63 @@ +/* + * 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 + */ + +/** + * SCAPI Schemas operations for B2C Commerce. + * + * Provides utilities for working with SCAPI OpenAPI schemas, including + * collapsing large schemas for context-efficient representation in + * agentic/LLM scenarios. + * + * ## Schema Collapsing + * + * Use {@link collapseOpenApiSchema} to reduce the size of large OpenAPI schemas + * while preserving structure for discovery: + * + * ```typescript + * import { collapseOpenApiSchema } from '@salesforce/b2c-tooling-sdk/operations/scapi-schemas'; + * + * // Collapse to outline form (default) + * const collapsed = collapseOpenApiSchema(fullSchema); + * // Result: paths show only ["get", "post"], schemas show only {} + * + * // Selectively expand specific items + * const collapsed = collapseOpenApiSchema(fullSchema, { + * expandPaths: ['/products/search'], + * expandSchemas: ['Product', 'SearchResult'] + * }); + * ``` + * + * ## Utility Functions + * + * Helper functions for inspecting schemas: + * + * ```typescript + * import { + * getPathKeys, + * getSchemaNames, + * isCollapsedSchema + * } from '@salesforce/b2c-tooling-sdk/operations/scapi-schemas'; + * + * // Get available paths + * const paths = getPathKeys(schema); // ["/products", "/orders", ...] + * + * // Get available schemas + * const schemas = getSchemaNames(schema); // ["Product", "Order", ...] + * + * // Check if schema is collapsed + * if (isCollapsedSchema(schema)) { + * console.log('Schema is in collapsed form'); + * } + * ``` + * + * @module operations/scapi-schemas + */ + +// Collapse utilities +export {collapseOpenApiSchema, isCollapsedSchema, getPathKeys, getSchemaNames, getExampleNames} from './collapse.js'; + +// Types +export type {SchemaCollapseOptions, OpenApiSchemaInput, CollapsedOpenApiSchema, CollapsedPath} from './collapse.js'; diff --git a/packages/b2c-tooling-sdk/test/clients/scapi-schemas.test.ts b/packages/b2c-tooling-sdk/test/clients/scapi-schemas.test.ts new file mode 100644 index 00000000..a5a4fb68 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/scapi-schemas.test.ts @@ -0,0 +1,223 @@ +/* + * 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 {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createScapiSchemasClient, SCAPI_SCHEMAS_DEFAULT_SCOPES} from '@salesforce/b2c-tooling-sdk/clients'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const SHORT_CODE = 'kv7kzm78'; +const TENANT_ID = 'zzxy_prd'; +const BASE_URL = `https://${SHORT_CODE}.api.commercecloud.salesforce.com/dx/scapi-schemas/v1`; + +describe('clients/scapi-schemas', () => { + describe('createScapiSchemasClient', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + it('creates a client with the correct base URL', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/schemas`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({ + limit: 10, + total: 0, + data: [], + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiSchemasClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await client.GET('/organizations/{organizationId}/schemas', { + params: {path: {organizationId: 'f_ecom_zzxy_prd'}}, + }); + + expect(error).to.be.undefined; + expect(data?.data).to.deep.equal([]); + }); + + it('lists schemas with metadata', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/schemas`, () => { + return HttpResponse.json({ + limit: 10, + total: 2, + data: [ + { + schemaVersion: '1.0.0', + apiFamily: 'shopper', + apiName: 'products', + apiVersion: 'v1', + status: 'current', + link: '/organizations/f_ecom_zzxy_prd/schemas/shopper/products/v1', + }, + { + schemaVersion: '1.0.0', + apiFamily: 'shopper', + apiName: 'orders', + apiVersion: 'v1', + status: 'current', + link: '/organizations/f_ecom_zzxy_prd/schemas/shopper/orders/v1', + }, + ], + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiSchemasClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data} = await client.GET('/organizations/{organizationId}/schemas', { + params: {path: {organizationId: 'f_ecom_zzxy_prd'}}, + }); + + expect(data?.total).to.equal(2); + expect(data?.data).to.have.length(2); + expect(data?.data?.[0]?.apiFamily).to.equal('shopper'); + expect(data?.data?.[0]?.apiName).to.equal('products'); + }); + + it('filters schemas by apiFamily', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/schemas`, ({request}) => { + const url = new URL(request.url); + const apiFamily = url.searchParams.get('apiFamily'); + + expect(apiFamily).to.equal('shopper'); + + return HttpResponse.json({ + limit: 10, + total: 1, + filter: {apiFamily: 'shopper'}, + data: [ + { + schemaVersion: '1.0.0', + apiFamily: 'shopper', + apiName: 'products', + apiVersion: 'v1', + status: 'current', + }, + ], + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiSchemasClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data} = await client.GET('/organizations/{organizationId}/schemas', { + params: { + path: {organizationId: 'f_ecom_zzxy_prd'}, + query: {apiFamily: 'shopper'}, + }, + }); + + expect(data?.filter?.apiFamily).to.equal('shopper'); + expect(data?.data).to.have.length(1); + }); + + it('fetches a specific schema', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/schemas/:apiFamily/:apiName/:apiVersion`, ({params}) => { + expect(params.apiFamily).to.equal('shopper'); + expect(params.apiName).to.equal('products'); + expect(params.apiVersion).to.equal('v1'); + + return HttpResponse.json({ + openapi: '3.0.3', + info: { + title: 'Shopper Products', + version: 'v1', + }, + paths: { + '/products': { + get: {summary: 'Get products'}, + }, + }, + components: { + schemas: { + Product: {type: 'object'}, + }, + }, + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiSchemasClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data} = await client.GET('/organizations/{organizationId}/schemas/{apiFamily}/{apiName}/{apiVersion}', { + params: { + path: { + organizationId: 'f_ecom_zzxy_prd', + apiFamily: 'shopper', + apiName: 'products', + apiVersion: 'v1', + }, + }, + }); + + expect(data?.openapi).to.equal('3.0.3'); + expect(data?.info?.title).to.equal('Shopper Products'); + expect(data?.paths).to.have.property('/products'); + }); + + it('handles API errors', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/schemas/:apiFamily/:apiName/:apiVersion`, () => { + return HttpResponse.json( + { + title: 'Not Found', + type: 'https://api.commercecloud.salesforce.com/documentation/error/v1/errors/not-found', + detail: 'Schema not found', + }, + {status: 404}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiSchemasClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await client.GET( + '/organizations/{organizationId}/schemas/{apiFamily}/{apiName}/{apiVersion}', + { + params: { + path: { + organizationId: 'f_ecom_zzxy_prd', + apiFamily: 'shopper', + apiName: 'nonexistent', + apiVersion: 'v1', + }, + }, + }, + ); + + expect(data).to.be.undefined; + expect(error).to.have.property('title', 'Not Found'); + expect(error).to.have.property('detail'); + }); + }); + + describe('SCAPI_SCHEMAS_DEFAULT_SCOPES', () => { + it('includes the correct default scope', () => { + expect(SCAPI_SCHEMAS_DEFAULT_SCOPES).to.deep.equal(['sfcc.scapi-schemas']); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/scapi-schemas.test.ts b/packages/b2c-tooling-sdk/test/operations/scapi-schemas.test.ts new file mode 100644 index 00000000..eb7d351e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/scapi-schemas.test.ts @@ -0,0 +1,258 @@ +/* + * 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 {expect} from 'chai'; +import { + collapseOpenApiSchema, + isCollapsedSchema, + getPathKeys, + getSchemaNames, + getExampleNames, + type OpenApiSchemaInput, +} from '@salesforce/b2c-tooling-sdk/operations/scapi-schemas'; + +describe('operations/scapi-schemas', () => { + // Sample OpenAPI schema for testing + const sampleSchema: OpenApiSchemaInput = { + openapi: '3.0.3', + info: { + title: 'Test API', + version: '1.0.0', + }, + servers: [{url: 'https://api.example.com'}], + paths: { + '/products': { + get: {summary: 'List products', responses: {}}, + post: {summary: 'Create product', responses: {}}, + }, + '/products/{id}': { + get: {summary: 'Get product', responses: {}}, + put: {summary: 'Update product', responses: {}}, + delete: {summary: 'Delete product', responses: {}}, + }, + '/orders': { + get: {summary: 'List orders', responses: {}}, + }, + }, + components: { + schemas: { + Product: {type: 'object', properties: {id: {type: 'string'}, name: {type: 'string'}}}, + Order: {type: 'object', properties: {id: {type: 'string'}, total: {type: 'number'}}}, + Customer: {type: 'object', properties: {id: {type: 'string'}, email: {type: 'string'}}}, + }, + examples: { + ProductExample: {value: {id: '123', name: 'Test Product'}}, + OrderExample: {value: {id: '456', total: 99.99}}, + }, + }, + security: [{oauth2: ['read']}], + tags: [{name: 'products'}, {name: 'orders'}], + }; + + describe('collapseOpenApiSchema', () => { + it('collapses paths to method names by default', () => { + const result = collapseOpenApiSchema(sampleSchema); + + // Paths should be arrays of method names + expect(result.paths?.['/products']).to.deep.equal(['get', 'post']); + expect(result.paths?.['/products/{id}']).to.deep.equal(['get', 'put', 'delete']); + expect(result.paths?.['/orders']).to.deep.equal(['get']); + }); + + it('collapses schemas to empty objects by default', () => { + const result = collapseOpenApiSchema(sampleSchema); + + // Schemas should be empty objects + expect(result.components?.schemas?.Product).to.deep.equal({}); + expect(result.components?.schemas?.Order).to.deep.equal({}); + expect(result.components?.schemas?.Customer).to.deep.equal({}); + }); + + it('collapses examples to empty objects by default', () => { + const result = collapseOpenApiSchema(sampleSchema); + + // Examples should be empty objects + expect(result.components?.examples?.ProductExample).to.deep.equal({}); + expect(result.components?.examples?.OrderExample).to.deep.equal({}); + }); + + it('preserves non-collapsible sections', () => { + const result = collapseOpenApiSchema(sampleSchema); + + // These should be preserved as-is + expect(result.openapi).to.equal('3.0.3'); + expect(result.info).to.deep.equal({title: 'Test API', version: '1.0.0'}); + expect(result.servers).to.deep.equal([{url: 'https://api.example.com'}]); + expect(result.security).to.deep.equal([{oauth2: ['read']}]); + expect(result.tags).to.deep.equal([{name: 'products'}, {name: 'orders'}]); + }); + + it('expands specified paths', () => { + const result = collapseOpenApiSchema(sampleSchema, { + expandPaths: ['/products'], + }); + + // /products should be fully expanded + expect(result.paths?.['/products']).to.have.property('get'); + expect(result.paths?.['/products']).to.have.property('post'); + expect((result.paths?.['/products'] as Record).get).to.have.property('summary'); + + // Other paths should still be collapsed + expect(result.paths?.['/products/{id}']).to.deep.equal(['get', 'put', 'delete']); + expect(result.paths?.['/orders']).to.deep.equal(['get']); + }); + + it('expands specified schemas', () => { + const result = collapseOpenApiSchema(sampleSchema, { + expandSchemas: ['Product'], + }); + + // Product should be fully expanded + expect(result.components?.schemas?.Product).to.have.property('type', 'object'); + expect(result.components?.schemas?.Product).to.have.property('properties'); + + // Other schemas should still be collapsed + expect(result.components?.schemas?.Order).to.deep.equal({}); + expect(result.components?.schemas?.Customer).to.deep.equal({}); + }); + + it('expands specified examples', () => { + const result = collapseOpenApiSchema(sampleSchema, { + expandExamples: ['ProductExample'], + }); + + // ProductExample should be fully expanded + expect(result.components?.examples?.ProductExample).to.have.property('value'); + + // Other examples should still be collapsed + expect(result.components?.examples?.OrderExample).to.deep.equal({}); + }); + + it('expands multiple items', () => { + const result = collapseOpenApiSchema(sampleSchema, { + expandPaths: ['/products', '/orders'], + expandSchemas: ['Product', 'Order'], + expandExamples: ['ProductExample', 'OrderExample'], + }); + + // Multiple paths expanded + expect(result.paths?.['/products']).to.have.property('get'); + expect(result.paths?.['/orders']).to.have.property('get'); + expect(result.paths?.['/products/{id}']).to.deep.equal(['get', 'put', 'delete']); // Still collapsed + + // Multiple schemas expanded + expect(result.components?.schemas?.Product).to.have.property('type'); + expect(result.components?.schemas?.Order).to.have.property('type'); + expect(result.components?.schemas?.Customer).to.deep.equal({}); // Still collapsed + + // Multiple examples expanded + expect(result.components?.examples?.ProductExample).to.have.property('value'); + expect(result.components?.examples?.OrderExample).to.have.property('value'); + }); + + it('handles empty paths', () => { + const schemaWithNoPaths: OpenApiSchemaInput = { + openapi: '3.0.3', + info: {title: 'Test', version: '1.0.0'}, + }; + + const result = collapseOpenApiSchema(schemaWithNoPaths); + + expect(result.paths).to.be.undefined; + }); + + it('handles empty components', () => { + const schemaWithNoComponents: OpenApiSchemaInput = { + openapi: '3.0.3', + info: {title: 'Test', version: '1.0.0'}, + paths: {'/test': {get: {}}}, + }; + + const result = collapseOpenApiSchema(schemaWithNoComponents); + + expect(result.components).to.be.undefined; + }); + + it('handles missing schemas in components', () => { + const schemaWithPartialComponents: OpenApiSchemaInput = { + openapi: '3.0.3', + info: {title: 'Test', version: '1.0.0'}, + components: { + parameters: {foo: {name: 'foo', in: 'query'}}, + }, + }; + + const result = collapseOpenApiSchema(schemaWithPartialComponents); + + expect(result.components?.schemas).to.be.undefined; + expect(result.components?.parameters).to.deep.equal({foo: {name: 'foo', in: 'query'}}); + }); + }); + + describe('isCollapsedSchema', () => { + it('returns true when paths are collapsed', () => { + const collapsed = collapseOpenApiSchema(sampleSchema); + expect(isCollapsedSchema(collapsed)).to.be.true; + }); + + it('returns true when schemas are empty objects', () => { + const collapsed = collapseOpenApiSchema(sampleSchema); + expect(isCollapsedSchema(collapsed)).to.be.true; + }); + + it('returns false when fully expanded', () => { + // The original schema is not collapsed + expect(isCollapsedSchema(sampleSchema as unknown as ReturnType)).to.be.false; + }); + }); + + describe('getPathKeys', () => { + it('returns all path keys', () => { + const keys = getPathKeys(sampleSchema); + expect(keys).to.have.members(['/products', '/products/{id}', '/orders']); + }); + + it('returns empty array when no paths', () => { + const schemaWithNoPaths: OpenApiSchemaInput = { + openapi: '3.0.3', + info: {title: 'Test', version: '1.0.0'}, + }; + const keys = getPathKeys(schemaWithNoPaths); + expect(keys).to.deep.equal([]); + }); + }); + + describe('getSchemaNames', () => { + it('returns all schema names', () => { + const names = getSchemaNames(sampleSchema); + expect(names).to.have.members(['Product', 'Order', 'Customer']); + }); + + it('returns empty array when no schemas', () => { + const schemaWithNoSchemas: OpenApiSchemaInput = { + openapi: '3.0.3', + info: {title: 'Test', version: '1.0.0'}, + }; + const names = getSchemaNames(schemaWithNoSchemas); + expect(names).to.deep.equal([]); + }); + }); + + describe('getExampleNames', () => { + it('returns all example names', () => { + const names = getExampleNames(sampleSchema); + expect(names).to.have.members(['ProductExample', 'OrderExample']); + }); + + it('returns empty array when no examples', () => { + const schemaWithNoExamples: OpenApiSchemaInput = { + openapi: '3.0.3', + info: {title: 'Test', version: '1.0.0'}, + }; + const names = getExampleNames(schemaWithNoExamples); + expect(names).to.deep.equal([]); + }); + }); +}); diff --git a/plugins/b2c-cli/skills/b2c-scapi-schemas/SKILL.md b/plugins/b2c-cli/skills/b2c-scapi-schemas/SKILL.md new file mode 100644 index 00000000..bef7c859 --- /dev/null +++ b/plugins/b2c-cli/skills/b2c-scapi-schemas/SKILL.md @@ -0,0 +1,132 @@ +--- +name: b2c-scapi-schemas +description: Using the b2c CLI to browse and retrieve SCAPI schema specifications +--- + +# B2C SCAPI Schemas Skill + +Use the `b2c` CLI plugin to browse and retrieve SCAPI OpenAPI schema specifications. + +## Required: Tenant ID + +The `--tenant-id` flag is **required** for all commands. The tenant ID identifies your B2C Commerce instance. + +**Important:** The tenant ID is NOT the same as the organization ID: +- **Tenant ID**: `zzxy_prd` (used with commands that require `--tenant-id`) +- **Organization ID**: `f_ecom_zzxy_prd` (used in SCAPI URLs, has `f_ecom_` prefix) + +### Deriving Tenant ID from Hostname + +For sandbox instances, you can derive the tenant ID from the hostname by replacing hyphens with underscores: + +| Hostname | Tenant ID | +|----------|-----------| +| `zzpq-013.dx.commercecloud.salesforce.com` | `zzpq_013` | +| `zzxy-001.dx.commercecloud.salesforce.com` | `zzxy_001` | +| `abcd-dev.dx.commercecloud.salesforce.com` | `abcd_dev` | + +For production instances, use your realm and instance identifier (e.g., `zzxy_prd`). + +## Examples + +### List Available Schemas + +```bash +# list all available SCAPI schemas +b2c scapi schemas list --tenant-id zzxy_prd + +# list with JSON output +b2c scapi schemas list --tenant-id zzxy_prd --json +``` + +### Filter Schemas + +```bash +# filter by API family (shopper or admin) +b2c scapi schemas list --tenant-id zzxy_prd --api-family shopper + +# filter by API name +b2c scapi schemas list --tenant-id zzxy_prd --api-name products + +# filter by status +b2c scapi schemas list --tenant-id zzxy_prd --status current +``` + +### Get Schema (Collapsed/Outline - Default) + +By default, schemas are output in a collapsed format optimized for context efficiency. This is ideal for agentic use cases and LLM consumption. + +```bash +# get collapsed schema (paths show methods, schemas show names only) +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd + +# save to file +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd > schema.json +``` + +### Get Schema with Selective Expansion + +Expand only the parts of the schema you need: + +```bash +# expand specific paths +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --expand-paths /products,/products/{id} + +# expand specific schemas +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --expand-schemas Product,SearchResult + +# combine expansions +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --expand-paths /products --expand-schemas Product +``` + +### Get Full Schema + +```bash +# get full schema without any collapsing +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --expand-all +``` + +### List Available Paths/Schemas/Examples + +Discover what's available in a schema before expanding: + +```bash +# list all paths in the schema +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --list-paths + +# list all schema names +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --list-schemas + +# list all examples +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --list-examples +``` + +### Output Formats + +```bash +# output as YAML +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --yaml + +# output wrapped JSON with metadata (apiFamily, apiName, apiVersion, schema) +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --json +``` + +### Custom Properties + +```bash +# include custom properties (default behavior) +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd + +# exclude custom properties +b2c scapi schemas get shopper products v1 --tenant-id zzxy_prd --no-expand-custom-properties +``` + +### Configuration + +The tenant ID and short code can be set via environment variables: +- `SFCC_TENANT_ID`: Tenant ID (e.g., `zzxy_prd`, not the organization ID) +- `SFCC_SHORTCODE`: SCAPI short code + +### More Commands + +See `b2c scapi schemas --help` for a full list of available commands and options.