Skip to content

Commit f69edb7

Browse files
authored
Add apiVersion option to client config (#377)
2 parents ac8adbe + 3325cf0 commit f69edb7

9 files changed

Lines changed: 136 additions & 50 deletions

File tree

gen/generator.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ CONFIG.RESOURCES_ACCESSORS_ONLY = CONFIG.RESOURCES_INSTANCE_STYLE === 'accessors
5858
/**** **** **** **** **** **** **** **** ****/
5959

6060
const SCHEMA_VERSION_CONST = 'API_SCHEMA_VERSION'
61+
const SUPPORTED_VERSIONS_CONST = 'API_SUPPORTED_VERSIONS'
6162
// const SDK_VERSION_CONST = 'SDK_VERSION'
6263
const RESOURCE_COMMON_FIELDS = ['type', 'id', 'reference', 'reference_origin', 'metadata', 'created_at', 'updated_at']
6364

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

7778
const global: {
7879
version?: string
80+
supportedVersions?: readonly string[]
7981
} = {}
8082

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

152154
const schema = apiSchema.parse(schemaPath, { apiHost, apiVersion })
153155
global.version = schema.version
156+
global.supportedVersions = schema.supportedVersions
154157

155158
loadTemplates()
156159

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

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

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

259+
// The versions a caller may pass as `apiVersion`, as a readonly tuple so
260+
// `ApiVersion` narrows to their union (empty tuple → `never` on legacy
261+
// builds, where the API is unversioned and the option is unusable).
262+
const supportedLine = findLine(SUPPORTED_VERSIONS_CONST, lines)
263+
const supportedPrefix = supportedLine.text.substring(0, supportedLine.offset).trim()
264+
if (supportedLine.index >= 0) {
265+
const tuple = (global.supportedVersions ?? []).map((v) => `'${v}'`).join(', ')
266+
lines[supportedLine.index] = `${supportedPrefix} ${SUPPORTED_VERSIONS_CONST} = [${tuple}] as const`
267+
}
268+
255269
writeFileSync(filePath, lines.join('\n'), { encoding: 'utf-8' })
256270

257271
console.log(`SDK version updated [${global.version}].`)

gen/schema.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import Inflector from './inflector'
55

66
type ApiSchema = {
77
version: string
8+
/** Every API version the catalogue knows about (sorted, oldest→newest). Empty for legacy payloads. */
9+
supportedVersions: readonly string[]
810
resources: Record<string, Resource>
911
components: ComponentMap
1012
}
@@ -157,18 +159,14 @@ type PublicResource = {
157159
filters?: Record<string, unknown>
158160
parent_resource?: string
159161
/**
160-
* API versions the resource lives in. Synonymous with `meta.api_versions`
161-
* (same array, always identical). The parser reads `meta.api_versions`
162-
* canonically. Unified schema only.
162+
* API versions the resource lives in. The parser reads this as the
163+
* canonical source of a resource's version info. Unified schema only —
164+
* absent in the legacy payload.
163165
*/
164166
versions?: string[]
165-
/** Legacy schema only — replaced by `meta.api_versions` in the unified shape. */
167+
/** Legacy schema only — superseded by `versions` in the unified shape. */
166168
deprecated?: boolean
167169
}
168-
/** Unified schema only — absent in the legacy payload. */
169-
meta?: {
170-
api_versions?: string[]
171-
}
172170
}
173171

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

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

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

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

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

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

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

803-
return { version: targetVersion, resources, components }
801+
return { version: targetVersion, supportedVersions, resources, components }
804802
}
805803

806804
export default {

specs/api-version-url.spec.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,42 @@
11
import { describe, expect, test } from 'vitest'
2-
import { API_SCHEMA_VERSION } from '../src/commercelayer'
2+
import type { CommerceLayerInitConfig } from '../src/commercelayer'
33
import { application, CommerceLayer } from '../src/single-client'
44
import { handleError, interceptRequest } from '../test/common'
55

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

8-
const isUnifiedBuild = API_SCHEMA_VERSION !== 'latest'
8+
describe('apiVersion in request URL', () => {
9+
test('omitting apiVersion keeps requests unversioned', async () => {
10+
const client = CommerceLayer(baseConfig)
11+
client.addRequestInterceptor((request) => {
12+
expect(request.url.pathname).toBe('/api/application')
13+
return interceptRequest()
14+
})
15+
await application
16+
.retrieve({})
17+
.catch(handleError)
18+
.finally(() => client.removeInterceptor('request'))
19+
})
20+
21+
test('setting apiVersion adds it as a path segment', async () => {
22+
// On a legacy build `ApiVersion` is `never`, so cast past the public type
23+
// constraint to exercise the runtime URL logic build-agnostically.
24+
const client = CommerceLayer({ ...baseConfig, apiVersion: '2099-01' } as CommerceLayerInitConfig)
25+
client.addRequestInterceptor((request) => {
26+
expect(request.url.pathname).toBe('/api/2099-01/application')
27+
return interceptRequest()
28+
})
29+
await application
30+
.retrieve({})
31+
.catch(handleError)
32+
.finally(() => client.removeInterceptor('request'))
33+
})
934

10-
describe('API schema version in request URL', () => {
11-
test('unified builds embed the build target as a path segment; legacy stays unversioned', async () => {
35+
test('apiVersion can be changed via config()', async () => {
1236
const client = CommerceLayer(baseConfig)
13-
const expected = isUnifiedBuild ? `/api/${API_SCHEMA_VERSION}/application` : '/api/application'
37+
client.config({ apiVersion: '2099-02' } as Partial<CommerceLayerInitConfig>)
1438
client.addRequestInterceptor((request) => {
15-
expect(request.url.pathname).toBe(expected)
39+
expect(request.url.pathname).toBe('/api/2099-02/application')
1640
return interceptRequest()
1741
})
1842
await application

src/client.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,19 @@ import type { Fetch, FetchClientOptions, FetchRequestOptions, FetchResponse } fr
55
import { fetchURL } from './fetch'
66
import type { InterceptorManager } from './interceptor'
77
import { extractTokenData, isTokenExpired } from './util'
8-
import { API_SCHEMA_VERSION, SDK_VERSION } from './version'
8+
import { SDK_VERSION } from './version'
99

1010
const CLIENT_HEADER_NAME = 'X-CL-SDK'
1111

1212
const debug = Debug('client')
1313

14-
// Unified builds embed the schema version as a URL path segment
15-
// (`/api/2026-05/orders`). Legacy builds carry the literal 'latest' marker
16-
// from the generator and stay unversioned (`/api/orders`).
17-
const URL_VERSION_SEGMENT = API_SCHEMA_VERSION === 'latest' ? '' : `/${API_SCHEMA_VERSION}`
18-
19-
const baseURL = (organization: string, domain?: string): string => {
20-
return `https://${organization.toLowerCase()}.${domain || config.default.domain}/api${URL_VERSION_SEGMENT}`
14+
// The API version is chosen at runtime via the optional `apiVersion` init
15+
// option. When set it becomes a URL path segment (`/api/2026-05/orders`);
16+
// when omitted the request stays unversioned (`/api/orders`) and the API
17+
// resolves the organization's default version.
18+
const baseURL = (organization: string, domain?: string, apiVersion?: string): string => {
19+
const versionSegment = apiVersion ? `/${apiVersion}` : ''
20+
return `https://${organization.toLowerCase()}.${domain || config.default.domain}/api${versionSegment}`
2121
}
2222

2323
type RequestParams = Record<string, string | number | boolean>
@@ -39,6 +39,7 @@ type ApiConfig = {
3939
organization?: string
4040
domain?: string
4141
accessToken: string
42+
apiVersion?: string
4243
}
4344

4445
type ApiClientInitConfig = ApiConfig & RequestConfig
@@ -64,13 +65,15 @@ class ApiClient {
6465
#accessToken: string
6566
#organization: string
6667
#domain?: string
68+
#apiVersion?: string
6769
readonly #clientConfig: RequestConfig
6870
readonly #interceptors: InterceptorManager
6971

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

73-
this.#baseUrl = baseURL(options.organization ?? '', options.domain)
75+
this.#apiVersion = options.apiVersion
76+
this.#baseUrl = baseURL(options.organization ?? '', options.domain, this.#apiVersion)
7477
this.#accessToken = options.accessToken
7578
this.#organization = options.organization ?? '' // organization is always defined
7679
this.#domain = options.domain
@@ -137,8 +140,13 @@ class ApiClient {
137140
if (config.refreshToken) this.#clientConfig.refreshToken = config.refreshToken
138141

139142
// API Client config
140-
if (config.organization || config.domain)
141-
this.#baseUrl = baseURL(config.organization || this.#organization, config.domain || this.#domain)
143+
if (config.apiVersion !== undefined) this.#apiVersion = config.apiVersion
144+
if (config.organization || config.domain || config.apiVersion !== undefined)
145+
this.#baseUrl = baseURL(
146+
config.organization || this.#organization,
147+
config.domain || this.#domain,
148+
this.#apiVersion,
149+
)
142150
if (config.organization) this.#organization = config.organization
143151
if (config.domain) this.#domain = config.domain
144152
if (config.accessToken) {
@@ -162,7 +170,10 @@ class ApiClient {
162170
if (options?.userAgent) debug('User-Agent header ignored in request config')
163171

164172
// URL
165-
const baseUrl = options?.organization ? baseURL(options.organization, options.domain) : this.#baseUrl
173+
const baseUrl =
174+
options?.organization || options?.apiVersion !== undefined
175+
? baseURL(options.organization || this.#organization, options.domain, options.apiVersion ?? this.#apiVersion)
176+
: this.#baseUrl
166177
const url = new URL(`${baseUrl}/${path}`)
167178

168179
// Body
@@ -255,6 +266,11 @@ class ApiClient {
255266
get currentOrganization(): string {
256267
return this.#organization
257268
}
269+
270+
/** The API version pinned via `apiVersion`, or `undefined` when requests are unversioned. */
271+
get currentApiVersion(): string | undefined {
272+
return this.#apiVersion
273+
}
258274
}
259275

260276
export default ApiClient

src/commercelayer.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,36 @@ import type {
1313
ResponseObj,
1414
} from './interceptor'
1515
import { ApiResourceAdapter, type ResourceAdapter, type ResourcesInitConfig } from './resource'
16-
import { API_SCHEMA_VERSION, SDK_VERSION } from './version'
16+
import { API_SCHEMA_VERSION, API_SUPPORTED_VERSIONS, type ApiVersion, SDK_VERSION } from './version'
1717

1818
const debug = Debug('commercelayer')
1919

20-
export { API_SCHEMA_VERSION, SDK_VERSION }
20+
export { API_SCHEMA_VERSION, API_SUPPORTED_VERSIONS, type ApiVersion, SDK_VERSION }
2121

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

28-
type CommerceLayerInitConfig = SdkConfig & ResourcesInitConfig
28+
/**
29+
* The `apiVersion` init option, conditioned on how the SDK was generated:
30+
* - **Version-aware (unified) builds** — {@link API_SUPPORTED_VERSIONS} is
31+
* non-empty — **require** `apiVersion`. The chosen value becomes a URL path
32+
* segment (`/api/2026-05/orders`), keeping requests aligned with the version
33+
* the types were generated for.
34+
* - **Legacy builds** — empty {@link API_SUPPORTED_VERSIONS}, so {@link ApiVersion}
35+
* is `never` — take **no** `apiVersion` argument. The API is unversioned
36+
* (`/api/orders`).
37+
*/
38+
type ApiVersionConfig = [ApiVersion] extends [never] ? { apiVersion?: never } : { apiVersion: ApiVersion }
39+
40+
type CommerceLayerInitConfig = SdkConfig & ApiVersionConfig & ResourcesInitConfig
2941
type CommerceLayerConfig = Partial<CommerceLayerInitConfig>
3042

3143
class CommerceLayerSingleClient {
3244
readonly apiSchemaVersion = API_SCHEMA_VERSION
45+
readonly apiSupportedVersions = API_SUPPORTED_VERSIONS
3346

3447
protected static cl: CommerceLayerSingleClient
3548

@@ -83,6 +96,10 @@ class CommerceLayerSingleClient {
8396
get currentAccessToken(): string {
8497
return this.adapter.client?.currentAccessToken
8598
}
99+
/** The API version pinned via `apiVersion`, or `undefined` when requests are unversioned. */
100+
get currentApiVersion(): string | undefined {
101+
return this.adapter.client?.currentApiVersion
102+
}
86103
private get interceptors(): InterceptorManager {
87104
return this.adapter.client?.interceptors
88105
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// `@commercelayer/sdk/single-client`.
55
import { CommerceLayer, type CommerceLayerBundle, type CommerceLayerClient } from './bundle'
66

7-
export { SDK_VERSION } from './commercelayer'
7+
export { API_SCHEMA_VERSION, API_SUPPORTED_VERSIONS, type ApiVersion, SDK_VERSION } from './commercelayer'
88
// Preferred: the named export. Clean, and the form we're standardising on.
99
// `CommerceLayerClient` is the bundled client type (sdk6 naming);
1010
// `CommerceLayerBundle` is a deprecated alias kept for backwards compatibility.

src/single-client.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ export * from './api'
55
import { CommerceLayer } from './commercelayer'
66

77
// Preferred: the named export. Clean, and the form we're standardising on.
8-
export { CommerceLayer, SDK_VERSION } from './commercelayer'
8+
export {
9+
API_SCHEMA_VERSION,
10+
API_SUPPORTED_VERSIONS,
11+
type ApiVersion,
12+
CommerceLayer,
13+
SDK_VERSION,
14+
} from './commercelayer'
915

1016
/**
1117
* @deprecated Use the named import instead:

src/version.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,14 @@ export const SDK_VERSION: string = pkg.version
44

55
// Autogenerated API schema version, do not remove this line
66
export const API_SCHEMA_VERSION: string = 'latest'
7+
8+
// Autogenerated supported API versions, do not remove this line
9+
export const API_SUPPORTED_VERSIONS = [] as const
10+
11+
/**
12+
* The API versions this build accepts as the `apiVersion` init option — the
13+
* union of {@link API_SUPPORTED_VERSIONS}. On a legacy build the tuple is
14+
* empty, so this resolves to `never` and the option can only be omitted (the
15+
* legacy API is unversioned).
16+
*/
17+
export type ApiVersion = (typeof API_SUPPORTED_VERSIONS)[number]

test/common.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import {
1212
} from '../src/single-client'
1313
import getToken from './token'
1414

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

2222
dotenv.config()
2323

0 commit comments

Comments
 (0)