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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.22.0] - 2026-05-27

### Added

- `buildUrl(baseURL, path, pathParams?, queryParams?)` utility for building request URLs without executing a fetch. Exported from the package index.
- `urlFor()` method on every generated query operation (`api.opName.urlFor(pathParams?, queryParams?)`) that returns a plain URL string. Useful for `<img :src>`, anchor `href`, `window.open`, etc. Only flat scalar query params are supported; mutation operations do not get `urlFor`.

## [0.21.5] - 2026-05-26

### Fixed
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# OpenApiEndpoint

[![npm version](https://badge.fury.io/js/@qualisero%2Fopenapi-endpoint.svg?v=0.18.1)](https://badge.fury.io/js/@qualisero%2Fopenapi-endpoint)
[![CI](https://github.com/qualisero/openapi-endpoint/workflows/CI/badge.svg?refresh=20260226)](https://github.com/qualisero/openapi-endpoint/actions/workflows/ci.yml)
[![npm version](https://badge.fury.io/js/@qualisero%2Fopenapi-endpoint.svg?v=0.22.0)](https://badge.fury.io/js/@qualisero%2Fopenapi-endpoint)
[![CI](https://github.com/qualisero/openapi-endpoint/workflows/CI/badge.svg?refresh=20260527)](https://github.com/qualisero/openapi-endpoint/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Documentation](https://img.shields.io/badge/docs-online-brightgreen.svg)](https://qualisero.github.io/openapi-endpoint/)

Expand Down Expand Up @@ -106,6 +106,22 @@ const query = api.getPet.useQuery({ petId: '123' })
query.onLoad((pet) => console.log('Pet:', pet.name))
```

### Getting a URL without fetching

Every query operation also exposes `urlFor()`, which returns a plain URL string without performing a request. This is the right tool for `<img :src>`, anchor `href`, `window.open`, or native `fetch`:

```ts
const url = api.getAssetDocumentFile.urlFor({ asset_id, document_ref }, { view: true })
// → "https://api.example.com/api/v3/document/asset/.../file?view=true"
```

Notes:

- Synchronous; takes plain values only (not refs or getters). Wrap in `computed(...)` for reactivity.
- Only flat scalar query params (`string | number | boolean`) are supported.
- `baseURL` is read from the axios instance at call time.
- Available on query operations only (GET / HEAD / OPTIONS), not mutations.

### Mutations (POST/PUT/PATCH/DELETE)

```typescript
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@qualisero/openapi-endpoint",
"version": "0.21.5",
"version": "0.22.0",
"repository": {
"type": "git",
"url": "https://github.com/qualisero/openapi-endpoint.git"
Expand Down
36 changes: 36 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,17 @@ function _queryNoParams<Op extends AllOps>(
* @returns Lazy query result object
*/
useLazyQuery,
/**
* Build a URL string for this operation without executing a fetch.
*
* Synchronous; only accepts plain values. Only flat scalar query params
* are supported (see \`buildUrl\`). The axios \`baseURL\` is read at call time.
*
* @param queryParams - Optional flat query parameters to append.
* @returns Full URL string.
*/
urlFor: (queryParams?: QueryParams): string =>
buildUrl(base.axios.defaults.baseURL, cfg.path, undefined, queryParams),
enums,
} as const
}
Expand Down Expand Up @@ -1216,6 +1227,30 @@ function _queryWithParams<Op extends AllOps>(
* @returns Lazy query result object
*/
useLazyQuery: _lazyImpl as _UseLazyQuery,
/**
* Build a URL string for this operation without executing a fetch.
*
* Useful when a URL is needed directly — e.g. for <img :src>, anchor hrefs,
* or any context where the browser/native element handles the request.
*
* Unlike \`useQuery\`, \`urlFor\` is synchronous and only accepts plain values —
* not refs, computed, or getter functions. Wrap in \`computed(...)\` for reactivity.
*
* Only flat scalar query params are supported (see \`buildUrl\`).
* The axios \`baseURL\` is read at call time.
*
* @param pathParams - Path parameters to substitute into the URL template (plain object).
* @param queryParams - Optional flat query parameters to append.
* @returns Full URL string.
*
* @example
* const url = api.getAssetDocumentFile.urlFor(
* { asset_id: assetId, document_ref: doc.document_ref },
* { view: true }
* )
*/
urlFor: (pathParams: PathParamsInput, queryParams?: QueryParams): string =>
buildUrl(base.axios.defaults.baseURL, cfg.path, pathParams, queryParams),
enums,
} as const
}
Expand Down Expand Up @@ -1398,6 +1433,7 @@ import {
useEndpointLazyQuery,
defaultQueryClient,
HttpMethod,
buildUrl,
type QueryOptions,
type MutationOptions,
type QueryReturn,
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export type {
// ============================================================================
export { HttpMethod, QUERY_METHODS, MUTATION_METHODS, isQueryMethod, isMutationMethod } from './types'

// ============================================================================
// URL building utilities
// ============================================================================
export { buildUrl } from './openapi-utils'

// ============================================================================
// Re-export Vue types (ensures consumer's version is used)
// ============================================================================
Expand Down
32 changes: 32 additions & 0 deletions src/openapi-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,35 @@ export function normalizeParamsOptions<PathParams extends Record<string, unknown
options: options ?? ({} as Options),
}
}

/**
* Build a full URL string from a baseURL, an OpenAPI path template,
* optional path parameters, and optional query parameters.
*
* Intended for use-cases where a URL string is needed directly —
* e.g. <img :src="api.getFile.urlFor({ id })" /> — without executing a fetch.
*
* NOTE: Only flat scalar query params (string | number | boolean) are supported.
*
* @param baseURL - The axios instance baseURL. Read at call time.
* @param path - OpenAPI path template (e.g. "/v3/document/asset/{id}/file")
* @param pathParams - Values to substitute into the path template
* @param queryParams - Key/value pairs to append as query string (undefined/null values are omitted)
*/
export function buildUrl(
baseURL: string | undefined,
path: string,
pathParams?: Record<string, string | number | undefined> | null,
queryParams?: Record<string, unknown> | null,
): string {
const base = (baseURL ?? '').replace(/\/$/, '')
const resolvedPath = resolvePath(path, pathParams)
let url = base + resolvedPath
if (queryParams) {
const pairs = Object.entries(queryParams)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
if (pairs.length > 0) url += '?' + pairs.join('&')
}
return url
}
43 changes: 43 additions & 0 deletions tests/unit/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,7 @@ export type OperationId = keyof OpenApiOperations
useEndpointLazyQuery,
defaultQueryClient,
HttpMethod,
buildUrl,
type QueryOptions,
type MutationOptions,
type QueryReturn,
Expand Down Expand Up @@ -1089,6 +1090,8 @@ function _queryNoParams<Op extends AllOps>(
* @returns Lazy query result object
*/
useLazyQuery,
urlFor: (queryParams?: QueryParams): string =>
buildUrl(base.axios.defaults.baseURL, cfg.path, undefined, queryParams),
enums,
} as const
}
Expand Down Expand Up @@ -1184,6 +1187,8 @@ function _queryWithParams<Op extends AllOps>(
* @returns Lazy query result object
*/
useLazyQuery: _lazyImpl as _UseLazyQuery,
urlFor: (pathParams: PathParamsInput, queryParams?: QueryParams): string =>
buildUrl(base.axios.defaults.baseURL, cfg.path, pathParams, queryParams),
enums,
} as const
}
Expand Down Expand Up @@ -1249,6 +1254,44 @@ function _queryWithParams<Op extends AllOps>(
expect(content).toContain('useLazyQuery: _lazyImpl as _UseLazyQuery')
expect(content).toContain('): LazyQueryReturn<Response, PathParams, QueryParams>')
})

it('should include buildUrl in generated imports', () => {
const operationIds = ['listPets', 'createPet', 'getPet']
const operationInfoMap = {
listPets: { path: '/pets', method: 'GET' },
createPet: { path: '/pets', method: 'POST' },
getPet: { path: '/pets/{petId}', method: 'GET' },
}

const content = generateApiClientContent(operationIds, operationInfoMap)

expect(content).toContain('buildUrl')
})

it('should include urlFor in _queryNoParams helper', () => {
const operationIds = ['listPets', 'createPet']
const operationInfoMap = {
listPets: { path: '/pets', method: 'GET' },
createPet: { path: '/pets', method: 'POST' },
}

const content = generateApiClientContent(operationIds, operationInfoMap)

expect(content).toContain('urlFor: (queryParams?: QueryParams): string =>')
expect(content).toContain('buildUrl(base.axios.defaults.baseURL, cfg.path, undefined, queryParams)')
})

it('should include urlFor in _queryWithParams helper', () => {
const operationIds = ['getPet']
const operationInfoMap = {
getPet: { path: '/pets/{petId}', method: 'GET' },
}

const content = generateApiClientContent(operationIds, operationInfoMap)

expect(content).toContain('urlFor: (pathParams: PathParamsInput, queryParams?: QueryParams): string =>')
expect(content).toContain('buildUrl(base.axios.defaults.baseURL, cfg.path, pathParams, queryParams)')
})
})
})

Expand Down
47 changes: 46 additions & 1 deletion tests/unit/openapi-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
import { resolvePath, isPathResolved, generateQueryKey, normalizeParamsOptions } from '@/openapi-utils'
import { resolvePath, isPathResolved, generateQueryKey, normalizeParamsOptions, buildUrl } from '@/openapi-utils'
import type { QueryOptions } from '@/types'

describe('openapi-utils', () => {
Expand Down Expand Up @@ -114,4 +114,49 @@ describe('openapi-utils', () => {
expect(result.options).toBe(options)
})
})

describe('buildUrl', () => {
it('substitutes path params', () => {
expect(buildUrl('https://api.example.com', '/v3/asset/{id}/file', { id: 'abc' })).toBe(
'https://api.example.com/v3/asset/abc/file',
)
})

it('appends query params', () => {
expect(buildUrl('https://api.example.com', '/v3/file', undefined, { view: true, variant: 'thumbnail' })).toBe(
'https://api.example.com/v3/file?view=true&variant=thumbnail',
)
})

it('encodes booleans as "true"/"false"', () => {
expect(buildUrl('https://api.example.com', '/v3/file', undefined, { view: true, draft: false })).toBe(
'https://api.example.com/v3/file?view=true&draft=false',
)
})

it('omits undefined/null query values', () => {
expect(buildUrl('https://api.example.com', '/v3/file', undefined, { view: undefined, variant: null })).toBe(
'https://api.example.com/v3/file',
)
})

it('strips trailing slash from baseURL', () => {
expect(buildUrl('https://api.example.com/', '/v3/file', undefined)).toBe('https://api.example.com/v3/file')
})

it('handles undefined baseURL', () => {
expect(buildUrl(undefined, '/v3/file', undefined)).toBe('/v3/file')
})

it('combines path params and query params', () => {
expect(
buildUrl(
'https://api.example.com',
'/v3/asset/{asset_id}/doc/{doc_ref}/file',
{ asset_id: 'uuid-1', doc_ref: 'ref-2' },
{ view: true },
),
).toBe('https://api.example.com/v3/asset/uuid-1/doc/ref-2/file?view=true')
})
})
})
Loading