From 3d82fdce757c2fd1296065fbf02c1d0a9821849e Mon Sep 17 00:00:00 2001 From: Dave dV Date: Wed, 27 May 2026 17:33:28 +0900 Subject: [PATCH] feat: add urlFor() to query operations and buildUrl() utility --- CHANGELOG.md | 7 +++++ README.md | 20 ++++++++++++-- package-lock.json | 4 +-- package.json | 2 +- src/cli.ts | 36 ++++++++++++++++++++++++ src/index.ts | 5 ++++ src/openapi-utils.ts | 32 ++++++++++++++++++++++ tests/unit/cli.test.ts | 43 +++++++++++++++++++++++++++++ tests/unit/openapi-utils.test.ts | 47 +++++++++++++++++++++++++++++++- 9 files changed, 190 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4178c8..916f8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``, 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 diff --git a/README.md b/README.md index 6f0f44f..1f338b9 100644 --- a/README.md +++ b/README.md @@ -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/) @@ -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 ``, 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 diff --git a/package-lock.json b/package-lock.json index 637d433..4a7a9bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qualisero/openapi-endpoint", - "version": "0.21.5", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qualisero/openapi-endpoint", - "version": "0.21.5", + "version": "0.22.0", "license": "MIT", "bin": { "openapi-codegen": "bin/openapi-codegen.js" diff --git a/package.json b/package.json index 2cca32a..41bd19b 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/cli.ts b/src/cli.ts index 5516602..a6e0367 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1121,6 +1121,17 @@ function _queryNoParams( * @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 } @@ -1216,6 +1227,30 @@ function _queryWithParams( * @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 , 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 } @@ -1398,6 +1433,7 @@ import { useEndpointLazyQuery, defaultQueryClient, HttpMethod, + buildUrl, type QueryOptions, type MutationOptions, type QueryReturn, diff --git a/src/index.ts b/src/index.ts index 8a32eac..52e1289 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) // ============================================================================ diff --git a/src/openapi-utils.ts b/src/openapi-utils.ts index 786a9e2..f549850 100644 --- a/src/openapi-utils.ts +++ b/src/openapi-utils.ts @@ -116,3 +116,35 @@ export function normalizeParamsOptions — 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 | null, + queryParams?: Record | 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 +} diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts index 02081ea..b8bddbd 100644 --- a/tests/unit/cli.test.ts +++ b/tests/unit/cli.test.ts @@ -1014,6 +1014,7 @@ export type OperationId = keyof OpenApiOperations useEndpointLazyQuery, defaultQueryClient, HttpMethod, + buildUrl, type QueryOptions, type MutationOptions, type QueryReturn, @@ -1089,6 +1090,8 @@ function _queryNoParams( * @returns Lazy query result object */ useLazyQuery, + urlFor: (queryParams?: QueryParams): string => + buildUrl(base.axios.defaults.baseURL, cfg.path, undefined, queryParams), enums, } as const } @@ -1184,6 +1187,8 @@ function _queryWithParams( * @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 } @@ -1249,6 +1254,44 @@ function _queryWithParams( expect(content).toContain('useLazyQuery: _lazyImpl as _UseLazyQuery') expect(content).toContain('): LazyQueryReturn') }) + + 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)') + }) }) }) diff --git a/tests/unit/openapi-utils.test.ts b/tests/unit/openapi-utils.test.ts index 15286ed..d9797cf 100644 --- a/tests/unit/openapi-utils.test.ts +++ b/tests/unit/openapi-utils.test.ts @@ -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', () => { @@ -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') + }) + }) })