@norriq/nuxt-api-proxy 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +33 -0
- package/nuxt.config.ts +5 -0
- package/package.json +27 -0
- package/server/api/proxy/[...path].ts +92 -0
- package/server/api/proxy/proxy.test.ts +197 -0
- package/server/test-helpers.ts +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright (c) 2026 NORRIQ. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and associated documentation files (the "Software") are the
|
|
4
|
+
proprietary property of NORRIQ. Unauthorized use, copying, modification,
|
|
5
|
+
distribution, or any other exploitation of the Software, in whole or in part,
|
|
6
|
+
is strictly prohibited without the prior written consent of NORRIQ.
|
|
7
|
+
|
|
8
|
+
For licensing inquiries, contact: info@norriq.com
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @norriq/nuxt-api-proxy
|
|
2
|
+
|
|
3
|
+
Nuxt layer that adds a catch-all `/api/proxy/*` server route. Forwards requests to a backend API with the user's auth token automatically injected.
|
|
4
|
+
|
|
5
|
+
Supports multipart uploads, binary responses, and distributed tracing (traceparent header forwarding).
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Requires `@norriq/nuxt-auth` (or `@sidebase/nuxt-auth`) to be configured in the consuming app — the proxy reads the session token from the auth module.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// nuxt.config.ts
|
|
13
|
+
export default defineNuxtConfig({
|
|
14
|
+
extends: ['@norriq/nuxt-auth', '@norriq/nuxt-api-proxy'],
|
|
15
|
+
})
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Environment variables
|
|
19
|
+
|
|
20
|
+
| Variable | Scope | Required | Description |
|
|
21
|
+
|----------|-------|----------|-------------|
|
|
22
|
+
| `NUXT_API_URI` | Server | Yes | Target API base URL |
|
|
23
|
+
| `NUXT_PUBLIC_API_URI` | Public | No | Fallback if `NUXT_API_URI` not set |
|
|
24
|
+
|
|
25
|
+
## How it works
|
|
26
|
+
|
|
27
|
+
All requests to `/api/proxy/*` are forwarded to the target API:
|
|
28
|
+
|
|
29
|
+
- HTTP method, headers, query params, and body are preserved
|
|
30
|
+
- `Authorization: Bearer <token>` is injected from the user's session
|
|
31
|
+
- Multipart uploads are detected and forwarded as binary
|
|
32
|
+
- JSON responses are parsed; binary responses are streamed through
|
|
33
|
+
- `traceparent` header is forwarded for distributed tracing
|
package/nuxt.config.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@norriq/nuxt-api-proxy",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./nuxt.config.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./nuxt.config.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"nuxt.config.ts",
|
|
11
|
+
"server"
|
|
12
|
+
],
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"vitest": "^3.2.4"
|
|
15
|
+
},
|
|
16
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"nuxt": ">=3.0.0",
|
|
22
|
+
"@sidebase/nuxt-auth": ">=1.0.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "vitest run"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { getToken } from '#auth'
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const config = useRuntimeConfig()
|
|
5
|
+
// Server-only NUXT_API_URI, falls back to NUXT_PUBLIC_API_URI for backward compatibility
|
|
6
|
+
const apiTarget = String(config.apiUri || config.public.apiUri || '')
|
|
7
|
+
|
|
8
|
+
if (!apiTarget) {
|
|
9
|
+
setResponseStatus(event, 500)
|
|
10
|
+
return { message: 'API proxy target not configured (set NUXT_API_URI)' }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Auth: read token from httpOnly session cookie
|
|
14
|
+
const token = await getToken({ event })
|
|
15
|
+
const rawToken = token?.impersonateToken || token?.accessToken
|
|
16
|
+
const accessToken = typeof rawToken === 'string' ? rawToken : undefined
|
|
17
|
+
|
|
18
|
+
// Reconstruct target URL
|
|
19
|
+
const path = getRouterParam(event, 'path') ?? ''
|
|
20
|
+
const query = getQuery(event)
|
|
21
|
+
const qs = new URLSearchParams(
|
|
22
|
+
Object.fromEntries(Object.entries(query).map(([k, v]) => [k, String(v)]))
|
|
23
|
+
).toString()
|
|
24
|
+
const targetUrl = `${apiTarget}/${path}${qs ? `?${qs}` : ''}`
|
|
25
|
+
|
|
26
|
+
// Detect request mode from content-type
|
|
27
|
+
const requestContentType = getRequestHeader(event, 'content-type') || ''
|
|
28
|
+
const isMultipartUpload = requestContentType.startsWith('multipart/')
|
|
29
|
+
|
|
30
|
+
const method = event.method
|
|
31
|
+
const headers: Record<string, string> = {}
|
|
32
|
+
|
|
33
|
+
// Read request body
|
|
34
|
+
let body: BodyInit | undefined
|
|
35
|
+
if (isMultipartUpload) {
|
|
36
|
+
// Raw buffer preserves binary/multipart integrity
|
|
37
|
+
body = (await readRawBody(event, false)) as BodyInit | undefined
|
|
38
|
+
headers['Content-Type'] = requestContentType
|
|
39
|
+
} else if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
40
|
+
body = (await readRawBody(event)) as BodyInit | undefined
|
|
41
|
+
headers['Content-Type'] = 'application/json'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`
|
|
45
|
+
|
|
46
|
+
// Forward APM traceparent for distributed tracing
|
|
47
|
+
try {
|
|
48
|
+
const apm = await import('elastic-apm-node').then((m) => m.default)
|
|
49
|
+
const traceparent = apm.currentTransaction?.traceparent
|
|
50
|
+
if (traceparent) headers['traceparent'] = traceparent
|
|
51
|
+
} catch {
|
|
52
|
+
// APM not available
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Multipart uploads need native fetch to avoid body corruption
|
|
57
|
+
if (isMultipartUpload) {
|
|
58
|
+
const response = await fetch(targetUrl, { method, headers, body })
|
|
59
|
+
setResponseStatus(event, response.status)
|
|
60
|
+
const data = await response.json().catch(() => null)
|
|
61
|
+
return data
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Use native fetch for all requests so we can inspect the response
|
|
65
|
+
// content-type before deciding how to return the body
|
|
66
|
+
const response = await fetch(targetUrl, { method, headers, body })
|
|
67
|
+
setResponseStatus(event, response.status)
|
|
68
|
+
|
|
69
|
+
const responseContentType = response.headers.get('content-type') || ''
|
|
70
|
+
|
|
71
|
+
// Binary response: stream body back with original headers
|
|
72
|
+
if (response.ok && !responseContentType.includes('application/json')) {
|
|
73
|
+
const contentDisposition = response.headers.get('content-disposition')
|
|
74
|
+
if (responseContentType) setResponseHeader(event, 'content-type', responseContentType)
|
|
75
|
+
if (contentDisposition) setResponseHeader(event, 'content-disposition', contentDisposition)
|
|
76
|
+
return response.body
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// JSON response (or error): parse and return
|
|
80
|
+
const data = await response.json().catch(() => null)
|
|
81
|
+
|
|
82
|
+
if (import.meta.dev && response.status >= 400) {
|
|
83
|
+
console.log(`[api-proxy] ${method} ${response.status} ${targetUrl}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return data
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error('[api-proxy] Proxy error:', err)
|
|
89
|
+
setResponseStatus(event, 502)
|
|
90
|
+
return { message: 'Proxy error', error: String(err) }
|
|
91
|
+
}
|
|
92
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import type { EventHandler } from 'h3'
|
|
3
|
+
import { fakeEvent, fetchCallUrl, fetchCallHeaders } from '../../test-helpers'
|
|
4
|
+
|
|
5
|
+
const mockGetToken = vi.fn()
|
|
6
|
+
|
|
7
|
+
vi.mock('#auth', () => ({
|
|
8
|
+
getToken: (...args: unknown[]) => mockGetToken(...args),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
let capturedConfig = {
|
|
12
|
+
apiUri: 'https://api.example.com',
|
|
13
|
+
public: { apiUri: '' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let mockPath = 'users/123'
|
|
17
|
+
let mockQuery: Record<string, string> = {}
|
|
18
|
+
let mockHeaders: Record<string, string> = {}
|
|
19
|
+
|
|
20
|
+
vi.stubGlobal('defineEventHandler', <T>(fn: T): T => fn)
|
|
21
|
+
vi.stubGlobal('useRuntimeConfig', () => capturedConfig)
|
|
22
|
+
vi.stubGlobal('getRouterParam', (_event: unknown, param: string) => {
|
|
23
|
+
if (param === 'path') return mockPath
|
|
24
|
+
return undefined
|
|
25
|
+
})
|
|
26
|
+
vi.stubGlobal('getQuery', () => mockQuery)
|
|
27
|
+
vi.stubGlobal('getRequestHeader', (_event: unknown, header: string) => {
|
|
28
|
+
return mockHeaders[header.toLowerCase()]
|
|
29
|
+
})
|
|
30
|
+
vi.stubGlobal('setResponseStatus', vi.fn())
|
|
31
|
+
vi.stubGlobal('setResponseHeader', vi.fn())
|
|
32
|
+
vi.stubGlobal('readRawBody', vi.fn().mockResolvedValue('{"key":"value"}'))
|
|
33
|
+
|
|
34
|
+
const mockFetch = vi.fn()
|
|
35
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
36
|
+
|
|
37
|
+
function jsonResponse(status: number, data: unknown, headers: Record<string, string> = {}) {
|
|
38
|
+
return {
|
|
39
|
+
status,
|
|
40
|
+
ok: status >= 200 && status < 300,
|
|
41
|
+
headers: new Headers({ 'content-type': 'application/json', ...headers }),
|
|
42
|
+
json: () => Promise.resolve(data),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('api-proxy handler', () => {
|
|
47
|
+
let handler: EventHandler
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
vi.clearAllMocks()
|
|
51
|
+
mockPath = 'users/123'
|
|
52
|
+
mockQuery = {}
|
|
53
|
+
mockHeaders = {}
|
|
54
|
+
capturedConfig = {
|
|
55
|
+
apiUri: 'https://api.example.com',
|
|
56
|
+
public: { apiUri: '' },
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
vi.resetModules()
|
|
60
|
+
const mod = await import('./[...path]')
|
|
61
|
+
handler = mod.default
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns 500 when API target is not configured', async () => {
|
|
65
|
+
capturedConfig = { apiUri: '', public: { apiUri: '' } }
|
|
66
|
+
|
|
67
|
+
const result = await handler(fakeEvent())
|
|
68
|
+
|
|
69
|
+
expect(result).toEqual({ message: 'API proxy target not configured (set NUXT_API_URI)' })
|
|
70
|
+
expect(setResponseStatus).toHaveBeenCalledWith(fakeEvent(), 500)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('forwards GET request to target URL', async () => {
|
|
74
|
+
mockGetToken.mockResolvedValue({ accessToken: 'test-token' })
|
|
75
|
+
mockFetch.mockResolvedValue(jsonResponse(200, { data: 'response' }))
|
|
76
|
+
|
|
77
|
+
const result = await handler(fakeEvent())
|
|
78
|
+
|
|
79
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
80
|
+
'https://api.example.com/users/123',
|
|
81
|
+
expect.objectContaining({
|
|
82
|
+
method: 'GET',
|
|
83
|
+
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
expect(result).toEqual({ data: 'response' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('appends query string to target URL', async () => {
|
|
90
|
+
mockQuery = { page: '2', limit: '10' }
|
|
91
|
+
mockGetToken.mockResolvedValue({ accessToken: 'tok' })
|
|
92
|
+
mockFetch.mockResolvedValue(jsonResponse(200, {}))
|
|
93
|
+
|
|
94
|
+
await handler(fakeEvent())
|
|
95
|
+
|
|
96
|
+
const url = fetchCallUrl(mockFetch)
|
|
97
|
+
expect(url).toContain('page=2')
|
|
98
|
+
expect(url).toContain('limit=10')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('uses impersonateToken over accessToken when present', async () => {
|
|
102
|
+
mockGetToken.mockResolvedValue({ accessToken: 'user-token', impersonateToken: 'admin-token' })
|
|
103
|
+
mockFetch.mockResolvedValue(jsonResponse(200, {}))
|
|
104
|
+
|
|
105
|
+
await handler(fakeEvent())
|
|
106
|
+
|
|
107
|
+
expect(fetchCallHeaders(mockFetch)['Authorization']).toBe('Bearer admin-token')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not set Authorization header when no token exists', async () => {
|
|
111
|
+
mockGetToken.mockResolvedValue(null)
|
|
112
|
+
mockFetch.mockResolvedValue(jsonResponse(200, {}))
|
|
113
|
+
|
|
114
|
+
await handler(fakeEvent())
|
|
115
|
+
|
|
116
|
+
expect(fetchCallHeaders(mockFetch)['Authorization']).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('forwards POST body as JSON', async () => {
|
|
120
|
+
mockGetToken.mockResolvedValue(null)
|
|
121
|
+
mockFetch.mockResolvedValue(jsonResponse(201, { id: 1 }))
|
|
122
|
+
|
|
123
|
+
await handler(fakeEvent('POST'))
|
|
124
|
+
|
|
125
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
126
|
+
expect.any(String),
|
|
127
|
+
expect.objectContaining({
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
|
130
|
+
body: '{"key":"value"}',
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('detects multipart uploads and preserves content-type', async () => {
|
|
136
|
+
mockHeaders = { 'content-type': 'multipart/form-data; boundary=----abc' }
|
|
137
|
+
mockGetToken.mockResolvedValue(null)
|
|
138
|
+
mockFetch.mockResolvedValue({ status: 200, json: () => Promise.resolve({ uploaded: true }) })
|
|
139
|
+
|
|
140
|
+
await handler(fakeEvent('POST'))
|
|
141
|
+
|
|
142
|
+
expect(fetchCallHeaders(mockFetch)['Content-Type']).toBe('multipart/form-data; boundary=----abc')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('streams binary responses with correct headers', async () => {
|
|
146
|
+
mockGetToken.mockResolvedValue({ accessToken: 'tok' })
|
|
147
|
+
const mockBody = new ReadableStream()
|
|
148
|
+
mockFetch.mockResolvedValue({
|
|
149
|
+
status: 200,
|
|
150
|
+
ok: true,
|
|
151
|
+
headers: new Headers({
|
|
152
|
+
'content-type': 'application/pdf',
|
|
153
|
+
'content-disposition': 'attachment; filename="report.pdf"',
|
|
154
|
+
}),
|
|
155
|
+
body: mockBody,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const event = fakeEvent()
|
|
159
|
+
const result = await handler(event)
|
|
160
|
+
|
|
161
|
+
expect(result).toBe(mockBody)
|
|
162
|
+
expect(setResponseHeader).toHaveBeenCalledWith(event, 'content-type', 'application/pdf')
|
|
163
|
+
expect(setResponseHeader).toHaveBeenCalledWith(event, 'content-disposition', 'attachment; filename="report.pdf"')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('returns 502 on fetch error', async () => {
|
|
167
|
+
mockGetToken.mockResolvedValue(null)
|
|
168
|
+
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
|
|
169
|
+
|
|
170
|
+
const event = fakeEvent()
|
|
171
|
+
const result = await handler(event)
|
|
172
|
+
|
|
173
|
+
expect(setResponseStatus).toHaveBeenCalledWith(event, 502)
|
|
174
|
+
expect(result).toEqual({ message: 'Proxy error', error: 'Error: ECONNREFUSED' })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('falls back to public apiUri when server apiUri is not set', async () => {
|
|
178
|
+
capturedConfig = { apiUri: '', public: { apiUri: 'https://public-api.example.com' } }
|
|
179
|
+
mockGetToken.mockResolvedValue(null)
|
|
180
|
+
mockFetch.mockResolvedValue(jsonResponse(200, {}))
|
|
181
|
+
|
|
182
|
+
await handler(fakeEvent())
|
|
183
|
+
|
|
184
|
+
expect(fetchCallUrl(mockFetch).startsWith('https://public-api.example.com/')).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('forwards upstream status code', async () => {
|
|
188
|
+
mockGetToken.mockResolvedValue(null)
|
|
189
|
+
mockFetch.mockResolvedValue(jsonResponse(404, { error: 'Not found' }))
|
|
190
|
+
|
|
191
|
+
const event = fakeEvent()
|
|
192
|
+
const result = await handler(event)
|
|
193
|
+
|
|
194
|
+
expect(setResponseStatus).toHaveBeenCalledWith(event, 404)
|
|
195
|
+
expect(result).toEqual({ error: 'Not found' })
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
import type { H3Event } from 'h3'
|
|
3
|
+
|
|
4
|
+
export function fakeEvent(method = 'GET'): H3Event {
|
|
5
|
+
return { method } as H3Event
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function fetchCallUrl(mock: ReturnType<typeof vi.fn>): string {
|
|
9
|
+
return mock.mock.calls[0][0]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function fetchCallHeaders(mock: ReturnType<typeof vi.fn>): Record<string, string> {
|
|
13
|
+
return mock.mock.calls[0][1].headers
|
|
14
|
+
}
|