Skip to content
Merged
4 changes: 2 additions & 2 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ function nav(): DefaultTheme.NavItem[] {
items: [
{ text: 'Caching', link: '/guide/caching' },
{ text: 'Interceptors', link: '/guide/interceptors' },
{ text: 'Hooks', link: '/guide/hooks' },
{ text: 'Cookies', link: '/guide/cookies' },
{ text: 'Retries', link: '/guide/retries' },
{ text: 'Dynamic Backend URL', link: '/guide/dynamic-backend-url' },
{ text: 'Hooks', link: '/guide/hooks' },
],
},
],
Expand Down Expand Up @@ -147,10 +147,10 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
items: [
{ text: 'Caching', link: '/guide/caching' },
{ text: 'Interceptors', link: '/guide/interceptors' },
{ text: 'Hooks', link: '/guide/hooks' },
{ text: 'Cookies', link: '/guide/cookies' },
{ text: 'Retries', link: '/guide/retries' },
{ text: 'Dynamic Backend URL', link: '/guide/dynamic-backend-url' },
{ text: 'Hooks', link: '/guide/hooks' },
],
},
{ text: 'Migration', link: '/guide/migration' },
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ Prepare your first API connection by setting an endpoint object. Each key repres
- `query`: Query parameters to send with each request (optional)
- `headers`: Headers to send with each request (optional)

::: tip Dynamic Headers
To set headers dynamically, use [runtime hooks](/guide/hooks.md).
:::

```ts
// `nuxt.config.ts`
export default defineNuxtConfig({
Expand Down
67 changes: 59 additions & 8 deletions docs/guide/hooks.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
# Hooks

Nuxt API Party provides a number of hooks that can be used to customize the module's behavior. Hooks are functions that are called at specific points in the module's lifecycle. You can use hooks to modify the module's configuration.
Nuxt API Party provides a set of powerful hooks that allow you to customize the behavior of the module at various stages. The hook system supports both Nuxt and Nitro environments with fully typed, merged hooks that ensure both generic and endpoint-specific handlers are executed in the correct order.

For more information on how to work with hooks, see the [Nuxt documentation](https://nuxt.com/docs/guide/going-further/hooks).

## Available Hooks

| Hook Name | Arguments | Description |
| ---------- | --------- | ----------- |
| `api-party:extend` | `options` | Called during module initialization after the options have been resolved. Can be used to modify the endpoint configuration. |
| Hook Name | Arguments | Description |
| ---------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------- |
| `api-party:extend` | `options` | Called during module initialization after options are resolved. Allows modifying endpoint configuration. |
| `api-party:request` | `ctx, [event]` | Called before each API request. This generic hook runs on both Nuxt and Nitro platforms. |
| `api-party:request:${endpointId}` | `ctx, [event]` | Called specifically for the designated endpoint. Merged with the generic request hook. |
| `api-party:response` | `ctx, [event]` | Called after each API response. This generic hook is used to handle response modifications on both platforms. |
| `api-party:response:${endpointId}` | `ctx, [event]` | Called for the specific endpoint response. Merged with the generic response hook. |

## Usage
::: info Merging Hooks
Both generic and endpoint-specific hooks are merged so that:

To use hooks, define them in the `hooks` property of your `nuxt.config.ts` file. The following example demonstrates how to use the `api-party:extend` hook:
- For requests: The generic `api-party:request` hook executes first, followed by `api-party:request:${endpointId}`.
- For responses: The endpoint-specific `api-party:response:${endpointId}` hook executes first, followed by `api-party:response`.
:::

```ts
// `nuxt.config.ts`
## Nuxt Runtime Hooks

Register Nuxt runtime hooks either in your `nuxt.config.ts` file, in a client plugin or at runtime. These hooks are useful for extending [API endpoints](/config/#apiparty-endpoints) with additional configuration or for intercepting API calls for tasks like logging, metrics, or dynamically adding headers.

The only hook called at module initialization is `api-party:extend`. This hook is useful for modifying endpoint configuration before the module is fully initialized. For example, you can log the resolved server endpoints:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['nuxt-api-party'],

Expand All @@ -26,3 +38,42 @@ export default defineNuxtConfig({
},
})
```

All other hooks are called at runtime, either on the client side (or the server side on SSR requests). For example, you can add headers to all requests using the `api-party:request` hook:

```ts [plugins/my-plugin.ts]
export default defineNuxtPlugin((nuxtApp) => {
// Generic hook: executes on every API request
nuxtApp.hook('api-party:request', (ctx) => {
// Add a unique request ID to each request
ctx.request.headers['X-Request-Id'] = Math.random().toString(36).substring(7)
})
})
```

## Nitro Runtime Hooks

For server-side processing, register these hooks in a server plugin. They are geared for tasks like dynamically fetching tokens or logging responses.

Example: Retrieve a token dynamically and attach it as the Authorization header.

```ts [server/plugins/my-plugin.ts]
export default defineNitroPlugin((nitroApp) => {
// Generic request hook: runs before any API request on the server
nitroApp.hook('api-party:request', async (ctx, event) => {
// Do something before each request
})

// Endpoint-specific request hook for `myapi`
nitroApp.hook('api-party:request:myapi', async (ctx, event) => {
// Fetch a user token and attach it to the request
const token = await getUserToken(event)
ctx.request.headers.Authorization = `Bearer ${token}`
})

// Example of a response hook to modify or log responses
nitroApp.hook('api-party:response:myapi', async (ctx, event) => {
// Custom response handling
})
})
```
8 changes: 8 additions & 0 deletions playground/plugins/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineNuxtPlugin } from '#imports'

export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hooks.hook('api-party:request:jsonPlaceholder', async ({ request }) => {
const url = typeof request === 'string' ? request : request.url
console.log('[nuxt-api-party] [Nuxt] jsonPlaceholder request', url)
})
})
8 changes: 8 additions & 0 deletions playground/server/plugins/server-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineNitroPlugin } from '#imports'

export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('api-party:request:jsonPlaceholder', async ({ request }) => {
const url = typeof request === 'string' ? request : request.url
console.log('[nuxt-api-party] [Nitro] jsonPlaceholder request', url)
})
})
50 changes: 49 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { HookResult } from '@nuxt/schema'
import type { H3Event } from 'h3'
import type { FetchContext, FetchResponse } from 'ofetch'
import type { OpenAPI3, OpenAPITSOptions } from 'openapi-typescript'
import type { QueryObject } from 'ufo'
import { addImportsSources, addServerHandler, addTemplate, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
import { addImportsSources, addServerHandler, addTemplate, addTypeTemplate, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
import { defu } from 'defu'
import { createJiti } from 'jiti'
import { relative } from 'pathe'
Expand Down Expand Up @@ -93,6 +95,20 @@ declare module '@nuxt/schema' {
}
}

declare module '#app' {
interface RuntimeNuxtHooks {
'api-party:request': (options: FetchContext) => HookResult
'api-party:response': (options: Omit<FetchContext, 'response'> & { response: FetchResponse<any> }) => HookResult
}
}

declare module 'nitropack/types' {
interface NitroRuntimeHooks {
'api-party:request': (options: FetchContext, event: H3Event) => HookResult
'api-party:response': (options: Omit<FetchContext, 'response'> & { response: FetchResponse<any> }, event: H3Event) => HookResult
}
}

export default defineNuxtModule<ModuleOptions>({
meta: {
name,
Expand Down Expand Up @@ -210,6 +226,7 @@ export const ${getRawComposableName(i)} = (...args) => _$api('${i}', ...args)
config.typescript.tsConfig ||= {}
config.typescript.tsConfig.include ||= []
config.typescript.tsConfig.include.push(`./module/${moduleName}-schema.d.ts`)
config.typescript.tsConfig.include.push(`./module/${moduleName}-hooks.d.ts`)
}

// Add Nitro auto-imports for generated composables
Expand Down Expand Up @@ -281,6 +298,37 @@ export { ${endpointKeys.map(getRawComposableName).join(', ')} } from './${module
},
})

// Add types for Nuxt and Nitro runtime hooks
addTypeTemplate({
filename: `module/${moduleName}-hooks.d.ts`,
getContents() {
return `
// Generated by ${moduleName}
import type { HookResult } from '@nuxt/schema'
import type { FetchContext, FetchResponse } from 'ofetch'
import type { H3Event } from 'h3'

declare module '#app' {
interface RuntimeNuxtHooks {
${endpointKeys.flatMap(i => [
`'api-party:request:${i}': (option: FetchContext) => HookResult`,
`'api-party:response:${i}': (option: Omit<FetchContext, 'response'> & { response: FetchResponse<any> }) => HookResult`,
]).join('\n ')}
}
}

declare module 'nitropack/types' {
interface NitroRuntimeHooks {
${endpointKeys.flatMap(i => [
`'api-party:request:${i}': (option: FetchContext, event: H3Event) => HookResult`,
`'api-party:response:${i}': (option: Omit<FetchContext, 'response'> & { response: FetchResponse<any> }, event: H3Event) => HookResult`,
]).join('\n ')}
}
}
`.trimStart()
},
})

// Add type references for endpoints with OpenAPI schemas
if (schemaEndpointIds.length) {
addTemplate({
Expand Down
16 changes: 16 additions & 0 deletions src/runtime/composables/$api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { hash } from 'ohash'
import { joinURL } from 'ufo'
import { CACHE_KEY_PREFIX } from '../constants'
import { isFormData } from '../form-data'
import { mergeFetchHooks } from '../hooks'
import { resolvePathParams } from '../openapi'
import { headersToObject, serializeMaybeEncodedBody } from '../utils'

Expand Down Expand Up @@ -112,8 +113,22 @@ export function _$api<T = unknown>(

const endpoint = (apiParty.endpoints || {})[endpointId]

const fetchHooks = mergeFetchHooks(fetchOptions, {
async onRequest(ctx) {
await nuxt.callHook('api-party:request', ctx)
// @ts-expect-error: Types will be generated on Nuxt prepare
await nuxt.callHook(`api-party:request:${endpointId}`, ctx)
},
async onResponse(ctx) {
// @ts-expect-error: Types will be generated on Nuxt prepare
await nuxt.callHook(`api-party:response:${endpointId}`, ctx)
await nuxt.callHook('api-party:response', ctx)
},
})

const clientFetcher = () => globalThis.$fetch<T>(resolvePathParams(path, pathParams), {
...fetchOptions,
...fetchHooks,
baseURL: endpoint.url,
method,
query: {
Expand All @@ -131,6 +146,7 @@ export function _$api<T = unknown>(
const serverFetcher = async () =>
(await globalThis.$fetch<T>(joinURL('/api', apiParty.server.basePath!, endpointId), {
...fetchOptions,
...fetchHooks,
method: 'POST',
body: {
path: resolvePathParams(path, pathParams),
Expand Down
26 changes: 22 additions & 4 deletions src/runtime/composables/useApiData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { joinURL } from 'ufo'
import { computed, reactive, toValue } from 'vue'
import { CACHE_KEY_PREFIX } from '../constants'
import { isFormData } from '../form-data'
import { mergeFetchHooks } from '../hooks'
import { resolvePathParams } from '../openapi'
import { headersToObject, serializeMaybeEncodedBody } from '../utils'

Expand Down Expand Up @@ -55,17 +56,19 @@ export type SharedAsyncDataOptions<ResT, DataT = ResT> = Omit<AsyncDataOptions<R

export type UseApiDataOptions<T> = Pick<
ComputedOptions<NitroFetchOptions<string>>,
| 'onRequest'
| 'onRequestError'
| 'onResponse'
| 'onResponseError'
| 'query'
| 'headers'
| 'method'
| 'retry'
| 'retryDelay'
| 'retryStatusCodes'
| 'timeout'
> & Pick<
NitroFetchOptions<string>,
| 'onRequest'
| 'onRequestError'
| 'onResponse'
| 'onResponseError'
> & {
path?: MaybeRefOrGetter<Record<string, string>>
body?: MaybeRef<string | Record<string, any> | FormData | null>
Expand Down Expand Up @@ -190,10 +193,24 @@ export function _useApiData<T = unknown>(

let result: T | undefined

const fetchHooks = mergeFetchHooks(fetchOptions, {
async onRequest(ctx) {
await nuxt?.callHook('api-party:request', ctx)
// @ts-expect-error: Types will be generated on Nuxt prepare
await nuxt?.callHook(`api-party:request:${endpointId}`, ctx)
},
async onResponse(ctx) {
// @ts-expect-error: Types will be generated on Nuxt prepare
await nuxt?.callHook(`api-party:response:${endpointId}`, ctx)
await nuxt?.callHook('api-party:response', ctx)
},
})

try {
if (client) {
result = (await globalThis.$fetch<T>(_path.value, {
..._fetchOptions,
...fetchHooks,
signal: controller.signal,
baseURL: endpoint.url,
method: _endpointFetchOptions.method,
Expand All @@ -214,6 +231,7 @@ export function _useApiData<T = unknown>(
joinURL('/api', apiParty.server.basePath!, endpointId),
{
..._fetchOptions,
...fetchHooks,
signal: controller.signal,
method: 'POST',
body: {
Expand Down
31 changes: 31 additions & 0 deletions src/runtime/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { FetchHooks } from 'ofetch'

type Arrayify<T> = { [P in keyof T]-?: Extract<T[P], unknown[]> }

export function mergeFetchHooks(...hooks: FetchHooks[]): FetchHooks {
const result: Arrayify<FetchHooks> = {
onRequest: [],
onResponse: [],
onRequestError: [],
onResponseError: [],
}

for (const hook of hooks) {
for (const [key, value] of Object.entries(hook)) {
maybePush(result[key as keyof typeof result], value)
}
}

return result
}

function maybePush<T>(array: T[], values?: T | T[]) {
if (values) {
if (Array.isArray(values)) {
array.push(...values)
}
else {
array.push(values)
}
}
}
2 changes: 1 addition & 1 deletion src/runtime/server/$api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ModuleOptions } from '../../module'
import type { ApiClientFetchOptions } from '../composables/$api'
import { useRuntimeConfig } from '#imports'
import { useRuntimeConfig } from 'nitropack/runtime'
import { resolvePathParams } from '../openapi'
import { headersToObject } from '../utils'

Expand Down
13 changes: 12 additions & 1 deletion src/runtime/server/handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { ModuleOptions } from '../../module'
import type { EndpointFetchOptions } from '../types'
import { useRuntimeConfig } from '#imports'
import {
createError,
defineEventHandler,
Expand All @@ -13,6 +12,7 @@ import {
setResponseStatus,
splitCookiesString,
} from 'h3'
import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime'
import { deserializeMaybeEncodedBody } from '../utils'

const ALLOWED_REQUEST_HEADERS = [
Expand All @@ -22,6 +22,7 @@ const ALLOWED_REQUEST_HEADERS = [
]

export default defineEventHandler(async (event) => {
const nitro = useNitroApp()
const endpointId = getRouterParam(event, 'endpointId')!
const apiParty = useRuntimeConfig().apiParty as Required<ModuleOptions>
const endpoints = apiParty.endpoints || {}
Expand Down Expand Up @@ -94,6 +95,16 @@ export default defineEventHandler(async (event) => {
...(body && { body: await deserializeMaybeEncodedBody(body) }),
responseType: 'arrayBuffer',
ignoreResponseError: true,
async onRequest(ctx) {
await nitro.hooks.callHook('api-party:request', ctx, event)
// @ts-expect-error: Types will be generated on Nuxt prepare
await nitro.hooks.callHook(`api-party:request:${endpointId}`, ctx, event)
},
async onResponse(ctx) {
// @ts-expect-error: Types will be generated on Nuxt prepare
await nitro.hooks.callHook(`api-party:response:${endpointId}`, ctx, event)
await nitro.hooks.callHook('api-party:response', ctx, event)
},
},
)

Expand Down