Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ 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

## [0.21.0] - 2026-03-15

### Added

- `responseHeaders` field on `QueryReturn` and `LazyQueryReturn` — a `ShallowRef<Record<string, string>>` populated with the response headers from the last successful request
- `ShallowRef` is now re-exported from the package entry point

## [0.20.1] - 2026-03-09

### Fixed
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.20.1",
"version": "0.21.1",
"repository": {
"type": "git",
"url": "https://github.com/qualisero/openapi-endpoint.git"
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
16 changes: 14 additions & 2 deletions src/openapi-query.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -24,6 +24,7 @@ function buildQueryFn<TResponse>(
hookAxiosOptions?: AxiosRequestConfigExtended,
callAxiosOptions?: AxiosRequestConfigExtended,
errorHandler?: (error: AxiosError) => TResponse | void | Promise<TResponse | void>,
headersSink?: ShallowRef<Record<string, string>>,
): () => Promise<TResponse> {
return async () => {
try {
Expand All @@ -42,6 +43,7 @@ function buildQueryFn<TResponse>(
...(callAxiosOptions?.headers || {}),
},
})
if (headersSink) headersSink.value = response.headers as Record<string, string>
return response.data
} catch (error: unknown) {
if (errorHandler && isAxiosError(error)) {
Expand Down Expand Up @@ -84,6 +86,8 @@ export type QueryReturn<TResponse, TPathParams extends Record<string, unknown> =
pathParams: ComputedRef<TPathParams>
/** 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<Record<string, string>>
}

/**
Expand All @@ -105,7 +109,7 @@ export type LazyQueryReturn<
TQueryParams extends Record<string, unknown> = Record<string, never>,
> = Pick<
QueryReturn<TResponse, TPathParams>,
'data' | 'isPending' | 'isSuccess' | 'isError' | 'error' | 'isEnabled' | 'pathParams' | 'queryKey'
'data' | 'isPending' | 'isSuccess' | 'isError' | 'error' | 'isEnabled' | 'pathParams' | 'queryKey' | 'responseHeaders'
> & {
/**
* Execute a query imperatively.
Expand Down Expand Up @@ -178,6 +182,8 @@ export function useEndpointQuery<
return baseEnabled && isResolved.value
})

const responseHeaders = shallowRef<Record<string, string>>({})

const queryOptions = {
queryKey: queryKey as ComputedRef<readonly unknown[]>,
queryFn: buildQueryFn<TResponse>(
Expand All @@ -187,6 +193,7 @@ export function useEndpointQuery<
axiosOptions,
undefined,
errorHandler,
responseHeaders,
),
enabled: isEnabled,
staleTime: 1000 * 60,
Expand Down Expand Up @@ -232,6 +239,7 @@ export function useEndpointQuery<
queryKey,
onLoad,
pathParams: resolvedPathParams as ComputedRef<TPathParams>,
responseHeaders,
} as unknown as QueryReturn<TResponse, TPathParams>
}

Expand Down Expand Up @@ -298,6 +306,8 @@ export function useEndpointLazyQuery<
staleTime: useQueryOptions?.staleTime ?? Infinity,
})

const responseHeaders = shallowRef<Record<string, string>>({})

const fetch = async (fetchOptions?: LazyQueryFetchOptions<TQueryParams>): Promise<TResponse> => {
if (!isResolved.value) {
throw new Error(
Expand All @@ -320,6 +330,7 @@ export function useEndpointLazyQuery<
axiosOptions,
fetchOptions?.axiosOptions,
errorHandler,
responseHeaders,
),
staleTime: useQueryOptions?.staleTime ?? Infinity,
})
Expand All @@ -334,6 +345,7 @@ export function useEndpointLazyQuery<
isEnabled: query.isEnabled,
pathParams: resolvedPathParams as ComputedRef<TPathParams>,
queryKey: queryKey as ComputedRef<string[]>,
responseHeaders,
fetch,
}
}
129 changes: 129 additions & 0 deletions tests/unit/response-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* 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<typeof effectScope>
let api: ReturnType<typeof createApiClient>

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')
})
})

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({})
})
})
})
Loading