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.
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.
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โ
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.
ddn connector introspect my_ts
Then, we can generate an hml
file for the function using the following command.
ddn command add my_ts hello
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.
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.
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
Don't forget to start your GraphQL engine using the following command.
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:
ddn console --local
query MyQuery {
hello(name: "Hasura")
}
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.
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:
- Ensure you have Node.js version
>=20.0.0
installed on your machine. - Install the necessary dependencies:
cd my_subgraph/connector/my_ts && npm i
- 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:
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.
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 adding 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.
We're using the pg
PostgreSQL client to connect to the database and run the query
and bcrypt
to hash the password, but since this is a Node.js Lambda function,
you can use any combination of Node.js libraries you want.
You can then repeat this process to scale your mutations independently.
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.
To see more examples, check out our getting started guide for adding custom business logic or database-specific guides for creating mutations.