Skip to content
Closed
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
16 changes: 16 additions & 0 deletions .changeset/contract-client-version-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@kubb/plugin-react-query': patch
'@kubb/plugin-vue-query': patch
'@kubb/plugin-swr': patch
---

Setup now throws a clear error when the registered `@kubb/plugin-fetch` or `@kubb/plugin-axios`
predates the `unwrap()` method these hooks call, instead of generating hooks that fail at runtime.

```
`@kubb/plugin-fetch` is registered at version 5.1.2, but this plugin needs `@kubb/plugin-fetch@5.2.0` or newer.
Generated hooks call `unwrap()` on the client's result, which `@kubb/plugin-fetch` only added in 5.2.0.
Upgrade `@kubb/plugin-fetch` to 5.2.0 or later.
```

Upgrade `@kubb/plugin-fetch` or `@kubb/plugin-axios` to `5.2.0` or later to clear the error.
60 changes: 58 additions & 2 deletions internals/client/src/resolveClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, test } from 'vitest'
import { resolveClient } from './resolveClient.ts'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
import { resolveClient, resolveContractClient } from './resolveClient.ts'

describe('resolveClient', () => {
test("client: 'fetch' selects plugin-fetch when it is registered", () => {
Expand Down Expand Up @@ -36,3 +39,56 @@ describe('resolveClient', () => {
}
})
})

describe('resolveContractClient', () => {
let root: string

function installPackage(name: string, version: string) {
const pkgDir = path.join(root, 'node_modules', ...name.split('/'))
fs.mkdirSync(pkgDir, { recursive: true })
fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name, version }))
}

beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'kubb-resolve-contract-client-'))
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '0.0.0' }))
})

afterEach(() => {
fs.rmSync(root, { recursive: true, force: true })
})

test('a consumer without requireUnwrap ignores an old contract client version', () => {
installPackage('@kubb/plugin-fetch', '5.1.2')

expect(resolveContractClient({ client: 'fetch', plugins: [{ name: 'plugin-fetch' }], root })).toStrictEqual({
kind: 'contract',
pluginName: 'plugin-fetch',
})
})

test('requireUnwrap throws when plugin-fetch predates unwrap()', () => {
installPackage('@kubb/plugin-fetch', '5.1.2')

expect(() => resolveContractClient({ client: 'fetch', plugins: [{ name: 'plugin-fetch' }], root, requireUnwrap: true })).toThrowError(
/@kubb\/plugin-fetch.*5\.1\.2.*@kubb\/plugin-fetch@5\.2\.0/s,
)
})

test('requireUnwrap throws when plugin-axios predates unwrap()', () => {
installPackage('@kubb/plugin-axios', '5.1.0')

expect(() => resolveContractClient({ client: 'axios', plugins: [{ name: 'plugin-axios' }], root, requireUnwrap: true })).toThrowError(
/@kubb\/plugin-axios.*5\.1\.0.*@kubb\/plugin-axios@5\.2\.0/s,
)
})

test('requireUnwrap passes when the contract client is new enough', () => {
installPackage('@kubb/plugin-fetch', '5.2.0')

expect(resolveContractClient({ client: 'fetch', plugins: [{ name: 'plugin-fetch' }], root, requireUnwrap: true })).toStrictEqual({
kind: 'contract',
pluginName: 'plugin-fetch',
})
})
})
57 changes: 55 additions & 2 deletions internals/client/src/resolveClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,20 @@
* contract client plugin is picked up automatically.
*/

import { createRequire } from 'node:module'
import path from 'node:path'

// Canonical plugin names. They mirror the `pluginFetchName` / `pluginAxiosName` consts the plugins
// export, kept as literals so this internal needs no plugin install deps.
const pluginFetchName = 'plugin-fetch'
const pluginAxiosName = 'plugin-axios'

// react-query, vue-query, and swr build their generated hooks on `unwrap()`, which plugin-fetch
// and plugin-axios only started attaching to their result at this version. An older client plugin
// would generate a client without `unwrap()`, so the hooks would call a method that does not
// exist at runtime.
const MIN_CLIENT_VERSION_FOR_UNWRAP = '5.2.0'

/**
* The client selector accepted by a consumer's `client` option. Both call a registered contract
* client plugin.
Expand Down Expand Up @@ -96,17 +105,61 @@ export function resolveClient(options: { client: ClientSelector | undefined; plu
}
}

/**
* Compares dot-separated `major.minor.patch` version strings. Missing or non-numeric parts count
* as `0`, and a prerelease suffix (`-beta.1`) is ignored.
*/
function isVersionAtLeast(version: string, minVersion: string): boolean {
const parts = (value: string) => value.split('-')[0]!.split('.').map(Number)
const [major = 0, minor = 0, patch = 0] = parts(version)
const [minMajor = 0, minMinor = 0, minPatch = 0] = parts(minVersion)

if (major !== minMajor) return major > minMajor
if (minor !== minMinor) return minor > minMinor
return patch >= minPatch
}

/**
* Resolves the contract client during a consumer plugin's setup hook. Extracts the plugin names
* from the raw `plugins` config, applies {@link resolveClient}, and throws the diagnostic on a
* misconfiguration so every consumer fails fast with the same message.
*
* Pass `requireUnwrap: true` for a consumer (react-query, vue-query, swr) whose generated hooks
* call `unwrap()` on the client's result. This reads the resolved client plugin's installed
* version from the user's project and throws when it predates `unwrap()`, so setup fails with a
* clear diagnostic instead of the generated hooks calling a method that does not exist.
*/
export function resolveContractClient(options: { client: ClientSelector | undefined; plugins?: ReadonlyArray<unknown> }): ResolvedContractClient {
const { client, plugins = [] } = options
export function resolveContractClient(options: {
client: ClientSelector | undefined
plugins?: ReadonlyArray<unknown>
root: string
requireUnwrap?: boolean
}): ResolvedContractClient {
const { client, plugins = [], root, requireUnwrap = false } = options
const pluginNames = plugins.map((plugin) => (plugin as { name?: string }).name).filter((name): name is string => Boolean(name))
const resolved = resolveClient({ client, pluginNames })
if (resolved.kind === 'error') {
throw new Error(resolved.message)
}
if (!requireUnwrap) {
return resolved
}

const packageName = `@kubb/${resolved.pluginName}`
let installedVersion: string | undefined
try {
installedVersion = (createRequire(path.join(root, 'package.json'))(`${packageName}/package.json`) as { version?: string }).version
} catch {
installedVersion = undefined
}

if (installedVersion && !isVersionAtLeast(installedVersion, MIN_CLIENT_VERSION_FOR_UNWRAP)) {
throw new Error(
`\`${packageName}\` is registered at version ${installedVersion}, but this plugin needs \`${packageName}@${MIN_CLIENT_VERSION_FOR_UNWRAP}\` or newer. ` +
`Generated hooks call \`unwrap()\` on the client's result, which \`${packageName}\` only added in ${MIN_CLIENT_VERSION_FOR_UNWRAP}. ` +
`Upgrade \`${packageName}\` to ${MIN_CLIENT_VERSION_FOR_UNWRAP} or later.`,
)
}

return resolved
}
2 changes: 1 addition & 1 deletion packages/plugin-mcp/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const pluginMcp = definePlugin<PluginMcp>((options) => {
include,
override,
group: groupConfig,
client: resolveContractClient({ client, plugins: ctx.config.plugins }),
client: resolveContractClient({ client, plugins: ctx.config.plugins, root: ctx.config.root }),
resolver,
})
ctx.setResolver(resolver)
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-react-query/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const pluginReactQuery = definePlugin<PluginReactQuery>((options) => {

ctx.setOptions({
output,
client: resolveContractClient({ client, plugins: ctx.config.plugins }),
client: resolveContractClient({ client, plugins: ctx.config.plugins, root: ctx.config.root, requireUnwrap: true }),
queryKey,
query: resolveQueryConfig(query, { importPath: '@tanstack/react-query' }),
mutationKey,
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-swr/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const pluginSwr = definePlugin<PluginSwr>((options) => {

ctx.setOptions({
output,
client: resolveContractClient({ client, plugins: ctx.config.plugins }),
client: resolveContractClient({ client, plugins: ctx.config.plugins, root: ctx.config.root, requireUnwrap: true }),
queryKey,
query: resolveQueryConfig(query, { importPath: 'swr' }),
mutationKey,
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-vue-query/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const pluginVueQuery = definePlugin<PluginVueQuery>((options) => {

ctx.setOptions({
output,
client: resolveContractClient({ client, plugins: ctx.config.plugins }),
client: resolveContractClient({ client, plugins: ctx.config.plugins, root: ctx.config.root, requireUnwrap: true }),
queryKey,
query: resolveQueryConfig(query, { importPath: '@tanstack/vue-query' }),
mutationKey,
Expand Down
Loading