Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/http-connection/connection-provider.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default class HttpConnectionProvider extends ConnectionProvider {
private _scheme: HttpScheme
private _authTokenManager: AuthTokenManager
private _config: types.InternalConfig
private _path: string
private _queryEndpoint?: string
private _openConnections: { [n: number]: HttpConnection }
private _pool: internal.pool.Pool<HttpConnection>
Expand All @@ -87,8 +88,9 @@ export default class HttpConnectionProvider extends ConnectionProvider {
this._scheme = config.scheme
this._authTokenManager = config.authTokenManager
this._config = config.config
this._path = config.path ?? ''
this._openConnections = {}
this._queryEndpoint = `${this._scheme}://${this._address.asHostPort()}/db/{databaseName}/query/v2`
this._queryEndpoint = `${this._scheme}://${this._address.asHostPort()}${this._path}/db/{databaseName}/query/v2`
this._newHttpConnection = newHttpConnection
this._pool = newPool({
create: this._createConnection.bind(this),
Expand All @@ -106,7 +108,7 @@ export default class HttpConnectionProvider extends ConnectionProvider {


async verifyConnectivityAndGetServerInfo(param: { database: string; accessMode?: string | undefined } | undefined): Promise<ServerInfo> {
const discoveryInfo = await HttpConnection.discover({ scheme: this._scheme, address: this._address })
const discoveryInfo = await HttpConnection.discover({ scheme: this._scheme, address: this._address, path: this._path })

// @ts-expect-error sending unexpected data
const connection = await this._pool.acquire({ queryEndpoint: this._queryEndpoint }, this._address)
Expand Down
4 changes: 2 additions & 2 deletions src/http-connection/connection.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,12 +337,12 @@ export default class HttpConnection extends Connection {
return this._queryEndpoint.replace('{databaseName}', database) + '/tx'
}

static async discover({ scheme, address }: { scheme: HttpScheme, address: internal.serverAddress.ServerAddress }): Promise<{
static async discover({ scheme, address, path }: { scheme: HttpScheme, address: internal.serverAddress.ServerAddress, path?: string }): Promise<{
query: string
version: string
edition: string
}> {
return await fetch(`${scheme}://${address.asHostPort()}`, {
return await fetch(`${scheme}://${address.asHostPort()}${path ?? ''}`, {
headers: {
Accept: 'application/json',
}
Expand Down
15 changes: 15 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ function wrapper (
): Wrapper {
assertString(url, 'Http URL')
const parsedUrl = urlUtil.parseDatabaseUrl(url)
const path = extractPath(url as string)

// enabling set boltAgent
const _config = config as unknown as InternalConfig
Expand Down Expand Up @@ -206,6 +207,7 @@ function wrapper (
authTokenManager,
scheme: parsedUrl.scheme as unknown as 'http' | 'https',
address,
path,
userAgent: config.userAgent,
boltAgent: config.boltAgent,
routingContext: parsedUrl.query
Expand All @@ -214,6 +216,19 @@ function wrapper (
}
}

function extractPath (url: string): string {
const withoutScheme = url.replace(/^https?:\/\//, '')
const pathStart = withoutScheme.indexOf('/')
if (pathStart === -1) {
return ''
}
const raw = withoutScheme.substring(pathStart)
const withoutQuery = raw.split('?')[0]
const withoutFragment = withoutQuery.split('#')[0]
const trimmed = withoutFragment.replace(/\/+$/, '')
return trimmed
}


/**
* Object containing constructors for all neo4j types.
Expand Down
70 changes: 68 additions & 2 deletions test/unit/http-connection/connection-provider.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,71 @@ describe('HttpConnectionProvider', () => {
})
})

describe('URL path handling', () => {
it('should include path in _queryEndpoint when provided', () => {
const address = internal.serverAddress.ServerAddress.fromUrl('localhost:7474')
const { provider } = newProvider(address, { newPool: jest.fn() }, { path: '/my-proxy' })

// @ts-expect-error
expect(provider._queryEndpoint).toBe('http://localhost:7474/my-proxy/db/{databaseName}/query/v2')
})

it('should not alter _queryEndpoint when path is empty', () => {
const address = internal.serverAddress.ServerAddress.fromUrl('localhost:7474')
const { provider } = newProvider(address, { newPool: jest.fn() })

// @ts-expect-error
expect(provider._queryEndpoint).toBe('http://localhost:7474/db/{databaseName}/query/v2')
})

it('should not alter _queryEndpoint when path is undefined', () => {
const address = internal.serverAddress.ServerAddress.fromUrl('localhost:7474')
const { provider } = newProvider(address, { newPool: jest.fn() }, { path: undefined })

// @ts-expect-error
expect(provider._queryEndpoint).toBe('http://localhost:7474/db/{databaseName}/query/v2')
})

it('should include deep path in _queryEndpoint', () => {
const address = internal.serverAddress.ServerAddress.fromUrl('localhost:7474')
const { provider } = newProvider(address, { newPool: jest.fn() }, { path: '/deep/nested/path' })

// @ts-expect-error
expect(provider._queryEndpoint).toBe('http://localhost:7474/deep/nested/path/db/{databaseName}/query/v2')
})

it('should propagate path in queryEndpoint to created connections', async () => {
const newPool = jest.fn<internal.pool.Pool<HttpConnection>, ConstructorParameters<typeof internal.pool.Pool<HttpConnection>>>()
const address = internal.serverAddress.ServerAddress.fromUrl('localhost:7474')
newProvider(address, { newPool }, { path: '/my-proxy' })

const [[{ create }]] = newPool.mock.calls
const connection = await create!({ queryEndpoint: 'http://localhost:7474/my-proxy/db/{databaseName}/query/v2' }, address, async () => {})

// @ts-expect-error
expect(connection._queryEndpoint).toBe('http://localhost:7474/my-proxy/db/{databaseName}/query/v2')
})

it('should pass path to discover() in verifyConnectivityAndGetServerInfo', async () => {
const address = internal.serverAddress.ServerAddress.fromUrl('localhost:7474')
const { newHttpConnection, discoverSpy, spyOnRunners, spyOnRelease } = setupSpies(address)
const { provider, params: { scheme } } = newProvider(address, { newHttpConnection }, { path: '/my-proxy' })
const expectedQueryApi = `${scheme}://${address.asHostPort()}/my-proxy/db/{databaseName}/query/v2`

discoverSpy.mockResolvedValue({
query: expectedQueryApi,
version: '5.19.0',
edition: 'enterprise'
})

await provider.verifyConnectivityAndGetServerInfo({ database: 'neo4j', accessMode: 'READ' })

expect(discoverSpy).toHaveBeenCalledWith(expect.objectContaining({
path: '/my-proxy'
}))
})
})

describe('.supportsMultiDb()', () => {
it ('should resolves true', async () => {
const address = internal.serverAddress.ServerAddress.fromUrl('localhost:7474')
Expand Down Expand Up @@ -718,14 +783,15 @@ function setupSpies(address: internal.serverAddress.ServerAddress, spies?: {
return { newHttpConnection, discoverSpy, spyOnRunners, spyOnRelease }
}

function newProvider(address: internal.serverAddress.ServerAddress, injectable?: Partial<HttpConnectionProviderInjectable>) {
function newProvider(address: internal.serverAddress.ServerAddress, injectable?: Partial<HttpConnectionProviderInjectable>, extra?: { path?: string }) {
const params = {
address,
authTokenManager: staticAuthTokenManager({ authToken: auth.basic('neo4j', 'password ') }),
config: { encrypted: false },
id: 1,
scheme: 'http' as HttpScheme,
log: new internal.logger.Logger('debug', () => { })
log: new internal.logger.Logger('debug', () => { }),
...(extra?.path !== undefined ? { path: extra.path } : {})
}

return { provider :new HttpConnectionProvider(params, {
Expand Down
127 changes: 127 additions & 0 deletions test/unit/wrapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Copyright (c) "Neo4j"
* Neo4j Sweden AB [https://neo4j.com]
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { auth } from 'neo4j-driver-core'
import { HttpConnectionProvider } from '../../src/http-connection'
import { wrapper } from '../../src'

let capturedConfigs: any[] = []

jest.mock('../../src/http-connection', () => {
const actual = jest.requireActual('../../src/http-connection')
const OriginalProvider = actual.HttpConnectionProvider

class MockHttpConnectionProvider extends OriginalProvider {
constructor(config: any, ...rest: any[]) {
super(config, ...rest)
capturedConfigs.push(config)
}
}

return {
...actual,
HttpConnectionProvider: MockHttpConnectionProvider
}
})

describe('wrapper()', () => {
beforeEach(() => {
capturedConfigs = []
})

describe('URL path handling', () => {
it.each([
['http://localhost:7474', ''],
['http://localhost:7474/', ''],
['http://localhost:7474/my-proxy', '/my-proxy'],
['http://localhost:7474/deep/nested/path', '/deep/nested/path'],
['http://localhost:7474/trailing/', '/trailing'],
['https://localhost:7473/path', '/path'],
['http://localhost:7474/a/b/c/d', '/a/b/c/d'],
])('should pass path to HttpConnectionProvider for URL %s', async (url, expectedPath) => {
const w = wrapper(url, auth.basic('neo4j', 'password'))

try {
// The provider is created lazily by the Driver, force it
await w.supportsMultiDb()

expect(capturedConfigs.length).toBe(1)
expect(capturedConfigs[0].path).toBe(expectedPath)
} finally {
await w.close()
}
})

it('should construct correct query endpoint with path', async () => {
const w = wrapper('http://localhost:7474/my-proxy', auth.basic('neo4j', 'password'))

try {
await w.supportsMultiDb()

expect(capturedConfigs.length).toBe(1)
const provider = capturedConfigs[0]
expect(provider.path).toBe('/my-proxy')
expect(provider.scheme).toBe('http')
} finally {
await w.close()
}
})

it('should construct correct query endpoint without path', async () => {
const w = wrapper('http://localhost:7474', auth.basic('neo4j', 'password'))

try {
await w.supportsMultiDb()

expect(capturedConfigs.length).toBe(1)
expect(capturedConfigs[0].path).toBe('')
} finally {
await w.close()
}
})

it('should construct correct query endpoint for https with path', async () => {
const w = wrapper('https://localhost:7473/proxy/path', auth.basic('neo4j', 'password'))

try {
await w.supportsMultiDb()

expect(capturedConfigs.length).toBe(1)
expect(capturedConfigs[0].path).toBe('/proxy/path')
expect(capturedConfigs[0].scheme).toBe('https')
} finally {
await w.close()
}
})
})

describe('scheme validation', () => {
it('should accept http URLs', async () => {
const w = wrapper('http://localhost:7474', auth.basic('neo4j', 'password'))
await w.close()
})

it('should accept https URLs', async () => {
const w = wrapper('https://localhost:7473', auth.basic('neo4j', 'password'))
await w.close()
})

it('should reject unknown schemes', () => {
expect(() => wrapper('bolt://localhost:7687', auth.basic('neo4j', 'password'))).toThrow('Unknown scheme')
})
})
})