@furystack/rest-service 10.0.28 → 10.1.1
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/README.md +249 -12
- package/esm/api-manager.d.ts +1 -3
- package/esm/api-manager.d.ts.map +1 -1
- package/esm/api-manager.js +4 -6
- package/esm/api-manager.js.map +1 -1
- package/esm/header-processor.d.ts +39 -0
- package/esm/header-processor.d.ts.map +1 -0
- package/esm/header-processor.js +113 -0
- package/esm/header-processor.js.map +1 -0
- package/esm/header-processor.spec.d.ts +2 -0
- package/esm/header-processor.spec.d.ts.map +1 -0
- package/esm/header-processor.spec.js +420 -0
- package/esm/header-processor.spec.js.map +1 -0
- package/esm/helpers.d.ts +69 -1
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +70 -1
- package/esm/helpers.js.map +1 -1
- package/esm/helpers.spec.js +21 -5
- package/esm/helpers.spec.js.map +1 -1
- package/esm/http-proxy-handler.d.ts +53 -0
- package/esm/http-proxy-handler.d.ts.map +1 -0
- package/esm/http-proxy-handler.js +179 -0
- package/esm/http-proxy-handler.js.map +1 -0
- package/esm/http-user-context.d.ts +4 -4
- package/esm/http-user-context.d.ts.map +1 -1
- package/esm/http-user-context.js +4 -4
- package/esm/http-user-context.js.map +1 -1
- package/esm/index.d.ts +1 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +1 -0
- package/esm/index.js.map +1 -1
- package/esm/path-processor.d.ts +33 -0
- package/esm/path-processor.d.ts.map +1 -0
- package/esm/path-processor.js +58 -0
- package/esm/path-processor.js.map +1 -0
- package/esm/path-processor.spec.d.ts +2 -0
- package/esm/path-processor.spec.d.ts.map +1 -0
- package/esm/path-processor.spec.js +256 -0
- package/esm/path-processor.spec.js.map +1 -0
- package/esm/proxy-manager.d.ts +52 -0
- package/esm/proxy-manager.d.ts.map +1 -0
- package/esm/proxy-manager.js +84 -0
- package/esm/proxy-manager.js.map +1 -0
- package/esm/proxy-manager.spec.d.ts +2 -0
- package/esm/proxy-manager.spec.d.ts.map +1 -0
- package/esm/proxy-manager.spec.js +1781 -0
- package/esm/proxy-manager.spec.js.map +1 -0
- package/esm/server-manager.d.ts +7 -0
- package/esm/server-manager.d.ts.map +1 -1
- package/esm/server-manager.js +12 -0
- package/esm/server-manager.js.map +1 -1
- package/esm/static-server-manager.d.ts.map +1 -1
- package/esm/static-server-manager.js +5 -7
- package/esm/static-server-manager.js.map +1 -1
- package/esm/websocket-proxy-handler.d.ts +44 -0
- package/esm/websocket-proxy-handler.d.ts.map +1 -0
- package/esm/websocket-proxy-handler.js +157 -0
- package/esm/websocket-proxy-handler.js.map +1 -0
- package/package.json +12 -10
- package/src/api-manager.ts +5 -15
- package/src/header-processor.spec.ts +514 -0
- package/src/header-processor.ts +140 -0
- package/src/helpers.spec.ts +23 -5
- package/src/helpers.ts +72 -1
- package/src/http-proxy-handler.ts +215 -0
- package/src/http-user-context.ts +6 -6
- package/src/index.ts +1 -0
- package/src/path-processor.spec.ts +318 -0
- package/src/path-processor.ts +69 -0
- package/src/proxy-manager.spec.ts +2094 -0
- package/src/proxy-manager.ts +101 -0
- package/src/server-manager.ts +19 -0
- package/src/static-server-manager.ts +5 -7
- package/src/websocket-proxy-handler.ts +204 -0
package/src/helpers.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { User } from '@furystack/core'
|
|
2
2
|
import type { Injector } from '@furystack/inject'
|
|
3
|
-
import { HttpAuthenticationSettings } from './http-authentication-settings.js'
|
|
4
3
|
import type { RestApi } from '@furystack/rest'
|
|
5
4
|
import type { ImplementApiOptions } from './api-manager.js'
|
|
6
5
|
import { ApiManager } from './api-manager.js'
|
|
6
|
+
import { HttpAuthenticationSettings } from './http-authentication-settings.js'
|
|
7
7
|
import type { DefaultSession } from './models/default-session.js'
|
|
8
|
+
import type { ProxyOptions } from './proxy-manager.js'
|
|
9
|
+
import { ProxyManager } from './proxy-manager.js'
|
|
8
10
|
import type { StaticServerOptions } from './static-server-manager.js'
|
|
9
11
|
import { StaticServerManager } from './static-server-manager.js'
|
|
10
12
|
|
|
@@ -38,3 +40,72 @@ export const useStaticFiles = (options: { injector: Injector } & StaticServerOpt
|
|
|
38
40
|
const { injector, ...settings } = options
|
|
39
41
|
return injector.getInstance(StaticServerManager).addStaticSite(settings)
|
|
40
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Sets up a proxy server that forwards HTTP requests from a source URL to a target URL.
|
|
46
|
+
*
|
|
47
|
+
* The proxy acts as an intermediary, forwarding requests and responses while allowing
|
|
48
|
+
* transformation of headers, cookies, and paths. It returns 502 Bad Gateway on errors
|
|
49
|
+
* and emits 'onProxyFailed' events for monitoring.
|
|
50
|
+
*
|
|
51
|
+
* WebSocket connections can also be proxied by setting `enableWebsockets: true`, allowing
|
|
52
|
+
* bidirectional real-time communication through the proxy.
|
|
53
|
+
*
|
|
54
|
+
* @param options The settings for the proxy server
|
|
55
|
+
* @param options.injector The Injector instance
|
|
56
|
+
* @param options.sourceBaseUrl The base URL path to match for proxying (e.g., '/api', '/old').
|
|
57
|
+
* Can be specified with or without a trailing slash.
|
|
58
|
+
* @param options.targetBaseUrl The target server URL (must be a valid HTTP/HTTPS URL)
|
|
59
|
+
* @param options.pathRewrite Optional function to rewrite the path before forwarding.
|
|
60
|
+
* Receives the path after sourceBaseUrl, including leading slash and query string.
|
|
61
|
+
* Example: for 'GET /api/users?active=true' with sourceBaseUrl='/api',
|
|
62
|
+
* pathRewrite receives '/users?active=true'
|
|
63
|
+
* @param options.sourceHostName The hostname for the source server (optional, defaults to all interfaces)
|
|
64
|
+
* @param options.sourcePort The port for the source server
|
|
65
|
+
* @param options.headers Optional function to transform request headers.
|
|
66
|
+
* **Note**: Receives headers AFTER filtering hop-by-hop headers
|
|
67
|
+
* (Connection, Keep-Alive, Transfer-Encoding, Upgrade, etc.) for security
|
|
68
|
+
* and protocol compliance. The proxy automatically adds X-Forwarded-* headers.
|
|
69
|
+
* This transformation applies to both HTTP and WebSocket requests.
|
|
70
|
+
* @param options.cookies Optional function to transform request cookies (array of cookie strings)
|
|
71
|
+
* @param options.responseCookies Optional function to transform response Set-Cookie headers
|
|
72
|
+
* @param options.timeout Optional timeout in milliseconds for proxy requests (default: 30000).
|
|
73
|
+
* If exceeded, the request is aborted and 502 is returned.
|
|
74
|
+
* Applies to both HTTP and WebSocket upgrade requests.
|
|
75
|
+
* @param options.enableWebsockets Optional flag to enable WebSocket proxying (default: false).
|
|
76
|
+
* When enabled, WebSocket upgrade requests will be forwarded to the target.
|
|
77
|
+
* @returns a promise that resolves when the proxy is set up
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* // Basic HTTP proxy with timeout
|
|
81
|
+
* await useProxy({
|
|
82
|
+
* injector,
|
|
83
|
+
* sourceBaseUrl: '/api',
|
|
84
|
+
* targetBaseUrl: 'https://api.example.com',
|
|
85
|
+
* sourcePort: 3000,
|
|
86
|
+
* timeout: 5000,
|
|
87
|
+
* })
|
|
88
|
+
*
|
|
89
|
+
* // Proxy with WebSocket support
|
|
90
|
+
* await useProxy({
|
|
91
|
+
* injector,
|
|
92
|
+
* sourceBaseUrl: '/ws',
|
|
93
|
+
* targetBaseUrl: 'https://ws.example.com',
|
|
94
|
+
* sourcePort: 3000,
|
|
95
|
+
* enableWebsockets: true,
|
|
96
|
+
* })
|
|
97
|
+
*
|
|
98
|
+
* // Proxy with error monitoring (HTTP and WebSocket)
|
|
99
|
+
* const proxyManager = injector.getInstance(ProxyManager)
|
|
100
|
+
* proxyManager.subscribe('onProxyFailed', ({ from, to, error }) => {
|
|
101
|
+
* console.error(`HTTP Proxy failed: ${from} -> ${to}`, error)
|
|
102
|
+
* })
|
|
103
|
+
* proxyManager.subscribe('onWebSocketProxyFailed', ({ from, to, error }) => {
|
|
104
|
+
* console.error(`WebSocket Proxy failed: ${from} -> ${to}`, error)
|
|
105
|
+
* })
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export const useProxy = (options: { injector: Injector } & ProxyOptions) => {
|
|
109
|
+
const { injector, ...settings } = options
|
|
110
|
+
return injector.getInstance(ProxyManager).addProxy(settings)
|
|
111
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'http'
|
|
2
|
+
import { Readable } from 'stream'
|
|
3
|
+
import { HeaderProcessor } from './header-processor.js'
|
|
4
|
+
import { PathProcessor } from './path-processor.js'
|
|
5
|
+
|
|
6
|
+
export interface HttpProxyOptions {
|
|
7
|
+
sourceBaseUrl: string
|
|
8
|
+
targetBaseUrl: string
|
|
9
|
+
pathRewrite?: (sourcePath: string) => string
|
|
10
|
+
headers?: (originalHeaders: OutgoingHttpHeaders) => OutgoingHttpHeaders
|
|
11
|
+
cookies?: (originalCookies: string[]) => string[]
|
|
12
|
+
responseCookies?: (responseCookies: string[]) => string[]
|
|
13
|
+
timeout?: number
|
|
14
|
+
onError?: (error: { from: string; to: string; error: unknown }) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handles HTTP request proxying with streaming support
|
|
19
|
+
*/
|
|
20
|
+
export class HttpProxyHandler {
|
|
21
|
+
private readonly headerProcessor = new HeaderProcessor()
|
|
22
|
+
private readonly pathProcessor = new PathProcessor()
|
|
23
|
+
|
|
24
|
+
constructor(private readonly options: HttpProxyOptions) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sets up abort controller and timeout for request cancellation
|
|
28
|
+
*/
|
|
29
|
+
private setupAbortHandling(
|
|
30
|
+
req: IncomingMessage,
|
|
31
|
+
res: ServerResponse,
|
|
32
|
+
timeoutMs: number,
|
|
33
|
+
): { abortController: AbortController; timeoutId: NodeJS.Timeout } {
|
|
34
|
+
const abortController = new AbortController()
|
|
35
|
+
|
|
36
|
+
const abortUpstream = () => {
|
|
37
|
+
try {
|
|
38
|
+
abortController.abort()
|
|
39
|
+
} catch {
|
|
40
|
+
// Ignore abort errors
|
|
41
|
+
}
|
|
42
|
+
// Clean up listeners to prevent memory leaks
|
|
43
|
+
res.off('close', abortUpstream)
|
|
44
|
+
req.off('aborted', abortUpstream)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
res.once('close', abortUpstream)
|
|
48
|
+
req.once('aborted', abortUpstream)
|
|
49
|
+
|
|
50
|
+
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs)
|
|
51
|
+
|
|
52
|
+
return { abortController, timeoutId }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extracts Set-Cookie headers from the response, handling both standard and undici formats
|
|
57
|
+
*/
|
|
58
|
+
private extractSetCookieHeaders(proxyResponse: Response): string[] {
|
|
59
|
+
const setCookieHeaders: string[] = []
|
|
60
|
+
const anyHeaders = proxyResponse.headers as unknown as { getSetCookie?: () => string[] }
|
|
61
|
+
const fromGetter = anyHeaders.getSetCookie?.()
|
|
62
|
+
|
|
63
|
+
if (fromGetter && Array.isArray(fromGetter) && fromGetter.length) {
|
|
64
|
+
setCookieHeaders.push(...fromGetter)
|
|
65
|
+
} else {
|
|
66
|
+
proxyResponse.headers.forEach((value, key) => {
|
|
67
|
+
if (key.toLowerCase() === 'set-cookie') {
|
|
68
|
+
setCookieHeaders.push(value)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return setCookieHeaders
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Copies response headers from proxy response to client response
|
|
78
|
+
*/
|
|
79
|
+
private copyResponseHeaders(proxyResponse: Response, res: ServerResponse): void {
|
|
80
|
+
proxyResponse.headers.forEach((value, key) => {
|
|
81
|
+
if (!this.headerProcessor.isHopByHopHeader(key)) {
|
|
82
|
+
if (key.toLowerCase() !== 'set-cookie') {
|
|
83
|
+
res.setHeader(key, value)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Handles Set-Cookie headers with optional transformation
|
|
91
|
+
*/
|
|
92
|
+
private handleSetCookieHeaders(setCookieHeaders: string[], res: ServerResponse): void {
|
|
93
|
+
if (setCookieHeaders.length > 0) {
|
|
94
|
+
const finalSetCookies = this.options.responseCookies
|
|
95
|
+
? this.options.responseCookies(setCookieHeaders)
|
|
96
|
+
: setCookieHeaders
|
|
97
|
+
res.setHeader('set-cookie', finalSetCookies)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Streams the response body from proxy to client
|
|
103
|
+
*/
|
|
104
|
+
private async streamResponseBody(proxyResponse: Response, res: ServerResponse): Promise<void> {
|
|
105
|
+
if (!proxyResponse.body) {
|
|
106
|
+
res.end()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const reader = proxyResponse.body.getReader()
|
|
111
|
+
try {
|
|
112
|
+
while (true) {
|
|
113
|
+
const { done, value } = await reader.read()
|
|
114
|
+
if (done) break
|
|
115
|
+
if (!res.write(value)) {
|
|
116
|
+
await new Promise((resolve) => res.once('drain', resolve))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
res.end()
|
|
120
|
+
} catch (error) {
|
|
121
|
+
try {
|
|
122
|
+
await reader.cancel()
|
|
123
|
+
} catch {
|
|
124
|
+
// Ignore cancel errors
|
|
125
|
+
}
|
|
126
|
+
if (!res.destroyed) {
|
|
127
|
+
res.destroy()
|
|
128
|
+
}
|
|
129
|
+
throw error
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Handles proxy errors and sends appropriate response to client
|
|
135
|
+
*/
|
|
136
|
+
private handleProxyError(error: unknown, res: ServerResponse): void {
|
|
137
|
+
if (this.options.onError) {
|
|
138
|
+
this.options.onError({
|
|
139
|
+
from: this.options.sourceBaseUrl,
|
|
140
|
+
to: this.options.targetBaseUrl,
|
|
141
|
+
error,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!res.headersSent) {
|
|
146
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' })
|
|
147
|
+
res.end('Bad Gateway')
|
|
148
|
+
} else if (!res.destroyed) {
|
|
149
|
+
res.destroy()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Main handler for proxying HTTP requests
|
|
155
|
+
*/
|
|
156
|
+
public async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
157
|
+
try {
|
|
158
|
+
// Build target URL
|
|
159
|
+
const targetUrl = this.pathProcessor.processUrl(
|
|
160
|
+
req.url as string,
|
|
161
|
+
this.options.sourceBaseUrl,
|
|
162
|
+
this.options.targetBaseUrl,
|
|
163
|
+
this.options.pathRewrite,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const parsedTargetUrl = new URL(targetUrl)
|
|
167
|
+
|
|
168
|
+
// Process headers
|
|
169
|
+
const { proxyHeaders } = this.headerProcessor.processRequestHeaders(req, parsedTargetUrl.host, {
|
|
170
|
+
headers: this.options.headers,
|
|
171
|
+
cookies: this.options.cookies,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// Set up timeout and abort handling
|
|
175
|
+
const timeoutMs = this.options.timeout ?? 30000
|
|
176
|
+
const { abortController, timeoutId } = this.setupAbortHandling(req, res, timeoutMs)
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
// Make the proxy request
|
|
180
|
+
const proxyResponse = await fetch(targetUrl, {
|
|
181
|
+
method: req.method,
|
|
182
|
+
headers: proxyHeaders,
|
|
183
|
+
body:
|
|
184
|
+
req.method !== 'GET' && req.method !== 'HEAD'
|
|
185
|
+
? (Readable.toWeb(req) as ReadableStream<Uint8Array>)
|
|
186
|
+
: undefined,
|
|
187
|
+
// @ts-expect-error - duplex is not in the types yet, but required for streaming bodies
|
|
188
|
+
duplex: 'half',
|
|
189
|
+
signal: abortController.signal,
|
|
190
|
+
})
|
|
191
|
+
clearTimeout(timeoutId)
|
|
192
|
+
|
|
193
|
+
// Extract and handle Set-Cookie headers
|
|
194
|
+
const setCookieHeaders = this.extractSetCookieHeaders(proxyResponse)
|
|
195
|
+
|
|
196
|
+
// Copy other response headers
|
|
197
|
+
this.copyResponseHeaders(proxyResponse, res)
|
|
198
|
+
|
|
199
|
+
// Handle Set-Cookie headers with transformation
|
|
200
|
+
this.handleSetCookieHeaders(setCookieHeaders, res)
|
|
201
|
+
|
|
202
|
+
// Set status code
|
|
203
|
+
res.writeHead(proxyResponse.status, res.getHeaders())
|
|
204
|
+
|
|
205
|
+
// Stream the response body
|
|
206
|
+
await this.streamResponseBody(proxyResponse, res)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
clearTimeout(timeoutId)
|
|
209
|
+
throw error
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
this.handleProxyError(error, res)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/http-user-context.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { IncomingMessage } from 'http'
|
|
2
1
|
import type { User } from '@furystack/core'
|
|
3
2
|
import { StoreManager } from '@furystack/core'
|
|
4
3
|
import { Injectable, Injected } from '@furystack/inject'
|
|
5
|
-
import { HttpAuthenticationSettings } from './http-authentication-settings.js'
|
|
6
|
-
import type { DefaultSession } from './models/default-session.js'
|
|
7
4
|
import { PasswordAuthenticator, UnauthenticatedError } from '@furystack/security'
|
|
8
5
|
import { randomBytes } from 'crypto'
|
|
6
|
+
import type { IncomingMessage } from 'http'
|
|
7
|
+
import { HttpAuthenticationSettings } from './http-authentication-settings.js'
|
|
8
|
+
import type { DefaultSession } from './models/default-session.js'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Injectable UserContext for FuryStack HTTP Api
|
|
@@ -38,7 +38,7 @@ export class HttpUserContext {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* @param request The request to be authenticated
|
|
41
|
-
* @returns
|
|
41
|
+
* @returns whether the current user is authenticated
|
|
42
42
|
*/
|
|
43
43
|
public async isAuthenticated(request: IncomingMessage) {
|
|
44
44
|
try {
|
|
@@ -50,10 +50,10 @@ export class HttpUserContext {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* Returns
|
|
53
|
+
* Returns whether the current user can be authorized with ALL of the specified roles
|
|
54
54
|
* @param request The request to be authenticated
|
|
55
55
|
* @param roles The list of roles to authorize
|
|
56
|
-
* @returns a boolean value that indicates
|
|
56
|
+
* @returns a boolean value that indicates whether the user is authorized
|
|
57
57
|
*/
|
|
58
58
|
public async isAuthorized(request: IncomingMessage, ...roles: string[]): Promise<boolean> {
|
|
59
59
|
const currentUser = await this.getCurrentUser(request)
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './http-authentication-settings.js'
|
|
|
10
10
|
export * from './http-user-context.js'
|
|
11
11
|
export * from './mime-types.js'
|
|
12
12
|
export * from './models/index.js'
|
|
13
|
+
export * from './proxy-manager.js'
|
|
13
14
|
export * from './read-post-body.js'
|
|
14
15
|
export * from './request-action-implementation.js'
|
|
15
16
|
export * from './schema-validator/index.js'
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { PathProcessor } from './path-processor.js'
|
|
3
|
+
|
|
4
|
+
describe('PathProcessor', () => {
|
|
5
|
+
const processor = new PathProcessor()
|
|
6
|
+
|
|
7
|
+
describe('validateUrl', () => {
|
|
8
|
+
it('should validate and return a valid URL', () => {
|
|
9
|
+
const url = processor.validateUrl('http://example.com/path')
|
|
10
|
+
expect(url).toBeInstanceOf(URL)
|
|
11
|
+
expect(url.href).toBe('http://example.com/path')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('should throw error for invalid URL', () => {
|
|
15
|
+
expect(() => processor.validateUrl('not-a-valid-url')).toThrow('Invalid URL')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('should include context in error message', () => {
|
|
19
|
+
expect(() => processor.validateUrl('invalid', 'targetBaseUrl')).toThrow('Invalid targetBaseUrl')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should validate HTTPS URLs', () => {
|
|
23
|
+
const url = processor.validateUrl('https://secure.example.com')
|
|
24
|
+
expect(url.protocol).toBe('https:')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should validate URLs with query strings', () => {
|
|
28
|
+
const url = processor.validateUrl('http://example.com/path?foo=bar')
|
|
29
|
+
expect(url.search).toBe('?foo=bar')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('validateHttpProtocol', () => {
|
|
34
|
+
it('should accept HTTP protocol', () => {
|
|
35
|
+
const url = new URL('http://example.com')
|
|
36
|
+
expect(() => processor.validateHttpProtocol(url)).not.toThrow()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should accept HTTPS protocol', () => {
|
|
40
|
+
const url = new URL('https://example.com')
|
|
41
|
+
expect(() => processor.validateHttpProtocol(url)).not.toThrow()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should reject FTP protocol', () => {
|
|
45
|
+
const url = new URL('ftp://example.com')
|
|
46
|
+
expect(() => processor.validateHttpProtocol(url)).toThrow('Invalid targetBaseUrl protocol: ftp:')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should reject WS protocol', () => {
|
|
50
|
+
const url = new URL('ws://example.com')
|
|
51
|
+
expect(() => processor.validateHttpProtocol(url)).toThrow('Invalid targetBaseUrl protocol: ws:')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should reject file protocol', () => {
|
|
55
|
+
const url = new URL('file:///path/to/file')
|
|
56
|
+
expect(() => processor.validateHttpProtocol(url)).toThrow('Invalid targetBaseUrl protocol: file:')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('extractSourcePath', () => {
|
|
61
|
+
it('should extract path after source base URL', () => {
|
|
62
|
+
const path = processor.extractSourcePath('/api/users/123', '/api')
|
|
63
|
+
expect(path).toBe('/users/123')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should extract empty path when URL matches exactly', () => {
|
|
67
|
+
const path = processor.extractSourcePath('/api', '/api')
|
|
68
|
+
expect(path).toBe('')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should preserve query strings', () => {
|
|
72
|
+
const path = processor.extractSourcePath('/api/users?page=1&limit=10', '/api')
|
|
73
|
+
expect(path).toBe('/users?page=1&limit=10')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should handle source base URL with trailing slash', () => {
|
|
77
|
+
const path = processor.extractSourcePath('/api/users/123', '/api/')
|
|
78
|
+
// PathHelper.extractPath now consistently returns paths with leading slashes
|
|
79
|
+
expect(path).toBe('/users/123')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('applyPathRewrite', () => {
|
|
84
|
+
it('should return path as-is when no rewrite function provided', () => {
|
|
85
|
+
const path = processor.applyPathRewrite('/users/123')
|
|
86
|
+
expect(path).toBe('/users/123')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should apply rewrite function when provided', () => {
|
|
90
|
+
const rewrite = (path: string) => path.replace('/old', '/new')
|
|
91
|
+
const path = processor.applyPathRewrite('/old/path', rewrite)
|
|
92
|
+
expect(path).toBe('/new/path')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should handle complex rewrite logic', () => {
|
|
96
|
+
const rewrite = (path: string) => {
|
|
97
|
+
// Remove version prefix
|
|
98
|
+
return path.replace(/^\/v\d+/, '')
|
|
99
|
+
}
|
|
100
|
+
const path = processor.applyPathRewrite('/v1/users/123', rewrite)
|
|
101
|
+
expect(path).toBe('/users/123')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should allow complete path transformation', () => {
|
|
105
|
+
const rewrite = () => '/completely/different/path'
|
|
106
|
+
const path = processor.applyPathRewrite('/original', rewrite)
|
|
107
|
+
expect(path).toBe('/completely/different/path')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('buildTargetUrl', () => {
|
|
112
|
+
it('should combine base URL and path', () => {
|
|
113
|
+
const url = processor.buildTargetUrl('http://example.com', '/api/users')
|
|
114
|
+
expect(url).toBe('http://example.com/api/users')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should handle base URL with trailing slash', () => {
|
|
118
|
+
const url = processor.buildTargetUrl('http://example.com/', '/api/users')
|
|
119
|
+
// PathHelper.joinUrl now correctly handles trailing slashes, avoiding double slashes
|
|
120
|
+
expect(url).toBe('http://example.com/api/users')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should preserve query strings in path', () => {
|
|
124
|
+
const url = processor.buildTargetUrl('http://example.com', '/api/users?page=1')
|
|
125
|
+
expect(url).toBe('http://example.com/api/users?page=1')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('should handle empty path', () => {
|
|
129
|
+
const url = processor.buildTargetUrl('http://example.com', '')
|
|
130
|
+
expect(url).toBe('http://example.com')
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('processUrl', () => {
|
|
135
|
+
it('should process complete URL transformation', () => {
|
|
136
|
+
const url = processor.processUrl('/api/users/123', '/api', 'http://target.com')
|
|
137
|
+
expect(url).toBe('http://target.com/users/123')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should apply path rewrite during processing', () => {
|
|
141
|
+
const rewrite = (path: string) => path.replace('/old', '/new')
|
|
142
|
+
const url = processor.processUrl('/api/old/path', '/api', 'http://target.com', rewrite)
|
|
143
|
+
expect(url).toBe('http://target.com/new/path')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should preserve query strings', () => {
|
|
147
|
+
const url = processor.processUrl('/api/users?page=1&limit=10', '/api', 'http://target.com')
|
|
148
|
+
expect(url).toBe('http://target.com/users?page=1&limit=10')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should throw error for invalid resulting URL', () => {
|
|
152
|
+
// Use a base URL that when combined with the rewritten path will be invalid
|
|
153
|
+
const rewrite = () => '://invalid-path'
|
|
154
|
+
expect(() => processor.processUrl('/api/test', '/api', 'http:', rewrite)).toThrow('Invalid')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should handle complex source URLs', () => {
|
|
158
|
+
const url = processor.processUrl(
|
|
159
|
+
'/proxy/v1/api/users/123?sort=name&order=asc',
|
|
160
|
+
'/proxy',
|
|
161
|
+
'http://backend.example.com:8080',
|
|
162
|
+
)
|
|
163
|
+
expect(url).toBe('http://backend.example.com:8080/v1/api/users/123?sort=name&order=asc')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should work with HTTPS target URLs', () => {
|
|
167
|
+
const url = processor.processUrl('/api/secure', '/api', 'https://secure.example.com')
|
|
168
|
+
expect(url).toBe('https://secure.example.com/secure')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should handle exact URL matches', () => {
|
|
172
|
+
const url = processor.processUrl('/api', '/api', 'http://target.com')
|
|
173
|
+
expect(url).toBe('http://target.com')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('should validate the resulting target URL', () => {
|
|
177
|
+
// This should not throw because the result is valid
|
|
178
|
+
expect(() => processor.processUrl('/api/test', '/api', 'http://target.com')).not.toThrow()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('Edge cases and complex scenarios', () => {
|
|
183
|
+
it('should handle very long URLs', () => {
|
|
184
|
+
const longPath = `/api${'/segment'.repeat(100)}`
|
|
185
|
+
const url = processor.processUrl(longPath, '/api', 'http://target.com')
|
|
186
|
+
expect(url).toContain('target.com')
|
|
187
|
+
expect(url).toContain('/segment')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should handle URLs with special characters in path', () => {
|
|
191
|
+
const url = processor.processUrl('/api/path%20with%20spaces', '/api', 'http://target.com')
|
|
192
|
+
expect(url).toBe('http://target.com/path%20with%20spaces')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should handle URLs with encoded special characters', () => {
|
|
196
|
+
const url = processor.processUrl('/api/user%2Fname', '/api', 'http://target.com')
|
|
197
|
+
expect(url).toBe('http://target.com/user%2Fname')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should handle URLs with hash fragments', () => {
|
|
201
|
+
const url = processor.processUrl('/api/resource#section', '/api', 'http://target.com')
|
|
202
|
+
expect(url).toBe('http://target.com/resource#section')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should handle multiple query parameters', () => {
|
|
206
|
+
const url = processor.processUrl('/api/search?q=test&sort=date&order=desc&page=1', '/api', 'http://target.com')
|
|
207
|
+
expect(url).toBe('http://target.com/search?q=test&sort=date&order=desc&page=1')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('should handle empty query parameter values', () => {
|
|
211
|
+
const url = processor.processUrl('/api/test?empty=&another=value', '/api', 'http://target.com')
|
|
212
|
+
expect(url).toBe('http://target.com/test?empty=&another=value')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('should handle source base URL ending with slash and path starting with slash', () => {
|
|
216
|
+
const path = processor.extractSourcePath('/api//users', '/api/')
|
|
217
|
+
// PathHelper.extractPath preserves the double slash from the input URL
|
|
218
|
+
// If normalization is needed, it should be done on the input URL first
|
|
219
|
+
expect(path).toBe('//users')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('should handle double slashes in paths', () => {
|
|
223
|
+
const url = processor.buildTargetUrl('http://target.com', '//path//to//resource')
|
|
224
|
+
// PathHelper.joinUrl preserves the double slash at the start since it's already there
|
|
225
|
+
expect(url).toBe('http://target.com//path//to//resource')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('should handle target URLs with ports', () => {
|
|
229
|
+
const url = processor.processUrl('/api/test', '/api', 'http://target.com:8080')
|
|
230
|
+
expect(url).toBe('http://target.com:8080/test')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('should handle target URLs with paths', () => {
|
|
234
|
+
const url = processor.processUrl('/api/test', '/api', 'http://target.com/base')
|
|
235
|
+
expect(url).toBe('http://target.com/base/test')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('should validate URL with only protocol', () => {
|
|
239
|
+
expect(() => processor.validateUrl('http://')).toThrow('Invalid URL')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('should handle path rewrite that adds query parameters', () => {
|
|
243
|
+
const rewrite = (path: string) => `${path}?added=param`
|
|
244
|
+
const url = processor.processUrl('/api/test?existing=value', '/api', 'http://target.com', rewrite)
|
|
245
|
+
expect(url).toBe('http://target.com/test?existing=value?added=param')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('should handle path rewrite that removes path completely', () => {
|
|
249
|
+
const rewrite = () => ''
|
|
250
|
+
const url = processor.processUrl('/api/test', '/api', 'http://target.com', rewrite)
|
|
251
|
+
expect(url).toBe('http://target.com')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should handle path rewrite that adds leading slash', () => {
|
|
255
|
+
const rewrite = (path: string) => `/${path}`
|
|
256
|
+
const url = processor.processUrl('/api/test', '/api', 'http://target.com', rewrite)
|
|
257
|
+
expect(url).toBe('http://target.com//test')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('should validate HTTPS URLs with authentication', () => {
|
|
261
|
+
const url = processor.validateUrl('https://user:pass@example.com/path')
|
|
262
|
+
expect(url.protocol).toBe('https:')
|
|
263
|
+
expect(url.username).toBe('user')
|
|
264
|
+
expect(url.password).toBe('pass')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('should handle URLs with international domain names', () => {
|
|
268
|
+
const url = processor.validateUrl('http://例え.jp/path')
|
|
269
|
+
expect(url.hostname).toBeTruthy()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('should handle target URLs with trailing slash and path without leading slash', () => {
|
|
273
|
+
const url = processor.buildTargetUrl('http://target.com/', 'path')
|
|
274
|
+
expect(url).toBe('http://target.com/path')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('should extract path with multiple slashes in source base URL', () => {
|
|
278
|
+
const path = processor.extractSourcePath('/api//users', '/api')
|
|
279
|
+
expect(path).toBe('//users')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should handle complex path rewrite with regex', () => {
|
|
283
|
+
const rewrite = (path: string) => path.replace(/\/v\d+\//, '/')
|
|
284
|
+
const url = processor.processUrl('/api/v2/users', '/api', 'http://target.com', rewrite)
|
|
285
|
+
expect(url).toBe('http://target.com/users')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should validate data URLs are rejected', () => {
|
|
289
|
+
expect(() => processor.validateHttpProtocol(new URL('data:text/plain,hello'))).toThrow(
|
|
290
|
+
'Invalid targetBaseUrl protocol',
|
|
291
|
+
)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('should validate mailto URLs are rejected', () => {
|
|
295
|
+
expect(() => processor.validateHttpProtocol(new URL('mailto:test@example.com'))).toThrow(
|
|
296
|
+
'Invalid targetBaseUrl protocol',
|
|
297
|
+
)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('should handle IPv4 addresses in URLs', () => {
|
|
301
|
+
const url = processor.validateUrl('http://192.168.1.1:8080/path')
|
|
302
|
+
expect(url.hostname).toBe('192.168.1.1')
|
|
303
|
+
expect(url.port).toBe('8080')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('should handle IPv6 addresses in URLs', () => {
|
|
307
|
+
const url = processor.validateUrl('http://[::1]:8080/path')
|
|
308
|
+
expect(url.hostname).toBe('[::1]')
|
|
309
|
+
expect(url.port).toBe('8080')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('should handle localhost in URLs', () => {
|
|
313
|
+
const url = processor.validateUrl('http://localhost:3000/api')
|
|
314
|
+
expect(url.hostname).toBe('localhost')
|
|
315
|
+
expect(url.port).toBe('3000')
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
})
|