Skip to content

Commit bf82c19

Browse files
authored
Add Jsonata transformation server (#62)
Add Jsonata transform Signed-off-by: Pierangelo Di Pilato <pierdipi@redhat.com>
1 parent c3a09de commit bf82c19

File tree

10 files changed

+1490
-0
lines changed

10 files changed

+1490
-0
lines changed

transform-jsonata/.dockerignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
examples

transform-jsonata/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/node_modules

transform-jsonata/Dockerfile

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
FROM registry.access.redhat.com/ubi8/nodejs-20 AS builder
2+
3+
# Set working directory
4+
WORKDIR /app
5+
6+
# Ensure the working directory has appropriate permissions
7+
RUN mkdir -p /app && chown -R 1001:0 /app
8+
USER 1001
9+
10+
# Install pnpm globally
11+
RUN npm install -g pnpm
12+
13+
# Copy package.json and pnpm-lock.yaml before installing dependencies
14+
COPY package.json pnpm-lock.yaml ./
15+
16+
# Install dependencies using pnpm
17+
RUN pnpm install --frozen-lockfile
18+
19+
# Copy the rest of the application files
20+
COPY . .
21+
22+
# Build the application (if necessary)
23+
RUN pnpm build || echo "No build step found, skipping"
24+
25+
# Use a minimal base image for runtime
26+
FROM registry.access.redhat.com/ubi8/nodejs-20
27+
28+
ENV NODE_ENV=production
29+
30+
# Set working directory
31+
WORKDIR /app
32+
33+
# Ensure the working directory has appropriate permissions
34+
RUN mkdir -p /app && chown -R 1001:0 /app
35+
USER 1001
36+
37+
# Copy built files and dependencies
38+
COPY --from=builder /app /app
39+
40+
# Expose the application port
41+
EXPOSE 8080
42+
43+
# Set the default command
44+
CMD ["node", "jsonata.js"]

transform-jsonata/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# JSON Transformations
2+
3+
```text
4+
5+
source or webhook -> transform -> broker
6+
7+
trigger -> transform -> sink or webhook
8+
9+
broker -> trigger -> (request) -> transform
10+
broker <- trigger <- (response) <-
11+
```
12+
13+
## Development
14+
15+
Assuming current working directory is `transform-jsonata`
16+
17+
```shell
18+
pnpm dev
19+
20+
# or pnpm dev-kubevirt to inject a different example transformation
21+
```
22+
23+
## Building
24+
25+
Assuming current working directory is `transform-jsonata`
26+
27+
```shell
28+
IMAGE_NAME=...
29+
docker build -t "${IMAGE_NAME}" -f Dockerfile .
30+
docker push "${IMAGE_NAME}"
31+
```
32+
33+
### Running
34+
35+
```shell
36+
docker run --user $(id -u):$(id -g) -ti --rm --mount src="$(pwd)/examples",dst=/var/examples,type=bind -e NODE_ENV=development -e JSONATA_TRANSFORM_FILE_NAME=/var/examples/ce_apiserversource_kubevirt.jsonata -p 8080:8080 "${IMAGE_NAME}"
37+
```
38+
39+
### Testing the example
40+
41+
```shell
42+
curl -v -XPOST http://localhost:8080 -d @examples/ce_apiserversource_kubevirt.json
43+
```
44+
45+
## Testing transformations
46+
47+
https://try.jsonata.org/
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"specversion": "1.0",
3+
"type": "dev.knative.apiserver.resource.update",
4+
"source": "https://172.30.0.1:443",
5+
"subject": "/apis/kubevirt.io/v1/namespaces/user-kn-eventing/virtualmachines/user-test-rhel9",
6+
"id": "afab5084-356d-45b6-bc8c-b40f958a0a15",
7+
"time": "2025-02-03T20:06:23.403976118Z",
8+
"datacontenttype": "application/json",
9+
"apiversion": "kubevirt.io/v1",
10+
"kind": "VirtualMachine",
11+
"knativearrivaltime": "2025-02-03T20:06:23.404980773Z",
12+
"name": "user-test-rhel9",
13+
"namespace": "user-kn-eventing",
14+
"data": {
15+
"apiVersion": "kubevirt.io/v1",
16+
"kind": "VirtualMachine",
17+
"metadata": {
18+
"annotations": {
19+
"kubemacpool.io/transaction-timestamp": "2025-02-03T20:06:17.149157666Z",
20+
"kubevirt.io/latest-observed-api-version": "v1",
21+
"kubevirt.io/storage-observed-api-version": "v1",
22+
"vm.kubevirt.io/validations": "[\n {\n \"name\": \"minimal-required-memory\",\n \"path\": \"jsonpath::.spec.domain.memory.guest\",\n \"rule\": \"integer\",\n \"message\": \"This VM requires more memory.\",\n \"min\": 1610612736\n }\n]\n"
23+
},
24+
"creationTimestamp": "2025-01-17T14:34:55Z",
25+
"finalizers": [
26+
"kubevirt.io/virtualMachineControllerFinalize"
27+
],
28+
"generation": 11,
29+
"labels": {
30+
"app": "user-test-rhel9",
31+
"kubevirt.io/dynamic-credentials-support": "true",
32+
"vm.kubevirt.io/template": "rhel9-server-small",
33+
"vm.kubevirt.io/template.namespace": "openshift",
34+
"vm.kubevirt.io/template.revision": "1",
35+
"vm.kubevirt.io/template.version": "v0.31.1"
36+
}
37+
}
38+
}
39+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"specversion": specversion,
3+
"type": type,
4+
"source": source,
5+
"subject": subject,
6+
"id": id,
7+
"time": time,
8+
"datacontenttype": datacontenttype,
9+
"apiversion": apiversion,
10+
"kind": kind,
11+
"knativearrivaltime": knativearrivaltime,
12+
"name": name,
13+
"namespace": namespace,
14+
"vmtemplate": data.metadata.labels."vm.kubevirt.io/template",
15+
"data": data
16+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"specversion": "1.0",
3+
"id": "67dd883c-36a7-4d8f-b311-db4997325fa1",
4+
"type": "transformation.jsonata",
5+
"source": "transformation.json.identity",
6+
"data": $
7+
}

transform-jsonata/jsonata.js

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
const express = require('express');
2+
const {HTTP} = require("cloudevents");
3+
const jsonata = require('jsonata');
4+
const fs = require('node:fs');
5+
6+
const port = process.env.PORT = process.env.PORT || 8080;
7+
const k_sink = process.env.K_SINK || undefined;
8+
const jsonata_transform_file_name = process.env.JSONATA_TRANSFORM_FILE_NAME || undefined;
9+
10+
if (!jsonata_transform_file_name) {
11+
throw new Error("undefined JSONATA_TRANSFORM_FILE_NAME env variable");
12+
}
13+
14+
let jsonata_transform = null
15+
16+
try {
17+
const jsonata_transform_file_content = fs.readFileSync(jsonata_transform_file_name, "utf-8")
18+
jsonata_transform = jsonata(jsonata_transform_file_content);
19+
} catch (error) {
20+
throw new Error(`Failed to parse Jsonata transform file in ${jsonata_transform_file_name}: ${error}`);
21+
}
22+
23+
function logDebug(...inputs) {
24+
if (process.env.NODE_ENV === "development") {
25+
console.debug(...inputs);
26+
}
27+
}
28+
29+
const app = express()
30+
app.use(express.json());
31+
app.use(express.text());
32+
app.use(express.raw({type: '*/*'}));
33+
app.use((req, res, next) => {
34+
if (Buffer.isBuffer(req.body)) {
35+
try {
36+
req.rawBody = JSON.parse(req.body);
37+
} catch (error) {
38+
logDebug("Falling back to convert body to string");
39+
req.rawBody = req.body.toString()
40+
}
41+
} else {
42+
req.rawBody = req.body;
43+
}
44+
next();
45+
});
46+
47+
app.post("/", async (req, res) => {
48+
try {
49+
let input = null
50+
try {
51+
const ceInput = HTTP.toEvent({headers: req.headers, body: req.rawBody});
52+
if (Array.isArray(ceInput)) {
53+
logDebug("Unsupported batch ceInput")
54+
return res
55+
.header("Reason", "Unsupported batch input")
56+
.status(400)
57+
.send();
58+
}
59+
input = JSON.parse(HTTP.structured(ceInput).body)
60+
} catch (error) {
61+
logDebug(`Failed to deserialize CloudEvent, falling back to raw body`, JSON.stringify(req.rawBody), error)
62+
input = req.rawBody
63+
}
64+
65+
logDebug("input", JSON.stringify(input));
66+
67+
const transformed = await jsonata_transform.evaluate(input)
68+
if (k_sink) {
69+
logDebug(`K_SINK is set, sending event to it ${k_sink}`)
70+
71+
try {
72+
const response = await fetch(k_sink, {
73+
method: "POST",
74+
headers: {
75+
"Content-Type": "application/json",
76+
},
77+
body: JSON.stringify(transformed),
78+
})
79+
logDebug(`K_SINK received response ${response.status}`)
80+
81+
return res
82+
.status(response.status)
83+
.send()
84+
} catch (error) {
85+
return res
86+
.header("Reason", error.toString())
87+
.status(502)
88+
.send()
89+
}
90+
}
91+
92+
logDebug("Transformed input", JSON.stringify(transformed, null, 2))
93+
94+
return res
95+
.header("Content-Type", "application/json")
96+
.status(200)
97+
.send(transformed);
98+
99+
} catch (error) {
100+
console.error(error);
101+
return res
102+
.header("Reason", error.toString())
103+
.status(500)
104+
.send()
105+
}
106+
});
107+
108+
app.get('/healthz', (req, res) => {
109+
res.status(200).send('OK');
110+
});
111+
112+
app.get('/readyz', (req, res) => {
113+
res.status(200).send('READY');
114+
});
115+
116+
app.disable('x-powered-by');
117+
118+
const server = app.listen(port, () => {
119+
console.log(`Jsonata server listening on port ${port}`)
120+
})
121+
122+
process.on('SIGINT', shutDown);
123+
process.on('SIGTERM', shutDownNow);
124+
125+
let connections = [];
126+
127+
server.on('connection', connection => {
128+
connections.push(connection);
129+
connection.on('close', () => connections = connections.filter(curr => curr !== connection));
130+
});
131+
132+
function shutDown() {
133+
console.log('Received interrupt signal, shutting down gracefully');
134+
135+
if (connections.length === 0) {
136+
shutDownNow()
137+
} else {
138+
setTimeout(() => {
139+
shutDownNow()
140+
}, 5000);
141+
}
142+
}
143+
144+
function shutDownNow() {
145+
console.log('Shutting down gracefully');
146+
147+
server.close(() => {
148+
console.log('Closed out remaining connections');
149+
process.exit(0);
150+
});
151+
152+
setTimeout(() => {
153+
console.error('Could not close connections in time, forcefully shutting down');
154+
process.exit(1);
155+
}, 10000);
156+
157+
connections.forEach(curr => {
158+
if (curr) {
159+
curr.end()
160+
}
161+
});
162+
163+
setTimeout(() => connections.forEach(curr => {
164+
if (curr) {
165+
curr.destroy()
166+
}
167+
}), 7000);
168+
}

transform-jsonata/package.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"version": "0.0.1",
3+
"author": "Knative authors",
4+
"scripts": {
5+
"dev": "NODE_ENV=development JSONATA_TRANSFORM_FILE_NAME=./examples/jsonata_transform_identity.jsonata nodemon ./jsonata.js",
6+
"dev-kubevirt": "NODE_ENV=development JSONATA_TRANSFORM_FILE_NAME=./examples/ce_apiserversource_kubevirt.jsonata nodemon ./jsonata.js"
7+
},
8+
"dependencies": {
9+
"jsonata": "^2.0.6",
10+
"cloudevents": "^8.0.2",
11+
"express": "^4.21.2"
12+
},
13+
"devDependencies": {
14+
"nodemon": "^3.1.9",
15+
"@types/node": "^22.13.1",
16+
"@types/express": "^4.17.21"
17+
}
18+
}

0 commit comments

Comments
 (0)