diff --git a/CHANGELOG.md b/CHANGELOG.md index e02d57c..a05bf5d 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.21.1] - 2026-03-15 + +### Added + +- `responseHeaders` shallowRef on `QueryReturn` and `LazyQueryReturn` — exposes response headers (e.g. `X-Pagination`) from the last successful query +- `ShallowRef` is now re-exported from the package entry point + ## [0.20.1] - 2026-03-09 ### Fixed diff --git a/package-lock.json b/package-lock.json index 89b19c8..ae4c3f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qualisero/openapi-endpoint", - "version": "0.20.1", + "version": "0.21.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qualisero/openapi-endpoint", - "version": "0.20.1", + "version": "0.21.1", "license": "MIT", "bin": { "openapi-codegen": "bin/openapi-codegen.js" diff --git a/package.json b/package.json index be8d4f3..b908d48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qualisero/openapi-endpoint", - "version": "0.20.1", + "version": "0.21.1", "repository": { "type": "git", "url": "https://github.com/qualisero/openapi-endpoint.git" diff --git a/src/index.ts b/src/index.ts index 09704d5..8a32eac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,5 +57,5 @@ export { HttpMethod, QUERY_METHODS, MUTATION_METHODS, isQueryMethod, isMutationM // ============================================================================ // Re-export Vue types (ensures consumer's version is used) // ============================================================================ -export type { Ref, ComputedRef } from 'vue' +export type { Ref, ComputedRef, ShallowRef } from 'vue' export type { MaybeRefOrGetter } from '@vue/reactivity' diff --git a/src/openapi-query.ts b/src/openapi-query.ts index 0d6e05c..96d8e19 100644 --- a/src/openapi-query.ts +++ b/src/openapi-query.ts @@ -1,4 +1,4 @@ -import { computed, watch, toValue, type ComputedRef } from 'vue' +import { computed, shallowRef, watch, toValue, type ComputedRef, type ShallowRef } from 'vue' import type { MaybeRefOrGetter } from '@vue/reactivity' import { useQuery, type UseQueryReturnType, type QueryClient } from '@tanstack/vue-query' import { isAxiosError, type AxiosError } from 'axios' @@ -24,6 +24,7 @@ function buildQueryFn( hookAxiosOptions?: AxiosRequestConfigExtended, callAxiosOptions?: AxiosRequestConfigExtended, errorHandler?: (error: AxiosError) => TResponse | void | Promise, + headersSink?: ShallowRef>, ): () => Promise { return async () => { try { @@ -42,6 +43,18 @@ function buildQueryFn( ...(callAxiosOptions?.headers || {}), }, }) + if (headersSink) { + const raw = response.headers + if (raw && typeof raw === 'object') { + const plain: Record = {} + for (const [k, v] of Object.entries(raw)) { + if (v != null) plain[k] = String(v) + } + headersSink.value = plain + } else { + headersSink.value = {} + } + } return response.data } catch (error: unknown) { if (errorHandler && isAxiosError(error)) { @@ -84,6 +97,8 @@ export type QueryReturn = pathParams: ComputedRef /** Register a callback for when data loads successfully for the first time. */ onLoad: (callback: (data: TResponse) => void) => void + /** Response headers from the last successful query. */ + responseHeaders: ShallowRef> } /** @@ -105,7 +120,7 @@ export type LazyQueryReturn< TQueryParams extends Record = Record, > = Pick< QueryReturn, - 'data' | 'isPending' | 'isSuccess' | 'isError' | 'error' | 'isEnabled' | 'pathParams' | 'queryKey' + 'data' | 'isPending' | 'isSuccess' | 'isError' | 'error' | 'isEnabled' | 'pathParams' | 'queryKey' | 'responseHeaders' > & { /** * Execute a query imperatively. @@ -178,6 +193,8 @@ export function useEndpointQuery< return baseEnabled && isResolved.value }) + const responseHeaders = shallowRef>({}) + const queryOptions = { queryKey: queryKey as ComputedRef, queryFn: buildQueryFn( @@ -187,6 +204,7 @@ export function useEndpointQuery< axiosOptions, undefined, errorHandler, + responseHeaders, ), enabled: isEnabled, staleTime: 1000 * 60, @@ -232,6 +250,7 @@ export function useEndpointQuery< queryKey, onLoad, pathParams: resolvedPathParams as ComputedRef, + responseHeaders, } as unknown as QueryReturn } @@ -298,6 +317,8 @@ export function useEndpointLazyQuery< staleTime: useQueryOptions?.staleTime ?? Infinity, }) + const responseHeaders = shallowRef>({}) + const fetch = async (fetchOptions?: LazyQueryFetchOptions): Promise => { if (!isResolved.value) { throw new Error( @@ -320,6 +341,7 @@ export function useEndpointLazyQuery< axiosOptions, fetchOptions?.axiosOptions, errorHandler, + responseHeaders, ), staleTime: useQueryOptions?.staleTime ?? Infinity, }) @@ -334,6 +356,7 @@ export function useEndpointLazyQuery< isEnabled: query.isEnabled, pathParams: resolvedPathParams as ComputedRef, queryKey: queryKey as ComputedRef, + responseHeaders, fetch, } } diff --git a/tests/unit/response-headers.test.ts b/tests/unit/response-headers.test.ts new file mode 100644 index 0000000..80705a8 --- /dev/null +++ b/tests/unit/response-headers.test.ts @@ -0,0 +1,138 @@ +/** + * Tests for responseHeaders feature on useQuery and useLazyQuery + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { effectScope } from 'vue' +import { flushPromises } from '@vue/test-utils' +import { mockAxios } from '../setup' +import { createTestScope } from '../helpers' +import { createApiClient } from '../fixtures/api-client' + +describe('Response Headers', () => { + let scope: ReturnType + let api: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + ;({ api, scope } = createTestScope()) + }) + + afterEach(() => { + scope.stop() + }) + + describe('useQuery - responseHeaders', () => { + it('should have empty headers initially', () => { + mockAxios.mockResolvedValueOnce({ data: [] }) + const query = scope.run(() => api.listPets.useQuery({ enabled: false }))! + expect(query.responseHeaders.value).toEqual({}) + }) + + it('should populate responseHeaders after successful fetch', async () => { + mockAxios.mockResolvedValueOnce({ + data: [{ id: '1', name: 'Fluffy' }], + headers: { 'x-pagination': '{"total":100}', 'x-request-id': 'abc123' }, + }) + + const query = scope.run(() => api.listPets.useQuery())! + await flushPromises() + + expect(query.responseHeaders.value).toEqual({ + 'x-pagination': '{"total":100}', + 'x-request-id': 'abc123', + }) + }) + + it('should update responseHeaders on refetch', async () => { + mockAxios.mockResolvedValueOnce({ + data: [{ id: '1', name: 'Fluffy' }], + headers: { 'x-request-id': 'first-request' }, + }) + + const query = scope.run(() => api.listPets.useQuery())! + await flushPromises() + + expect(query.responseHeaders.value['x-request-id']).toBe('first-request') + + mockAxios.mockResolvedValueOnce({ + data: [{ id: '2', name: 'Spot' }], + headers: { 'x-request-id': 'second-request' }, + }) + + await query.refetch() + await flushPromises() + + expect(query.responseHeaders.value['x-request-id']).toBe('second-request') + }) + + it('should have responseHeaders as a ShallowRef', () => { + mockAxios.mockResolvedValueOnce({ data: [] }) + const query = scope.run(() => api.listPets.useQuery({ enabled: false }))! + // ShallowRef has a .value property and is reactive + expect(query.responseHeaders).toHaveProperty('value') + expect(typeof query.responseHeaders.value).toBe('object') + }) + + it('should default to empty object when response has no headers', async () => { + mockAxios.mockResolvedValueOnce({ data: [{ id: '1', name: 'Fluffy' }] }) + + const query = scope.run(() => api.listPets.useQuery())! + await flushPromises() + + expect(query.responseHeaders.value).toEqual({}) + }) + }) + + describe('useLazyQuery - responseHeaders', () => { + it('should have empty headers before fetch', () => { + const query = scope.run(() => api.listPets.useLazyQuery())! + expect(query.responseHeaders.value).toEqual({}) + }) + + it('should populate responseHeaders after lazy fetch', async () => { + mockAxios.mockResolvedValueOnce({ + data: [{ id: '1', name: 'Fluffy' }], + headers: { 'x-pagination': '{"total":50}', 'x-request-id': 'lazy-abc' }, + }) + + const query = scope.run(() => api.listPets.useLazyQuery())! + + await query.fetch() + await flushPromises() + + expect(query.responseHeaders.value).toEqual({ + 'x-pagination': '{"total":50}', + 'x-request-id': 'lazy-abc', + }) + }) + + it('should update responseHeaders on repeated lazy fetch', async () => { + mockAxios.mockResolvedValueOnce({ + data: [{ id: '1', name: 'Fluffy' }], + headers: { 'x-request-id': 'lazy-first' }, + }) + mockAxios.mockResolvedValueOnce({ + data: [{ id: '2', name: 'Spot' }], + headers: { 'x-request-id': 'lazy-second' }, + }) + + const query = scope.run(() => api.listPets.useLazyQuery())! + + await query.fetch({ queryParams: { limit: 10 } }) + await flushPromises() + expect(query.responseHeaders.value['x-request-id']).toBe('lazy-first') + + await query.fetch({ queryParams: { limit: 20 } }) + await flushPromises() + expect(query.responseHeaders.value['x-request-id']).toBe('lazy-second') + }) + + it('should not have headers if fetch has not been called', () => { + const query = scope.run(() => api.listPets.useLazyQuery())! + + expect(mockAxios).not.toHaveBeenCalled() + expect(query.responseHeaders.value).toEqual({}) + }) + }) +})