Skip to content

Commit c730277

Browse files
committed
Added sample implementation of ConnectService for notifications and usage
Signed-off-by: Mitch Gaffigan <mitch.gaffigan@comcast.net>
1 parent ea04b1e commit c730277

File tree

13 files changed

+1717
-0
lines changed

13 files changed

+1717
-0
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
hs_err_pid*
1212
replay_pid*
1313

14+
##############################
15+
## Javascript
16+
##############################
17+
node_modules/
18+
.env
19+
1420
##############################
1521
## Maven
1622
##############################

connectservice/.dockerignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules/
2+
docker-compose.yml
3+
test.http
4+
Readme.md

connectservice/Dockerfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
FROM node:18
2+
WORKDIR /app
3+
4+
COPY package*.json ./
5+
RUN npm install
6+
7+
COPY . .
8+
9+
EXPOSE 3000
10+
USER 1000
11+
CMD ["npm", "run", "start"]

connectservice/Readme.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Connect Service
2+
3+
Connect Service is a sample implementation of a web service which records usage
4+
of Mirth Server, and provides access to a notifications feed. The service implements
5+
the operations called by the client in `ConnectServiceUtil.java`.
6+
7+
## Running and testing
8+
9+
To start the example, run `docker-compose build` in this directory, followed by
10+
`docker-compose up -d`. You can use `http://localhost:3000` as `URL_CONNECT_SERVER`
11+
in `ConnectServiceUtil.java`, and see recorded data
12+
[in Kibana.](http://localhost:5601/app/management/data/index_management/indices/index_details?indexName=registration)
13+
14+
## Not fit for production use as-is
15+
16+
This example is purely for expository purposes only. Use as-is is not recommended.
17+
At a minimum, it would be neccesary to:
18+
19+
- Use a reverse proxy to encrypt in-transit data
20+
- Secure communication between the connectservice and elasticsearch
21+
- Require authentication for elasticsearch and kibana

connectservice/docker-compose.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: connectservice
2+
3+
volumes:
4+
connectservice_elastic_data: {}
5+
6+
services:
7+
elasticsearch:
8+
image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
9+
environment:
10+
- discovery.type=single-node
11+
# Don't do this in prod.
12+
- xpack.security.enabled=false
13+
ports:
14+
- "9200:9200"
15+
volumes:
16+
- connectservice_elastic_data:/usr/share/elasticsearch/data
17+
18+
connectservice:
19+
build:
20+
context: .
21+
dockerfile: Dockerfile
22+
environment:
23+
- ELASTICSEARCH_URL=http://elasticsearch:9200
24+
ports:
25+
- "3000:3000"
26+
depends_on:
27+
- elasticsearch
28+
29+
kibana:
30+
image: docker.elastic.co/kibana/kibana:9.0.1
31+
ports:
32+
- "5601:5601"
33+
environment:
34+
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
35+
depends_on:
36+
- elasticsearch

connectservice/elastic.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Client } from '@elastic/elasticsearch';
2+
3+
export const client = new Client({
4+
node: process.env.ELASTICSEARCH_URL || 'http://localhost:9200',
5+
auth: process.env.ELASTICSEARCH_USERNAME ? {
6+
username: process.env.ELASTICSEARCH_USERNAME,
7+
password: process.env.ELASTICSEARCH_PASSWORD || 'changeme',
8+
} : undefined,
9+
tls: {
10+
rejectUnauthorized: process.env.ELASTICSEARCH_IGNORETLSERRORS && !!JSON.parse(process.env.ELASTICSEARCH_IGNORETLSERRORS),
11+
},
12+
requestTimeout: process.env.ELASTICSEARCH_TIMEOUT ? JSON.parse(process.env.ELASTICSEARCH_TIMEOUT) : 30000,
13+
});

connectservice/github.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const REPO_URL = process.env.NOTIFICATIONS_URL ?? 'https://api.github.com/repos/OpenIntegrationEngine/engine/releases?per_page=10';
2+
3+
export async function getLatestReleases() {
4+
const response = await fetch(REPO_URL, {
5+
headers: {
6+
'Accept': 'application/vnd.github.html+json',
7+
'User-Agent': 'OIEConnectService',
8+
},
9+
});
10+
11+
if (!response.ok) {
12+
throw new Error(`Failed to fetch latest release: ${response.statusText}`);
13+
}
14+
15+
const releases = await response.json();
16+
return releases.map(release => ({
17+
id: release.id,
18+
name: release.name,
19+
body_html: release.body_html,
20+
published_at: release.published_at,
21+
}));
22+
}
23+
24+
/*
25+
interface Release {
26+
id: number;
27+
name: string;
28+
body_html: string;
29+
published_at: string;
30+
}
31+
*/

connectservice/index.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import express from 'express';
2+
import * as yup from 'yup';
3+
import { postRegistration, postUsage, registrationBodySchema, usageBodySchema } from './usage.js';
4+
import { postNotifications, notificationBodySchema } from './notifications.js';
5+
6+
const app = express();
7+
8+
app.use(express.urlencoded({ extended: true }));
9+
10+
app.post('/RegistrationServlet', [validateFormUrlEncoded, makeValidator(registrationBodySchema)], postRegistration);
11+
app.post('/UsageStatisticsServlet', [validateFormUrlEncoded, makeValidator(usageBodySchema)], postUsage);
12+
13+
app.post('/NotificationServlet', [validateFormUrlEncoded, makeValidator(notificationBodySchema)], postNotifications);
14+
15+
const port = process.env.PORT || 3000;
16+
app.listen(port, () =>
17+
console.log(`connectservice available from http://localhost:${port}`),
18+
);
19+
20+
function validateFormUrlEncoded(req, res, next) {
21+
const contentType = req.headers['content-type'];
22+
if (!contentType || !contentType.startsWith('application/x-www-form-urlencoded')) {
23+
return res.status(415).send('Unsupported content-type');
24+
}
25+
26+
next();
27+
}
28+
29+
function makeValidator(schema) {
30+
return async (req, res, next) => {
31+
try {
32+
// abortEarly: false collects all errors rather than stopping on the first
33+
await schema.validate(req.body, { abortEarly: false });
34+
next();
35+
} catch (error) {
36+
if (error instanceof yup.ValidationError) {
37+
return res.status(400).json({
38+
type: error.name,
39+
message: error.message,
40+
errors: error.inner.map(err => ({
41+
path: err.path,
42+
message: err.message,
43+
type: err.type,
44+
})),
45+
});
46+
}
47+
next(error);
48+
}
49+
};
50+
}

connectservice/notifications.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import * as yup from 'yup';
2+
import { getLatestReleases } from './github.js';
3+
4+
export async function postNotifications(req, res) {
5+
const notifications = await getLatestReleases();
6+
7+
if (req.body.op === 'getNotificationCount') {
8+
return res.status(200).json(notifications.map(rel => rel.id));
9+
} else if (req.body.op === 'getNotifications') {
10+
return res.status(200).json(notifications.map(rel => ({
11+
id: rel.id,
12+
name: rel.name,
13+
content: makeFullPageFromDiv(rel.body_html, rel.name),
14+
date: rel.published_at,
15+
})));
16+
} else {
17+
return res.status(400).json({
18+
error: 'Invalid operation',
19+
});
20+
}
21+
}
22+
23+
function makeFullPageFromDiv(div, name) {
24+
name = htmlEncode(name);
25+
return `<html>
26+
<head>
27+
<title>${name}</title>
28+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4Q6Gf2aSP4eDXB8Miphtr37CMZZQ5oXLH2yaXMJ2w8e2ZtHTl7GptT4jmndRuHDT" crossorigin="anonymous">
29+
<style>
30+
body {
31+
padding: 1em;
32+
}
33+
</style>
34+
</head>
35+
<body>
36+
<main>
37+
<h1>${name}</h1>
38+
<div>
39+
${div}
40+
</div>
41+
</main>
42+
</body>
43+
</html>`;
44+
}
45+
46+
function htmlEncode(text) {
47+
return text
48+
.replace(/&/g, '&amp;')
49+
.replace(/</g, '&lt;')
50+
.replace(/>/g, '&gt;')
51+
.replace(/"/g, '&quot;')
52+
.replace(/'/g, '&#39;');
53+
}
54+
55+
/*
56+
POST https://connect.mirthcorp.com/NotificationServlet HTTP/1.1
57+
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
58+
User-Agent: Apache-HttpClient/4.5.13 (Java/21.0.7)
59+
Host: connect.mirthcorp.com
60+
Content-Length: 2116
61+
62+
op=getNotificationCount&serverId=49885e13-4f2e-41a6-b66a-8e65f5492d9a&version=4.5.2&extensionVersions=url_encode_json
63+
*/
64+
export const notificationBodySchema = yup.object({
65+
op: yup.string().oneOf(['getNotificationCount', 'getNotifications']).required(),
66+
serverId: yup.string().required(),
67+
version: yup.string().required(),
68+
extensionVersions: yup.string().required(),
69+
}).required();
70+
71+
/*
72+
extensionVersions is a JSON object with the version of each extension:
73+
{
74+
"Server Log":"4.5.2",
75+
"File Writer":"4.5.2",
76+
...
77+
}
78+
*/

0 commit comments

Comments
 (0)