From c97b41a5b75af557d897067c124ab448b9230d4e Mon Sep 17 00:00:00 2001 From: Dixon Whitmire Date: Fri, 10 Jun 2022 13:18:53 -0400 Subject: [PATCH] 0.57.0 Release (#131) * Implement Fast API optional component (#117) * added Fast API optional component Signed-off-by: Dixon Whitmire * updating version number Signed-off-by: Dixon Whitmire * updating CI to test on Python 3.9 and 3.10 (#118) Signed-off-by: Dixon Whitmire * X12 Image Build (#121) * adding Dockerfile for image build Signed-off-by: Dixon Whitmire * updating GitHub workflows Signed-off-by: Dixon Whitmire * updating image ci to use build-args Signed-off-by: Dixon Whitmire * configuring PR based image CI to build a single image to reduce wait times Signed-off-by: Dixon Whitmire * updating image build ci Signed-off-by: Dixon Whitmire * removing Dockerfile path condition since it's not working Signed-off-by: Dixon Whitmire * Updated container support documentation (#127) Signed-off-by: Dixon Whitmire * update /x12 [POST] endpoint segment mode to return an equivalent response as the CLI in segment mode (#129) Signed-off-by: Dixon Whitmire * adding script to build and push multi-platform images (#130) Signed-off-by: Dixon Whitmire * removing tag version from GitHub actions Signed-off-by: Dixon Whitmire --- .dockerignore | 11 ++ .github/workflows/continuous-integration.yml | 4 +- .github/workflows/test-image-build.yml | 27 +++++ Dockerfile | 49 +++++++++ README.md | 24 ++++- demo-file/demo-single-line.270 | 1 + push-image.sh | 38 +++++++ repo-docs/CONTAINER_SUPPORT.md | 43 ++++++++ setup.cfg | 6 +- src/linuxforhealth/x12/__init__.py | 2 +- src/linuxforhealth/x12/api.py | 104 +++++++++++++++++++ src/linuxforhealth/x12/config.py | 23 ++++ src/tests/test_api.py | 96 +++++++++++++++++ 13 files changed, 421 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/test-image-build.yml create mode 100644 Dockerfile create mode 100644 demo-file/demo-single-line.270 create mode 100755 push-image.sh create mode 100644 repo-docs/CONTAINER_SUPPORT.md create mode 100644 src/linuxforhealth/x12/api.py create mode 100644 src/tests/test_api.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7ee2332 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.github +.idea +.vscode +**/.pytest_cache +**/linuxforhealth_x12.egg-info +demo-file +repo-docs +**/tests +venv +.gitignore +LICENSE diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 46750fa..7c94191 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9] + python-version: ["3.9.13", "3.10.5"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -22,7 +22,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip setuptools - pip install -e .[dev] + pip install -e .[dev,api] - name: Validate Formatting run: | black -t py38 --check --diff ./src diff --git a/.github/workflows/test-image-build.yml b/.github/workflows/test-image-build.yml new file mode 100644 index 0000000..5eed3d4 --- /dev/null +++ b/.github/workflows/test-image-build.yml @@ -0,0 +1,27 @@ +name: Test X12 Image Build + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-image: + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + - name: QEMU setup + uses: docker/setup-qemu-action@v2 + - name: Docker buildx setup + uses: docker/setup-buildx-action@v2 + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + build-args: | + X12_SEM_VER=0.57.0 + platforms: linux/amd64 + push: false + tags: ci-testing \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b1f44f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# Builds the LinuxForHealth X12 API container using a multi-stage build + +# build stage +FROM python:3.10-slim-buster AS builder + +# the full semantic version number, used to match to the generated wheel file in dist/ +ARG X12_SEM_VER + +# OS library updates and build tooling +RUN apt-get update +RUN apt-get install -y --no-install-recommends \ + build-essential \ + gcc + +# copy source and build files +WORKDIR /tmp/lfh-x12 +COPY setup.cfg . +COPY pyproject.toml . +COPY ../src src/ + +# build the service +RUN python -m venv /tmp/lfh-x12/venv +ENV PATH="/tmp/lfh-x12/venv/bin:$PATH" +RUN python -m pip install --upgrade pip setuptools wheel build +RUN python -m build +RUN python -m pip install dist/linuxforhealth_x12-"$X12_SEM_VER"-py3-none-any.whl[api] + +# main image +FROM python:3.10-slim-buster + +# container build arguments +# lfh user id and group ids +ARG LFH_USER_ID=1000 +ARG LFH_GROUP_ID=1000 + +# create service user and group +RUN groupadd -g $LFH_GROUP_ID lfh && \ + useradd -m -u $LFH_USER_ID -g lfh lfh +USER lfh +WORKDIR /home/lfh + +# configure and execute application +COPY --from=builder /tmp/lfh-x12/venv ./venv +# set venv executables first in path +ENV PATH="/home/lfh/venv/bin:$PATH" +# listening address for application +ENV X12_UVICORN_HOST=0.0.0.0 +EXPOSE 5000 +CMD ["python", "-m", "linuxforhealth.x12.api"] diff --git a/README.md b/README.md index 4ca067d..7d20290 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ git clone https://github.com/LinuxForHealth/x12 cd x12 python3 -m venv venv && source venv/bin/activate && pip install --upgrade pip setuptools -pip install -e .[dev] +pip install -e .[dev, api] # installs dev packages and optional API endpoint pytest ``` @@ -130,6 +130,27 @@ To parse a X12 message into models with pretty printing enabled In "model" mode, the `-x` option excludes `None` values from output. +### API +LinuxForHealth X12 includes an experimental "api" setup "extra" which activates a [Fast API](https://fastapi.tiangolo.com/) +endpoint used to submit X12 payloads. + +```shell +user@mbp x12 % source venv/bin/activate +(venv) user@mbp x12 % pip install -e ".[api]" +(venv) user@mbp x12 % lfhx12-api +``` +Browse to http://localhost:5000/docs to view the Open API UI. + +API server configurations are located in the [config module](./src/linuxforhealth/x12/config.py). The `X12ApiConfig` model +is a [Pydantic Settings Model](https://pydantic-docs.helpmanual.io/usage/settings/) which can be configured using environment +variables. + +```shell +user@mbp x12 % source venv/bin/activate +(venv) user@mbp x12 % export X12_UVICORN_PORT=5002 +(venv) user@mbp x12 % lfhx12-api +``` + ### Code Formatting LinuxForHealth X12 adheres to the [Black Code Style and Convention](https://black.readthedocs.io/en/stable/index.html) @@ -161,3 +182,4 @@ python3 -m build --no-isolation ## Additional Resources - [Design Overview](repo-docs/DESIGN.md) - [New Transaction Support](repo-docs/NEW_TRANSACTION.md) +- [Container Support](repo-docs/CONTAINER_SUPPORT.md) diff --git a/demo-file/demo-single-line.270 b/demo-file/demo-single-line.270 new file mode 100644 index 0000000..f2497db --- /dev/null +++ b/demo-file/demo-single-line.270 @@ -0,0 +1 @@ +ISA*03*9876543210*01*9876543210*30*000000005 *30*12345 *131031*1147*^*00501*000000907*1*T*:~GS*HS*000000005*54321*20131031*1147*1*X*005010X279A1~ST*270*1234*005010X279A1~BHT*0022*13*10001234*20060501*1319~HL*1**20*1~NM1*PR*2*ABC COMPANY*****PI*842610001~HL*2*1*21*1~NM1*1P*2*BONE AND JOINT CLINIC*****SV*2000035~HL*3*2*22*0~TRN*1*93175-012547*9877281234~NM1*IL*1*SMITH*ROBERT****MI*11122333301~DMG*D8*19430519~DTP*291*D8*20060501~EQ*30~SE*13*1234~GE*1*1~IEA*1*000000907~ \ No newline at end of file diff --git a/push-image.sh b/push-image.sh new file mode 100755 index 0000000..c691ce6 --- /dev/null +++ b/push-image.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# push-image.sh +# push-image.sh builds and pushes the multi-platform linuxforhealth/x12 image to an image repository. +# +# Pre-Requisites: +# - The script environment is authenticated to the target image repository. +# - The project has been built and a wheel exists in /dist. +# +# Usage: +# ./push-image [image_tag] [image_url] [platforms] +# +# Positional Script arguments: +# IMAGE_TAG - Aligns with the project's semantic version. Required. +# IMAGE_URL - The image's URL. Defaults to ghcr.io/linuxforhealth/x12. +# PLATFORMS - String containing the list of platforms. Defaults to linux/amd64,linux/arm64,linux/s390x. + +set -o errexit +set -o nounset +set -o pipefail + +if [[ $# == 0 ]] + then + echo "Missing required argument IMAGE_TAG" + echo "Usage: ./push-image.sh [image tag] [image url] [platforms]" + exit 1; +fi + +IMAGE_TAG=$1 +IMAGE_URL="${2:-ghcr.io/linuxforhealth/x12}" +PLATFORMS="${3:-linux/amd64,linux/arm64,linux/s390x}" + +docker buildx build \ + --pull \ + --push \ + --platform "$PLATFORMS" \ + --build-arg X12_SEM_VER="$IMAGE_TAG" \ + --tag "$IMAGE_URL":"$IMAGE_TAG" \ + --tag "$IMAGE_URL":latest . diff --git a/repo-docs/CONTAINER_SUPPORT.md b/repo-docs/CONTAINER_SUPPORT.md new file mode 100644 index 0000000..1fc4a9a --- /dev/null +++ b/repo-docs/CONTAINER_SUPPORT.md @@ -0,0 +1,43 @@ +# LinuxForHealth X12 Container Support + +The LinuxForHealth X12 API component supports a containerized execution environment. This guide provides an overview of +how to build and run the image. + +## Image Build + +### Supported Build Arguments + + +| Build Argument | Description | Default Value | +|----------------|------------------------------------------------|---------------| +| X12_SEM_VER | The current X12 library sematic version number | None | +| LFH_USER_ID | The user id used for the LFH container user | 1000 | +| LFH_GROUP_ID | The group id used for the LFH container group | 1000 | + +The `X12_SEM_VER`. This argument should align with the current `linuxforhealth.x12.__version__` attribute value and the +desired image tag. + +```shell +docker build --build-arg X12_SEM_VER=0.57.0 -t x12:0.57.0 . +``` + +## Run Container + +### Supported Environment Configurations + +| Build Argument | Description | Default Value | +|------------------|-----------------------------------|---------------| +| X12_UVICORN_HOST | The container's listening address | 0.0.0.0 | + + +The following command launches the LinuxForHealth X12 container: +```shell +docker run --name lfh-x12 --rm -d -p 5000:5000 ghcr.io/linuxforhealth/x12:latest +``` + +To access the Open API UI, browse to http://localhost:5000/docs + +Finally, to stop and remove the container: +```shell +docker stop lfh-x12 +``` diff --git a/setup.cfg b/setup.cfg index 8872cad..366bd08 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,7 +15,6 @@ classifiers = Intended Audience :: Developers [options] -include_package_data = True install_requires = pydantic >= 1.9 python-dotenv >= 0.19.0 @@ -32,7 +31,8 @@ where=src console_scripts = lfhx12 = linuxforhealth.x12.cli:main black = black:patched_main + lfhx12-api = linuxforhealth.x12.api:run_server [options.extras_require] -api = fastapi; uvicorn[standard] -dev = black>=21.8b0; pre-commit>=2.14.1;pytest>=6.2.5 \ No newline at end of file +api = fastapi>=0.78.0; uvicorn[standard]>=0.17.0; requests>=2.27.0 +dev = black>=22.3.0; pre-commit>=2.14.1;pytest>=7.1.0 \ No newline at end of file diff --git a/src/linuxforhealth/x12/__init__.py b/src/linuxforhealth/x12/__init__.py index a3dcdfb..5ac10f8 100644 --- a/src/linuxforhealth/x12/__init__.py +++ b/src/linuxforhealth/x12/__init__.py @@ -7,4 +7,4 @@ load_dotenv() -__version__ = "0.56.02" +__version__ = "0.57.0" diff --git a/src/linuxforhealth/x12/api.py b/src/linuxforhealth/x12/api.py new file mode 100644 index 0000000..94e3368 --- /dev/null +++ b/src/linuxforhealth/x12/api.py @@ -0,0 +1,104 @@ +from fastapi import FastAPI, Header, HTTPException, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder +import uvicorn +from typing import Dict, Optional, List +from linuxforhealth.x12.config import get_x12_api_config, X12ApiConfig +from linuxforhealth.x12.io import X12SegmentReader, X12ModelReader +from linuxforhealth.x12.parsing import X12ParseException +from pydantic import ValidationError, BaseModel, Field + +app = FastAPI() + + +@app.exception_handler(RequestValidationError) +async def request_validation_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=jsonable_encoder( + {"detail": "Invalid request. Expected {'x12': }"} + ), + ) + + +class X12Request(BaseModel): + """ + The X12 Request object + """ + + x12: str = Field(description="The X12 payload to process, conveyed as a string") + + class Config: + schema_extra = { + "example": { + "x12": "ISA*03*9876543210*01*9876543210*30*000000005 *30*12345 *131031*1147*^*00501*000000907*1*T*:~GS*HS*000000005*54321*20131031*1147*1*X*005010X279A1~ST*270*1234*005010X279A1~BHT*0022*13*10001234*20060501*1319~HL*1**20*1~NM1*PR*2*ABC COMPANY*****PI*842610001~HL*2*1*21*1~NM1*1P*2*BONE AND JOINT CLINIC*****SV*2000035~HL*3*2*22*0~TRN*1*93175-012547*9877281234~NM1*IL*1*SMITH*ROBERT****MI*11122333301~DMG*D8*19430519~DTP*291*D8*20060501~EQ*30~SE*13*1234~GE*1*1~IEA*1*000000907~", + } + } + + +@app.post("/x12") +async def post_x12( + x12_request: X12Request, + lfh_x12_response: Optional[str] = Header(default="models"), +) -> List[Dict]: + """ + Processes an incoming X12 payload. + + Requests are submitted as: + + { + "x12": + } + + The response payload is a JSON document containing either the "raw" X12 segments, or a rich + X12 domain model. The response type defaults to the domain model and is configured using the + LFH-X12-RESPONSE header. Valid values include: "segments" or "models". + + :param x12_request: The X12 request model/object. + :param lfh_x12_response: A header value used to drive processing. + + :return: The X12 response - List[List] (segments) or List[Dict] (models) + """ + if lfh_x12_response.lower() not in ("models", "segments"): + lfh_x12_response = "models" + + try: + if lfh_x12_response.lower() == "models": + with X12ModelReader(x12_request.x12) as r: + api_results = [m.dict() for m in r.models()] + else: + with X12SegmentReader(x12_request.x12) as r: + api_results = [] + for segment_name, segment in r.segments(): + segment_data = { + f"{segment_name}{str(i).zfill(2)}": v + for i, v in enumerate(segment) + } + api_results.append(segment_data) + + except (X12ParseException, ValidationError) as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid X12 payload. To troubleshoot please run the LFH X12 CLI", + ) + else: + return api_results + + +def run_server(): + """Launches the API server""" + config: X12ApiConfig = get_x12_api_config() + + uvicorn_params = { + "app": config.x12_uvicorn_app, + "host": config.x12_uvicorn_host, + "port": config.x12_uvicorn_port, + "reload": config.x12_uvicorn_reload, + } + + uvicorn.run(**uvicorn_params) + + +if __name__ == "__main__": + run_server() diff --git a/src/linuxforhealth/x12/config.py b/src/linuxforhealth/x12/config.py index d7c32ca..c7d65fe 100644 --- a/src/linuxforhealth/x12/config.py +++ b/src/linuxforhealth/x12/config.py @@ -63,3 +63,26 @@ class Config: def get_config() -> "X12Config": """Returns the X12Config""" return X12Config() + + +class X12ApiConfig(BaseSettings): + """ + Settings for optional Fast API "server" components. + """ + + x12_uvicorn_app: str = Field( + "linuxforhealth.x12.api:app", description="The path the ASGI app object" + ) + x12_uvicorn_host: str = Field( + "0.0.0.0", description="The ASGI listening address (host)" + ) + x12_uvicorn_port: int = Field(5000, description="The ASGI listening port (host)") + x12_uvicorn_reload: bool = Field( + False, description="Set to True to support hot reloads. Defaults to False" + ) + + +@lru_cache +def get_x12_api_config() -> "X12ApiConfig": + """Returns the X12ApiConfig""" + return X12ApiConfig() diff --git a/src/tests/test_api.py b/src/tests/test_api.py new file mode 100644 index 0000000..edb53dc --- /dev/null +++ b/src/tests/test_api.py @@ -0,0 +1,96 @@ +""" +test_api.py + +Tests the optional Fast API x12/endpoint +""" +import pytest +from linuxforhealth.x12.api import app +from typing import Dict + +# determine if we need to skip the API tests, based on the installation +try: + from fastapi.testclient import TestClient + + is_fastapi_disabled = False +except ImportError: + is_fastapi_disabled = True + + +@pytest.fixture() +def api_test_client() -> TestClient: + """Returns the API test client fixture""" + test_client = TestClient(app) + return test_client + + +@pytest.fixture +def mock_x12_payload(simple_270_with_new_lines: str) -> Dict: + x12_payload = {"x12": simple_270_with_new_lines} + return x12_payload + + +@pytest.mark.skipif(is_fastapi_disabled, reason="X12 API endpoint is not enabled") +@pytest.mark.parametrize( + "header, expected_first_value", + [ + (None, "header"), + ("models", "header"), + ("Models", "header"), + ("MODELS", "header"), + ], +) +def test_x12_ok_models(mock_x12_payload, api_test_client, header, expected_first_value): + """ + Tests the x12 models response with parameterized header and expected values. + :param mock_x12_payload: The X12 request payload for the test + :param api_test_client: The configured Fast API test client + :param header: The parameterized header value + :param expected_first_value: The parameterized first value + """ + api_response = api_test_client.post( + "/x12", json=mock_x12_payload, headers={"LFH-X12-RESPONSE": header} + ) + assert api_response.status_code == 200 + assert expected_first_value in api_response.json()[0] + + +@pytest.mark.skipif(is_fastapi_disabled, reason="X12 API endpoint is not enabled") +@pytest.mark.parametrize( + "header, expected_key", + [ + ("segments", "ISA01"), + ("Segments", "ISA01"), + ("SEGMENTS", "ISA01"), + ], +) +def test_x12_ok_segments(mock_x12_payload, api_test_client, header, expected_key): + """ + Tests the x12 segments response with parameterized header and expected values. + :param mock_x12_payload: The X12 request payload for the test + :param api_test_client: The configured Fast API test client + :param header: The parameterized header value + :param expected_key: The parameterized expected key + """ + api_response = api_test_client.post( + "/x12", json=mock_x12_payload, headers={"LFH-X12-RESPONSE": header} + ) + assert api_response.status_code == 200 + assert expected_key in api_response.json()[0].keys() + + +@pytest.mark.skipif(is_fastapi_disabled, reason="X12 API endpoint is not enabled") +def test_x12_invalid_request(api_test_client, mock_x12_payload): + """ + Validates that invalid + :param api_test_client: The configured Fast API test client + :param mock_x12_payload: The X12 request payload for the test + """ + api_response = api_test_client.post("/x12", json={"data": mock_x12_payload}) + assert api_response.status_code == 400 + + invalid_x12 = list(mock_x12_payload.values())[0].replace( + "NM1*IL*1*DOE*JOHN****MI*00000000001~", "NM1*FF*1*DOE*JOHN****MI*00000000001~" + ) + invalid_payload = {"x12": invalid_x12} + api_response = api_test_client.post("/x12", json=invalid_payload) + assert api_response.status_code == 400