fix(dashboard): proxy API requests through dashboard route
Build & Deploy / Build & Push Docker Image (push) Successful in 2m58s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 39s
Test / API Integration Tests (push) Successful in 59s
Build & Deploy / Build & Push Docker Image (push) Successful in 2m58s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 39s
Test / API Integration Tests (push) Successful in 59s
This commit is contained in:
@@ -96,10 +96,6 @@ const nextConfig = {
|
|||||||
async rewrites() {
|
async rewrites() {
|
||||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
source: '/api/:path*',
|
|
||||||
destination: `${apiOrigin}/api/:path*`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
source: '/storage/:path*',
|
source: '/storage/:path*',
|
||||||
destination: `${apiOrigin}/storage/:path*`,
|
destination: `${apiOrigin}/storage/:path*`,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
delete process.env.API_INTERNAL_URL
|
||||||
|
delete process.env.API_URL
|
||||||
|
delete process.env.NEXT_PUBLIC_API_URL
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dashboard API proxy route', () => {
|
||||||
|
it('forwards dashboard API requests to the configured API origin', async () => {
|
||||||
|
process.env.API_INTERNAL_URL = 'http://api:4000/api/v1'
|
||||||
|
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: { ok: true } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||||
|
|
||||||
|
const { GET } = await import('./route')
|
||||||
|
const response = await GET(
|
||||||
|
new Request('https://rentaldrivego.ma/dashboard/api/v1/auth/employee/me?fresh=1', {
|
||||||
|
headers: { cookie: 'employee_session=token-123' },
|
||||||
|
}),
|
||||||
|
{ params: { path: ['auth', 'employee', 'me'] } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(new URL('http://api:4000/api/v1/auth/employee/me?fresh=1'), expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: expect.any(Headers),
|
||||||
|
}))
|
||||||
|
const forwardedInit = (fetchMock as any).mock.calls[0][1] as RequestInit
|
||||||
|
expect((forwardedInit.headers as Headers).get('cookie')).toBe('employee_session=token-123')
|
||||||
|
await expect(response.json()).resolves.toEqual({ data: { ok: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rewrites upstream session cookies for the dashboard host', async () => {
|
||||||
|
process.env.API_URL = 'https://api.rentaldrivego.ma'
|
||||||
|
const headers = new Headers({ 'content-type': 'application/json' })
|
||||||
|
headers.append('set-cookie', 'employee_session=token-123; Path=/; Domain=api.rentaldrivego.ma; HttpOnly; Secure; SameSite=Lax')
|
||||||
|
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: { employee: { id: 'emp_1' } } }), {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
}))
|
||||||
|
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||||
|
|
||||||
|
const { POST } = await import('./route')
|
||||||
|
const response = await POST(
|
||||||
|
new Request('https://rentaldrivego.ma/dashboard/api/v1/auth/employee/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'owner@example.com', password: 'secret' }),
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ path: ['auth', 'employee', 'login'] }) },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(new URL('https://api.rentaldrivego.ma/api/v1/auth/employee/login'), expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
}))
|
||||||
|
expect(response.headers.get('set-cookie')).toBe('employee_session=token-123; Path=/; HttpOnly; Secure; SameSite=Lax')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the public API origin when the internal API origin is unavailable', async () => {
|
||||||
|
process.env.API_INTERNAL_URL = 'http://api:4000/api/v1'
|
||||||
|
process.env.API_URL = 'https://api.rentaldrivego.ma'
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND api'))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'unauthenticated' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||||
|
|
||||||
|
const { GET } = await import('./route')
|
||||||
|
const response = await GET(
|
||||||
|
new Request('https://rentaldrivego.ma/dashboard/api/v1/auth/employee/me'),
|
||||||
|
{ params: { path: ['auth', 'employee', 'me'] } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(1, new URL('http://api:4000/api/v1/auth/employee/me'), expect.any(Object))
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(2, new URL('https://api.rentaldrivego.ma/api/v1/auth/employee/me'), expect.any(Object))
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
await expect(response.json()).resolves.toEqual({ error: 'unauthenticated' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ path?: string[] }> | { path?: string[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOP_BY_HOP_HEADERS = new Set([
|
||||||
|
'connection',
|
||||||
|
'content-encoding',
|
||||||
|
'content-length',
|
||||||
|
'host',
|
||||||
|
'keep-alive',
|
||||||
|
'proxy-authenticate',
|
||||||
|
'proxy-authorization',
|
||||||
|
'set-cookie',
|
||||||
|
'te',
|
||||||
|
'trailer',
|
||||||
|
'transfer-encoding',
|
||||||
|
'upgrade',
|
||||||
|
])
|
||||||
|
|
||||||
|
function normalizeApiOrigin(value: string): string {
|
||||||
|
return value.replace(/\/+$/, '').replace(/\/api(?:\/v1)?$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiOrigins(): string[] {
|
||||||
|
return Array.from(new Set([
|
||||||
|
process.env.API_INTERNAL_URL,
|
||||||
|
process.env.API_URL,
|
||||||
|
process.env.NEXT_PUBLIC_API_URL,
|
||||||
|
'http://localhost:4000',
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.map(normalizeApiOrigin)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteCookieForDashboard(cookie: string): string {
|
||||||
|
return cookie
|
||||||
|
.replace(/;\s*Domain=[^;]*/gi, '')
|
||||||
|
.replace(/;\s*SameSite=None/gi, '; SameSite=Lax')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSetCookieHeaders(headers: Headers): string[] {
|
||||||
|
const withGetter = headers as Headers & { getSetCookie?: () => string[] }
|
||||||
|
if (typeof withGetter.getSetCookie === 'function') return withGetter.getSetCookie()
|
||||||
|
|
||||||
|
const cookie = headers.get('set-cookie')
|
||||||
|
return cookie ? [cookie] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyRequestHeaders(request: Request): Headers {
|
||||||
|
const headers = new Headers()
|
||||||
|
request.headers.forEach((value, key) => {
|
||||||
|
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) headers.set(key, value)
|
||||||
|
})
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyResponseHeaders(upstream: Response): Headers {
|
||||||
|
const headers = new Headers()
|
||||||
|
upstream.headers.forEach((value, key) => {
|
||||||
|
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) headers.set(key, value)
|
||||||
|
})
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyDashboardApi(request: Request, context: RouteContext) {
|
||||||
|
const params = await Promise.resolve(context.params)
|
||||||
|
const path = (params.path ?? []).map(encodeURIComponent).join('/')
|
||||||
|
const requestUrl = new URL(request.url)
|
||||||
|
const method = request.method.toUpperCase()
|
||||||
|
const hasBody = method !== 'GET' && method !== 'HEAD'
|
||||||
|
const body = hasBody ? await request.arrayBuffer() : undefined
|
||||||
|
const headers = copyRequestHeaders(request)
|
||||||
|
let lastError: unknown = null
|
||||||
|
let upstream: Response | null = null
|
||||||
|
|
||||||
|
for (const origin of resolveApiOrigins()) {
|
||||||
|
const upstreamUrl = new URL(`/api/v1/${path}${requestUrl.search}`, origin)
|
||||||
|
try {
|
||||||
|
upstream = await fetch(upstreamUrl, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstream) {
|
||||||
|
const message = lastError instanceof Error ? lastError.message : 'API proxy request failed'
|
||||||
|
return NextResponse.json({ error: 'api_proxy_unavailable', message }, { status: 502 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = new NextResponse(await upstream.arrayBuffer(), {
|
||||||
|
status: upstream.status,
|
||||||
|
statusText: upstream.statusText,
|
||||||
|
headers: copyResponseHeaders(upstream),
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const cookie of getSetCookieHeaders(upstream.headers)) {
|
||||||
|
response.headers.append('set-cookie', rewriteCookieForDashboard(cookie))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
|
return proxyDashboardApi(request, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
|
return proxyDashboardApi(request, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request, context: RouteContext) {
|
||||||
|
return proxyDashboardApi(request, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, context: RouteContext) {
|
||||||
|
return proxyDashboardApi(request, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request, context: RouteContext) {
|
||||||
|
return proxyDashboardApi(request, context)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user