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

Create a Simple Engine Plugin: TypeScript and Express

Introduction

In this tutorial, we’ll create a pair of simple engine plugins using TypeScript and Express:

  • A pre-parse plugin to log incoming session information before the query is executed.
  • A pre-pesponse plugin to log responses before they are sent to clients.

By the end of this guide, you'll understand how to configure, deploy, and test plugins that extend Hasura DDN's core capabilities, unlocking new possibilities for your API workflows.

This tutorial should take less than thirty minutes.

Setup

Step 1. Create the project structure

Begin by creating a new directory:
mkdir plugin-tutorial && cd plugin-tutorial

Step 2. Initialize your local Hasura DDN project

Within this directory, initialize your local Hasura DDN project:
ddn supergraph init ddn && cd ddn

This will create a ddn directory — which will house your local DDN metadata — with all the necessary files and directories scaffolded out.

Step 3. Initialize a data connector and seed the database

In your project directory, run:
ddn connector init my_pg -i

From the dropdown, start typing PostgreSQL and hit enter to advance through all the options.

The CLI will output something similar to this:

HINT To access the local Postgres database:
- Run: docker compose -f app/connector/my_pg/compose.postgres-adminer.yaml up -d
- Open Adminer in your browser at http://localhost:5143 and create tables
- To connect to the database using other clients use postgresql://user:[email protected]:8105/dev
Use the hint from the CLI output:
docker compose -f app/connector/my_pg/compose.postgres-adminer.yaml up -d

Run docker ps to see on which port Adminer is running. Then, you can then navigate to the address below to access it:

http://localhost:<ADMINER_PORT>
Next, via Adminer select SQL command from the left-hand nav, then enter the following:
--- Create the customers table
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);

--- Create the orders table with a default value for order_date
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers(id),
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
total_amount DECIMAL(10, 2) NOT NULL
);

--- Insert some data into customers
INSERT INTO customers (name, email) VALUES ('Alice', '[email protected]');
INSERT INTO customers (name, email) VALUES ('Bob', '[email protected]');
INSERT INTO customers (name, email) VALUES ('Charlie', '[email protected]');

--- Insert some data into orders (order_date will use the default value if not provided)
INSERT INTO orders (customer_id, total_amount) VALUES (1, 99.99);
INSERT INTO orders (customer_id, total_amount) VALUES (2, 49.50);
INSERT INTO orders (customer_id, total_amount) VALUES (3, 75.00);

You'll see the execution return OK for each table creation and row insert.

Step 4. Generate the Hasura metadata

Next, use the CLI to introspect your PostgreSQL database:
ddn connector introspect my_pg

After running this, you should see a representation of your database's schema in the app/connector/my_pg/configuration.json file; you can view this using cat or open the file in your editor.

Now, track the entities from your PostgreSQL database in your DDN metadata:
ddn models add my_pg "*" && ddn commands add my_pg "*" && ddn relationships add my_pg "*"

Open the app/metadata directory and you'll find newly-generated files representing your API. The DDN CLI will use these Hasura Metadata Language files to represent the tables, their operations, and relationships from PostgreSQL in your API.

Step 5. Create and test a local build

To create a local build, run:
ddn supergraph build local

The build is stored as a set of JSON files in engine/build.

Start your local Hasura DDN Engine and PostgreSQL connector:
ddn run docker-start

Your terminal will be taken over by logs for the different services.

In a new terminal tab, open your local console:
ddn console --local
In the GraphiQL explorer of the console, write this query:
query GET_CUSTOMERS_AND_ORDERS {
customers {
id
name
email
orders {
id
orderDate
totalAmount
}
}
}
You'll get the following response:
{
"data": {
"customers": [
{
"id": 1,
"name": "Alice",
"email": "[email protected]",
"orders": [
{
"id": 1,
"orderDate": "2025-01-14",
"totalAmount": "99.99"
}
]
},
{
"id": 2,
"name": "Bob",
"email": "[email protected]",
"orders": [
{
"id": 2,
"orderDate": "2025-01-14",
"totalAmount": "49.50"
}
]
},
{
"id": 3,
"name": "Charlie",
"email": "[email protected]",
"orders": [
{
"id": 3,
"orderDate": "2025-01-14",
"totalAmount": "75.00"
}
]
}
]
}
}

With our DDN project set up with a data source, we can now create the plugins.

Create the plugins

Step 6. Create the project directory

From the root of your plugin-example directory, initialize a new Node.js project as a sibling of the ddn directory:
mkdir simple-plugin && cd simple-plugin && npm init -y
Your top-level project structure should look like this:
├── ddn
└── simple-plugin

Step 7. Install dependencies

Execute the following from your simple-plugin directory:
npm install express && npm install --save-dev typescript @types/node @types/express ts-node-dev

Step 8. Initialize your tsconfig

Create a tsconfig.json in the simple-plugin directory:
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
}

Step 9. Create the index.ts and types.ts files

Create the index.ts file
mkdir src && touch src/index.ts && touch src/types.ts
Then, populate index.ts with this:
import express, { Request, Response, NextFunction } from "express";
import preparse from "./routes/pre-parse";
import preresponse from "./routes/pre-response";

const app = express();
const PORT = process.env.PORT || 4000;

// Middleware to check for hasura-m-auth header
const checkHasuraMAuth = (req: Request, res: Response, next: NextFunction): void => {
const authHeader = req.header("hasura-m-auth");
if (authHeader === "super-secret-key") {
next();
} else {
res.status(401).json({ error: "Unauthorized: Invalid or missing header" });
}
};

// Middleware
app.use(express.json());
app.use(checkHasuraMAuth);

// Apply the middleware selectively for specific routes
app.use("/pre-parse", preparse);
app.use("/pre-response", preresponse);

app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});

Don't worry about the warnings; we'll take care of adding the routes in the next step.

Add types.ts with the following:
export type PreParseRequest = {
session: {
role: string;
variables: Record<string, any>;
};
rawRequest: {
query: string;
variables: Record<string, any> | null;
operationName: string | null;
};
};

export type PreResponseRequest = {
response: {
data: unknown;
};
session: {
role: string;
variables: Record<string, any> | null;
};
rawRequest: {
query: string;
variables: Record<string, any> | null;
operationName: string | null;
};
};

These types will serve as generic type definitions for the requests our webhook can expect from Hasura DDN.

Step 10. Create the routes

Create a routes directory and files for each route handler:
mkdir src/routes && touch src/routes/pre-parse.ts && touch src/routes/pre-response.ts
Add the following to the pre-parse.ts file:
import { Router, Request, Response } from "express";
import { PreParseRequest } from "../types";

const router = Router();

router.post("/", (req: Request<any, any, PreParseRequest>, res: Response): void => {
console.log(
`This is running before the request is parsed! The user making this request is of role ${req.body.session.role}.`,
);
res.status(204).send();
});

export default router;
And this to the pre-response.ts file:
import { Router, Request, Response } from "express";
import { PreResponseRequest } from "../types";

const router = Router();

router.post("/", (req: Request<unknown, unknown, PreResponseRequest>, res: Response): void => {
const { response } = req.body;
console.log(
`\n\nThis is running after the request is logged via the webhook before the response is sent to the client! \n\n
Response Data: ${JSON.stringify(response.data, null, 2)}`,
);

res.status(204).send();
});

export default router;

These two handlers will be responsible for processing and responding to requests to the webhook from Hasura DDN. You can learn more about the contract between Hasura DDN and a plugin here.

Step 11. Update the package.json

Update the package.json to include these scripts:
{
"name": "simple-plugin",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"express": "^4.21.2"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.6",
"ts-node-dev": "^2.0.0",
"typescript": "^5.7.3"
}
}

Step 12. Run the server

From the simple-plugin directory, run:
npm run dev

Step 13. Update the Hasura metadata

Open your ddn/globals/metadata directory and create a new file called plugins-config.hml.

Populate the file with the following:
kind: LifecyclePluginHook
version: v1
definition:
name: Pre-Parse Plugin
url:
valueFromEnv: PRE_PARSE_URL
pre: parse
config:
request:
headers:
additional:
hasura-m-auth:
value: "super-secret-key"
session: {}
rawRequest:
query: {}
variables: {}

---
kind: LifecyclePluginHook
version: v1
definition:
name: Pre-Response Plugin
url:
valueFromEnv: PRE_RESPONSE_URL
pre: response
config:
request:
headers:
additional:
hasura-m-auth:
value: "super-secret-key"
session: {}
rawRequest:
query: {}
variables: {}

These two metadata objects serve as the configuration for our pre-parse and pre-response plugins respectively. They'll tell the Hasura DDN Engine to utilize these URLs whenever a query hits the API. You can learn more about them here.

Step 14. Add the environment variables

Since we've used the keys PRE_PARSE_URL and PRE_RESPONSE_URL, we'll need to map these values in our .env file in the root of our project.

Add the following key-value pairs to your globals/metadata/subgraph.yaml file:
kind: Subgraph
version: v2
definition:
name: globals
generator:
rootPath: .
includePaths:
- metadata
envMapping:
PRE_PARSE_URL:
fromEnv: PRE_PARSE_URL
PRE_RESPONSE_URL:
fromEnv: PRE_RESPONSE_URL
And these key-value pairs to your ddn directory's .env
PRE_PARSE_URL="http://local.hasura.dev:4000/pre-parse"
PRE_RESPONSE_URL="http://local.hasura.dev:4000/pre-response"

Step 15. Create a new build and test

Use the DDN CLI to create a new build:
ddn supergraph build local
Then, kill your local services using CTRL+C before starting them back up:
ddn run docker-start
Finally, exeucte this query:
query GET_CUSTOMERS_AND_ORDERS {
customers {
id
name
email
orders {
id
orderDate
totalAmount
}
}
}
You'll see similar values logged to your server's terminal tab at the appropriate point in the query's execution:
This is running before the request is parsed! The user making this request is of role admin.


This is running after the request is logged via the webhook before the response is sent to the client!


Response Data: {
"customers": [
{
"id": 1,
"name": "Alice",
"email": "[email protected]",
"orders": [
{
"id": 1,
"orderDate": "2025-01-14",
"totalAmount": "99.99"
}
]
},
{
"id": 2,
"name": "Bob",
"email": "[email protected]",
"orders": [
{
"id": 2,
"orderDate": "2025-01-14",
"totalAmount": "49.50"
}
]
},
{
"id": 3,
"name": "Charlie",
"email": "[email protected]",
"orders": [
{
"id": 3,
"orderDate": "2025-01-14",
"totalAmount": "75.00"
}
]
}
]
}

Next steps

While this works locally, it's just as easy to also deploy this anywhere an Express application can be hosted. You can also use a separate context configuration for local vs. cloud development to test, collaborate, and deploy your DDN application — along with your plugins — whenever you wish. Learn more here.

Check out the other plugins we have available out-of-the-box in this directory, too!