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
mkdir plugin-tutorial && cd plugin-tutorial
Step 2. 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
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
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>
--- 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
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.
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
ddn supergraph build local
The build is stored as a set of JSON files in engine/build
.
ddn run docker-start
Your terminal will be taken over by logs for the different services.
ddn console --local
query GET_CUSTOMERS_AND_ORDERS {
customers {
id
name
email
orders {
id
orderDate
totalAmount
}
}
}
{
"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
mkdir simple-plugin && cd simple-plugin && npm init -y
├── ddn
└── simple-plugin
Step 7. Install dependencies
npm install express && npm install --save-dev typescript @types/node @types/express ts-node-dev
Step 8. Initialize your tsconfig
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
}
Step 9. Create the index.ts
and types.ts
files
mkdir src && touch src/index.ts && touch src/types.ts
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.
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
mkdir src/routes && touch src/routes/pre-parse.ts && touch src/routes/pre-response.ts
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;
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
{
"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
npm run dev
Step 13. Update the Hasura metadata
Open your ddn/globals/metadata
directory and create a new file called plugins-config.hml
.
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.
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
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
ddn supergraph build local
ddn run docker-start
query GET_CUSTOMERS_AND_ORDERS {
customers {
id
name
email
orders {
id
orderDate
totalAmount
}
}
}
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!