├── .gitignore
├── .prettierrc.yml
├── Dockerfile
├── LICENSE.txt
├── Makefile
├── README.md
├── doc
└── logo.png
├── mllp_http
├── __init__.py
├── http2mllp.py
├── main.py
├── mllp.py
├── mllp2http.py
├── net.py
└── version.py
├── package.json
├── setup.cfg
├── setup.py
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | *.egg-info/
2 | *.pyc
3 |
4 | node_modules/
5 |
6 | /build/
7 | /target/
8 |
--------------------------------------------------------------------------------
/.prettierrc.yml:
--------------------------------------------------------------------------------
1 | proseWrap: always
2 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.7-stretch AS build
2 |
3 | COPY . /tmp/mllp-http
4 |
5 | RUN pip install --no-cache-dir /tmp/mllp-http
6 |
7 | FROM gcr.io/distroless/python3-debian10
8 |
9 | ENV PYTHONPATH=/usr/local/lib/python3.7/site-packages
10 |
11 | RUN python -c "import os; os.makedirs('/usr/local/bin', exist_ok=True); os.symlink('/usr/bin/python', '/usr/local/bin/python')"
12 |
13 | COPY --from=build /usr/local/lib/python3.7/site-packages /usr/local/lib/python3.7/site-packages
14 |
15 | COPY --from=build /usr/local/bin/http2mllp /usr/local/bin/http2mllp
16 |
17 | COPY --from=build /usr/local/bin/mllp2http /usr/local/bin/mllp2http
18 |
19 | ENTRYPOINT [ ]
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright © 2019 Rivet Health, Inc.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | ###
2 | # Config
3 | ###
4 |
5 | JOBS ?= $(shell nproc)
6 | MAKEFLAGS += -j $(JOBS) -r
7 |
8 | PATH := $(abspath node_modules)/.bin:$(PATH)
9 |
10 | .DELETE_ON_ERROR:
11 | .SECONDARY:
12 | .SUFFIXES:
13 |
14 | LPAREN := (
15 | RPAREN := )
16 |
17 | ###
18 | # Clean
19 | ###
20 |
21 | TARGET := mllp_http.egg-info build target
22 |
23 | .PHONY: clean
24 | clean:
25 | rm -fr $(TARGET)
26 |
27 | ###
28 | # Format
29 | ###
30 | FORMAT_SRC := $(shell find . $(TARGET:%=-not \$(LPAREN) -name % -prune \$(RPAREN)) -name '*.py')
31 |
32 | .PHONY: format
33 | format: target/format.log
34 |
35 | .PHONY: test-format
36 | test-format: target/format-test.log
37 |
38 | target/format.log: $(FORMAT_SRC) target/node_modules.target
39 | black $(FORMAT_SRC)
40 | node_modules/.bin/prettier --write .
41 | mkdir -p $(@D)
42 | touch $@ target/format-test.log
43 |
44 | target/format-test.log: $(FORMAT_SRC)
45 | black --check $(FORMAT_SRC)
46 | mkdir -p $(@D)
47 | touch $@ target/format.log
48 |
49 | ###
50 | # Npm
51 | ###
52 | target/node_modules.target:
53 | yarn install
54 | > $@
55 |
56 | ###
57 | # Pip
58 | ###
59 | PY_SRC := $(shell find . $(TARGET:%=-not \$(LPAREN) -name % -prune \$(RPAREN)) -name '*.py')
60 |
61 | .PHONY: install
62 | install:
63 | pip3 install -e .
64 |
65 | .PHONY: package
66 | package: target/package.log
67 |
68 | upload: target/package-test.log
69 | python3 -m twine upload target/package/*
70 |
71 | target/package.log: setup.py README.md $(PY_SRC)
72 | rm -fr $(@:.log=)
73 | mkdir -p $(@:.log=)
74 | ./$< bdist_wheel -d $(@:.log=) sdist -d $(@:.log=)
75 | > $@
76 |
77 | target/package-test.log: target/package.log
78 | python3 -m twine check target/package/*
79 | mkdir -p $(@D)
80 | > $@
81 |
82 | ###
83 | # Docker
84 | ###
85 |
86 | .PHONY: docker
87 | docker:
88 | docker build -t rivethealth/mllp-http .
89 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MLLP/HTTP
2 |
3 | [](https://pypi.org/project/mllp-http/)
4 |
5 |
6 |
7 |
8 |
9 | ## Overview
10 |
11 | Convert MLLP to HTTP and vice versa.
12 |
13 | `http2mllp` is an HTTP server that translates to MLLP.
14 |
15 | `mllp2http` is an MLLP server that translates to HTTP.
16 |
17 | Keywords: MLLP, HTTP, HL7, HL7 over HTTP
18 |
19 | ## Description
20 |
21 | MLLP (Minimum Lower Layer Protocol) is the traditional session protocol for HL7
22 | messages.
23 |
24 | Many modern tools (load balancers, application frameworks, API monitoring) are
25 | designed around HTTP. This observation is the foundation for the
26 | [HL7 over HTTP](https://hapifhir.github.io/hapi-hl7v2/hapi-hl7overhttp/specification.html)
27 | specification.
28 |
29 | This project, MLLP/HTTP, bridges these two protocols, allowing network engineers
30 | and application developers to work with familiar HTTP technology while
31 | interfacing with MLLP-based programs.
32 |
33 | Implements
34 | [MLLP release 1](https://www.hl7.org/documentcenter/public/wg/inm/mllp_transport_specification.PDF)
35 | and [HTTP/1.1](https://tools.ietf.org/html/rfc2616). Each MLLP message is
36 | assumed to have a corresponding response message (e.g. HL7 acknoledgment).
37 |
38 | Note that this project deals only with the MLLP layer; it does not process HL7
39 | messages themselves. Notably, the HTTP participant must be able to intepret HL7
40 | messages and generate acknowledgements. This separation imposes no requirements
41 | for HL7 usage and leaves application developers with full access to the features
42 | of the HL7 protocol.
43 |
44 | ## Install
45 |
46 | ### [Pip](https://pypi.org/project/awscli-saml/)
47 |
48 | ```sh
49 | pip install mllp-http
50 | ```
51 |
52 | Run as
53 |
54 | ```sh
55 | http2mllp mllp://localhost:2575
56 |
57 | mllp2http http://localhost:8000
58 | ```
59 |
60 | ### [Docker](https://hub.docker.com/r/rivethealth/aws-saml)
61 |
62 | ```sh
63 | docker pull rivethealth/mllp-http
64 | ```
65 |
66 | Run as
67 |
68 | ```sh
69 | docker run -it -p 2575:2575 --rm rivethealth/mllp-http http2mllp mllp://localhost:2575
70 |
71 | docker run -it -p 2575:2575 --rm rivethealth/mllp-http mllp2http http://localhost:8000
72 | ```
73 |
74 | ## Usage
75 |
76 | ### http2mllp
77 |
78 | ```
79 | usage: http2mllp [-h] [-H HOST] [-p PORT] [--keep-alive KEEP_ALIVE] [--log-level {error,warn,info}] [--mllp-max-messages MLLP_MAX_MESSAGES] [--mllp-release {1}]
80 | [--timeout TIMEOUT] [-v]
81 | mllp_url
82 |
83 | HTTP server that proxies an MLLP server.
84 | Expects an MLLP response message and uses it as the HTTP response.
85 |
86 |
87 | positional arguments:
88 | mllp_url MLLP URL, e.g. mllp://hostname:port
89 |
90 | optional arguments:
91 | -h, --help show this help message and exit
92 | -H HOST, --host HOST HTTP host (default: 0.0.0.0)
93 | -p PORT, --port PORT HTTP port (default: 8000)
94 | --keep-alive KEEP_ALIVE
95 | keep-alive in milliseconds, or unlimited if -1. (default: 0)
96 | --log-level {error,warn,info}
97 | --mllp-max-messages MLLP_MAX_MESSAGES
98 | maximum number of messages per connection, or unlimited if -1. (default: -1)
99 | --mllp-release {1} MLLP release version (default: 1)
100 | --timeout TIMEOUT socket timeout, in milliseconds, or unlimited if 0. (default: 0)
101 | -v, --version show program's version number and exit
102 | ```
103 |
104 | ### mllp2http
105 |
106 | ```
107 | usage: mllp2http [-h] [-H HOST] [-p PORT] [--content-type CONTENT_TYPE] [--log-level {error,warn,info}] [--mllp-release {1}]
108 | [--timeout TIMEOUT] [-v]
109 | http_url
110 |
111 | MLLP server that proxies an HTTP server. Sends back the HTTP response.
112 |
113 | positional arguments:
114 | http_url HTTP URL
115 |
116 | optional arguments:
117 | -h, --help show this help message and exit
118 | -H HOST, --host HOST MLLP host (default: 0.0.0.0)
119 | -p PORT, --port PORT MLLP port (default: 2575)
120 | --content-type CONTENT_TYPE
121 | HTTP Content-Type header (default: x-application/hl7-v2+er7)
122 | --log-level {error,warn,info}
123 | --mllp-release {1} MLLP release version (default: 1)
124 | --timeout TIMEOUT timeout in milliseconds (default: 0)
125 | -v, --version show program's version number and exit
126 |
127 | environment variables:
128 | HTTP_AUTHORIZATION - HTTP Authorization header
129 | API_KEY - HTTP X-API-KEY header
130 | ```
131 |
132 | ## Examples
133 |
134 | ### mllp2http
135 |
136 | Run an HTTP debugging server:
137 |
138 | ```sh
139 | docker run -p 8000:80 --rm kennethreitz/httpbin
140 | ```
141 |
142 | Run the MLLP connector:
143 |
144 | ```sh
145 | mllp2http http://localhost:8000/post
146 | ```
147 |
148 | Send an MLLP message:
149 |
150 | ```sh
151 | printf '\x0bMESSAGE\x1c\x0d' | socat - TCP:localhost:2575
152 | ```
153 |
154 | and see the HTTP server's response (which describes the HTTP request that the
155 | connector made):
156 |
157 | ```json
158 | {
159 | "args": {},
160 | "data": "MESSAGE",
161 | "files": {},
162 | "form": {},
163 | "headers": {
164 | "Accept": "*/*",
165 | "Accept-Encoding": "gzip, deflate",
166 | "Connection": "keep-alive",
167 | "Content-Length": "7",
168 | "Content-Type": "x-application/hl7-v2+er7",
169 | "Forwarded": "by=127.0.0.1:2575;for=127.0.0.1:54572;proto=mllp",
170 | "Host": "localhost:8000",
171 | "User-Agent": "mllp2http/1.0.2"
172 | },
173 | "json": null,
174 | "origin": "127.0.0.1:54572",
175 | "url": "mllp://localhost:8000/post"
176 | }
177 | ```
178 |
179 | ## Developing
180 |
181 | To install:
182 |
183 | ```sh
184 | make install
185 | ```
186 |
187 | Before committing, format:
188 |
189 | ```sh
190 | make format
191 | ```
192 |
--------------------------------------------------------------------------------
/doc/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rivethealth/mllp-http/7f5a259c5a214f2255c450b6dd3cda31dbb44785/doc/logo.png
--------------------------------------------------------------------------------
/mllp_http/__init__.py:
--------------------------------------------------------------------------------
1 | from .version import __version__
2 |
--------------------------------------------------------------------------------
/mllp_http/http2mllp.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import http.server
3 | import logging
4 | import socket
5 | import threading
6 | import time
7 | from .mllp import read_mllp, write_mllp_socket
8 | from .net import read_real_socket_bytes
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 |
13 | class MllpClientOptions:
14 | def __init__(self, keep_alive, max_messages, timeout):
15 | self.keep_alive = keep_alive
16 | self.max_messages = max_messages
17 | self.timeout = timeout
18 |
19 |
20 | class MllpClient:
21 | def __init__(self, address, options):
22 | self.address = address
23 | self.options = options
24 | self.connections = []
25 | self.lock = threading.Lock()
26 |
27 | def _check_connection(self, connection):
28 | while not connection.closed:
29 | elapsed = (
30 | connection.last_update - time.monotonic()
31 | if connection.last_update is not None
32 | else 0
33 | )
34 | remaining = self.options.keep_alive + elapsed
35 | if 0 < remaining:
36 | time.sleep(remaining)
37 | else:
38 | try:
39 | with self.lock:
40 | self.connections.remove(connection)
41 | except ValueError:
42 | pass
43 | else:
44 | connection.close()
45 |
46 | def _connect(self):
47 | logger.info("connecting to " + self.address[0] + ":" + str(self.address[1]))
48 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
49 | if self.options.timeout:
50 | s.settimeout(self.options.timeout)
51 | s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 10)
52 | s.connect(self.address)
53 | connection = MllpConnection(s)
54 | if self.options.keep_alive is not None:
55 | thread = threading.Thread(
56 | daemon=False, target=self._check_connection, args=(connection,)
57 | )
58 | thread.start()
59 | return connection
60 |
61 | def send(self, data):
62 | with self.lock:
63 | try:
64 | connection = self.connections.pop()
65 | except IndexError:
66 | connection = None
67 | else:
68 | connection.last_update = None
69 | if connection is None:
70 | connection = self._connect()
71 | response = connection.send(data)
72 | if (
73 | self.options.max_messages > 0
74 | and self.options.max_messages <= connection.message_count
75 | ):
76 | connection.close()
77 | else:
78 | connection.last_update = time.monotonic()
79 | with self.lock:
80 | self.connections.append(connection)
81 | return response
82 |
83 |
84 | class MllpConnection:
85 | def __init__(self, socket):
86 | self.cancel = None
87 | self.closed = False
88 | self.socket = socket
89 | self.responses = read_mllp(read_real_socket_bytes(self.socket))
90 | self.message_count = 0
91 | self.last_update = time.monotonic()
92 |
93 | def close(self):
94 | self.closed = True
95 | self.socket.close()
96 |
97 | def send(self, data):
98 | write_mllp_socket(self.socket, data)
99 | # self.socket.flush()
100 | self.message_count += 1
101 | return next(self.responses)
102 |
103 |
104 | class HttpServerOptions:
105 | def __init__(self, timeout):
106 | self.timeout = timeout
107 |
108 |
109 | class HttpHandler(http.server.BaseHTTPRequestHandler):
110 | def __init__(self, request, client_address, server, mllp_client):
111 | self.mllp_client = mllp_client
112 | super().__init__(request, client_address, server)
113 |
114 | def do_POST(self):
115 | content_length = int(self.headers["Content-Length"])
116 | data = self.rfile.read(content_length)
117 | logger.info("Message: %s bytes", len(data))
118 | response = self.mllp_client.send(data)
119 | logger.info("Response: %s bytes", len(response))
120 | self.send_response(201)
121 | self.send_header("Content-Length", len(response))
122 | self.end_headers()
123 | self.wfile.write(response)
124 |
125 |
126 | def serve(address, options, mllp_address, mllp_options):
127 | client = MllpClient(mllp_address, mllp_options)
128 | handler = functools.partial(
129 | HttpHandler,
130 | mllp_client=client,
131 | )
132 |
133 | server = http.server.ThreadingHTTPServer(address, handler)
134 | server.protocol_version = "HTTP/1.1"
135 | logger.info("HTTP server on %s:%s", address[0], address[1])
136 | server.serve_forever()
137 |
--------------------------------------------------------------------------------
/mllp_http/main.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import logging
3 | import urllib.parse
4 | from .version import __version__
5 |
6 |
7 | class ArgumentFormatter(
8 | argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter
9 | ):
10 | pass
11 |
12 |
13 | def log_level(arg):
14 | if arg == "error":
15 | return logging.ERROR
16 | elif arg == "warn":
17 | return logging.WARNING
18 | elif arg == "info":
19 | return logging.INFO
20 |
21 |
22 | def url_type(arg):
23 | return urllib.parse.urlparse(arg)
24 |
25 |
26 | def http2mllp():
27 | parser = argparse.ArgumentParser(
28 | "http2mllp",
29 | description="""
30 | HTTP server that proxies an MLLP server.
31 | Expects an MLLP response message and uses it as the HTTP response.
32 | """,
33 | formatter_class=ArgumentFormatter,
34 | )
35 | parser.add_argument(
36 | "-H",
37 | "--host",
38 | default="0.0.0.0",
39 | help="HTTP host",
40 | )
41 | parser.add_argument(
42 | "-p",
43 | "--port",
44 | default=8000,
45 | type=int,
46 | help="HTTP port",
47 | )
48 | parser.add_argument(
49 | "--keep-alive",
50 | type=int,
51 | default=0,
52 | help="keep-alive in milliseconds, or unlimited if -1.",
53 | )
54 | parser.add_argument(
55 | "--log-level",
56 | choices=("error", "warn", "info"),
57 | default="info",
58 | )
59 | parser.add_argument(
60 | "--mllp_keep_alive",
61 | type=int,
62 | default=10 * 1000,
63 | )
64 | parser.add_argument(
65 | "--mllp_max_messages",
66 | type=int,
67 | default=-1,
68 | help="maximum number of messages per connection, or unlimited if -1.",
69 | )
70 | parser.add_argument(
71 | "--mllp_release",
72 | default="1",
73 | choices=("1"),
74 | help="MLLP release version",
75 | )
76 | parser.add_argument(
77 | "--timeout",
78 | default=0,
79 | type=float,
80 | help="socket timeout, in milliseconds, or unlimited if 0.",
81 | )
82 | parser.add_argument(
83 | "-v", "--version", action="version", version="%(prog)s {}".format(__version__)
84 | )
85 | parser.add_argument(
86 | "mllp_url", type=url_type, help="MLLP URL, e.g. mllp://hostname:port"
87 | )
88 | args = parser.parse_args()
89 |
90 | import mllp_http.http2mllp
91 |
92 | logging.basicConfig(
93 | format="%(asctime)s [%(levelname)s] %(name)s %(message)s",
94 | level=log_level(args.log_level),
95 | )
96 |
97 | http_server_options = mllp_http.http2mllp.HttpServerOptions(
98 | timeout=args.timeout / 1000
99 | )
100 | mllp_client_options = mllp_http.http2mllp.MllpClientOptions(
101 | keep_alive=args.mllp_keep_alive / 1000,
102 | max_messages=args.mllp_max_messages,
103 | timeout=args.timeout / 100,
104 | )
105 | try:
106 | mllp_http.http2mllp.serve(
107 | address=(
108 | args.host,
109 | args.port,
110 | ),
111 | options=http_server_options,
112 | mllp_address=(args.mllp_url.hostname, args.mllp_url.port),
113 | mllp_options=mllp_client_options,
114 | )
115 | except KeyboardInterrupt:
116 | pass
117 |
118 |
119 | def mllp2http():
120 | parser = argparse.ArgumentParser(
121 | "mllp2http",
122 | description="MLLP server that proxies an HTTP server. Sends back the HTTP response.",
123 | formatter_class=ArgumentFormatter,
124 | epilog="""
125 | environment variables:
126 | HTTP_AUTHORIZATION - HTTP Authorization header
127 | X-API-KEY - HTTP X-API-KEY header
128 | """,
129 | )
130 | parser.add_argument(
131 | "-H",
132 | "--host",
133 | default="0.0.0.0",
134 | help="MLLP host",
135 | )
136 | parser.add_argument(
137 | "-p",
138 | "--port",
139 | default=2575,
140 | type=int,
141 | help="MLLP port",
142 | )
143 | parser.add_argument(
144 | "--content-type",
145 | default="x-application/hl7-v2+er7",
146 | help="HTTP Content-Type header",
147 | )
148 | parser.add_argument(
149 | "--log-level",
150 | choices=("error", "warn", "info"),
151 | default="info",
152 | )
153 | parser.add_argument(
154 | "--mllp-release",
155 | default="1",
156 | choices=("1"),
157 | help="MLLP release version",
158 | )
159 | parser.add_argument(
160 | "--timeout",
161 | default=0,
162 | type=float,
163 | help="timeout in milliseconds",
164 | )
165 | parser.add_argument(
166 | "-v", "--version", action="version", version="%(prog)s {}".format(__version__)
167 | )
168 | parser.add_argument("http_url", help="HTTP URL", type=url_type)
169 | args = parser.parse_args()
170 |
171 | import mllp_http.mllp2http
172 |
173 | logging.basicConfig(
174 | format="%(asctime)s [%(levelname)s] %(name)s %(message)s",
175 | level=log_level(args.log_level),
176 | )
177 |
178 | http_client_options = mllp_http.mllp2http.HttpClientOptions(
179 | content_type=args.content_type,
180 | timeout=args.timeout if args.timeout else None,
181 | )
182 | mllp_server_options = mllp_http.mllp2http.MllpServerOptions(
183 | timeout=args.timeout / 1000 if args.timeout else None
184 | )
185 |
186 | try:
187 | mllp_http.mllp2http.serve(
188 | address=(args.host, args.port),
189 | http_options=http_client_options,
190 | http_url=args.http_url,
191 | options=mllp_server_options,
192 | )
193 | except KeyboardInterrupt:
194 | pass
195 |
--------------------------------------------------------------------------------
/mllp_http/mllp.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import logging
3 |
4 |
5 | class Format:
6 | START_BLOCK = 0x0B
7 | END_BLOCK = 0x1C
8 | CARRIAGE_RETURN = 0x0D
9 |
10 |
11 | class State:
12 | AFTER_BLOCK = 0
13 | BEFORE_BLOCK = 1
14 | BLOCK = 2
15 |
16 |
17 | def to_hex(byte):
18 | return "EOF" if byte is None else hex(byte)
19 |
20 |
21 | def read_mllp(it):
22 | logger = logging.getLogger("mllp.parse")
23 |
24 | content = None
25 | state = State.BEFORE_BLOCK
26 | byte = None
27 | i = -1
28 |
29 | def advance():
30 | nonlocal byte
31 | nonlocal i
32 | byte = next(it, None)
33 | i += 1
34 |
35 | advance()
36 | while True:
37 | if state == State.AFTER_BLOCK:
38 | if byte == Format.CARRIAGE_RETURN:
39 | state = State.BEFORE_BLOCK
40 | advance()
41 | else:
42 | logger.error(
43 | "Expected %s instead of %s (byte:%s)",
44 | to_hex(Format.CARRIAGE_RETURN),
45 | to_hex(byte),
46 | i,
47 | )
48 | break
49 | elif state == State.BEFORE_BLOCK:
50 | if byte is None:
51 | break
52 | if byte == Format.START_BLOCK:
53 | content = bytearray()
54 | state = State.BLOCK
55 | advance()
56 | else:
57 | logger.error(
58 | "Expected %s instead of %s (byte:%s)",
59 | to_hex(Format.START_BLOCK),
60 | to_hex(byte),
61 | i,
62 | )
63 | break
64 | elif state == State.BLOCK:
65 | if byte == Format.START_BLOCK:
66 | logger.error(
67 | "Expected content instead of %s (byte:%s)",
68 | to_hex(Format.CARRIAGE_RETURN),
69 | to_hex(byte),
70 | i,
71 | )
72 | break
73 | elif byte == Format.END_BLOCK:
74 | yield bytes(content)
75 | content = None
76 | state = State.AFTER_BLOCK
77 | advance()
78 | else:
79 | content.append(byte)
80 | advance()
81 |
82 |
83 | def write_mllp(wfile, content):
84 | wfile.write(bytes([Format.START_BLOCK]))
85 | wfile.write(content)
86 | wfile.write(bytes([Format.END_BLOCK, Format.CARRIAGE_RETURN]))
87 |
88 |
89 | def write_mllp_socket(socket, content):
90 | socket.sendall(
91 | bytes([Format.START_BLOCK])
92 | + content
93 | + bytes([Format.END_BLOCK, Format.CARRIAGE_RETURN])
94 | )
95 |
--------------------------------------------------------------------------------
/mllp_http/mllp2http.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import logging
3 | import os
4 | import requests
5 | import socket
6 | import socketserver
7 | import urllib
8 | from .mllp import read_mllp, write_mllp
9 | from .net import read_socket_bytes
10 | from .version import __version__
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | def display_address(address):
16 | return f"{address[0]}:{address[1]}"
17 |
18 |
19 | class MllpServerOptions:
20 | def __init__(self, timeout):
21 | self.timeout = timeout
22 |
23 |
24 | class MllpHandler(socketserver.StreamRequestHandler):
25 | def __init__(self, request, address, server, timeout, http_url, http_options):
26 | self.http_url = http_url
27 | self.http_options = http_options
28 | self.timeout = timeout
29 | super().__init__(request, address, server)
30 |
31 | def handle(self):
32 | if self.timeout:
33 | self.request.settimeout(self.timeout)
34 | self.request.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 10)
35 | session = requests.Session()
36 | local_address = self.request.getsockname()
37 | remote_address = self.request.getpeername()
38 |
39 | stream = read_socket_bytes(self.rfile)
40 |
41 | try:
42 | for message in read_mllp(stream):
43 | try:
44 | logger.info("Message: %s bytes", len(message))
45 | headers = {
46 | "Forwarded": f"by={display_address(local_address)};for={display_address(remote_address)};proto=mllp",
47 | "User-Agent": f"mllp2http/{__version__}",
48 | "X-Forwarded-For": display_address(remote_address),
49 | "X-Forwarded-Proto": "mllp",
50 | }
51 |
52 | if os.environ.get("HTTP_AUTHORIZATION"):
53 | headers["Authorization"] = os.environ["HTTP_AUTHORIZATION"]
54 | if os.environ.get("API_KEY"):
55 | headers["X-API-KEY"] = os.environ["API_KEY"]
56 |
57 | if self.http_options.content_type is not None:
58 | headers["Content-Type"] = self.http_options.content_type
59 | response = session.post(
60 | urllib.parse.urlunparse(self.http_url),
61 | data=message,
62 | headers=headers,
63 | timeout=self.http_options.timeout,
64 | )
65 | response.raise_for_status()
66 | except requests.exceptions.HTTPError as e:
67 | logger.error("HTTP response error: %s", e.response.status_code)
68 | break
69 | except Exception as e:
70 | logger.error("HTTP connection error: %s", e)
71 | break
72 | else:
73 | content = response.content
74 | logger.info("Response: %s bytes", len(content))
75 | write_mllp(self.wfile, content)
76 | self.wfile.flush()
77 | except Exception as e:
78 | logger.error("Failed read MLLP message: %s", e)
79 |
80 |
81 | class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
82 | allow_reuse_address = True
83 |
84 |
85 | class HttpClientOptions:
86 | def __init__(self, content_type, timeout):
87 | self.content_type = content_type
88 | self.timeout = timeout
89 |
90 |
91 | def serve(address, options, http_url, http_options):
92 | logger = logging.getLogger(__name__)
93 |
94 | handler = functools.partial(
95 | MllpHandler,
96 | http_url=http_url,
97 | http_options=http_options,
98 | timeout=options.timeout or None,
99 | )
100 |
101 | server = ThreadedTCPServer(address, handler)
102 | logger.info("Listening on %s:%s", address[0], address[1])
103 | server.serve_forever()
104 |
--------------------------------------------------------------------------------
/mllp_http/net.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import socket
3 |
4 |
5 | def read_socket_bytes(s):
6 | try:
7 | for b in iter(functools.partial(s.read, 1), b""):
8 | yield ord(b)
9 | except socket.timeout:
10 | pass
11 |
12 |
13 | def read_real_socket_bytes(s):
14 | try:
15 | for b in iter(functools.partial(s.recv, 1), b""):
16 | yield ord(b)
17 | except socket.timeout:
18 | pass
19 |
--------------------------------------------------------------------------------
/mllp_http/version.py:
--------------------------------------------------------------------------------
1 | __version__ = "1.1.4"
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "prettier": "~2.2.0"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [bdist_wheel]
2 | universal=1
3 |
4 | [metadata]
5 | license_files = LICENSE.txt
6 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import os
3 | import setuptools
4 |
5 | version = {}
6 | with open("mllp_http/version.py", "r") as f:
7 | exec(f.read(), version)
8 |
9 | with open("README.md", "r") as f:
10 | long_description = f.read()
11 |
12 | setuptools.setup(
13 | author="Rivet Health",
14 | author_email="ops@rivethealth.com",
15 | classifiers=[
16 | "License :: OSI Approved :: MIT License",
17 | "Operating System :: OS Independent",
18 | "Programming Language :: Python",
19 | "Programming Language :: Python :: 3",
20 | ],
21 | description="Translate between MLLP and HTTP",
22 | entry_points={
23 | "console_scripts": [
24 | "mllp2http=mllp_http.main:mllp2http",
25 | "http2mllp=mllp_http.main:http2mllp",
26 | ]
27 | },
28 | long_description=long_description,
29 | long_description_content_type="text/markdown",
30 | install_requires=["requests"],
31 | name="mllp-http",
32 | packages=setuptools.find_packages(),
33 | project_urls={
34 | "Issues": "https://github.com/rivethealth/mllp-http/issues",
35 | },
36 | url="https://github.com/rivethealth/mllp-http",
37 | version=version["__version__"],
38 | )
39 |
--------------------------------------------------------------------------------
/yarn.lock:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 | prettier@~2.2.0:
6 | version "2.2.1"
7 | resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5"
8 | integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==
9 |
--------------------------------------------------------------------------------