Skip to content

feat: add support for dgraph connection strings #263

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 4, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Thumbs.db

# Dgraph


dgraph-local-data/
tls/
p/
w/
Expand Down
83 changes: 36 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,38 @@

### Creating a Client

A `DgraphClient` object can be initialised by passing it a list of `DgraphClientStub` clients as
variadic arguments. Connecting to multiple Dgraph servers in the same cluster allows for better
distribution of workload.
#### Connection Strings

The following code snippet shows just one connection.
The dgraph-js supports connecting to a Dgraph cluster using connection strings. Dgraph connections
strings take the form `dgraph://{username:password@}host:port?args`.

```js
const dgraph = require("dgraph-js")
const grpc = require("@grpc/grpc-js")

const clientStub = new dgraph.DgraphClientStub(
// addr: optional, default: "localhost:9080"
"localhost:9080",
// credentials: optional, default: grpc.credentials.createInsecure()
grpc.credentials.createInsecure(),
)
const dgraphClient = new dgraph.DgraphClient(clientStub)
```
`username` and `password` are optional. If username is provided, a password must also be present. If
supplied, these credentials are used to log into a Dgraph cluster through the ACL mechanism.

Valid connection string args:

| Arg | Value | Description |
| ----------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| apikey | \<key\> | a Dgraph Cloud API Key |
| bearertoken | \<token\> | an access token |
| sslmode | disable \| require \| verify-ca | TLS option, the default is `disable`. If `verify-ca` is set, the TLS certificate configured in the Dgraph cluster must be from a valid certificate authority. |

## Some example connection strings: | Value | Explanation | |

| ----------------------------------------------------------------------------------- | |
dgraph://localhost:9080 | Connect to localhost, no ACL, no TLS | |
dgraph://sally:[email protected]:443?sslmode=verify-ca | Connect to remote server, use ACL
and require TLS and a valid certificate from a CA | |
dgraph://foo-bar.grpc.us-west-2.aws.cloud.dgraph.io:443?sslmode=verify-ca&apikey=\<your-api-connection-key\>
| Connect to a Dgraph Cloud cluster | |
dgraph://foo-bar.grpc.hypermode.com?sslmode=verify-ca&bearertoken=\<some access token\> | Connect to
a Dgraph cluster protected by a secure gateway |

Using the `Open` function with a connection string: // open a connection to an ACL-enabled, non-TLS
cluster and login as groot const {client,closeStub} =
dgraph.Open("dgraph://groot:password@localhost:8090")

````

Check notice on line 85 in README.md

View check run for this annotation

Trunk.io / Trunk Check

markdownlint(MD040)

[new] Fenced code blocks should have a language specified

To facilitate debugging, [debug mode](#debug-mode) can be enabled for a client.

Expand All @@ -83,31 +97,12 @@
```js
const dgraphClientStub = new dgraph.DgraphClientStub("localhost:9080")
await dgraphClientStub.loginIntoNamespace("groot", "password", 123) // where 123 is the namespaceId
```
````

In the example above, the client logs into namespace `123` using username `groot` and password
`password`. Once logged in, the client can perform all the operations allowed to the `groot` user of
namespace `123`.

### Creating a Client for Dgraph Cloud Endpoint

If you want to connect to Dgraph running on your [Dgraph Cloud](https://cloud.dgraph.io) instance,
then all you need is the URL of your Dgraph Cloud endpoint and the API key. You can get a client
using them as follows:

```js
const dgraph = require("dgraph-js")

const clientStub = dgraph.clientStubFromCloudEndpoint(
"https://frozen-mango.eu-central-1.aws.cloud.dgraph.io/graphql",
"<api-key>",
)
const dgraphClient = new dgraph.DgraphClient(clientStub)
```

**Note:** the `clientStubFromSlashGraphQLEndpoint` method is deprecated and will be removed in the
next release. Instead use `clientStubFromCloudEndpoint` method.

### Altering the Database

To set the schema, create an `Operation` object, set the schema and pass it to
Expand Down Expand Up @@ -376,27 +371,21 @@

### Cleanup Resources

To cleanup resources, you have to call `DgraphClientStub#close()` individually for all the instances
of `DgraphClientStub`.
To cleanup resources, you have to call `close()`.

```js
const SERVER_ADDR = "localhost:9080"
const SERVER_CREDENTIALS = grpc.credentials.createInsecure()

// Create instances of DgraphClientStub.
const stub1 = new dgraph.DgraphClientStub(SERVER_ADDR, SERVER_CREDENTIALS)
const stub2 = new dgraph.DgraphClientStub(SERVER_ADDR, SERVER_CREDENTIALS)

// Create an instance of DgraphClient.
const dgraphClient = new dgraph.DgraphClient(stub1, stub2)
// Create instances of DgraphClient.
const { client, closeStub } = dgraph.Open("dgraph://groot:password@${SERVER_ADDR}")

// ...
// Use dgraphClient
// ...

// Cleanup resources by closing all client stubs.
stub1.close()
stub2.close()
// Cleanup resources by closing client stubs.
closeStub()
```

### Debug mode
Expand Down
5 changes: 2 additions & 3 deletions examples/simple/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,7 @@ async function queryData(dgraphClient) {
}

async function main() {
const dgraphClientStub = newClientStub()
const dgraphClient = newClient(dgraphClientStub)
const { dgraphClient, closeStub } = dgraph.Open()
await dropAll(dgraphClient)
await setSchema(dgraphClient)
await createData(dgraphClient)
Expand All @@ -137,7 +136,7 @@ async function main() {
await queryData(dgraphClient)

// Close the client stub.
dgraphClientStub.close()
closeStub()
}

main()
Expand Down
107 changes: 107 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { Txn, TxnOptions } from "./txn"
import * as types from "./types"
import { isUnauthenticatedError, stringifyMessage } from "./util"

const dgraphScheme = "dgraph:"
const sslModeDisable = "disable"
const sslModeRequire = "require"
const sslModeVerifyCA = "verify-ca"

/**
* Client is a transaction aware client to a set of Dgraph server instances.
*/
Expand Down Expand Up @@ -127,3 +132,105 @@ export function deleteEdges(mu: types.Mutation, uid: string, ...predicates: stri
mu.addDel(nquad)
}
}

function addApiKeyToCredentials(
baseCreds: grpc.ChannelCredentials,
apiKey: string,
): grpc.ChannelCredentials {
const metaCreds = grpc.credentials.createFromMetadataGenerator((_, callback) => {
const metadata = new grpc.Metadata()
metadata.add("authorization", apiKey)
callback(null, metadata)
})
return grpc.credentials.combineChannelCredentials(baseCreds, metaCreds)
}

function addBearerTokenToCredentials(
baseCreds: grpc.ChannelCredentials,
bearerToken: string,
): grpc.ChannelCredentials {
const metaCreds = grpc.credentials.createFromMetadataGenerator((_, callback) => {
const metadata = new grpc.Metadata()
metadata.add("Authorization", `Bearer ${bearerToken}`)
callback(null, metadata)
})
return grpc.credentials.combineChannelCredentials(baseCreds, metaCreds)
}

export async function Open(
connStr: string,
): Promise<{ client: DgraphClient; closeStub: () => void }> {
const parsedUrl = new URL(connStr)
if (parsedUrl.protocol !== dgraphScheme) {
throw new Error("Invalid scheme: must start with dgraph://")
}

const host = parsedUrl.hostname
const port = parsedUrl.port
if (!host) {
throw new Error("Invalid connection string: hostname required")
}
if (!port) {
throw new Error("Invalid connection string: port required")
}

// Parse query parameters using searchParams
const queryParams: Record<string, string> = {}
if (parsedUrl.searchParams) {
parsedUrl.searchParams.forEach((value, key) => {
queryParams[key] = value
})
}

if (queryParams.apikey && queryParams.bearertoken) {
throw new Error("Both apikey and bearertoken cannot be provided")
}

let sslMode = queryParams.sslmode
if (sslMode === undefined) {
sslMode = sslModeDisable
}

let credentials
switch (sslMode) {
case sslModeDisable:
credentials = grpc.credentials.createInsecure()
break
case sslModeRequire:
credentials = grpc.credentials.createSsl(null, null, null, {
checkServerIdentity: () => undefined, // Skip certificate verification
})
break
case sslModeVerifyCA:
credentials = grpc.credentials.createSsl() // Use system CA for verification
break
default:
throw new Error(`Invalid SSL mode: ${sslMode} (must be one of disable, require, verify-ca)`)
}

// Add API key or Bearer token to credentials if provided
if (queryParams.apikey) {
credentials = addApiKeyToCredentials(credentials, queryParams.apikey)
} else if (queryParams.bearertoken) {
credentials = addBearerTokenToCredentials(credentials, queryParams.bearertoken)
}

const clientStub = new DgraphClientStub(`${host}:${port}`, credentials)

if (parsedUrl.username != "") {
if (parsedUrl.password === "") {
throw new Error("Invalid connection string: password required when username is provided")
} else {
try {
await clientStub.login(parsedUrl.username, parsedUrl.password)
} catch (err) {
throw new Error(`Failed to sign in user: ${err.message}`)
}
}
}

return {
client: new DgraphClient(clientStub),
closeStub: () => clientStub.close(),
}
}
4 changes: 4 additions & 0 deletions src/clientStubFromSlash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export function clientStubFromSlashGraphQLEndpoint(graphqlEndpoint: string, apiK
return clientStubFromCloudEndpoint(graphqlEndpoint, apiKey)
}

/**
* @deprecated
* Please use {@link Open} instead.
*/
export function clientStubFromCloudEndpoint(graphqlEndpoint: string, apiKey: string) {
const url = new Url(graphqlEndpoint)
const urlParts = url.host.split(".")
Expand Down
70 changes: 70 additions & 0 deletions tests/integration/connect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: © Hypermode Inc. <[email protected]>
* SPDX-License-Identifier: Apache-2.0
*/

import * as dgraph from "../../src"

import { SERVER_ADDR } from "../helper"

describe("open function", () => {
it("should connect with authentication and execute a query", async () => {
const url = `dgraph://groot:password@${SERVER_ADDR}`
const { client, closeStub } = await dgraph.Open(url)
const query = `
{
me(func: uid(1)) {
uid
}
}
`
const txn = client.newTxn({ readOnly: true })
const response = await txn.query(query)

// Assertions
expect(response).not.toBeNull()
const parsedJson = response.getJson() // No need for JSON.parse
expect(parsedJson.me[0].uid).toBe("0x1")
closeStub()
})

it("should throw an error for invalid scheme", async () => {
const invalidUrl = `http://${SERVER_ADDR}`
await expect(async () => dgraph.Open(invalidUrl)).rejects.toThrowError(
"Invalid scheme: must start with dgraph://",
)
})

it("should throw an error for missing hostname", async () => {
const invalidUrl = `dgraph://:9081`
await expect(async () => dgraph.Open(invalidUrl)).rejects.toThrowError("Invalid URL")
})

it("should throw an error for missing port", async () => {
const invalidUrl = `dgraph://localhost`
await expect(async () => await dgraph.Open(invalidUrl)).rejects.toThrowError(
"Invalid connection string: port required",
)
})

it("should throw an error for username without password", async () => {
const invalidUrl = `dgraph://groot@${SERVER_ADDR}`
await expect(async () => await dgraph.Open(invalidUrl)).rejects.toThrowError(
"Invalid connection string: password required when username is provided",
)
})

it("should throw an error for unsupported sslmode", async () => {
const invalidUrl = `dgraph://${SERVER_ADDR}?sslmode=invalidsllmode`
await expect(async () => await dgraph.Open(invalidUrl)).rejects.toThrowError(
"Invalid SSL mode: invalidsllmode (must be one of disable, require, verify-ca)",
)
})

it("should fail login with invalid credentials", async () => {
const invalidUrl = `dgraph://groot:wrongpassword@${SERVER_ADDR}`
await expect(async () => await dgraph.Open(invalidUrl)).rejects.toThrowError(
"Failed to sign in user:",
)
})
})
Loading