├── .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 | [![PyPI](https://img.shields.io/pypi/v/mllp-http)](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 | --------------------------------------------------------------------------------