Skip to main content
Version: v3.x (DDN)

Custom Business Logic via TypeScript

Introductionโ€‹

The Node.js Lambda Connector can be added to any project to incorporate custom business logic directly into your supergraph.

This can be used to enrich data returned to clients, or to write custom mutations in TypeScript with Node.js.

Prerequisites

If you've never used Hasura DDN, we recommend that you first go through the getting started. ๐Ÿ˜Š

Functions or Proceduresโ€‹

TypeScript functions defined in a file in the Node.js Lambda Connector directory and then tracked, will be available from your Hasura DDN GraphQL API in the form of either functions or procedures.

In Hasura metadata, functions are used for read operations. They will not modify the data in the database and can only be used to retrieve data.

Conversely, procedures are used for write operations. They can modify the data in the database and can be used to create, update, or delete data.

The distinction is important in metadata because it allows the system to know what types to expect for arguments and return values.

Add the TypeScript connector to a projectโ€‹

Step 1. Initialize the Node.js Lambda connectorโ€‹

Let's begin by initializing the connector on our project. In the example below, you'll see a familiar flow and use the hasura/nodejs connector from the connector hub.

Run the following command:
ddn connector init my_ts -i
  • Select hasura/nodejs from the list of connectors.
  • Choose a port (press enter to accept the default recommended by the CLI).
  • In this example, we've called the connector my_ts. You can name it something descriptive.

What did this do?โ€‹

This command created the following file structure in a app/connector/my_ts directory, with the functions.ts file being your connector's entrypoint:

.
โ”œโ”€โ”€ .ddnignore
โ”œโ”€โ”€ .hasura-connector
โ”‚ โ”œโ”€โ”€ ...
โ”œโ”€โ”€ compose.yaml
โ”œโ”€โ”€ connector.yaml
โ”œโ”€โ”€ functions.ts
โ”œโ”€โ”€ package-lock.json
โ”œโ”€โ”€ package.json
โ””โ”€โ”€ tsconfig.json

Step 2. Add a functionโ€‹

TypeScript developer tooling

You will likely edit your functions.ts file in a code editor with support for displaying TypeScript compiler errors and code completions, such as Visual Studio Code. In order for the code editor tooling to work correctly you will need to have Node.js (>= v20.0.0) installed locally and you will need to install the npm packages that the connector uses:

In the connector directory, run this to install packages:
npm install

By default, a function โ€” in the nomenclature of DDN โ€” is already present in our functions.ts file. It's designated as a function by the JSDoc comment using the @readonly tag:

/**
* @readonly Exposes the function as an NDC function (the function should only query data without making modifications)
*/
export function hello(name?: string) {
return `hello ${name ?? "world"}`;
}

You can add another function simply by exporting a valid TypeScript function.

Step 3. Track the functionโ€‹

To add our function, we can use the following to generate the related metadata that will link together any functions in this functions.ts file and our API.

Introspect the connector:
ddn connector introspect my_ts

Then, we can generate an hml file for the function using the following command.

Track the function:
ddn command add my_ts hello
Have a lot of functions?

If you have a lot of functions and want to add them all en masse, you can use the CLI to generate metadata for each.

Run the following:
ddn command add my_ts "*"

What did this do?โ€‹

The command introspected your functions.ts file and found any functions present along with their argument and return types.

The CLI then used this to create Hasura metadata for each function which can then be exposed in your API.

Each function or procedure has tracked metadata
For the boilerplate hello() function included by default, you'll see a Hello.hml file in the metadata directory of the subgraph. Click here to check it out ยป
---
kind: Command
version: v1
definition:
name: Hello
outputType: String!
arguments:
- name: name
type: String
source:
dataConnectorName: my_ts
dataConnectorCommand:
function: hello
graphql:
rootFieldName: hello
rootFieldKind: Query

---
kind: CommandPermissions
version: v1
definition:
commandName: Hello
permissions:
- role: admin
allowExecution: true

Step 4. Create a new API build and testโ€‹

Next, let's create a new build of our supergraph:

ddn supergraph build local
Start your engines!

Don't forget to start your GraphQL engine using the following command.

From the root of your project, run:
ddn run docker-start

This reads the docker-start script from the context config at .hasura/context.yaml and starts your Hasura engine, any connectors, and observability tools.

You should see your command available, along with its documentation, in the GraphiQL explorer on the console which you should be able to access using:

Run:
ddn console --local
You can then test your new function with the following query:
query MyQuery {
hello(name: "Hasura")
}
Demo Business Logic query

Privacy settings in some browsers

Your browser settings or privacy tools may prevent the Console from accessing your local Hasura instance. This could be due to features designed to protect your privacy and security. Should you encounter one of these issues, we recommend disabling these settings for the console.hasura.io domain.

Chrome and Firefox are the recommended browsers for the best experience with the Hasura Console including for local development.

Running Node.js locally

By default, Node.js and your functions run in a Docker container along with your other Hasura services. The connector init command created a compose.yaml file for you, which are used by default.

However, if you prefer to run Node.js directly on your local machine, you can do so by following these steps:

  1. Ensure you have Node.js version >=20.0.0 installed on your machine.
  2. Install the necessary dependencies:
Change to the connector directory and install dependencies:
cd my_subgraph/connector/my_ts && npm i
  1. From the my_ts directory, run this command to load environment variables from your project's .env file, start the connector, and watch for any changes:
Run the connector with env vars loaded from config
ddn connector setenv --connector connector.yaml -- npm run start

Make sure the port specified in your connector's .env file doesn't conflict with any other services you may be running.

Add a procedureโ€‹

Inside the same functions.ts file, let's add a custom mutation in the form of a procedure. A procedure is simply a TypeScript function that is exported from functions.ts that does not have the @readonly JSDoc annotation.

For example, let's say you want to add a custom mutation to create a user in a PostgreSQL table on your default app subgraph and also hash their password before storing it.

You can do this by first installing the pg package (the PostgreSQL client) and the bcrypt package (to hash the password). You should also install their associated TypeScript types packages. You are not limited to these packages; you can install any combination of Node.js packages that you need and use them at will.

In the connector directory, run the following commands to install the packages:
npm install pg bcrypt
npm install --save-dev @types/pg @types/bcrypt

Once those packages are installed, you add the following code to the functions.ts file:

import { Client } from "pg";
import bcrypt from "bcrypt";

// Define a type to capture a new user's details using TypeScript
type NewUser = {
name: string;
email: string;
password: string; // Add a password field to the User type
};

// Define a type to capture the created User
type User = {
id: number;
name: string;
email: string;
};

/**
* Inserts a user into the database and returns the inserted user.
* Hash password for secure storage.
*
* @param user The user to insert into the database. Expects a name, email, and password.
* @returns The user that was inserted into the database.
*/
export async function insertUser(user: NewUser): Promise<User> {
// Get your database connection string.
// NB!! Use env vars for this in production NB!!
const connectionString = "postgresql://username:password@localhost:5432/mydb";

const client = new Client({
connectionString,
});
await client.connect();

// Hash the user's password before storing it
const saltRounds = 10; // Pretty decent number of rounds
const hashedPassword = await bcrypt.hash(user.password, saltRounds);

const result = await client.query(
`INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING *`,
[user.name, user.email, hashedPassword] // Use the hashed password here
);

const rows = result.rows.map((row) => ({
id: row.id,
name: row.name,
email: row.email,
// Do not return the password field for security reasons
}));

await client.end();

return rows[0];
}

This:

  • creates a function called insertUser.
  • takes in a user's name, email, and password.
  • hashes their password.
  • inserts into the users table in the database.
  • returns the inserted user without password.

Now you can re-introspect your connector and generate an hml file for the function using the following commands:

Introspect the connector and track the new function:
ddn connector introspect my_ts
ddn command add my_ts insertUser

Don't forget to rebuild and restart your GraphQL engine after making these changes!

Create a new local build and restart your Docker containers:
ddn supergraph build local
ddn run docker-start

Testingโ€‹

After you've generated the metadata for the function or procedure and created a new build, you can use this in your GraphQL API.

More examples

To see more examples, check out our getting started guide for adding custom business logic or database-specific guides for creating mutations.