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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions gen/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ CONFIG.RESOURCES_ACCESSORS_ONLY = CONFIG.RESOURCES_INSTANCE_STYLE === 'accessors
/**** **** **** **** **** **** **** **** ****/

const SCHEMA_VERSION_CONST = 'API_SCHEMA_VERSION'
const SUPPORTED_VERSIONS_CONST = 'API_SUPPORTED_VERSIONS'
// const SDK_VERSION_CONST = 'SDK_VERSION'
const RESOURCE_COMMON_FIELDS = ['type', 'id', 'reference', 'reference_origin', 'metadata', 'created_at', 'updated_at']

Expand All @@ -76,6 +77,7 @@ const templates: { [key: string]: string } = {}

const global: {
version?: string
supportedVersions?: readonly string[]
} = {}

const loadTemplates = (): void => {
Expand Down Expand Up @@ -151,6 +153,7 @@ const generate = async (cli: CliOptions) => {

const schema = apiSchema.parse(schemaPath, { apiHost, apiVersion })
global.version = schema.version
global.supportedVersions = schema.supportedVersions

loadTemplates()

Expand Down Expand Up @@ -241,9 +244,10 @@ const updateSdkVersion = (): void => {

const lines = cl.split('\n')

// Build's target API version (e.g. '2026-05' for a unified build,
// 'latest' for a legacy build). Customers don't override this — the URL
// version segment is purely driven by the generator's output.
// Build's target API version (e.g. '2026-05' for a unified build, 'latest'
// for a legacy build) — the version the emitted types are generated for.
// It no longer drives the request URL: the URL version segment is chosen at
// runtime via the optional `apiVersion` init option (omit → unversioned).
// The `: string` annotation widens TS's inferred literal type so
// comparisons like `API_SCHEMA_VERSION === 'latest'` don't trip TS2367
// after regen against a unified host.
Expand All @@ -252,6 +256,16 @@ const updateSdkVersion = (): void => {
if (schemaLine.index >= 0)
lines[schemaLine.index] = `${schemaPrefix} ${SCHEMA_VERSION_CONST}: string = '${global.version}'`

// The versions a caller may pass as `apiVersion`, as a readonly tuple so
// `ApiVersion` narrows to their union (empty tuple → `never` on legacy
// builds, where the API is unversioned and the option is unusable).
const supportedLine = findLine(SUPPORTED_VERSIONS_CONST, lines)
const supportedPrefix = supportedLine.text.substring(0, supportedLine.offset).trim()
if (supportedLine.index >= 0) {
const tuple = (global.supportedVersions ?? []).map((v) => `'${v}'`).join(', ')
lines[supportedLine.index] = `${supportedPrefix} ${SUPPORTED_VERSIONS_CONST} = [${tuple}] as const`
}

writeFileSync(filePath, lines.join('\n'), { encoding: 'utf-8' })

console.log(`SDK version updated [${global.version}].`)
Expand Down
36 changes: 17 additions & 19 deletions gen/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Inflector from './inflector'

type ApiSchema = {
version: string
/** Every API version the catalogue knows about (sorted, oldest→newest). Empty for legacy payloads. */
supportedVersions: readonly string[]
resources: Record<string, Resource>
components: ComponentMap
}
Expand Down Expand Up @@ -157,18 +159,14 @@ type PublicResource = {
filters?: Record<string, unknown>
parent_resource?: string
/**
* API versions the resource lives in. Synonymous with `meta.api_versions`
* (same array, always identical). The parser reads `meta.api_versions`
* canonically. Unified schema only.
* API versions the resource lives in. The parser reads this as the
* canonical source of a resource's version info. Unified schema only —
* absent in the legacy payload.
*/
versions?: string[]
/** Legacy schema only — replaced by `meta.api_versions` in the unified shape. */
/** Legacy schema only — superseded by `versions` in the unified shape. */
deprecated?: boolean
}
/** Unified schema only — absent in the legacy payload. */
meta?: {
api_versions?: string[]
}
}

type PublicResourcesDoc = {
Expand Down Expand Up @@ -665,12 +663,12 @@ const parseSchema = (path: string, opts: GeneratorOptions = {}): ApiSchema => {
const raw = readFileSync(path, { encoding: 'utf-8' })
const doc = JSON.parse(raw) as PublicResourcesDoc

// Shape detection. Unified payloads carry `meta.api_versions` on every
// resource; legacy payloads (e.g. production today) don't have a per-resource
// `meta` block at all. Lenient rule: any resource with the field flips us
// into unified mode. In a hypothetical mixed payload, resources without
// the field are treated as version-agnostic (always included).
const isUnified = doc.data.some((r) => r.meta?.api_versions != null)
// Shape detection. Unified payloads carry `attributes.versions` on every
// resource; legacy payloads (e.g. production today) omit it entirely.
// Lenient rule: any resource with the field flips us into unified mode. In
// a hypothetical mixed payload, resources without the field are treated as
// version-agnostic (always included).
const isUnified = doc.data.some((r) => r.attributes.versions != null)
console.log(`Schema shape: ${isUnified ? 'unified' : 'legacy'}`)
if (doc.meta?.version) console.log(`Schema release: ${doc.meta.version}`)

Expand All @@ -681,11 +679,11 @@ const parseSchema = (path: string, opts: GeneratorOptions = {}): ApiSchema => {
)
}

// Union of every resource's api_versions, sorted; first/last entries
// Union of every resource's `versions`, sorted; first/last entries
// are the oldest/newest API versions the catalogue knows about. Empty
// when the payload is legacy.
const supportedVersions: readonly string[] = isUnified
? Array.from(new Set(doc.data.flatMap((r) => r.meta?.api_versions ?? []))).sort()
? Array.from(new Set(doc.data.flatMap((r) => r.attributes.versions ?? []))).sort()
: []

let targetVersion: string
Expand Down Expand Up @@ -714,7 +712,7 @@ const parseSchema = (path: string, opts: GeneratorOptions = {}): ApiSchema => {
// marker so callers keep getting type imports.
const resourceClassifications = new Map<string, Classification>()
for (const res of doc.data) {
const versioned = classifyVersions(res.meta?.api_versions, targetVersion)
const versioned = classifyVersions(res.attributes.versions, targetVersion)
if (versioned === 'deprecated' || versioned === 'exclude') {
resourceClassifications.set(res.id, versioned)
} else if (res.attributes.deprecated === true) {
Expand Down Expand Up @@ -763,7 +761,7 @@ const parseSchema = (path: string, opts: GeneratorOptions = {}): ApiSchema => {
if (operations.update)
resComponents[`${cam}Update`] = buildComponent(ctx, 'update', targetVersion, oldestSupported, excludedClassNames)

const apiVersions = res.meta?.api_versions
const apiVersions = res.attributes.versions
// If this resource is an STI parent (other resources declare it as
// `parent_resource`), collect the children whose own modules will be
// generated. Drop:
Expand Down Expand Up @@ -800,7 +798,7 @@ const parseSchema = (path: string, opts: GeneratorOptions = {}): ApiSchema => {

console.log('Public resources schema correctly parsed.')

return { version: targetVersion, resources, components }
return { version: targetVersion, supportedVersions, resources, components }
}

export default {
Expand Down
36 changes: 30 additions & 6 deletions specs/api-version-url.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,42 @@
import { describe, expect, test } from 'vitest'
import { API_SCHEMA_VERSION } from '../src/commercelayer'
import type { CommerceLayerInitConfig } from '../src/commercelayer'
import { application, CommerceLayer } from '../src/single-client'
import { handleError, interceptRequest } from '../test/common'

const baseConfig = { organization: 'test-org', accessToken: 'fake-token' } as const

const isUnifiedBuild = API_SCHEMA_VERSION !== 'latest'
describe('apiVersion in request URL', () => {
test('omitting apiVersion keeps requests unversioned', async () => {
const client = CommerceLayer(baseConfig)
client.addRequestInterceptor((request) => {
expect(request.url.pathname).toBe('/api/application')
return interceptRequest()
})
await application
.retrieve({})
.catch(handleError)
.finally(() => client.removeInterceptor('request'))
})

test('setting apiVersion adds it as a path segment', async () => {
// On a legacy build `ApiVersion` is `never`, so cast past the public type
// constraint to exercise the runtime URL logic build-agnostically.
const client = CommerceLayer({ ...baseConfig, apiVersion: '2099-01' } as CommerceLayerInitConfig)
client.addRequestInterceptor((request) => {
expect(request.url.pathname).toBe('/api/2099-01/application')
return interceptRequest()
})
await application
.retrieve({})
.catch(handleError)
.finally(() => client.removeInterceptor('request'))
})

describe('API schema version in request URL', () => {
test('unified builds embed the build target as a path segment; legacy stays unversioned', async () => {
test('apiVersion can be changed via config()', async () => {
const client = CommerceLayer(baseConfig)
const expected = isUnifiedBuild ? `/api/${API_SCHEMA_VERSION}/application` : '/api/application'
client.config({ apiVersion: '2099-02' } as Partial<CommerceLayerInitConfig>)
client.addRequestInterceptor((request) => {
expect(request.url.pathname).toBe(expected)
expect(request.url.pathname).toBe('/api/2099-02/application')
return interceptRequest()
})
await application
Expand Down
40 changes: 28 additions & 12 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@ import type { Fetch, FetchClientOptions, FetchRequestOptions, FetchResponse } fr
import { fetchURL } from './fetch'
import type { InterceptorManager } from './interceptor'
import { extractTokenData, isTokenExpired } from './util'
import { API_SCHEMA_VERSION, SDK_VERSION } from './version'
import { SDK_VERSION } from './version'

const CLIENT_HEADER_NAME = 'X-CL-SDK'

const debug = Debug('client')

// Unified builds embed the schema version as a URL path segment
// (`/api/2026-05/orders`). Legacy builds carry the literal 'latest' marker
// from the generator and stay unversioned (`/api/orders`).
const URL_VERSION_SEGMENT = API_SCHEMA_VERSION === 'latest' ? '' : `/${API_SCHEMA_VERSION}`

const baseURL = (organization: string, domain?: string): string => {
return `https://${organization.toLowerCase()}.${domain || config.default.domain}/api${URL_VERSION_SEGMENT}`
// The API version is chosen at runtime via the optional `apiVersion` init
// option. When set it becomes a URL path segment (`/api/2026-05/orders`);
// when omitted the request stays unversioned (`/api/orders`) and the API
// resolves the organization's default version.
const baseURL = (organization: string, domain?: string, apiVersion?: string): string => {
const versionSegment = apiVersion ? `/${apiVersion}` : ''
return `https://${organization.toLowerCase()}.${domain || config.default.domain}/api${versionSegment}`
}

type RequestParams = Record<string, string | number | boolean>
Expand All @@ -39,6 +39,7 @@ type ApiConfig = {
organization?: string
domain?: string
accessToken: string
apiVersion?: string
}

type ApiClientInitConfig = ApiConfig & RequestConfig
Expand All @@ -64,13 +65,15 @@ class ApiClient {
#accessToken: string
#organization: string
#domain?: string
#apiVersion?: string
readonly #clientConfig: RequestConfig
readonly #interceptors: InterceptorManager

private constructor(options: ApiClientInitConfig) {
debug('new client instance %O', options)

this.#baseUrl = baseURL(options.organization ?? '', options.domain)
this.#apiVersion = options.apiVersion
this.#baseUrl = baseURL(options.organization ?? '', options.domain, this.#apiVersion)
this.#accessToken = options.accessToken
this.#organization = options.organization ?? '' // organization is always defined
this.#domain = options.domain
Expand Down Expand Up @@ -137,8 +140,13 @@ class ApiClient {
if (config.refreshToken) this.#clientConfig.refreshToken = config.refreshToken

// API Client config
if (config.organization || config.domain)
this.#baseUrl = baseURL(config.organization || this.#organization, config.domain || this.#domain)
if (config.apiVersion !== undefined) this.#apiVersion = config.apiVersion
if (config.organization || config.domain || config.apiVersion !== undefined)
this.#baseUrl = baseURL(
config.organization || this.#organization,
config.domain || this.#domain,
this.#apiVersion,
)
if (config.organization) this.#organization = config.organization
if (config.domain) this.#domain = config.domain
if (config.accessToken) {
Expand All @@ -162,7 +170,10 @@ class ApiClient {
if (options?.userAgent) debug('User-Agent header ignored in request config')

// URL
const baseUrl = options?.organization ? baseURL(options.organization, options.domain) : this.#baseUrl
const baseUrl =
options?.organization || options?.apiVersion !== undefined
? baseURL(options.organization || this.#organization, options.domain, options.apiVersion ?? this.#apiVersion)
: this.#baseUrl
const url = new URL(`${baseUrl}/${path}`)

// Body
Expand Down Expand Up @@ -255,6 +266,11 @@ class ApiClient {
get currentOrganization(): string {
return this.#organization
}

/** The API version pinned via `apiVersion`, or `undefined` when requests are unversioned. */
get currentApiVersion(): string | undefined {
return this.#apiVersion
}
}

export default ApiClient
Expand Down
23 changes: 20 additions & 3 deletions src/commercelayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,36 @@ import type {
ResponseObj,
} from './interceptor'
import { ApiResourceAdapter, type ResourceAdapter, type ResourcesInitConfig } from './resource'
import { API_SCHEMA_VERSION, SDK_VERSION } from './version'
import { API_SCHEMA_VERSION, API_SUPPORTED_VERSIONS, type ApiVersion, SDK_VERSION } from './version'

const debug = Debug('commercelayer')

export { API_SCHEMA_VERSION, SDK_VERSION }
export { API_SCHEMA_VERSION, API_SUPPORTED_VERSIONS, type ApiVersion, SDK_VERSION }

// SDK local configuration
type SdkConfig = {
/** Set to `false` to omit the `X-CL-SDK` request header that identifies the SDK and its version. Defaults to `true`. */
telemetry?: boolean
}

type CommerceLayerInitConfig = SdkConfig & ResourcesInitConfig
/**
* The `apiVersion` init option, conditioned on how the SDK was generated:
* - **Version-aware (unified) builds** — {@link API_SUPPORTED_VERSIONS} is
* non-empty — **require** `apiVersion`. The chosen value becomes a URL path
* segment (`/api/2026-05/orders`), keeping requests aligned with the version
* the types were generated for.
* - **Legacy builds** — empty {@link API_SUPPORTED_VERSIONS}, so {@link ApiVersion}
* is `never` — take **no** `apiVersion` argument. The API is unversioned
* (`/api/orders`).
*/
type ApiVersionConfig = [ApiVersion] extends [never] ? { apiVersion?: never } : { apiVersion: ApiVersion }

type CommerceLayerInitConfig = SdkConfig & ApiVersionConfig & ResourcesInitConfig
type CommerceLayerConfig = Partial<CommerceLayerInitConfig>

class CommerceLayerSingleClient {
readonly apiSchemaVersion = API_SCHEMA_VERSION
readonly apiSupportedVersions = API_SUPPORTED_VERSIONS

protected static cl: CommerceLayerSingleClient

Expand Down Expand Up @@ -83,6 +96,10 @@ class CommerceLayerSingleClient {
get currentAccessToken(): string {
return this.adapter.client?.currentAccessToken
}
/** The API version pinned via `apiVersion`, or `undefined` when requests are unversioned. */
get currentApiVersion(): string | undefined {
return this.adapter.client?.currentApiVersion
}
private get interceptors(): InterceptorManager {
return this.adapter.client?.interceptors
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// `@commercelayer/sdk/single-client`.
import { CommerceLayer, type CommerceLayerBundle, type CommerceLayerClient } from './bundle'

export { SDK_VERSION } from './commercelayer'
export { API_SCHEMA_VERSION, API_SUPPORTED_VERSIONS, type ApiVersion, SDK_VERSION } from './commercelayer'
// Preferred: the named export. Clean, and the form we're standardising on.
// `CommerceLayerClient` is the bundled client type (sdk6 naming);
// `CommerceLayerBundle` is a deprecated alias kept for backwards compatibility.
Expand Down
8 changes: 7 additions & 1 deletion src/single-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ export * from './api'
import { CommerceLayer } from './commercelayer'

// Preferred: the named export. Clean, and the form we're standardising on.
export { CommerceLayer, SDK_VERSION } from './commercelayer'
export {
API_SCHEMA_VERSION,
API_SUPPORTED_VERSIONS,
type ApiVersion,
CommerceLayer,
SDK_VERSION,
} from './commercelayer'

/**
* @deprecated Use the named import instead:
Expand Down
11 changes: 11 additions & 0 deletions src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,14 @@ export const SDK_VERSION: string = pkg.version

// Autogenerated API schema version, do not remove this line
export const API_SCHEMA_VERSION: string = 'latest'

// Autogenerated supported API versions, do not remove this line
export const API_SUPPORTED_VERSIONS = [] as const

/**
* The API versions this build accepts as the `apiVersion` init option — the
* union of {@link API_SUPPORTED_VERSIONS}. On a legacy build the tuple is
* empty, so this resolves to `never` and the option can only be omitted (the
* legacy API is unversioned).
*/
export type ApiVersion = (typeof API_SUPPORTED_VERSIONS)[number]
10 changes: 5 additions & 5 deletions test/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import {
} from '../src/single-client'
import getToken from './token'

// On unified-schema builds the SDK embeds the build's target version as a URL
// path segment (e.g. `/api/2026-05/orders`). Legacy builds carry the literal
// 'latest' marker from the generator and stay unversioned (`/api/orders`).
// Tests use this prefix to assemble expected pathnames.
// A version segment only appears in the URL when a client is initialized with
// `apiVersion` (e.g. `/api/2026-05/orders`). The test clients here don't set
// it, so requests stay unversioned (`/api/orders`) on every build. The
// `apiVersion` path routing itself is covered by `specs/api-version-url.spec.ts`.
export const IS_UNIFIED_BUILD = API_SCHEMA_VERSION !== 'latest'
const API_PATH_PREFIX = IS_UNIFIED_BUILD ? `/api/${API_SCHEMA_VERSION}` : '/api'
const API_PATH_PREFIX = '/api'

dotenv.config()

Expand Down
Loading