Skip to content

Commit 68f627d

Browse files
feat: add runtime hooks for API request and response handling (#89)
* feat: add runtime hooks for API request and response handling * fix: tests * fix: update server imports to use nitropack/runtime * feat: remove hooks from server $api function * feat: enhance hooks with dynamic endpoints * docs: update hooks documentation * chore: prefer `@ts-expect-error` over `any` * refactor: use ofetch's `FetchResponse` over plain Response * docs: overhaul hooks docs --------- Co-authored-by: Johann Schopplich <[email protected]>
1 parent 1ef78ab commit 68f627d

File tree

11 files changed

+212
-17
lines changed

11 files changed

+212
-17
lines changed

docs/.vitepress/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,10 @@ function nav(): DefaultTheme.NavItem[] {
8484
items: [
8585
{ text: 'Caching', link: '/guide/caching' },
8686
{ text: 'Interceptors', link: '/guide/interceptors' },
87+
{ text: 'Hooks', link: '/guide/hooks' },
8788
{ text: 'Cookies', link: '/guide/cookies' },
8889
{ text: 'Retries', link: '/guide/retries' },
8990
{ text: 'Dynamic Backend URL', link: '/guide/dynamic-backend-url' },
90-
{ text: 'Hooks', link: '/guide/hooks' },
9191
],
9292
},
9393
],
@@ -147,10 +147,10 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
147147
items: [
148148
{ text: 'Caching', link: '/guide/caching' },
149149
{ text: 'Interceptors', link: '/guide/interceptors' },
150+
{ text: 'Hooks', link: '/guide/hooks' },
150151
{ text: 'Cookies', link: '/guide/cookies' },
151152
{ text: 'Retries', link: '/guide/retries' },
152153
{ text: 'Dynamic Backend URL', link: '/guide/dynamic-backend-url' },
153-
{ text: 'Hooks', link: '/guide/hooks' },
154154
],
155155
},
156156
{ text: 'Migration', link: '/guide/migration' },

docs/guide/getting-started.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ Prepare your first API connection by setting an endpoint object. Each key repres
2828
- `query`: Query parameters to send with each request (optional)
2929
- `headers`: Headers to send with each request (optional)
3030

31+
::: tip Dynamic Headers
32+
To set headers dynamically, use [runtime hooks](/guide/hooks.md).
33+
:::
34+
3135
```ts
3236
// `nuxt.config.ts`
3337
export default defineNuxtConfig({

docs/guide/hooks.md

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
# Hooks
22

3-
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.
3+
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.
44

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

77
## Available Hooks
88

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

13-
## Usage
17+
::: info Merging Hooks
18+
Both generic and endpoint-specific hooks are merged so that:
1419

15-
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:
20+
- For requests: The generic `api-party:request` hook executes first, followed by `api-party:request:${endpointId}`.
21+
- For responses: The endpoint-specific `api-party:response:${endpointId}` hook executes first, followed by `api-party:response`.
22+
:::
1623

17-
```ts
18-
// `nuxt.config.ts`
24+
## Nuxt Runtime Hooks
25+
26+
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.
27+
28+
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:
29+
30+
```ts [nuxt.config.ts]
1931
export default defineNuxtConfig({
2032
modules: ['nuxt-api-party'],
2133

@@ -26,3 +38,42 @@ export default defineNuxtConfig({
2638
},
2739
})
2840
```
41+
42+
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:
43+
44+
```ts [plugins/my-plugin.ts]
45+
export default defineNuxtPlugin((nuxtApp) => {
46+
// Generic hook: executes on every API request
47+
nuxtApp.hook('api-party:request', (ctx) => {
48+
// Add a unique request ID to each request
49+
ctx.request.headers['X-Request-Id'] = Math.random().toString(36).substring(7)
50+
})
51+
})
52+
```
53+
54+
## Nitro Runtime Hooks
55+
56+
For server-side processing, register these hooks in a server plugin. They are geared for tasks like dynamically fetching tokens or logging responses.
57+
58+
Example: Retrieve a token dynamically and attach it as the Authorization header.
59+
60+
```ts [server/plugins/my-plugin.ts]
61+
export default defineNitroPlugin((nitroApp) => {
62+
// Generic request hook: runs before any API request on the server
63+
nitroApp.hook('api-party:request', async (ctx, event) => {
64+
// Do something before each request
65+
})
66+
67+
// Endpoint-specific request hook for `myapi`
68+
nitroApp.hook('api-party:request:myapi', async (ctx, event) => {
69+
// Fetch a user token and attach it to the request
70+
const token = await getUserToken(event)
71+
ctx.request.headers.Authorization = `Bearer ${token}`
72+
})
73+
74+
// Example of a response hook to modify or log responses
75+
nitroApp.hook('api-party:response:myapi', async (ctx, event) => {
76+
// Custom response handling
77+
})
78+
})
79+
```

playground/plugins/logger.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineNuxtPlugin } from '#imports'
2+
3+
export default defineNuxtPlugin((nuxtApp) => {
4+
nuxtApp.hooks.hook('api-party:request:jsonPlaceholder', async ({ request }) => {
5+
const url = typeof request === 'string' ? request : request.url
6+
console.log('[nuxt-api-party] [Nuxt] jsonPlaceholder request', url)
7+
})
8+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineNitroPlugin } from '#imports'
2+
3+
export default defineNitroPlugin((nitroApp) => {
4+
nitroApp.hooks.hook('api-party:request:jsonPlaceholder', async ({ request }) => {
5+
const url = typeof request === 'string' ? request : request.url
6+
console.log('[nuxt-api-party] [Nitro] jsonPlaceholder request', url)
7+
})
8+
})

src/module.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { HookResult } from '@nuxt/schema'
2+
import type { H3Event } from 'h3'
3+
import type { FetchContext, FetchResponse } from 'ofetch'
24
import type { OpenAPI3, OpenAPITSOptions } from 'openapi-typescript'
35
import type { QueryObject } from 'ufo'
4-
import { addImportsSources, addServerHandler, addTemplate, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
6+
import { addImportsSources, addServerHandler, addTemplate, addTypeTemplate, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
57
import { defu } from 'defu'
68
import { createJiti } from 'jiti'
79
import { relative } from 'pathe'
@@ -93,6 +95,20 @@ declare module '@nuxt/schema' {
9395
}
9496
}
9597

98+
declare module '#app' {
99+
interface RuntimeNuxtHooks {
100+
'api-party:request': (options: FetchContext) => HookResult
101+
'api-party:response': (options: Omit<FetchContext, 'response'> & { response: FetchResponse<any> }) => HookResult
102+
}
103+
}
104+
105+
declare module 'nitropack/types' {
106+
interface NitroRuntimeHooks {
107+
'api-party:request': (options: FetchContext, event: H3Event) => HookResult
108+
'api-party:response': (options: Omit<FetchContext, 'response'> & { response: FetchResponse<any> }, event: H3Event) => HookResult
109+
}
110+
}
111+
96112
export default defineNuxtModule<ModuleOptions>({
97113
meta: {
98114
name,
@@ -210,6 +226,7 @@ export const ${getRawComposableName(i)} = (...args) => _$api('${i}', ...args)
210226
config.typescript.tsConfig ||= {}
211227
config.typescript.tsConfig.include ||= []
212228
config.typescript.tsConfig.include.push(`./module/${moduleName}-schema.d.ts`)
229+
config.typescript.tsConfig.include.push(`./module/${moduleName}-hooks.d.ts`)
213230
}
214231

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

301+
// Add types for Nuxt and Nitro runtime hooks
302+
addTypeTemplate({
303+
filename: `module/${moduleName}-hooks.d.ts`,
304+
getContents() {
305+
return `
306+
// Generated by ${moduleName}
307+
import type { HookResult } from '@nuxt/schema'
308+
import type { FetchContext, FetchResponse } from 'ofetch'
309+
import type { H3Event } from 'h3'
310+
311+
declare module '#app' {
312+
interface RuntimeNuxtHooks {
313+
${endpointKeys.flatMap(i => [
314+
`'api-party:request:${i}': (option: FetchContext) => HookResult`,
315+
`'api-party:response:${i}': (option: Omit<FetchContext, 'response'> & { response: FetchResponse<any> }) => HookResult`,
316+
]).join('\n ')}
317+
}
318+
}
319+
320+
declare module 'nitropack/types' {
321+
interface NitroRuntimeHooks {
322+
${endpointKeys.flatMap(i => [
323+
`'api-party:request:${i}': (option: FetchContext, event: H3Event) => HookResult`,
324+
`'api-party:response:${i}': (option: Omit<FetchContext, 'response'> & { response: FetchResponse<any> }, event: H3Event) => HookResult`,
325+
]).join('\n ')}
326+
}
327+
}
328+
`.trimStart()
329+
},
330+
})
331+
284332
// Add type references for endpoints with OpenAPI schemas
285333
if (schemaEndpointIds.length) {
286334
addTemplate({

src/runtime/composables/$api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { hash } from 'ohash'
77
import { joinURL } from 'ufo'
88
import { CACHE_KEY_PREFIX } from '../constants'
99
import { isFormData } from '../form-data'
10+
import { mergeFetchHooks } from '../hooks'
1011
import { resolvePathParams } from '../openapi'
1112
import { headersToObject, serializeMaybeEncodedBody } from '../utils'
1213

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

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

116+
const fetchHooks = mergeFetchHooks(fetchOptions, {
117+
async onRequest(ctx) {
118+
await nuxt.callHook('api-party:request', ctx)
119+
// @ts-expect-error: Types will be generated on Nuxt prepare
120+
await nuxt.callHook(`api-party:request:${endpointId}`, ctx)
121+
},
122+
async onResponse(ctx) {
123+
// @ts-expect-error: Types will be generated on Nuxt prepare
124+
await nuxt.callHook(`api-party:response:${endpointId}`, ctx)
125+
await nuxt.callHook('api-party:response', ctx)
126+
},
127+
})
128+
115129
const clientFetcher = () => globalThis.$fetch<T>(resolvePathParams(path, pathParams), {
116130
...fetchOptions,
131+
...fetchHooks,
117132
baseURL: endpoint.url,
118133
method,
119134
query: {
@@ -131,6 +146,7 @@ export function _$api<T = unknown>(
131146
const serverFetcher = async () =>
132147
(await globalThis.$fetch<T>(joinURL('/api', apiParty.server.basePath!, endpointId), {
133148
...fetchOptions,
149+
...fetchHooks,
134150
method: 'POST',
135151
body: {
136152
path: resolvePathParams(path, pathParams),

src/runtime/composables/useApiData.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { joinURL } from 'ufo'
1010
import { computed, reactive, toValue } from 'vue'
1111
import { CACHE_KEY_PREFIX } from '../constants'
1212
import { isFormData } from '../form-data'
13+
import { mergeFetchHooks } from '../hooks'
1314
import { resolvePathParams } from '../openapi'
1415
import { headersToObject, serializeMaybeEncodedBody } from '../utils'
1516

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

5657
export type UseApiDataOptions<T> = Pick<
5758
ComputedOptions<NitroFetchOptions<string>>,
58-
| 'onRequest'
59-
| 'onRequestError'
60-
| 'onResponse'
61-
| 'onResponseError'
6259
| 'query'
6360
| 'headers'
6461
| 'method'
6562
| 'retry'
6663
| 'retryDelay'
6764
| 'retryStatusCodes'
6865
| 'timeout'
66+
> & Pick<
67+
NitroFetchOptions<string>,
68+
| 'onRequest'
69+
| 'onRequestError'
70+
| 'onResponse'
71+
| 'onResponseError'
6972
> & {
7073
path?: MaybeRefOrGetter<Record<string, string>>
7174
body?: MaybeRef<string | Record<string, any> | FormData | null>
@@ -190,10 +193,24 @@ export function _useApiData<T = unknown>(
190193

191194
let result: T | undefined
192195

196+
const fetchHooks = mergeFetchHooks(fetchOptions, {
197+
async onRequest(ctx) {
198+
await nuxt?.callHook('api-party:request', ctx)
199+
// @ts-expect-error: Types will be generated on Nuxt prepare
200+
await nuxt?.callHook(`api-party:request:${endpointId}`, ctx)
201+
},
202+
async onResponse(ctx) {
203+
// @ts-expect-error: Types will be generated on Nuxt prepare
204+
await nuxt?.callHook(`api-party:response:${endpointId}`, ctx)
205+
await nuxt?.callHook('api-party:response', ctx)
206+
},
207+
})
208+
193209
try {
194210
if (client) {
195211
result = (await globalThis.$fetch<T>(_path.value, {
196212
..._fetchOptions,
213+
...fetchHooks,
197214
signal: controller.signal,
198215
baseURL: endpoint.url,
199216
method: _endpointFetchOptions.method,
@@ -214,6 +231,7 @@ export function _useApiData<T = unknown>(
214231
joinURL('/api', apiParty.server.basePath!, endpointId),
215232
{
216233
..._fetchOptions,
234+
...fetchHooks,
217235
signal: controller.signal,
218236
method: 'POST',
219237
body: {

src/runtime/hooks.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { FetchHooks } from 'ofetch'
2+
3+
type Arrayify<T> = { [P in keyof T]-?: Extract<T[P], unknown[]> }
4+
5+
export function mergeFetchHooks(...hooks: FetchHooks[]): FetchHooks {
6+
const result: Arrayify<FetchHooks> = {
7+
onRequest: [],
8+
onResponse: [],
9+
onRequestError: [],
10+
onResponseError: [],
11+
}
12+
13+
for (const hook of hooks) {
14+
for (const [key, value] of Object.entries(hook)) {
15+
maybePush(result[key as keyof typeof result], value)
16+
}
17+
}
18+
19+
return result
20+
}
21+
22+
function maybePush<T>(array: T[], values?: T | T[]) {
23+
if (values) {
24+
if (Array.isArray(values)) {
25+
array.push(...values)
26+
}
27+
else {
28+
array.push(values)
29+
}
30+
}
31+
}

src/runtime/server/$api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ModuleOptions } from '../../module'
22
import type { ApiClientFetchOptions } from '../composables/$api'
3-
import { useRuntimeConfig } from '#imports'
3+
import { useRuntimeConfig } from 'nitropack/runtime'
44
import { resolvePathParams } from '../openapi'
55
import { headersToObject } from '../utils'
66

0 commit comments

Comments
 (0)