├── .env.tpl ├── .github └── workflows │ ├── build.yaml │ └── publish.yaml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE.md ├── MANIFEST.in ├── Makefile ├── Pipfile ├── Pipfile.lock ├── README.md ├── activate_settings.sh ├── aidbox_python_sdk ├── __init__.py ├── aidboxpy.py ├── app_keys.py ├── db.py ├── db_migrations.py ├── exceptions.py ├── handlers.py ├── main.py ├── py.typed ├── pytest_plugin.py ├── sdk.py ├── settings.py └── types.py ├── config ├── config.edn ├── genkey.sh ├── jwtRS256.key └── jwtRS256.key.pub ├── docker-compose.yaml ├── env_tests ├── envs ├── aidbox ├── backend └── db ├── example ├── .env.example ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── aidbox-project │ ├── .gitignore │ ├── Dockerfile │ ├── resources │ │ └── seeds │ │ │ └── .gitkeep │ └── zenproject │ │ ├── zen-package.edn │ │ └── zrc │ │ └── main.edn ├── backend │ ├── .dockerignore │ ├── .env.tests.local.example │ ├── .gitignore │ ├── Dockerfile │ ├── Makefile │ ├── README.md │ ├── app │ │ ├── __init__.py │ │ ├── app_keys.py │ │ ├── config.py │ │ ├── operations │ │ │ ├── __init__.py │ │ │ └── example.py │ │ ├── sdk.py │ │ └── version.py │ ├── compose.test-env.yaml │ ├── conftest.py │ ├── contrib │ │ ├── __init__.py │ │ └── fhirpy.py │ ├── env_tests │ │ ├── aidbox │ │ ├── backend │ │ └── db │ ├── main.py │ ├── poetry.lock │ ├── pyproject.toml │ ├── run_test.sh │ ├── tests │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_db_isolation.py │ │ └── test_example.py │ ├── update-commit-hash.sh │ └── update-version.sh ├── compose.yaml └── env │ ├── aidbox │ ├── backend │ └── db ├── main.py ├── pyproject.toml ├── run_test.sh ├── tests ├── __init__.py ├── conftest.py └── test_sdk.py └── wait-for-it.sh /.env.tpl: -------------------------------------------------------------------------------- 1 | PYTHON=3.11 2 | AIDBOX_LICENSE= 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Aidbox Python SDK 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | include: 13 | - python-version: "3.9" 14 | - python-version: "3.10" 15 | - python-version: "3.11" 16 | - python-version: "3.12" 17 | 18 | env: 19 | PYTHON: ${{ matrix.python-version }} 20 | AIDBOX_LICENSE: ${{ secrets.AIDBOX_LICENSE}} 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Upgrade pip 28 | run: | 29 | python -m pip install --upgrade pip 30 | - name: Run tests 31 | run: ./run_test.sh 32 | shell: bash 33 | - name: Show logs 34 | if: ${{ failure() }} 35 | run: docker-compose logs 36 | # - name: Upload coverage to Codecov 37 | # uses: codecov/codecov-action@v3 38 | # with: 39 | # token: ${{ secrets.CODECOV_TOKEN }} 40 | # env_vars: PYTHON 41 | # fail_ci_if_error: true 42 | # files: ./coverage.xml 43 | # flags: unittests 44 | # name: codecov-umbrella 45 | # verbose: true 46 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to https://pypi.org/ 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | packages: write 12 | contents: read 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python 3.11 16 | uses: actions/setup-python@v3 17 | with: 18 | python-version: "3.11" 19 | - name: Install wheel and build 20 | run: pip install wheel build 21 | - name: Build a binary wheel and a source tarball 22 | run: python -m build 23 | - name: Publish package 24 | uses: pypa/gh-action-pypi-publish@release/v1 25 | with: 26 | user: __token__ 27 | password: ${{ secrets.PYPI_API_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env-* 3 | .idea 4 | pgdata 5 | config 6 | __pycache__ 7 | venv 8 | .DS_Store 9 | aidbox_python_sdk.egg-info/ 10 | .python-version 11 | .vscode 12 | dist 13 | .history 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.15 2 | 3 | - Add compliance_params (fhirCore, fhirUrl, fhirResource) as operation parameter 4 | 5 | ## 0.1.14 6 | 7 | - Fix db innitialization crash for fhirschema 8 | - Update aiohttp = "~=3.10.2" 9 | 10 | ## 0.1.13 11 | 12 | - Add py.typed marker 13 | 14 | ## 0.1.12 15 | 16 | - Update drop_before_all to support fhir-schema and old approach #68 17 | 18 | ## 0.1.11 19 | 20 | - Add `headers` to SDKOperationRequest 21 | 22 | ## 0.1.10 23 | 24 | - Improve exposed types.py 25 | - Use web.AppKey for client/db/sdk/settings app keys (backward compatible with str key) 26 | 27 | ## 0.1.9 28 | 29 | - Drop support of python3.8 30 | - Lint project source code 31 | 32 | ## 0.1.8 33 | 34 | - Move db proxy initialization after app registration 35 | 36 | ## 0.1.7 37 | 38 | - Fix sqlalchemy tables recreation (useful in tests) 39 | 40 | ## 0.1.6 41 | 42 | - Revert subscriptions `request` arg changes that made in 0.1.5 43 | 44 | ## 0.1.5 45 | 46 | - Add aidbox_db fixture 47 | - Adjust subscriptions to have `app` key in `request` dict 48 | 49 | ## 0.1.4 50 | 51 | - Optimize app registration (get rid of manifest conversion) 52 | 53 | ## 0.1.3 54 | 55 | - Initial pypi release 56 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION 2 | FROM python:$PYTHON_VERSION 3 | RUN pip install pipenv 4 | 5 | RUN mkdir /app 6 | WORKDIR /app 7 | 8 | COPY Pipfile Pipfile.lock ./ 9 | RUN pipenv install --dev --skip-lock --python $PYTHON_VERSION 10 | 11 | RUN pipenv check 12 | COPY . . 13 | 14 | EXPOSE 8081 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ilya Beda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.md 3 | recursive-include aidbox_python_sdk * 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | pytest --cov=app 4 | 5 | .PHONY: testcov 6 | testcov: 7 | pytest --cov=app && (echo "building coverage html, view at './htmlcov/index.html'"; coverage html) 8 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | aiohttp = "~=3.10.2" 8 | sqlalchemy = "~=2.0.5" 9 | fhirpy = "~=1.3.0" 10 | jsonschema = "~=4.17.3" 11 | 12 | [dev-packages] 13 | pytest-aiohttp = "~=1.0.4" 14 | pytest-cov = "~=4.0.0" 15 | pytest-asyncio = "~=0.20.2" 16 | pytest = "~=7.2.0" 17 | black = "~=22.12.0" 18 | autohooks = "~=23.10.0" 19 | autohooks-plugin-ruff = "~=23.11.0" 20 | autohooks-plugin-black = "~=23.7.0" 21 | coloredlogs = "*" 22 | ruff = "*" 23 | exceptiongroup = "~=1.2.1" 24 | tomli = "~=2.0.1" 25 | async-timeout = "~=4.0.3" 26 | fhirpathpy = "==2.0.0" 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build status](https://github.com/Aidbox/aidbox-python-sdk/actions/workflows/build.yaml/badge.svg)](https://github.com/Aidbox/aidbox-python-sdk/actions/workflows/build.yaml) 2 | [![pypi](https://img.shields.io/pypi/v/aidbox-python-sdk.svg)](https://pypi.org/project/aidbox-python-sdk/) 3 | [![Supported Python version](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/release/python-390/) 4 | 5 | # aidbox-python-sdk 6 | 7 | 1. Create a python 3.9+ environment `pyenv ` 8 | 2. Set env variables and activate virtual environment `source activate_settings.sh` 9 | 2. Install the required packages with `pipenv install --dev` 10 | 3. Make sure the app's settings are configured correctly (see `activate_settings.sh` and `aidbox_python_sdk/settings.py`). You can also 11 | use environment variables to define sensitive settings, eg. DB connection variables (see example `.env-ptl`) 12 | 4. You can then run example with `python example.py`. 13 | 14 | # Getting started 15 | 16 | ## Minimal application 17 | 18 | main.py 19 | ```python 20 | from aidbox_python_sdk.main import create_app as _create_app 21 | from aidbox_python_sdk.settings import Settings 22 | from aidbox_python_sdk.sdk import SDK 23 | 24 | 25 | settings = Settings(**{}) 26 | sdk = SDK(settings, resources={}, seeds={}) 27 | 28 | 29 | def create_app(): 30 | app = await _create_app(SDK) 31 | return app 32 | 33 | 34 | async def create_gunicorn_app() -> web.Application: 35 | return create_app() 36 | ``` 37 | 38 | ## Register handler for operation 39 | ```python 40 | import logging 41 | from aiohttp import web 42 | from aidbox_python_sdk.types import SDKOperation, SDKOperationRequest 43 | 44 | from yourappfolder import sdk 45 | 46 | 47 | @sdk.operation( 48 | methods=["POST", "PATCH"], 49 | path=["signup", "register", {"name": "date"}, {"name": "test"}], 50 | timeout=60000 ## Optional parameter to set a custom timeout for operation in milliseconds 51 | ) 52 | def signup_register_op(_operation: SDKOperation, request: SDKOperationRequest): 53 | """ 54 | POST /signup/register/21.02.19/testvalue 55 | PATCH /signup/register/22.02.19/patchtestvalue 56 | """ 57 | logging.debug("`signup_register_op` operation handler") 58 | logging.debug("Operation data: %s", operation) 59 | logging.debug("Request: %s", request) 60 | return web.json_response({"success": "Ok", "request": request["route-params"]}) 61 | 62 | ``` 63 | 64 | ## Usage of AppKeys 65 | 66 | To access Aidbox Client, SDK, settings, DB Proxy the `app` (`web.Application`) is extended by default with the following app keys that are defined in `aidbox_python_sdk.app_keys` module: 67 | 68 | ```python 69 | from aidbox_python_sdk import app_keys as ak 70 | from aidbox_python_sdk.types import SDKOperation, SDKOperationRequest 71 | 72 | @sdk.operation(["POST"], ["example"]) 73 | async def update_organization_op(_operation: SDKOperation, request: SDKOperationRequest): 74 | app = request.app 75 | client = app[ak.client] # AsyncAidboxClient 76 | sdk = app[ak.sdk] # SDK 77 | settings = app[ak.settings] # Settings 78 | db = app[ak.db] # DBProxy 79 | return web.json_response() 80 | ``` 81 | 82 | ## Usage of FHIR Client 83 | 84 | FHIR Client is not plugged in by default, however, to use it you can extend the app by adding new AppKey 85 | 86 | app/app_keys.py 87 | ```python 88 | from fhirpy import AsyncFHIRClient 89 | 90 | fhir_client: web.AppKey[AsyncFHIRClient] = web.AppKey("fhir_client", AsyncFHIRClient) 91 | ``` 92 | 93 | main.py 94 | ```python 95 | from collections.abc import AsyncGenerator 96 | 97 | from aidbox_python_sdk.main import create_app as _create_app 98 | from aidbox_python_sdk.settings import Settings 99 | from aidbox_python_sdk.sdk import SDK 100 | from aiohttp import BasicAuth, web 101 | from fhirpy import AsyncFHIRClient 102 | 103 | from app import app_keys as ak 104 | 105 | settings = Settings(**{}) 106 | sdk = SDK(settings, resources={}, seeds={) 107 | 108 | def create_app(): 109 | app = await _create_app(SDK) 110 | app.cleanup_ctx.append(fhir_clients_ctx) 111 | return app 112 | 113 | 114 | async def create_gunicorn_app() -> web.Application: 115 | return create_app() 116 | 117 | 118 | async def fhir_clients_ctx(app: web.Application) -> AsyncGenerator[None, None]: 119 | app[ak.fhir_client] = await init_fhir_client(app[ak.settings], "/fhir") 120 | 121 | yield 122 | 123 | 124 | async def init_fhir_client(settings: Settings, prefix: str = "") -> AsyncFHIRClient: 125 | basic_auth = BasicAuth( 126 | login=settings.APP_INIT_CLIENT_ID, 127 | password=settings.APP_INIT_CLIENT_SECRET, 128 | ) 129 | 130 | return AsyncFHIRClient( 131 | f"{settings.APP_INIT_URL}{prefix}", 132 | authorization=basic_auth.encode(), 133 | dump_resource=lambda x: x.model_dump(), 134 | ) 135 | ``` 136 | 137 | After that, you can use `app[ak.fhir_client]` that has the type `AsyncFHIRClient` everywhere where the app is available. 138 | 139 | 140 | ## Usage of request_schema 141 | ```python 142 | from aidbox_python_sdk.types import SDKOperation, SDKOperationRequest 143 | 144 | schema = { 145 | "required": ["params", "resource"], 146 | "properties": { 147 | "params": { 148 | "type": "object", 149 | "required": ["abc", "location"], 150 | "properties": {"abc": {"type": "string"}, "location": {"type": "string"}}, 151 | "additionalProperties": False, 152 | }, 153 | "resource": { 154 | "type": "object", 155 | "required": ["organizationType", "employeesCount"], 156 | "properties": { 157 | "organizationType": {"type": "string", "enum": ["profit", "non-profit"]}, 158 | "employeesCount": {"type": "number"}, 159 | }, 160 | "additionalProperties": False, 161 | }, 162 | }, 163 | } 164 | 165 | 166 | @sdk.operation(["POST"], ["Organization", {"name": "id"}, "$update"], request_schema=schema) 167 | async def update_organization_op(_operation: SDKOperation, request: SDKOperationRequest): 168 | location = request["params"]["location"] 169 | return web.json_response({"location": location}) 170 | ``` 171 | 172 | ### Valid request example 173 | ```shell 174 | POST /Organization/org-1/$update?abc=xyz&location=us 175 | 176 | organizationType: non-profit 177 | employeesCount: 10 178 | ``` 179 | 180 | 181 | -------------------------------------------------------------------------------- /activate_settings.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # App settings go here, they're validated in app.settings 3 | 4 | # the AIO_ env variables are used by `adev runserver` when serving your app for development 5 | export AIO_APP_PATH="aidbox_python_sdk/" 6 | export $(grep -v '^#' .env | xargs) 7 | 8 | # also activate the python virtualenv for convenience, you can remove this if you're python another way 9 | . env/bin/activate 10 | -------------------------------------------------------------------------------- /aidbox_python_sdk/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "aidbox-python-sdk" 2 | __version__ = "0.1.15" 3 | __author__ = "beda.software" 4 | __license__ = "None" 5 | __copyright__ = "Copyright 2024 beda.software" 6 | 7 | # Version synonym 8 | VERSION = __version__ 9 | -------------------------------------------------------------------------------- /aidbox_python_sdk/aidboxpy.py: -------------------------------------------------------------------------------- 1 | # type: ignore because fhir-py is not typed properly 2 | from abc import ABC 3 | 4 | from fhirpy.base import ( 5 | AsyncClient, 6 | AsyncReference, 7 | AsyncResource, 8 | AsyncSearchSet, 9 | SyncClient, 10 | SyncReference, 11 | SyncResource, 12 | SyncSearchSet, 13 | ) 14 | from fhirpy.base.resource import BaseReference, BaseResource 15 | from fhirpy.base.searchset import AbstractSearchSet 16 | 17 | __title__ = "aidbox-py" 18 | __version__ = "1.3.0" 19 | __author__ = "beda.software" 20 | __license__ = "None" 21 | __copyright__ = "Copyright 2021 beda.software" 22 | 23 | # Version synonym 24 | VERSION = __version__ 25 | 26 | 27 | class AidboxSearchSet(AbstractSearchSet, ABC): 28 | def assoc(self, element_path): 29 | return self.clone(**{"_assoc": element_path}) 30 | 31 | 32 | class SyncAidboxSearchSet(SyncSearchSet, AidboxSearchSet): 33 | pass 34 | 35 | 36 | class AsyncAidboxSearchSet(AsyncSearchSet, AidboxSearchSet): 37 | pass 38 | 39 | 40 | class BaseAidboxResource(BaseResource, ABC): 41 | def is_reference(self, value): 42 | if not isinstance(value, dict): 43 | return False 44 | 45 | return ( 46 | "resourceType" in value 47 | and ("id" in value or "url" in value) 48 | and not ( 49 | set(value.keys()) 50 | - { 51 | "resourceType", 52 | "id", 53 | "_id", 54 | "resource", 55 | "display", 56 | "uri", 57 | "localRef", 58 | "identifier", 59 | "extension", 60 | } 61 | ) 62 | ) 63 | 64 | 65 | class SyncAidboxResource(BaseAidboxResource, SyncResource): 66 | pass 67 | 68 | 69 | class AsyncAidboxResource(BaseAidboxResource, AsyncResource): 70 | pass 71 | 72 | 73 | class BaseAidboxReference(BaseReference, ABC): 74 | @property 75 | def reference(self): 76 | """ 77 | Returns reference if local resource is saved 78 | """ 79 | if self.is_local: 80 | return f"{self.resource_type}/{self.id}" 81 | return self.get("url", None) 82 | 83 | @property 84 | def id(self): 85 | if self.is_local: 86 | return self.get("id", None) 87 | return None 88 | 89 | @property 90 | def resource_type(self): 91 | """ 92 | Returns resource type if reference specifies to the local resource 93 | """ 94 | if self.is_local: 95 | return self.get("resourceType", None) 96 | return None 97 | 98 | @property 99 | def is_local(self): 100 | return not self.get("url") 101 | 102 | 103 | class SyncAidboxReference(BaseAidboxReference, SyncReference): 104 | pass 105 | 106 | 107 | class AsyncAidboxReference(BaseAidboxReference, AsyncReference): 108 | pass 109 | 110 | 111 | class SyncAidboxClient(SyncClient): 112 | searchset_class = SyncAidboxSearchSet 113 | resource_class = SyncAidboxResource 114 | 115 | def reference(self, resource_type=None, id=None, reference=None, **kwargs): # noqa: A002 116 | resource_type = kwargs.pop("resourceType", resource_type) 117 | if reference: 118 | if reference.count("/") > 1: 119 | return SyncAidboxReference(self, url=reference, **kwargs) 120 | resource_type, id = reference.split("/") # noqa: A001 121 | if not resource_type and not id: 122 | raise TypeError("Arguments `resource_type` and `id` or `reference`are required") 123 | return SyncAidboxReference(self, resourceType=resource_type, id=id, **kwargs) 124 | 125 | 126 | class AsyncAidboxClient(AsyncClient): 127 | searchset_class = AsyncAidboxSearchSet 128 | resource_class = AsyncAidboxResource 129 | 130 | def reference(self, resource_type=None, id=None, reference=None, **kwargs): # noqa: A002 131 | resource_type = kwargs.pop("resourceType", resource_type) 132 | if reference: 133 | if reference.count("/") > 1: 134 | return AsyncAidboxReference(self, url=reference, **kwargs) 135 | resource_type, id = reference.split("/") # noqa: A001 136 | if not resource_type and not id: 137 | raise TypeError("Arguments `resource_type` and `id` or `reference`are required") 138 | return AsyncAidboxReference(self, resourceType=resource_type, id=id, **kwargs) 139 | -------------------------------------------------------------------------------- /aidbox_python_sdk/app_keys.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | from aidbox_python_sdk.aidboxpy import AsyncAidboxClient 4 | from aidbox_python_sdk.db import DBProxy 5 | from aidbox_python_sdk.sdk import SDK 6 | from aidbox_python_sdk.settings import Settings 7 | 8 | db: web.AppKey[DBProxy] = web.AppKey("db", DBProxy) 9 | client: web.AppKey[AsyncAidboxClient] = web.AppKey("client", AsyncAidboxClient) 10 | sdk: web.AppKey[SDK] = web.AppKey("sdk", SDK) 11 | settings: web.AppKey[Settings] = web.AppKey("settings", Settings) 12 | -------------------------------------------------------------------------------- /aidbox_python_sdk/db.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from aiohttp import BasicAuth, ClientSession 5 | from sqlalchemy import ( 6 | BigInteger, 7 | Column, 8 | DateTime, 9 | Enum, 10 | MetaData, 11 | Table, 12 | Text, 13 | TypeDecorator, 14 | text, 15 | ) 16 | from sqlalchemy.dialects.postgresql import ARRAY, JSONB 17 | from sqlalchemy.dialects.postgresql import dialect as postgresql_dialect 18 | from sqlalchemy.sql.elements import ClauseElement 19 | 20 | from aidbox_python_sdk.settings import Settings 21 | 22 | from .exceptions import AidboxDBException 23 | 24 | logger = logging.getLogger("aidbox_sdk.db") 25 | table_metadata = MetaData() 26 | 27 | 28 | class AidboxPostgresqlDialect(postgresql_dialect): 29 | # We don't need escaping at this level 30 | # All escaping will be done on the aidbox side 31 | _backslash_escapes = False 32 | 33 | 34 | class _JSONB(TypeDecorator): 35 | impl = JSONB 36 | 37 | def process_literal_param(self, value, dialect): 38 | if isinstance(value, dict): 39 | return "'{}'".format(json.dumps(value).replace("'", "''")) 40 | if isinstance(value, str): 41 | return value 42 | raise ValueError(f"Don't know how to literal-quote value of type {type(value)}") 43 | 44 | 45 | class _ARRAY(TypeDecorator): 46 | impl = ARRAY 47 | 48 | def process_literal_param(self, value, dialect): 49 | if isinstance(value, list): 50 | return f"ARRAY{value}" 51 | if isinstance(value, str): 52 | return value 53 | raise ValueError(f"Don't know how to literal-quote value of type {type(value)}") 54 | 55 | 56 | def create_table(table_name): 57 | return Table( 58 | table_name, 59 | table_metadata, 60 | Column("id", Text, primary_key=True), 61 | Column("txid", BigInteger, nullable=False), 62 | Column("ts", DateTime(True), server_default=text("CURRENT_TIMESTAMP")), 63 | Column("cts", DateTime(True), server_default=text("CURRENT_TIMESTAMP")), 64 | Column("resource_type", Text, server_default=text("'App'::text")), 65 | Column( 66 | "status", 67 | Enum("created", "updated", "deleted", "recreated", name="resource_status"), 68 | nullable=False, 69 | ), 70 | Column("resource", _JSONB(astext_type=Text()), nullable=False, index=True), 71 | extend_existing=True, 72 | ) 73 | 74 | 75 | class DBProxy: 76 | _client = None 77 | _settings = None 78 | _table_cache = None 79 | 80 | def __init__(self, settings: Settings): 81 | self._settings = settings 82 | self._table_cache = {} 83 | 84 | async def initialize(self): 85 | basic_auth = BasicAuth( 86 | login=self._settings.APP_INIT_CLIENT_ID, 87 | password=self._settings.APP_INIT_CLIENT_SECRET, 88 | ) 89 | self._client = ClientSession(auth=basic_auth) 90 | # TODO: remove _init_table_cache 91 | await self._init_table_cache() 92 | 93 | async def deinitialize(self): 94 | await self._client.close() 95 | 96 | async def raw_sql(self, sql_query, *, execute=False): 97 | """ 98 | Executes SQL query and returns result. Specify `execute` to True 99 | if you want to execute `sql_query` that doesn't return result 100 | (for example, UPDATE without returning or CREATE INDEX and etc.) 101 | otherwise you'll get an AidboxDBException 102 | """ 103 | if not self._client: 104 | raise ValueError("Client not set") 105 | if not isinstance(sql_query, str): 106 | raise ValueError("sql_query must be a str") 107 | if not execute and sql_query.count(";") > 1: 108 | logger.warning("Check that your query does not contain two queries separated by `;`") 109 | query_url = f"{self._settings.APP_INIT_URL}/$psql" 110 | async with self._client.post( 111 | query_url, 112 | json={"query": sql_query}, 113 | params={"execute": "true"} if execute else {}, 114 | raise_for_status=True, 115 | ) as resp: 116 | logger.debug("$psql answer %s", await resp.text()) 117 | results = await resp.json() 118 | 119 | if results[0]["status"] == "error": 120 | raise AidboxDBException(results[0]) 121 | return results[0].get("result", None) 122 | 123 | def compile_statement(self, statement): 124 | return str( 125 | statement.compile( 126 | dialect=AidboxPostgresqlDialect(), 127 | compile_kwargs={"literal_binds": True}, 128 | ) 129 | ) 130 | 131 | async def alchemy(self, statement, *, execute=False): 132 | if not isinstance(statement, ClauseElement): 133 | raise ValueError("statement must be a sqlalchemy expression") 134 | query = self.compile_statement(statement) 135 | logger.debug("Built query:\n%s", query) 136 | return await self.raw_sql(query, execute=execute) 137 | 138 | async def _get_all_entities_name(self): 139 | # TODO: refactor using sdk.client and fetch_all 140 | query_url = f"{self._settings.APP_INIT_URL}/Entity?type=resource&_elements=id&_count=999" 141 | async with self._client.get(query_url, raise_for_status=True) as resp: 142 | json_resp = await resp.json() 143 | return [entry["resource"]["id"] for entry in json_resp.get("entry", [])] 144 | 145 | async def _init_table_cache(self): 146 | table_names = await self._get_all_entities_name() 147 | self._table_cache = { 148 | **{table_name: {"table_name": table_name.lower()} for table_name in table_names}, 149 | **{ 150 | f"{table_name}History": {"table_name": f"{table_name.lower()}_history"} 151 | for table_name in table_names 152 | }, 153 | } 154 | 155 | def __getattr__(self, item): 156 | if item in self._table_cache: 157 | cache = self._table_cache[item] 158 | if cache.get("table") is None: 159 | cache["table"] = create_table(cache["table_name"]) 160 | return cache["table"] 161 | 162 | raise AttributeError 163 | 164 | 165 | def row_to_resource(row): 166 | """ 167 | Transforms raw row from resource's table to resource representation 168 | >>> import pprint 169 | >>> pprint.pprint(row_to_resource({ 170 | ... 'resource': {'name': []}, 171 | ... 'ts': 'ts', 172 | ... 'txid': 'txid', 173 | ... 'resource_type': 'Patient', 174 | ... 'meta': {'tag': 'created'}, 175 | ... 'id': 'id', 176 | ... })) 177 | {'id': 'id', 178 | 'meta': {'lastUpdated': 'ts', 'versionId': 'txid'}, 179 | 'name': [], 180 | 'resourceType': 'Patient'} 181 | """ 182 | resource = row["resource"] 183 | meta = row["resource"].get("meta", {}) 184 | meta.update( 185 | { 186 | "lastUpdated": row["ts"], 187 | "versionId": str(row["txid"]), 188 | } 189 | ) 190 | resource.update( 191 | { 192 | "resourceType": row["resource_type"], 193 | "id": row["id"], 194 | "meta": meta, 195 | } 196 | ) 197 | return resource 198 | -------------------------------------------------------------------------------- /aidbox_python_sdk/db_migrations.py: -------------------------------------------------------------------------------- 1 | sdk_migrations = [ 2 | { 3 | "id": "20190909_add_drop_before", 4 | "sql": """ 5 | DROP FUNCTION IF EXISTS drop_before_all(integer); 6 | 7 | CREATE FUNCTION drop_before_all(integer) RETURNS VOID AS $$ 8 | declare 9 | e record; 10 | BEGIN 11 | FOR e IN (select LOWER(entity.id) as t_name from entity where resource#>>'{type}' = 'resource' and id != 'OperationOutcome') LOOP 12 | EXECUTE 'delete from "' || e.t_name || '" where txid > ' || $1 ; 13 | END LOOP; 14 | END; 15 | 16 | $$ LANGUAGE plpgsql;""", 17 | }, 18 | { 19 | "id": "20240913_change_drop_before_all", 20 | "sql": """ 21 | DROP FUNCTION IF EXISTS drop_before_all(integer); 22 | 23 | CREATE FUNCTION drop_before_all(integer) RETURNS VOID AS $$ 24 | declare 25 | e record; 26 | BEGIN 27 | FOR e IN ( 28 | SELECT table_name 29 | FROM information_schema.columns 30 | WHERE column_name = 'txid' AND table_schema = 'public' AND table_name NOT LIKE '%_history' 31 | ) LOOP 32 | EXECUTE 'DELETE FROM "' || e.table_name || '" WHERE txid > ' || $1 ; 33 | END LOOP; 34 | END; 35 | 36 | $$ LANGUAGE plpgsql;""", 37 | }, 38 | ] 39 | -------------------------------------------------------------------------------- /aidbox_python_sdk/exceptions.py: -------------------------------------------------------------------------------- 1 | class AidboxSDKException(Exception): # noqa: N818 2 | pass 3 | 4 | 5 | class AidboxDBException(AidboxSDKException): 6 | pass 7 | -------------------------------------------------------------------------------- /aidbox_python_sdk/handlers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Any 4 | 5 | from aiohttp import web 6 | from fhirpy.base.exceptions import OperationOutcome 7 | 8 | from . import app_keys as ak 9 | 10 | logger = logging.getLogger("aidbox_sdk") 11 | routes = web.RouteTableDef() 12 | 13 | 14 | async def subscription(request: web.Request, data: dict): 15 | logger.debug("Subscription handler: %s", data["handler"]) 16 | if "handler" not in data or "event" not in data: 17 | logger.error("`handler` and/or `event` param is missing, data: %s", data) 18 | raise web.HTTPBadRequest() 19 | handler = request.app[ak.sdk].get_subscription_handler(data["handler"]) 20 | if not handler: 21 | logger.error("Subscription handler `%s` was not found", "handler") 22 | raise web.HTTPNotFound() 23 | result = handler(data["event"], request) 24 | if asyncio.iscoroutine(result): 25 | asyncio.get_event_loop().create_task(result) 26 | return web.json_response({}) 27 | 28 | 29 | async def operation(request: web.Request, data: dict[str, Any]): 30 | logger.debug("Operation handler: %s", data["operation"]["id"]) 31 | if "operation" not in data or "id" not in data["operation"]: 32 | logger.error("`operation` or `operation[id]` param is missing, data: %s", data) 33 | raise web.HTTPBadRequest() 34 | handler = request.app[ak.sdk].get_operation_handler(data["operation"]["id"]) 35 | if not handler: 36 | logger.error("Operation handler `%s` was not found", data["handler"]) 37 | raise web.HTTPNotFound() 38 | try: 39 | data["request"]["app"] = request.app 40 | result = handler(data["operation"], data["request"]) 41 | if asyncio.iscoroutine(result): 42 | try: 43 | return await result 44 | except asyncio.CancelledError as err: 45 | logger.error("Aidbox timeout for %s", data["operation"]) 46 | raise err 47 | 48 | return result 49 | except OperationOutcome as exc: 50 | return web.json_response(exc.resource, status=422) 51 | 52 | 53 | TYPES = { 54 | "operation": operation, 55 | "subscription": subscription, 56 | } 57 | 58 | 59 | @routes.post("/aidbox") 60 | async def dispatch(request): 61 | logger.debug("Dispatch new request %s %s", request.method, request.url) 62 | json = await request.json() 63 | if "type" in json and json["type"] in TYPES: 64 | logger.debug("Dispatch to `%s` handler", json["type"]) 65 | return await TYPES[json["type"]](request, json) 66 | req = { 67 | "method": request.method, 68 | "url": str(request.url), 69 | "raw_path": request.raw_path, 70 | "headers": dict(request.headers), 71 | "text": await request.text(), 72 | "charset": request.charset, 73 | } 74 | return web.json_response(req, status=200) 75 | 76 | 77 | @routes.get("/health") 78 | async def health_check(request): 79 | return web.json_response({"status": "OK"}, status=200) 80 | 81 | 82 | @routes.get("/live") 83 | async def live_health_check(request): 84 | return web.json_response({"status": "OK"}, status=200) 85 | -------------------------------------------------------------------------------- /aidbox_python_sdk/main.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import json 3 | import logging 4 | import sys 5 | from pathlib import Path 6 | from typing import cast 7 | 8 | from aiohttp import BasicAuth, client_exceptions, web 9 | from fhirpy.base.exceptions import OperationOutcome 10 | 11 | from . import app_keys as ak 12 | from .aidboxpy import AsyncAidboxClient 13 | from .db import DBProxy 14 | from .handlers import routes 15 | from .sdk import SDK 16 | from .settings import Settings 17 | 18 | logger = logging.getLogger("aidbox_sdk") 19 | THIS_DIR = Path(__file__).parent 20 | BASE_DIR = THIS_DIR.parent 21 | 22 | 23 | def setup_routes(app): 24 | app.add_routes(routes) 25 | 26 | 27 | async def register_app(sdk: SDK, client: AsyncAidboxClient): 28 | app_manifest = sdk.build_manifest() 29 | 30 | try: 31 | # We create app directly using execute to avoid conversion 32 | await client.execute(f"/App/{app_manifest['id']}", method="put", data=app_manifest) 33 | 34 | logger.info("Creating seeds and applying migrations") 35 | await sdk.create_seed_resources(client) 36 | await sdk.apply_migrations(client) 37 | logger.info("Aidbox app successfully registered") 38 | except OperationOutcome as error: 39 | logger.error("Error during the App registration: %s", json.dumps(error, indent=2)) 40 | sys.exit(errno.EINTR) 41 | except ( 42 | client_exceptions.ServerDisconnectedError, 43 | client_exceptions.ClientConnectionError, 44 | ): 45 | logger.error("Aidbox address is unreachable %s", sdk.settings.APP_INIT_URL) 46 | sys.exit(errno.EINTR) 47 | 48 | 49 | async def init_client(settings: Settings): 50 | aidbox_client_cls = settings.AIDBOX_CLIENT_CLASS 51 | basic_auth = BasicAuth( 52 | login=cast(str, settings.APP_INIT_CLIENT_ID), 53 | password=cast(str, settings.APP_INIT_CLIENT_SECRET), 54 | ) 55 | 56 | return aidbox_client_cls(f"{settings.APP_INIT_URL}", authorization=basic_auth.encode()) 57 | 58 | 59 | async def init(app: web.Application): 60 | app[ak.client] = await init_client(app[ak.settings]) 61 | app["client"] = app[ak.client] # For backwards compatibility 62 | app[ak.db] = DBProxy(app[ak.settings]) 63 | app["db"] = app[ak.db] # For backwards compatibility 64 | await register_app(app[ak.sdk], app[ak.client]) 65 | await app[ak.db].initialize() 66 | yield 67 | await app[ak.db].deinitialize() 68 | 69 | 70 | def create_app(sdk: SDK): 71 | app = web.Application() 72 | app.cleanup_ctx.append(init) 73 | app[ak.sdk] = sdk 74 | app["sdk"] = app[ak.sdk] # For backwards compatibility 75 | app[ak.settings] = sdk.settings 76 | app["settings"] = app[ak.settings] # For backwards compatibility 77 | 78 | setup_routes(app) 79 | return app 80 | -------------------------------------------------------------------------------- /aidbox_python_sdk/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidbox/aidbox-python-sdk/6b309bd448eedca4e26e6ac0f2aa0bcd3b8deb94/aidbox_python_sdk/py.typed -------------------------------------------------------------------------------- /aidbox_python_sdk/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import cast 3 | 4 | import pytest 5 | import pytest_asyncio 6 | from aiohttp import BasicAuth, ClientSession, web 7 | from yarl import URL 8 | 9 | from main import create_app as _create_app 10 | 11 | from . import app_keys as ak 12 | 13 | 14 | async def start_app(aiohttp_client): 15 | app = await aiohttp_client(_create_app(), server_kwargs={"host": "0.0.0.0", "port": 8081}) 16 | sdk = cast(web.Application, app.server.app)[ak.sdk] 17 | sdk._test_start_txid = -1 18 | 19 | return app 20 | 21 | 22 | @pytest.fixture() 23 | def client(event_loop, aiohttp_client): 24 | """Instance of app's server and client""" 25 | return event_loop.run_until_complete(start_app(aiohttp_client)) 26 | 27 | 28 | class AidboxSession(ClientSession): 29 | def __init__(self, *args, base_url=None, **kwargs): 30 | base_url_resolved = base_url or os.environ.get("AIDBOX_BASE_URL") 31 | assert base_url_resolved, "Either base_url arg or AIDBOX_BASE_URL env var must be set" 32 | self.base_url = URL(base_url_resolved) 33 | super().__init__(*args, **kwargs) 34 | 35 | def make_url(self, path): 36 | return self.base_url.with_path(path) 37 | 38 | async def _request(self, method, path, *args, **kwargs): 39 | url = self.make_url(path) 40 | return await super()._request(method, url, *args, **kwargs) 41 | 42 | 43 | @pytest_asyncio.fixture 44 | async def aidbox(client): 45 | """HTTP client for making requests to Aidbox""" 46 | app = cast(web.Application, client.server.app) 47 | basic_auth = BasicAuth( 48 | login=app[ak.settings].APP_INIT_CLIENT_ID, 49 | password=app[ak.settings].APP_INIT_CLIENT_SECRET, 50 | ) 51 | session = AidboxSession(auth=basic_auth, base_url=app[ak.settings].APP_INIT_URL) 52 | yield session 53 | await session.close() 54 | 55 | 56 | @pytest_asyncio.fixture 57 | async def safe_db(aidbox, client, sdk): 58 | resp = await aidbox.post( 59 | "/$psql", 60 | json={"query": "SELECT last_value from transaction_id_seq;"}, 61 | raise_for_status=True, 62 | ) 63 | results = await resp.json() 64 | txid = results[0]["result"][0]["last_value"] 65 | sdk._test_start_txid = int(txid) 66 | 67 | yield txid 68 | 69 | sdk._test_start_txid = -1 70 | await aidbox.post( 71 | "/$psql", 72 | json={"query": f"select drop_before_all({txid});"}, 73 | params={"execute": "true"}, 74 | raise_for_status=True, 75 | ) 76 | 77 | 78 | @pytest.fixture() 79 | def sdk(client): 80 | return cast(web.Application, client.server.app)[ak.sdk] 81 | 82 | 83 | @pytest.fixture() 84 | def aidbox_client(client): 85 | return cast(web.Application, client.server.app)[ak.client] 86 | 87 | 88 | @pytest.fixture() 89 | def aidbox_db(client): 90 | return cast(web.Application, client.server.app)[ak.db] 91 | -------------------------------------------------------------------------------- /aidbox_python_sdk/sdk.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Optional 4 | 5 | import jsonschema 6 | from fhirpy.base.exceptions import OperationOutcome 7 | 8 | from .aidboxpy import AsyncAidboxClient 9 | from .db_migrations import sdk_migrations 10 | from .types import Compliance 11 | 12 | logger = logging.getLogger("aidbox_sdk") 13 | 14 | 15 | class SDK: 16 | def __init__( # noqa: PLR0913 17 | self, 18 | settings, 19 | *, 20 | entities=None, 21 | resources=None, 22 | seeds=None, 23 | migrations=None, 24 | ): 25 | self.settings = settings 26 | self._subscriptions = {} 27 | self._subscription_handlers = {} 28 | self._operations = {} 29 | self._operation_handlers = {} 30 | self._manifest = { 31 | "apiVersion": 1, 32 | "type": "app", 33 | "id": settings.APP_ID, 34 | "endpoint": { 35 | "url": f"{settings.APP_URL}/aidbox", 36 | "type": "http-rpc", 37 | "secret": settings.APP_SECRET, 38 | }, 39 | } 40 | self._resources = resources or {} 41 | self._entities = entities or {} 42 | self._seeds = seeds or {} 43 | self._migrations = migrations or [] 44 | self._app_endpoint_name = f"{settings.APP_ID}-endpoint" 45 | self._sub_triggered = {} 46 | self._test_start_txid = None 47 | 48 | async def apply_migrations(self, client: AsyncAidboxClient): 49 | await client.resource( 50 | "Bundle", 51 | type="transaction", 52 | entry=[ 53 | { 54 | "resource": self._migrations + sdk_migrations, 55 | "request": {"method": "POST", "url": "/db/migrations"}, 56 | } 57 | ], 58 | ).save() 59 | 60 | async def create_seed_resources(self, client: AsyncAidboxClient): 61 | entries = [] 62 | for entity, resources in self._seeds.items(): 63 | for resource_id, resource in resources.items(): 64 | if resource.get("id") and resource["id"] != resource_id: 65 | logger.warning( 66 | "Resource '%s' key id=%s is not equal to the resource 'id'=%s ", 67 | entity, 68 | resource_id, 69 | resource["id"], 70 | ) 71 | entry = {"resource": {**resource, "id": resource_id, "resourceType": entity}} 72 | # Conditional create 73 | entry["request"] = { 74 | "method": "POST", 75 | "url": f"/{entity}?_id={resource_id}", 76 | } 77 | entries.append(entry) 78 | bundle = client.resource("Bundle", type="transaction", entry=entries) 79 | await bundle.save() 80 | 81 | def build_manifest(self): 82 | if self._resources: 83 | self._manifest["resources"] = self._resources 84 | if self._entities: 85 | self._manifest["entities"] = self._entities 86 | if self._subscriptions: 87 | self._manifest["subscriptions"] = self._subscriptions 88 | if self._operations: 89 | self._manifest["operations"] = self._operations 90 | return self._manifest 91 | 92 | def subscription(self, entity): 93 | def wrap(func): 94 | path = func.__name__ 95 | self._subscriptions[entity] = {"handler": path} 96 | 97 | async def handler(event, request): 98 | if self._test_start_txid is not None: 99 | # Skip outside test 100 | if self._test_start_txid == -1: 101 | return None 102 | 103 | # Skip inside another test 104 | if int(event["tx"]["id"]) < self._test_start_txid: 105 | return None 106 | coro_or_result = func(event, request) 107 | if asyncio.iscoroutine(coro_or_result): 108 | result = await coro_or_result 109 | else: 110 | logger.warning("Synchronous subscription handler is deprecated: %s", path) 111 | result = coro_or_result 112 | 113 | if entity in self._sub_triggered: 114 | future, counter = self._sub_triggered[entity] 115 | if counter > 1: 116 | self._sub_triggered[entity] = (future, counter - 1) 117 | elif future.done(): 118 | pass 119 | # logger.warning('Uncaught subscription for %s', entity) 120 | else: 121 | future.set_result(True) 122 | 123 | return result 124 | 125 | self._subscription_handlers[path] = handler 126 | return func 127 | 128 | return wrap 129 | 130 | def get_subscription_handler(self, path): 131 | return self._subscription_handlers.get(path) 132 | 133 | def was_subscription_triggered_n_times(self, entity, counter): 134 | timeout = 10 135 | 136 | future = asyncio.Future() 137 | self._sub_triggered[entity] = (future, counter) 138 | asyncio.get_event_loop().call_later( 139 | timeout, 140 | lambda: None if future.done() else future.set_exception(Exception()), 141 | ) 142 | 143 | return future 144 | 145 | def was_subscription_triggered(self, entity): 146 | return self.was_subscription_triggered_n_times(entity, 1) 147 | 148 | def operation( # noqa: PLR0913 149 | self, 150 | methods, 151 | path, 152 | public=False, 153 | access_policy=None, 154 | request_schema=None, 155 | timeout=None, 156 | compliance: Optional[Compliance] = None, 157 | ): 158 | if public and access_policy is not None: 159 | raise ValueError("Operation might be public or have access policy, not both") 160 | 161 | request_validator = None 162 | if request_schema: 163 | request_validator = jsonschema.Draft202012Validator(schema=request_schema) 164 | 165 | def wrap(func): 166 | if not isinstance(path, list): 167 | raise ValueError("`path` must be a list") 168 | if not isinstance(methods, list): 169 | raise ValueError("`methods` must be a list") 170 | _str_path = [] 171 | for p in path: 172 | if isinstance(p, str): 173 | _str_path.append(p) 174 | elif isinstance(p, dict): 175 | _str_path.append("__{}__".format(p["name"])) 176 | 177 | def wrapped_func(operation, request): 178 | if request_validator: 179 | validate_request(request_validator, request) 180 | return func(operation, request) 181 | 182 | for method in methods: 183 | operation_id = "{}.{}.{}.{}".format( 184 | method, func.__module__, func.__name__, "_".join(_str_path) 185 | ).replace("$", "d") 186 | self._operations[operation_id] = { 187 | "method": method, 188 | "path": path, 189 | **({"timeout": timeout} if timeout else {}), 190 | **(compliance if compliance else {}), 191 | } 192 | self._operation_handlers[operation_id] = wrapped_func 193 | if public is True: 194 | self._set_access_policy_for_public_op(operation_id) 195 | elif access_policy is not None: 196 | self._set_operation_access_policy(operation_id, access_policy) 197 | return func 198 | 199 | return wrap 200 | 201 | def get_operation_handler(self, operation_id): 202 | return self._operation_handlers.get(operation_id) 203 | 204 | def _set_operation_access_policy(self, operation_id, access_policy): 205 | if "AccessPolicy" not in self._resources: 206 | self._resources["AccessPolicy"] = {} 207 | self._resources["AccessPolicy"][operation_id] = { 208 | "link": [{"id": operation_id, "resourceType": "Operation"}], 209 | "engine": access_policy["engine"], 210 | "schema": access_policy["schema"], 211 | } 212 | 213 | def _set_access_policy_for_public_op(self, operation_id): 214 | if "AccessPolicy" not in self._resources: 215 | self._resources["AccessPolicy"] = {} 216 | if self._app_endpoint_name not in self._resources["AccessPolicy"]: 217 | self._resources["AccessPolicy"][self._app_endpoint_name] = { 218 | "link": [], 219 | "engine": "allow", 220 | } 221 | self._resources["AccessPolicy"][self._app_endpoint_name]["link"].append( 222 | { 223 | "id": operation_id, 224 | "resourceType": "Operation", 225 | } 226 | ) 227 | 228 | 229 | def validate_request(request_validator, request): 230 | errors = list(request_validator.iter_errors(request)) 231 | 232 | if errors: 233 | raise OperationOutcome( 234 | resource={ 235 | "resourceType": "OperationOutcome", 236 | "text": {"status": "generated", "div": "Invalid request"}, 237 | "issue": [ 238 | { 239 | "severity": "fatal", 240 | "code": "invalid", 241 | "expression": [".".join([str(x) for x in ve.absolute_path])], 242 | "diagnostics": ve.message, 243 | } 244 | for ve in errors 245 | ], 246 | } 247 | ) 248 | -------------------------------------------------------------------------------- /aidbox_python_sdk/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from .aidboxpy import AsyncAidboxClient 5 | 6 | 7 | class Required: 8 | def __init__(self, v_type=None): 9 | self.v_type = v_type 10 | 11 | 12 | class Settings: 13 | """ 14 | Any setting defined here can be overridden by: 15 | 16 | Settings the appropriate environment variable, eg. to override FOOBAR, `export APP_FOOBAR="whatever"`. 17 | This is useful in production for secrets you do not wish to save in code and 18 | also plays nicely with docker(-compose). Settings will attempt to convert environment variables to match the 19 | type of the value here. See also activate.settings.sh. 20 | 21 | Or, passing the custom setting as a keyword argument when initialising settings (useful when testing) 22 | """ 23 | 24 | _ENV_PREFIX = "" 25 | 26 | APP_INIT_CLIENT_ID = Required(v_type=str) 27 | APP_INIT_CLIENT_SECRET = Required(v_type=str) 28 | APP_INIT_URL = Required(v_type=str) 29 | APP_ID = Required(v_type=str) 30 | APP_URL = Required(v_type=str) 31 | APP_PORT = Required(v_type=int) 32 | APP_SECRET = Required(v_type=str) 33 | AIO_HOST = Required(v_type=str) 34 | AIO_PORT = Required(v_type=str) 35 | AIDBOX_CLIENT_CLASS = AsyncAidboxClient 36 | 37 | def __init__(self, **custom_settings): 38 | """ 39 | :param custom_settings: Custom settings to override defaults, only attributes already defined can be set. 40 | """ 41 | self._custom_settings = custom_settings 42 | self.substitute_environ() 43 | for name, value in custom_settings.items(): 44 | # if not hasattr(self, name): 45 | # raise TypeError('{} is not a valid setting name'.format(name)) 46 | setattr(self, name, value) 47 | self.static_path = None 48 | 49 | def substitute_environ(self): 50 | """ 51 | Substitute environment variables into settings. 52 | """ 53 | for attr_name in dir(self): 54 | if attr_name.startswith("_") or attr_name.upper() != attr_name: 55 | continue 56 | orig_value = getattr(self, attr_name) 57 | is_required = isinstance(orig_value, Required) 58 | orig_type = orig_value.v_type if is_required else type(orig_value) 59 | env_var_name = self._ENV_PREFIX + attr_name 60 | env_var = os.getenv(env_var_name, None) 61 | if env_var is not None: 62 | if issubclass(orig_type, bool): 63 | env_var = env_var.upper() in ("1", "TRUE") 64 | elif issubclass(orig_type, int): 65 | env_var = int(env_var) 66 | elif issubclass(orig_type, Path): 67 | env_var = Path(env_var) 68 | elif issubclass(orig_type, bytes): 69 | env_var = env_var.encode() 70 | # could do floats here and lists etc via json 71 | setattr(self, attr_name, env_var) 72 | elif is_required and attr_name not in self._custom_settings: 73 | raise RuntimeError( 74 | f'The required environment variable "{env_var_name}" is currently not set, ' 75 | "you'll need to run `source activate.settings.sh` " 76 | "or you can set that single environment variable with " 77 | f'`export {env_var_name}=""` or pass variable in `custom_settings` ' 78 | "argument" 79 | ) 80 | -------------------------------------------------------------------------------- /aidbox_python_sdk/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from aiohttp import web 4 | from typing_extensions import TypedDict 5 | 6 | class Compliance(TypedDict, total=True): 7 | fhirUrl: str 8 | fhirCode: str 9 | fhirResource: List[str] 10 | 11 | SDKOperationRequest = TypedDict( 12 | "SDKOperationRequest", 13 | {"app": web.Application, "params": dict, "route-params": dict, "headers": dict, "resource": Any}, 14 | ) 15 | 16 | 17 | class SDKOperation(TypedDict): 18 | pass 19 | -------------------------------------------------------------------------------- /config/config.edn: -------------------------------------------------------------------------------- 1 | {:config 2 | { 3 | :logLevel :info ;; One of - :trace :debug :info :warn :error :fatal 4 | :_totalMethod :count ;; Default - :count , nil for disable counting 5 | } 6 | :import 7 | { 8 | ;;:icd-10 {} 9 | ;;:fhirterm-3.3.0 {} 10 | }} -------------------------------------------------------------------------------- /config/genkey.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ssh-keygen -t rsa -b 4096 -f jwtRS256.key 4 | openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub 5 | 6 | -------------------------------------------------------------------------------- /config/jwtRS256.key: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAACFwAAAAdzc2gtcn 3 | NhAAAAAwEAAQAAAgEAtequerYLGp3Rz3fG8Rd9HMHbZIYHwFDykYIos+CjlyO/9+KIJayr 4 | ZUg2/838lN9ICD/nKuOk1QWsrEBGbIzC/ULaNFVB3y4s/oUO85duv3SGKWcNMOqHLS58Sr 5 | ZFmSnxxODsQ5GOZZv6m0wUyxDsY0NAEx9HYnvUlUKm/+N1K5Mbc6dIIK9RWJ0vt1glGxJT 6 | Oqb42KwY0tJWWYZERANVQrT45e3X13OgfpMcqd55PQCBQFtsZbQXz8KFimBBRDmiFlDJb3 7 | WEUaCajwmxB+ixaYNRBD1v7vCtL4Aw0Oudh0WpzKjbN6ZJlq2LKGC62SzsF67/OkPSP0Sv 8 | 8pqFzh+vIVBfMMYl78RVTAXuhcCcb2MUla6oLu9sQwhMEzvx2yKtgZQyBHQgsnRJdVNkg0 9 | gi4NobSmSCTpMBgkAghLrz5PuYk/izqy3X/7jhL8o8fb4t2m8d4Y8UJVjRHqdW7ZrXQxZg 10 | b7muK3v7fz/bgdfmU+UVHEbImFaHN59PDzyNo7oDIeSBlPQNcXEA2dqyeS6wAJbv7uZoSx 11 | ZIvORpPCMdQ0hU+yHiOrMGDokE5Fpx9efi5SSSjc6MzgHa4iyOZJ6T28HeSTFeFWLPqpz1 12 | iOdc0nP6oqa636qJjZm6PkbkJQuqB0uIO+gzKJG9FPMpliQBW9yCeEOKmTDXR3bMss9Wu7 13 | EAAAdQae+4hGnvuIQAAAAHc3NoLXJzYQAAAgEAtequerYLGp3Rz3fG8Rd9HMHbZIYHwFDy 14 | kYIos+CjlyO/9+KIJayrZUg2/838lN9ICD/nKuOk1QWsrEBGbIzC/ULaNFVB3y4s/oUO85 15 | duv3SGKWcNMOqHLS58SrZFmSnxxODsQ5GOZZv6m0wUyxDsY0NAEx9HYnvUlUKm/+N1K5Mb 16 | c6dIIK9RWJ0vt1glGxJTOqb42KwY0tJWWYZERANVQrT45e3X13OgfpMcqd55PQCBQFtsZb 17 | QXz8KFimBBRDmiFlDJb3WEUaCajwmxB+ixaYNRBD1v7vCtL4Aw0Oudh0WpzKjbN6ZJlq2L 18 | KGC62SzsF67/OkPSP0Sv8pqFzh+vIVBfMMYl78RVTAXuhcCcb2MUla6oLu9sQwhMEzvx2y 19 | KtgZQyBHQgsnRJdVNkg0gi4NobSmSCTpMBgkAghLrz5PuYk/izqy3X/7jhL8o8fb4t2m8d 20 | 4Y8UJVjRHqdW7ZrXQxZgb7muK3v7fz/bgdfmU+UVHEbImFaHN59PDzyNo7oDIeSBlPQNcX 21 | EA2dqyeS6wAJbv7uZoSxZIvORpPCMdQ0hU+yHiOrMGDokE5Fpx9efi5SSSjc6MzgHa4iyO 22 | ZJ6T28HeSTFeFWLPqpz1iOdc0nP6oqa636qJjZm6PkbkJQuqB0uIO+gzKJG9FPMpliQBW9 23 | yCeEOKmTDXR3bMss9Wu7EAAAADAQABAAACAQCML4ArUfO2nB2duhiVm1svePgfO+XnPrne 24 | haXmD9sg0kzRskDmf2xJDWBMuijFdFbm/I+gjnJsMgZBavqaFdMBJa5PG9A6MzQ7IsBF2N 25 | FgOmRUrXQN2P04RRVE4bc6c4c7B3UqEJXiGVQCrptrbOwtBhrvYXA6JWDJcOaOVHLrlF5Y 26 | PnWnslKGbgLiT8uwU+vNS/SBzAUJGUuvi3c5zjO6xwjy7tkgz5rDT4yMHsPgzW3M8WbT7P 27 | dYg/cyjkBdWehdN/4ypSXFXXUdewNwK9HjlG0vTkTepZtf+nIj3R20q2tRKmJcPQmCUtPk 28 | 1r3GNAbvrUxDUxZG6SCJXKh2EhZ60gHZXH4RwOhXvUkmdSxuSzC72KPK4XUSWERFAS7Q/R 29 | 7mjOKJJo/jJ6FM5qn7yl4HTBE9jctr5VSCeD6J+ffwX8/YEDdvvvwtI6mEj1mHFDjAiJkc 30 | IePFWMJOopFkhKDdOkag2a8ElPDrytFKWM3GLQdOKRK5y/H0qdTl/Ay4Ywy8dM1Ff7cl/2 31 | e1f8a2X+BxGIu+4ao5Pt/movA2/IqO5nalSbsaJWRnTJ8AsO6VNKkXbZ6o7W9BmB5rvK7M 32 | rFVEBMIG6iMlOLJBJxXbYAiWQP4EDbR0YOdi0ldB4L4EY24/CHx5Kw2J1fSA6AF63lm0S1 33 | jFQ6qkzruGPCB4RpVsAQAAAQBVRs6Bjb15K//CYpijlTCfMG2lsQtLEC4zUUXJ+90VGKs3 34 | ny7+5QvWlkUAbv0OVppTT6+oI098PwTa61ny3yWiwU/MURYYh4Ryg6SPwoIP1omJB7I1KH 35 | JkV1GJzvu0KInmwFFemlSHYSmmHje1MxoiYjNGwePXcV9xgMwrQHiAf619TYAlgtnImqUn 36 | 3hhKCUstKz097lI9cSNAMm4Eb/zyBgxxrUMJWZ2Iwhl82oNHx0BUKasTK9LjG1UfZvNU/6 37 | h2z53p6zamgSM4krlMjsurcoIxsPZOJmvTSKhO6Fvu7yoPQxi6nI0lh7uEymk1xowdfdnH 38 | MvEsr1eG3qvNKqAEAAABAQDX8uZmo+BklSz8QPcF5U91X/zs3X0WJ+R86VsZHPjJLXi0dY 39 | vnsTZQMjGQTgphjg9AkVacRQnUBbcHQV2SiM74FxHbW9oKAiZC4/T1pyB9ENqxj1BXkd47 40 | fhZ6tQNwTpuCe8dfr5IwUvFDOBrDQV8O1WBIVL2ogNlZ9SALEpCsPgmvWNoGlB8cEqKIS5 41 | 5WZWoqS5CkoorGrAollHUFzB8/Twz8vSsVf7U4AF52AeTNhTlQd0LQ9QQWbWZSqNDcbW4r 42 | HGwSNcRyh54wisd0G/Pjdxff0QwPahqhsX0YS/CkeCjbcYq1w3ee809DUcYaPBmmFyrWpM 43 | ihP+xpQDfn3LbxAAABAQDXp/YDUNvp4WF6qV3klfx7n003fvTA1GLGf0wFopAznW0qU4G+ 44 | RhUtPPXPolgXopABn4SK7L0i3HfowtYcrxmk9SscIlLstyK+jHVgyq2LKYP+fh/wlCuRH1 45 | n2oIz7hZveGqrQQU46lV1fEV0f6DZJ9je0yQeFmc6tXADhGmA4bhZrrsYA49aMhMvlpSrZ 46 | 4P7KSvGV4xNsAge2gjQ9xq8G+49xq2kZ3qrxSwQe/QuYyRAN7Fnl548+doXGl17p0L1lLA 47 | pyq0b1Yi4Vxb8+6TmOJ8cUTex/fi4ZAfdS9csICpEJFXjoedW4tfp7qnDMJ2QuAmQlw8tQ 48 | BObxrQA0HdDBAAAAGHdoQFdIcy1NYWNCb29rLUFpci5sb2NhbAEC 49 | -----END OPENSSH PRIVATE KEY----- 50 | -------------------------------------------------------------------------------- /config/jwtRS256.key.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC16q56tgsandHPd8bxF30cwdtkhgfAUPKRgiiz4KOXI7/34oglrKtlSDb/zfyU30gIP+cq46TVBaysQEZsjML9Qto0VUHfLiz+hQ7zl26/dIYpZw0w6octLnxKtkWZKfHE4OxDkY5lm/qbTBTLEOxjQ0ATH0die9SVQqb/43Urkxtzp0ggr1FYnS+3WCUbElM6pvjYrBjS0lZZhkREA1VCtPjl7dfXc6B+kxyp3nk9AIFAW2xltBfPwoWKYEFEOaIWUMlvdYRRoJqPCbEH6LFpg1EEPW/u8K0vgDDQ652HRanMqNs3pkmWrYsoYLrZLOwXrv86Q9I/RK/ymoXOH68hUF8wxiXvxFVMBe6FwJxvYxSVrqgu72xDCEwTO/HbIq2BlDIEdCCydEl1U2SDSCLg2htKZIJOkwGCQCCEuvPk+5iT+LOrLdf/uOEvyjx9vi3abx3hjxQlWNEep1btmtdDFmBvua4re/t/P9uB1+ZT5RUcRsiYVoc3n08PPI2jugMh5IGU9A1xcQDZ2rJ5LrAAlu/u5mhLFki85Gk8Ix1DSFT7IeI6swYOiQTkWnH15+LlJJKNzozOAdriLI5knpPbwd5JMV4VYs+qnPWI51zSc/qiprrfqomNmbo+RuQlC6oHS4g76DMokb0U8ymWJAFb3IJ4Q4qZMNdHdsyyz1a7sQ== wh@WHs-MacBook-Air.local 2 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | services: 3 | aidbox-db: 4 | image: "healthsamurai/aidboxdb:16.1" 5 | env_file: 6 | - ./envs/db 7 | ports: 8 | - 5432:5432 9 | healthcheck: 10 | test: ["CMD", "pg_isready", "-U", "postgres"] 11 | interval: 5s 12 | timeout: 5s 13 | retries: 10 14 | aidbox: 15 | image: ${AIDBOX_PROJECT_IMAGE} 16 | build: example/aidbox-project/ 17 | depends_on: 18 | aidbox-db: 19 | condition: service_healthy 20 | env_file: 21 | - ./envs/aidbox 22 | environment: 23 | AIDBOX_LICENSE: ${AIDBOX_LICENSE} 24 | healthcheck: 25 | test: curl --fail http://localhost:8080/health || exit 1 26 | interval: 5s 27 | timeout: 30s 28 | retries: 50 29 | app: 30 | build: 31 | context: . 32 | args: 33 | PYTHON_VERSION: ${PYTHON:-3.11} 34 | command: ["pipenv", "run", "pytest"] 35 | depends_on: 36 | aidbox: 37 | condition: service_healthy 38 | env_file: 39 | - ./envs/backend 40 | ports: 41 | - "8081:8081" 42 | volumes: 43 | - .:/app 44 | -------------------------------------------------------------------------------- /env_tests: -------------------------------------------------------------------------------- 1 | AIDBOX_CLIENT_ID=root 2 | AIDBOX_CLIENT_SECRET=secret 3 | AIDBOX_BASE_URL=http://devbox:8080 4 | 5 | AIDBOX_PORT=8080 6 | AIDBOX_FHIR_VERSION=3.0.1 7 | 8 | APP_INIT_CLIENT_ID=root 9 | APP_INIT_CLIENT_SECRET=secret 10 | APP_INIT_URL=http://devbox:8080 11 | 12 | APP_ID=app-py-example 13 | APP_SECRET=secret 14 | APP_URL=http://app:8081 15 | APP_PORT=8081 16 | AIO_PORT=8081 17 | AIO_HOST=0.0.0.0 18 | AIO_APP_PATH=. 19 | 20 | OPENID_RSA=/var/config/jwtRS256.key 21 | OPENID_RSA_PUB=/var/config/jwtRS256.key.pub 22 | 23 | AIDBOX_STDOUT_PRETTY=debug -------------------------------------------------------------------------------- /envs/aidbox: -------------------------------------------------------------------------------- 1 | AIDBOX_STDOUT_PRETTY=all 2 | AIDBOX_CLIENT_ID=root 3 | AIDBOX_CLIENT_SECRET=secret 4 | AIDBOX_BASE_URL=http://aidbox:8080 5 | AIDBOX_PORT=8080 6 | AIDBOX_FHIR_VERSION=4.0.0 7 | 8 | PGHOST=aidbox-db 9 | PGPORT=5432 10 | PGDATABASE=aidbox-tests 11 | PGUSER=postgres 12 | PGPASSWORD=postgres 13 | 14 | BOX_PROJECT_GIT_TARGET__PATH=/aidbox-project 15 | AIDBOX_ZEN_PATHS=path:package-dir:/aidbox-project 16 | AIDBOX_ZEN_ENTRYPOINT=main/box 17 | AIDBOX_DEV_MODE=false 18 | AIDBOX_ZEN_DEV_MODE=false 19 | AIDBOX_COMPLIANCE=enabled 20 | -------------------------------------------------------------------------------- /envs/backend: -------------------------------------------------------------------------------- 1 | APP_INIT_CLIENT_ID=root 2 | APP_INIT_CLIENT_SECRET=secret 3 | APP_INIT_URL=http://aidbox:8080 4 | 5 | APP_ID=backend-test 6 | APP_SECRET=secret 7 | APP_URL=http://backend:8081 8 | APP_PORT=8081 9 | AIO_PORT=8081 10 | AIO_HOST=0.0.0.0 11 | -------------------------------------------------------------------------------- /envs/db: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PASSWORD=postgres 3 | POSTGRES_DB=aidbox-tests -------------------------------------------------------------------------------- /example/.env.example: -------------------------------------------------------------------------------- 1 | AIDBOX_LICENSE= 2 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .mypy_cache 3 | .pytest_cache 4 | .ruff_cache 5 | .history 6 | .vscode 7 | 8 | .env 9 | .env.tests.local 10 | 11 | .coverage 12 | 13 | htmlcov 14 | coverage.xml 15 | report.xml 16 | 17 | .DS_Store 18 | -------------------------------------------------------------------------------- /example/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 beda.software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/Makefile: -------------------------------------------------------------------------------- 1 | seeds: 2 | docker compose up -d build-seeds 3 | docker compose up -d --force-recreate --no-deps aidbox 4 | 5 | up: 6 | docker compose pull --quiet 7 | docker compose build 8 | docker compose up -d 9 | 10 | stop: 11 | docker compose stop 12 | 13 | down: 14 | docker compose down 15 | 16 | 17 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # aidbox-python-sdk-example 2 | 3 | This repository contains example of structure for aidbox-project and backend 4 | 5 | See backend/README.md for details 6 | 7 | ## Quick start 8 | 9 | 1. Obtain Aidbox dev license on [Aibox user portal](https://aidbox.app/) 10 | 2. Run `cp .env.example .env` 11 | 3. Put your aidbox license key to `.env` after `AIDBOX_LICENSE` key 12 | 4. Run `docker compose up` 13 | 5. Open http://localhost:8080 14 | 15 | -------------------------------------------------------------------------------- /example/aidbox-project/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | .DS_Store 4 | .clj-kondo 5 | .lsp 6 | .history 7 | 8 | zenproject/zen-packages 9 | seeds.ndjson.gz 10 | -------------------------------------------------------------------------------- /example/aidbox-project/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bedasoftware/fhirsnake as seeds_builder 2 | 3 | ADD resources/seeds /app/resources 4 | 5 | RUN mkdir /output 6 | RUN /app/entrypoint.sh export --output /output/seeds.ndjson.gz 7 | 8 | FROM eclipse-temurin:17 AS builder 9 | 10 | RUN apt-get update && apt-get install -y git unzip 11 | 12 | RUN mkdir /opt/zen 13 | RUN wget -O /opt/zen/zen.jar https://github.com/HealthSamurai/ftr/releases/latest/download/zen.jar 14 | 15 | RUN mkdir /opt/app 16 | RUN mkdir /opt/app/zenproject 17 | 18 | COPY --from=seeds_builder /output/seeds.ndjson.gz /opt/app/zenproject/seeds.ndjson.gz 19 | 20 | WORKDIR /opt/app 21 | 22 | ADD zenproject/zen-package.edn /opt/app/zenproject/zen-package.edn 23 | RUN cd /opt/app/zenproject && java -jar /opt/zen/zen.jar zen pull-deps 24 | 25 | ADD zenproject /opt/app/zenproject 26 | RUN cd /opt/app && java -jar /opt/zen/zen.jar zen build zenproject zen-package 27 | 28 | RUN ls -lah /opt/app/zenproject 29 | 30 | RUN mkdir /build 31 | RUN unzip /opt/app/zenproject/zen-package.zip -d /build/aidbox-project 32 | 33 | FROM healthsamurai/aidboxone:stable 34 | 35 | COPY --from=builder /build/aidbox-project /aidbox-project 36 | -------------------------------------------------------------------------------- /example/aidbox-project/resources/seeds/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidbox/aidbox-python-sdk/6b309bd448eedca4e26e6ac0f2aa0bcd3b8deb94/example/aidbox-project/resources/seeds/.gitkeep -------------------------------------------------------------------------------- /example/aidbox-project/zenproject/zen-package.edn: -------------------------------------------------------------------------------- 1 | {:deps {}} 2 | -------------------------------------------------------------------------------- /example/aidbox-project/zenproject/zrc/main.edn: -------------------------------------------------------------------------------- 1 | {ns main 2 | import #{aidbox 3 | hl7-fhir-r4-core} 4 | box 5 | {:zen/tags #{aidbox/system}}} 6 | -------------------------------------------------------------------------------- /example/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .mypy_cache 3 | .pytest_cache 4 | .ruff_cache 5 | .history 6 | .vscode 7 | 8 | .coverage 9 | 10 | htmlcov 11 | coverage.xml 12 | report.xml 13 | 14 | .DS_Store -------------------------------------------------------------------------------- /example/backend/.env.tests.local.example: -------------------------------------------------------------------------------- 1 | AIDBOX_LICENSE_TEST= -------------------------------------------------------------------------------- /example/backend/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .mypy_cache 3 | .pytest_cache 4 | .ruff_cache 5 | .history 6 | .vscode 7 | 8 | .env 9 | .env.tests.local 10 | 11 | .coverage 12 | 13 | htmlcov 14 | coverage.xml 15 | report.xml 16 | 17 | .DS_Store 18 | -------------------------------------------------------------------------------- /example/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | RUN addgroup --gid 1000 dockeruser 4 | RUN adduser --disabled-login --uid 1000 --gid 1000 dockeruser 5 | RUN mkdir -p /app/ 6 | RUN chown -R dockeruser:dockeruser /app/ 7 | 8 | RUN pip install poetry 9 | 10 | USER dockeruser 11 | 12 | WORKDIR /app 13 | 14 | COPY pyproject.toml . 15 | COPY poetry.lock . 16 | RUN poetry install 17 | 18 | COPY . . 19 | 20 | RUN poetry run mypy 21 | 22 | CMD ["poetry", "run", "gunicorn", "main:create_gunicorn_app", "--worker-class", "aiohttp.worker.GunicornWebWorker", "-b", "0.0.0.0:8081"] 23 | -------------------------------------------------------------------------------- /example/backend/Makefile: -------------------------------------------------------------------------------- 1 | build-test: 2 | docker compose -f compose.test-env.yaml build 3 | 4 | stop-test: 5 | docker compose -f compose.test-env.yaml stop 6 | 7 | down-test: 8 | docker compose -f compose.test-env.yaml down 9 | 10 | run-test: 11 | ./run_test.sh 12 | 13 | -------------------------------------------------------------------------------- /example/backend/README.md: -------------------------------------------------------------------------------- 1 | # aidbox-python-sdk-example 2 | 3 | # Development 4 | 5 | ## Local environment setup 6 | 7 | 1. Install pyenv and poetry globally 8 | 2. Install python3.12 python globally using pyenv 9 | 3. Inside the app directory, run `poetry env use /path/to/python312` 10 | 4. Run `poetry install` 11 | 5. Run `poetry shell` to activate virtual environment 12 | 6. Run `autohooks activate` to activate git hooks 13 | 14 | ## Testing 15 | 16 | 1. Run `cp .env.tests.local.example .env.tests.local` 17 | 2. Put your dev aidbox license key into `AIDBOX_LICENSE_TEST` in `.env.tests.local` 18 | 3. Run `make build-test` at the first time and every time once dependencies are updated/added 19 | 4. Run `make run-test` 20 | 5. You can kill the containers when complete to work with tests by run: `make down-test` 21 | 22 | ## IDE setup 23 | 24 | Strongly recommended to use ruff and mypy plugins for IDE 25 | -------------------------------------------------------------------------------- /example/backend/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidbox/aidbox-python-sdk/6b309bd448eedca4e26e6ac0f2aa0bcd3b8deb94/example/backend/app/__init__.py -------------------------------------------------------------------------------- /example/backend/app/app_keys.py: -------------------------------------------------------------------------------- 1 | from aidbox_python_sdk.app_keys import client, sdk, settings 2 | from aiohttp import ClientSession, web 3 | from fhirpy import AsyncFHIRClient 4 | 5 | fhir_client: web.AppKey[AsyncFHIRClient] = web.AppKey("fhir_client", AsyncFHIRClient) 6 | session = web.AppKey("session", ClientSession) 7 | 8 | __all__ = ["fhir_client", "client", "sdk", "settings", "session"] 9 | -------------------------------------------------------------------------------- /example/backend/app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | is_test_environment = os.environ.get("TEST_ENV", False) 4 | -------------------------------------------------------------------------------- /example/backend/app/operations/__init__.py: -------------------------------------------------------------------------------- 1 | from . import example # noqa: F401 2 | -------------------------------------------------------------------------------- /example/backend/app/operations/example.py: -------------------------------------------------------------------------------- 1 | import fhirpy_types_r4b as r4b 2 | from aidbox_python_sdk.types import SDKOperation, SDKOperationRequest 3 | from aiohttp import web 4 | 5 | from app import app_keys as ak 6 | from app.sdk import sdk 7 | 8 | 9 | @sdk.operation(["POST"], ["$example"]) 10 | async def example_op(_operation: SDKOperation, request: SDKOperationRequest) -> web.Response: 11 | fhir_client = request["app"][ak.fhir_client] 12 | 13 | await fhir_client.save(r4b.Patient(id="example")) 14 | patient = await fhir_client.get(r4b.Patient, "example") 15 | 16 | return web.json_response({"status": "ok", "patient": patient.model_dump()}) 17 | 18 | -------------------------------------------------------------------------------- /example/backend/app/sdk.py: -------------------------------------------------------------------------------- 1 | from aidbox_python_sdk.sdk import SDK 2 | from aidbox_python_sdk.settings import Settings 3 | 4 | settings = Settings(**{}) 5 | sdk = SDK(settings) 6 | -------------------------------------------------------------------------------- /example/backend/app/version.py: -------------------------------------------------------------------------------- 1 | """ 2 | NOTE: this file is automatically generated 3 | """ 4 | 5 | __version__ = '0.0.1' 6 | __build_commit_hash__ = "local" 7 | -------------------------------------------------------------------------------- /example/backend/compose.test-env.yaml: -------------------------------------------------------------------------------- 1 | name: aidbox-python-sdk-example-tests 2 | services: 3 | aidbox-db: 4 | image: "healthsamurai/aidboxdb:16.1" 5 | env_file: 6 | - ./env_tests/db 7 | healthcheck: 8 | test: ["CMD", "pg_isready", "-U", "postgres"] 9 | interval: 5s 10 | timeout: 5s 11 | retries: 10 12 | aidbox: 13 | image: ${AIDBOX_PROJECT_IMAGE} 14 | build: ../aidbox-project 15 | depends_on: 16 | aidbox-db: 17 | condition: service_healthy 18 | env_file: 19 | - ./env_tests/aidbox 20 | environment: 21 | AIDBOX_LICENSE: ${AIDBOX_LICENSE_TEST} 22 | healthcheck: 23 | test: curl --fail http://localhost:8080/health || exit 1 24 | interval: 5s 25 | timeout: 30s 26 | retries: 50 27 | backend: 28 | image: ${BUILD_IMAGE} 29 | build: . 30 | depends_on: 31 | aidbox: 32 | condition: service_healthy 33 | env_file: 34 | - ./env_tests/backend 35 | volumes: 36 | - ./:/app 37 | command: ["poetry", "run", "pytest"] 38 | environment: 39 | TEST_ENV: True 40 | -------------------------------------------------------------------------------- /example/backend/conftest.py: -------------------------------------------------------------------------------- 1 | # Don't remove this file 2 | # It is necessary for tests 3 | -------------------------------------------------------------------------------- /example/backend/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidbox/aidbox-python-sdk/6b309bd448eedca4e26e6ac0f2aa0bcd3b8deb94/example/backend/contrib/__init__.py -------------------------------------------------------------------------------- /example/backend/contrib/fhirpy.py: -------------------------------------------------------------------------------- 1 | import fhirpy_types_r4b as r4b 2 | from fhirpy import AsyncFHIRClient 3 | from fhirpy.base import ResourceProtocol 4 | from typing_extensions import TypeVar 5 | 6 | TResource = TypeVar("TResource") 7 | 8 | 9 | def make_reference_str(resource: ResourceProtocol) -> str: 10 | assert resource.id, "Field `id` must be presented" 11 | return f"{resource.resourceType}/{resource.id}" 12 | 13 | 14 | def to_reference(resource: ResourceProtocol) -> r4b.Reference: 15 | return r4b.Reference(reference=make_reference_str(resource)) 16 | 17 | 18 | async def to_resource( 19 | client: AsyncFHIRClient, resource_type: type[TResource], reference: r4b.Reference 20 | ) -> TResource: 21 | assert reference.reference, "Field `reference` must be presented" 22 | resource_dict = await client.get(reference.reference) 23 | 24 | return resource_type(**resource_dict) 25 | 26 | 27 | def get_resource_type(reference: r4b.Reference) -> str: 28 | assert reference.reference 29 | 30 | return reference.reference.split("/")[0] 31 | -------------------------------------------------------------------------------- /example/backend/env_tests/aidbox: -------------------------------------------------------------------------------- 1 | AIDBOX_STDOUT_PRETTY=all 2 | AIDBOX_CLIENT_ID=root 3 | AIDBOX_CLIENT_SECRET=secret 4 | AIDBOX_BASE_URL=http://aidbox:8080 5 | AIDBOX_PORT=8080 6 | AIDBOX_FHIR_VERSION=4.0.0 7 | 8 | PGHOST=aidbox-db 9 | PGPORT=5432 10 | PGDATABASE=aidbox-tests 11 | PGUSER=postgres 12 | PGPASSWORD=postgres 13 | 14 | BOX_PROJECT_GIT_TARGET__PATH=/aidbox-project 15 | AIDBOX_ZEN_PATHS=path:package-zip:/aidbox-project/zen-package.zip 16 | AIDBOX_ZEN_ENTRYPOINT=main/box 17 | AIDBOX_DEV_MODE=true 18 | AIDBOX_ZEN_DEV_MODE=true 19 | 20 | -------------------------------------------------------------------------------- /example/backend/env_tests/backend: -------------------------------------------------------------------------------- 1 | APP_INIT_CLIENT_ID=root 2 | APP_INIT_CLIENT_SECRET=secret 3 | APP_INIT_URL=http://aidbox:8080 4 | 5 | APP_ID=backend-test 6 | APP_SECRET=secret 7 | APP_URL=http://backend:8081 8 | APP_PORT=8081 9 | AIO_PORT=8081 10 | AIO_HOST=0.0.0.0 11 | -------------------------------------------------------------------------------- /example/backend/env_tests/db: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PASSWORD=postgres 3 | POSTGRES_DB=aidbox-tests -------------------------------------------------------------------------------- /example/backend/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import AsyncGenerator 3 | 4 | from aidbox_python_sdk.main import init_client, register_app, setup_routes 5 | from aidbox_python_sdk.settings import Settings 6 | from aiohttp import BasicAuth, ClientSession, web 7 | from fhirpy import AsyncFHIRClient 8 | 9 | from app import app_keys as ak 10 | from app import config, operations # noqa: F401 11 | from app.sdk import sdk 12 | 13 | logging.basicConfig( 14 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO 15 | ) 16 | 17 | 18 | async def init_fhir_client(settings: Settings, prefix: str = "") -> AsyncFHIRClient: 19 | basic_auth = BasicAuth( 20 | login=settings.APP_INIT_CLIENT_ID, 21 | password=settings.APP_INIT_CLIENT_SECRET, 22 | ) 23 | 24 | return AsyncFHIRClient( 25 | f"{settings.APP_INIT_URL}{prefix}", 26 | authorization=basic_auth.encode(), 27 | dump_resource=lambda x: x.model_dump(), 28 | ) 29 | 30 | 31 | async def fhir_client_ctx(app: web.Application) -> AsyncGenerator[None, None]: 32 | app[ak.fhir_client] = await init_fhir_client(app[ak.settings], "/fhir") 33 | yield 34 | 35 | 36 | async def client_ctx(app: web.Application) -> AsyncGenerator[None, None]: 37 | app[ak.client] = await init_client(app[ak.settings]) 38 | yield 39 | 40 | 41 | async def app_ctx(app: web.Application) -> AsyncGenerator[None, None]: 42 | await register_app(app[ak.sdk], app[ak.client]) 43 | yield 44 | 45 | 46 | async def client_session_ctx(app: web.Application) -> AsyncGenerator[None, None]: 47 | session = ClientSession() 48 | app[ak.session] = session 49 | yield 50 | await session.close() 51 | 52 | 53 | def create_app() -> web.Application: 54 | app = web.Application() 55 | app[ak.sdk] = sdk 56 | app[ak.settings] = sdk.settings 57 | app.cleanup_ctx.append(client_session_ctx) 58 | app.cleanup_ctx.append(client_ctx) 59 | app.cleanup_ctx.append(fhir_client_ctx) 60 | app.cleanup_ctx.append(app_ctx) 61 | 62 | setup_routes(app) 63 | 64 | return app 65 | 66 | 67 | async def create_gunicorn_app() -> web.Application: 68 | return create_app() 69 | -------------------------------------------------------------------------------- /example/backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "aidbox-python-sdk-example" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["None"] 6 | readme = "README.md" 7 | package-mode = false 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.12" 11 | aidbox-python-sdk = "^0.1.13" 12 | gunicorn = "^22.0.0" 13 | fhirpy = "^2.0.14" 14 | fhirpy-types-r4b = "^0.1.1" 15 | 16 | [tool.poetry.group.dev.dependencies] 17 | anaconda = "^0.0.1.1" 18 | jedi = "^0.19.1" 19 | ruff = "^0.6.3" 20 | mypy = "^1.11.1" 21 | black = "^24.4.2" 22 | autohooks-plugin-mypy = "^23.10.0" 23 | autohooks-plugin-ruff = "^24.1.0" 24 | autohooks-plugin-black = "^23.10.0" 25 | autohooks = "^24.2.0" 26 | pytest = "^8.3.2" 27 | pytest-aiohttp = "^1.0.5" 28 | pytest-cov = "^5.0.0" 29 | 30 | [build-system] 31 | requires = ["poetry-core"] 32 | build-backend = "poetry.core.masonry.api" 33 | 34 | [tool.black] 35 | target-version = ["py312"] 36 | line-length = 100 37 | 38 | [tool.ruff] 39 | target-version = "py312" 40 | line-length = 100 41 | extend-exclude = ["emded"] 42 | 43 | [tool.ruff.lint] 44 | select = ["I", "E", "F", "N", "B", "C4", "PT", "UP", "I001", "A", "RET", "TID251", "RUF", "SIM", "PYI", "T20", "PIE", "G", "ISC", "PL", "ANN"] 45 | # E501 is disabled because line limit is controlled by black 46 | # RUF015 is disabled because index access is preferred way for us 47 | # PIE804 is disabled because we often use FHIR like camelCase variables 48 | # SIM102 is disabled because nested if's are more readable 49 | # SIM117 is disabled because nested with's are more readable 50 | ignore = ["E501", "RUF006", "RUF015", "PIE804", "SIM102", "SIM117"] 51 | unfixable = ["F401"] 52 | 53 | [tool.autohooks] 54 | mode = "poetry" 55 | pre-commit = ["autohooks.plugins.mypy", "autohooks.plugins.ruff", "autohooks.plugins.black"] 56 | 57 | [tool.pytest.ini_options] 58 | minversion = "8.0" 59 | addopts = "-ra --color=yes --cov app --cov-report xml:coverage.xml --cov-report term --cov-report html --junitxml=report.xml" 60 | testpaths = [ 61 | "tests" 62 | ] 63 | asyncio_mode = "auto" 64 | log_cli = true 65 | log_cli_level = "WARNING" 66 | 67 | [tool.mypy] 68 | exclude = [ 69 | ".history", 70 | "embed" 71 | ] 72 | files = ["app", "tests"] 73 | ignore_missing_imports = true 74 | check_untyped_defs = true 75 | plugins = ["pydantic.mypy"] 76 | 77 | [tool.pydantic-mypy] 78 | init_typed = true 79 | -------------------------------------------------------------------------------- /example/backend/run_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -f ".env.tests.local" ]; then 4 | export `cat .env.tests.local` 5 | fi 6 | 7 | if [ -z "${AIDBOX_LICENSE_TEST}" ]; then 8 | echo "AIDBOX_LICENSE_TEST is required to run tests" 9 | exit 1 10 | fi 11 | 12 | 13 | docker compose -f compose.test-env.yaml pull --quiet 14 | docker compose -f compose.test-env.yaml up --exit-code-from backend backend 15 | 16 | exit $? 17 | -------------------------------------------------------------------------------- /example/backend/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidbox/aidbox-python-sdk/6b309bd448eedca4e26e6ac0f2aa0bcd3b8deb94/example/backend/tests/__init__.py -------------------------------------------------------------------------------- /example/backend/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | import pytest 4 | from aiohttp import web 5 | from aiohttp.test_utils import TestClient 6 | from fhirpy import AsyncFHIRClient 7 | 8 | from app import app_keys as ak 9 | 10 | pytest_plugins = ["aidbox_python_sdk.pytest_plugin"] 11 | 12 | 13 | class SafeDBFixture(Protocol): 14 | pass 15 | 16 | 17 | @pytest.fixture() 18 | def fhir_client(client: TestClient) -> AsyncFHIRClient: 19 | app: web.Application = client.server.app # type: ignore 20 | 21 | return app[ak.fhir_client] 22 | -------------------------------------------------------------------------------- /example/backend/tests/test_db_isolation.py: -------------------------------------------------------------------------------- 1 | import fhirpy_types_r4b as r4b 2 | from fhirpy import AsyncFHIRClient 3 | 4 | from tests.conftest import SafeDBFixture 5 | 6 | 7 | async def test_database_isolation__1(fhir_client: AsyncFHIRClient, safe_db: SafeDBFixture) -> None: 8 | patients = await fhir_client.resources(r4b.Patient).fetch_all() 9 | assert len(patients) == 0 10 | 11 | await fhir_client.create(r4b.Patient()) 12 | 13 | patients = await fhir_client.resources(r4b.Patient).fetch_all() 14 | assert len(patients) == 1 15 | 16 | 17 | async def test_database_isolation__2(fhir_client: AsyncFHIRClient, safe_db: SafeDBFixture) -> None: 18 | patients = await fhir_client.resources(r4b.Patient).fetch_all() 19 | assert len(patients) == 0 20 | 21 | await fhir_client.create(r4b.Patient()) 22 | await fhir_client.create(r4b.Patient()) 23 | 24 | patients = await fhir_client.resources(r4b.Patient).fetch_all() 25 | assert len(patients) == 2 # noqa: PLR2004 26 | -------------------------------------------------------------------------------- /example/backend/tests/test_example.py: -------------------------------------------------------------------------------- 1 | from aidbox_python_sdk.aidboxpy import AsyncAidboxClient 2 | 3 | from tests.conftest import SafeDBFixture 4 | 5 | 6 | async def test_example(safe_db: SafeDBFixture, aidbox_client: AsyncAidboxClient) -> None: 7 | example_output = await aidbox_client.execute("$example", method="POST") 8 | 9 | assert example_output["status"] == "ok" 10 | assert example_output["patient"]["id"] == "example" 11 | -------------------------------------------------------------------------------- /example/backend/update-commit-hash.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # NOTE: This file is run before build! Never run it locally! 3 | 4 | BUILD_COMMIT_HASH=$CI_COMMIT_SHORT_SHA 5 | 6 | # Shared 7 | sed -i -e "s/__build_commit_hash__ = .*/__build_commit_hash__ = '$BUILD_COMMIT_HASH'/g" app/version.py -------------------------------------------------------------------------------- /example/backend/update-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # NOTE: This file is run before build! Never run it locally! 3 | 4 | VERSION=$1 5 | 6 | # Shared 7 | sed -i -e "s/__version__ = .*/__version__ = '$VERSION'/g" app/version.py 8 | -------------------------------------------------------------------------------- /example/compose.yaml: -------------------------------------------------------------------------------- 1 | name: aidbox-python-sdk-example 2 | services: 3 | aidbox-db: 4 | image: "healthsamurai/aidboxdb:16.1" 5 | env_file: 6 | - ./env/db 7 | healthcheck: 8 | test: ["CMD", "pg_isready", "-U", "postgres"] 9 | interval: 5s 10 | timeout: 5s 11 | retries: 10 12 | aidbox: 13 | build: ./aidbox-project 14 | depends_on: 15 | build-seeds: 16 | condition: service_completed_successfully 17 | aidbox-db: 18 | condition: service_healthy 19 | ports: 20 | - 8080:8080 21 | env_file: 22 | - ./env/aidbox 23 | environment: 24 | - AIDBOX_LICENSE 25 | volumes: 26 | - ./aidbox-project/zenproject:/aidbox-project 27 | healthcheck: 28 | test: curl --fail http://localhost:8080/health || exit 1 29 | interval: 5s 30 | timeout: 30s 31 | retries: 50 32 | build-seeds: 33 | image: bedasoftware/fhirsnake:latest 34 | command: 35 | - export 36 | - --output 37 | - /app/zenproject/seeds.ndjson.gz 38 | volumes: 39 | - ./aidbox-project/zenproject:/app/zenproject 40 | - ./aidbox-project/resources/seeds:/app/resources 41 | backend: 42 | build: 43 | context: ./backend 44 | depends_on: 45 | aidbox: 46 | condition: service_healthy 47 | env_file: 48 | - ./env/backend 49 | volumes: 50 | - ./backend:/app 51 | command: 52 | [ 53 | "poetry", 54 | "run", 55 | "gunicorn", 56 | "main:create_gunicorn_app", 57 | "--reload", 58 | "--worker-class", 59 | "aiohttp.worker.GunicornWebWorker", 60 | "-b", 61 | "0.0.0.0:8081", 62 | ] 63 | 64 | -------------------------------------------------------------------------------- /example/env/aidbox: -------------------------------------------------------------------------------- 1 | AIDBOX_ZEN_PATHS=path:package-dir:/aidbox-project 2 | AIDBOX_ZEN_ENTRYPOINT=main/box 3 | BOX_PROJECT_GIT_TARGET__PATH=/aidbox-project 4 | AIDBOX_ZEN_DEV_MODE=true 5 | AIDBOX_DEV_MODE=true 6 | 7 | AIDBOX_CLIENT_ID=root 8 | AIDBOX_CLIENT_SECRET=secret 9 | AIDBOX_ADMIN_PASSWORD=password 10 | 11 | AIDBOX_BASE_URL=http://localhost:8080 12 | AIDBOX_PORT=8080 13 | AIDBOX_FHIR_VERSION=4.0.0 14 | 15 | 16 | PGHOST=aidbox-db 17 | PGPORT=5432 18 | PGDATABASE=aidbox 19 | PGUSER=postgres 20 | PGPASSWORD=postgres 21 | 22 | 23 | AIDBOX_CORRECT_AIDBOX_FORMAT=true 24 | BOX_FEATURES_FTR_PULL_ENABLE=false 25 | AIDBOX_SDC_ENABLED=false 26 | 27 | AIDBOX_STDOUT_PRETTY=all 28 | box_features_mapping_enable__access__control 29 | 30 | -------------------------------------------------------------------------------- /example/env/backend: -------------------------------------------------------------------------------- 1 | APP_INIT_CLIENT_ID=root 2 | APP_INIT_CLIENT_SECRET=secret 3 | APP_INIT_URL=http://aidbox:8080 4 | 5 | APP_ID=backend 6 | APP_SECRET=secret 7 | APP_URL=http:/backend:8081 8 | APP_PORT=8081 9 | AIO_PORT=8081 10 | AIO_HOST=0.0.0.0 11 | -------------------------------------------------------------------------------- /example/env/db: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PASSWORD=postgres 3 | POSTGRES_DB=aidbox -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import time 4 | from datetime import datetime 5 | 6 | import coloredlogs 7 | import sqlalchemy as sa 8 | from aiohttp import web 9 | from sqlalchemy.sql.expression import insert, select 10 | 11 | from aidbox_python_sdk.handlers import routes 12 | from aidbox_python_sdk.main import create_app as _create_app 13 | from aidbox_python_sdk.sdk import SDK 14 | from aidbox_python_sdk.settings import Settings 15 | 16 | logger = logging.getLogger() 17 | coloredlogs.install(level="DEBUG", fmt="%(asctime)s %(levelname)s %(message)s") 18 | 19 | settings = Settings(**{}) 20 | resources = { 21 | "Client": {"SPA": {"secret": "123456", "grant_types": ["password"]}}, 22 | "User": { 23 | "superadmin": { 24 | "email": "superadmin@example.com", 25 | "password": "12345678", 26 | } 27 | }, 28 | "AccessPolicy": {"superadmin": {"engine": "json-schema", "schema": {"required": ["user"]}}}, 29 | } 30 | seeds = { 31 | "Patient": { 32 | "dfaa8925-f32e-4687-8fd0-272844cff544": { 33 | "name": [ 34 | { 35 | "use": "official", 36 | "family": "Hauck852", 37 | "given": ["Alayna598"], 38 | "prefix": ["Ms."], 39 | } 40 | ], 41 | "gender": "female", 42 | }, 43 | "dfaa8925-f32e-4687-8fd0-272844cff545": { 44 | "name": [ 45 | { 46 | "use": "official", 47 | "family": "Doe12", 48 | "given": ["John46"], 49 | "prefix": ["Ms."], 50 | } 51 | ] 52 | }, 53 | }, 54 | "Contract": { 55 | "e9a3ce1d-745d-4fe1-8e97-807a820e6151": {}, 56 | "e9a3ce1d-745d-4fe1-8e97-807a820e6152": {}, 57 | "e9a3ce1d-745d-4fe1-8e97-807a820e6153": {}, 58 | }, 59 | } 60 | sdk = SDK(settings, resources=resources, seeds=seeds) 61 | 62 | 63 | def create_app(): 64 | return _create_app(sdk) 65 | 66 | 67 | @sdk.subscription("Appointment") 68 | async def appointment_sub(event, request): 69 | """ 70 | POST /Appointment 71 | """ 72 | await asyncio.sleep(5) 73 | return await _appointment_sub(event, request) 74 | 75 | 76 | async def _appointment_sub(event, request): 77 | participants = event["resource"]["participant"] 78 | patient_id = next( 79 | p["actor"]["id"] for p in participants if p["actor"]["resourceType"] == "Patient" 80 | ) 81 | patient = await request.app["client"].resources("Patient").get(id=patient_id) 82 | patient_name = "{} {}".format(patient["name"][0]["given"][0], patient["name"][0]["family"]) 83 | appointment_dates = "Start: {:%Y-%m-%d %H:%M}, end: {:%Y-%m-%d %H:%M}".format( 84 | datetime.fromisoformat(event["resource"]["start"]), 85 | datetime.fromisoformat(event["resource"]["end"]), 86 | ) 87 | logging.info("*" * 40) 88 | if event["action"] == "create": 89 | logging.info("%s, new appointment was created.", patient_name) 90 | logging.info(appointment_dates) 91 | elif event["action"] == "update": 92 | if event["resource"]["status"] == "booked": 93 | logging.info("%s, appointment was updated.", patient_name) 94 | logging.info(appointment_dates) 95 | elif event["resource"]["status"] == "cancelled": 96 | logging.info("%s, appointment was cancelled.", patient_name) 97 | logging.debug("`Appointment` subscription handler") 98 | logging.debug("Event: %s", event) 99 | 100 | 101 | @sdk.operation( 102 | methods=["POST", "PATCH"], 103 | path=["signup", "register", {"name": "date"}, {"name": "test"}], 104 | ) 105 | def signup_register_op(operation, request): 106 | """ 107 | POST /signup/register/21.02.19/testvalue 108 | PATCH /signup/register/22.02.19/patchtestvalue 109 | """ 110 | logging.debug("`signup_register_op` operation handler") 111 | logging.debug("Operation data: %s", operation) 112 | logging.debug("Request: %s", request) 113 | return web.json_response({"success": "Ok", "request": request["route-params"]}) 114 | 115 | 116 | @sdk.operation(methods=["GET"], path=["Patient", "$weekly-report"], public=True) 117 | @sdk.operation(methods=["GET"], path=["Patient", "$daily-report"]) 118 | async def daily_patient_report(operation, request): 119 | """ 120 | GET /Patient/$weekly-report 121 | GET /Patient/$daily-report 122 | """ 123 | patients = request.app["client"].resources("Patient") 124 | async for p in patients: 125 | logging.debug(p.serialize()) 126 | logging.debug("`daily_patient_report` operation handler") 127 | logging.debug("Operation data: %s", operation) 128 | logging.debug("Request: %s", request) 129 | return web.json_response({"type": "report", "success": "Ok", "msg": "Response from APP"}) 130 | 131 | 132 | @routes.get("/db_tests") 133 | async def db_tests(request): 134 | db = request.app["db"] 135 | app = db.App.__table__ 136 | app_res = { 137 | "type": "app", 138 | "resources": { 139 | "User": {}, 140 | }, 141 | } 142 | unique_id = f"abc{time.time()}" 143 | test_statements = [ 144 | insert(app) 145 | .values(id=unique_id, txid=123, status="created", resource=app_res) 146 | .returning(app.c.id), 147 | app.update() 148 | .where(app.c.id == unique_id) 149 | .values(resource=app.c.resource.op("||")({"additional": "property"})), 150 | app.select().where(app.c.id == unique_id), 151 | app.select().where(app.c.status == "recreated"), 152 | select([app.c.resource["type"].label("app_type")]), 153 | app.select().where(app.c.resource["resources"].has_key("User")), 154 | select([app.c.id]).where(app.c.resource["type"].astext == "app"), 155 | select([sa.func.count(app.c.id)]).where(app.c.resource.contains({"type": "app"})), 156 | # TODO: got an error for this query. 157 | # select('*').where(app.c.resource['resources'].has_all(array(['User', 'Client']))), 158 | ] 159 | for statement in test_statements: 160 | result = await db.alchemy(statement) 161 | logging.debug("Result:\n%s", result) 162 | return web.json_response({}) 163 | 164 | 165 | @sdk.operation( 166 | ["POST"], 167 | ["Observation", "observation-custom-op"], 168 | compliance={ 169 | "fhirCode": "observation-custom-op", 170 | "fhirUrl": "http://test.com", 171 | "fhirResource": ["Observation"], 172 | }) 173 | async def observation_custom_op(operation, request): 174 | return {"message": "Observation custom operation response"} 175 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | minversion = "6.0" 3 | addopts = "-ra -q --color=yes" 4 | testpaths = ["tests"] 5 | log_cli = true 6 | log_cli_level = "INFO" 7 | 8 | [tool.autohooks] 9 | mode = "pipenv" 10 | pre-commit = ["autohooks.plugins.black", "autohooks.plugins.ruff"] 11 | 12 | [tool.black] 13 | line-length = 100 14 | target-version = ['py311'] 15 | exclude = ''' 16 | ( 17 | /( 18 | | \.git 19 | | \.pytest_cache 20 | | pyproject.toml 21 | | dist 22 | )/ 23 | ) 24 | ''' 25 | 26 | [tool.ruff] 27 | target-version = "py311" 28 | line-length = 100 29 | extend-exclude = ["example"] 30 | 31 | [tool.ruff.lint] 32 | select = ["I", "E", "F", "N", "B", "C4", "PT", "UP", "I001", "A", "RET", "TID251", "RUF", "SIM", "PYI", "T20", "PIE", "G", "ISC", "PL"] 33 | # E501 is disabled because line limit is controlled by black 34 | # RUF005 is disabled because we use asyncio tasks without cancelling 35 | # RUF015 is disabled because index access is preferred way for us 36 | # RUF019 is disabled because it's needed for pyright 37 | # PIE804 is disabled because we often use FHIR like camelCase variables 38 | # SIM102 is disabled because nested if's are more readable 39 | # SIM117 is disabled because nested with's are more readable 40 | ignore = ["E501", "RUF006", "RUF015", "RUF019", "PIE804", "SIM102", "SIM117", "PLR2004"] 41 | unfixable = ["F401"] 42 | 43 | 44 | [tool.setuptools] 45 | zip-safe = false 46 | packages = ["aidbox_python_sdk"] 47 | 48 | [tool.setuptools.dynamic] 49 | version = { attr = "aidbox_python_sdk.VERSION" } 50 | 51 | [build-system] 52 | requires = ["setuptools"] 53 | build-backend = "setuptools.build_meta" 54 | 55 | [project] 56 | name = "aidbox-python-sdk" 57 | description = "Aidbox SDK for python" 58 | readme = "README.md" 59 | license = { file = "LICENSE.md" } 60 | keywords = ["fhir"] 61 | dynamic = ["version"] 62 | authors = [ 63 | { name = "beda.software", email = "aidbox-python-sdk@beda.software" }, 64 | ] 65 | dependencies = [ 66 | "aiohttp>=3.6.2", 67 | "SQLAlchemy>=1.3.10", 68 | "fhirpy>=1.3.0", 69 | "jsonschema>=4.4.0", 70 | ] 71 | classifiers = [ 72 | "Development Status :: 5 - Production/Stable", 73 | "Environment :: Web Environment", 74 | "Intended Audience :: Developers", 75 | "Operating System :: OS Independent", 76 | "Programming Language :: Python", 77 | "Programming Language :: Python :: 3", 78 | "Programming Language :: Python :: 3.9", 79 | "Programming Language :: Python :: 3.10", 80 | "Programming Language :: Python :: 3.11", 81 | "Programming Language :: Python :: 3.12", 82 | "Topic :: Internet :: WWW/HTTP", 83 | "Topic :: Software Development :: Libraries :: Python Modules", 84 | ] 85 | requires-python = ">=3.8" 86 | 87 | [project.optional-dependencies] 88 | test = [ 89 | "pytest~=6.0.0", 90 | "pytest-asyncio~=0.20.0", 91 | "pytest-aiohttp~=1.0.4", 92 | "pytest-cov~=4.0.0" 93 | ] 94 | dev = [ 95 | "black", 96 | "autohooks", 97 | "autohooks-plugin-ruff", 98 | "autohooks-plugin-black", 99 | ] 100 | 101 | [project.urls] 102 | homepage = "https://github.com/Aidbox/aidbox-python-sdk" 103 | documentation = "https://github.com/Aidbox/aidbox-python-sdk#readme" 104 | repository = "https://github.com/Aidbox/aidbox-python-sdk.git" 105 | -------------------------------------------------------------------------------- /run_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -f "envs/aidbox" ]; then 4 | export `cat envs/aidbox` 5 | fi 6 | 7 | if [ -z "${AIDBOX_LICENSE}" ]; then 8 | echo "AIDBOX_LICENSE is required to run tests" 9 | exit 1 10 | fi 11 | 12 | 13 | docker compose -f docker-compose.yaml pull --quiet 14 | docker compose -f docker-compose.yaml up --exit-code-from app app 15 | 16 | exit $? 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | aidbox-python-sdk tests 3 | """ 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = ["aidbox_python_sdk.pytest_plugin", "pytester"] 2 | -------------------------------------------------------------------------------- /tests/test_sdk.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from unittest import mock 4 | 5 | import pytest 6 | from fhirpathpy import evaluate 7 | 8 | import main 9 | 10 | 11 | @pytest.mark.asyncio 12 | @pytest.mark.parametrize( 13 | ("expression", "expected"), 14 | [ 15 | ( 16 | "CapabilityStatement.rest.operation.where(definition='http://test.com').count()", 17 | 1, 18 | ), 19 | ( 20 | "CapabilityStatement.rest.operation.where(definition='http://test.com').first().name", 21 | "observation-custom-op", 22 | ), 23 | ( 24 | "CapabilityStatement.rest.resource.where(type='Observation').operation.where(definition='http://test.com').count()", 25 | 1, 26 | ), 27 | ( 28 | "CapabilityStatement.rest.resource.where(type='Observation').operation.where(definition='http://test.com').first().name", 29 | "observation-custom-op", 30 | ), 31 | ], 32 | ) 33 | async def test_operation_with_compliance_params(aidbox_client, expression, expected): 34 | response = await aidbox_client.execute("fhir/metadata", method="GET") 35 | assert evaluate(response, expression, {})[0] == expected 36 | 37 | 38 | @pytest.mark.asyncio() 39 | async def test_health_check(client): 40 | resp = await client.get("/health") 41 | assert resp.status == 200 42 | json = await resp.json() 43 | assert json == {"status": "OK"} 44 | 45 | 46 | @pytest.mark.asyncio() 47 | async def test_live_health_check(client): 48 | resp = await client.get("/live") 49 | assert resp.status == 200 50 | json = await resp.json() 51 | assert json == {"status": "OK"} 52 | 53 | 54 | @pytest.mark.skip() 55 | async def test_signup_reg_op(client, aidbox): 56 | resp = await aidbox.post("signup/register/21.02.19/testvalue") 57 | assert resp.status == 200 58 | json = await resp.json() 59 | assert json == { 60 | "success": "Ok", 61 | "request": {"date": "21.02.19", "test": "testvalue"}, 62 | } 63 | 64 | 65 | @pytest.mark.skip() 66 | async def test_appointment_sub(client, aidbox): 67 | with mock.patch.object(main, "_appointment_sub") as appointment_sub: 68 | f = asyncio.Future() 69 | f.set_result("") 70 | appointment_sub.return_value = f 71 | sdk = client.server.app["sdk"] 72 | was_appointment_sub_triggered = sdk.was_subscription_triggered("Appointment") 73 | resp = await aidbox.post( 74 | "Appointment", 75 | json={ 76 | "status": "proposed", 77 | "participant": [{"status": "accepted"}], 78 | "resourceType": "Appointment", 79 | }, 80 | ) 81 | assert resp.status == 201 82 | await was_appointment_sub_triggered 83 | event = appointment_sub.call_args_list[0][0][0] 84 | logging.debug("event: %s", event) 85 | expected = { 86 | "resource": { 87 | "status": "proposed", 88 | "participant": [{"status": "accepted"}], 89 | "resourceType": "Appointment", 90 | }, 91 | "action": "create", 92 | } 93 | assert expected["resource"].items() <= event["resource"].items() 94 | 95 | 96 | @pytest.mark.asyncio() 97 | async def test_database_isolation__1(aidbox_client, safe_db): 98 | patients = await aidbox_client.resources("Patient").fetch_all() 99 | assert len(patients) == 2 100 | 101 | patient = aidbox_client.resource("Patient") 102 | await patient.save() 103 | 104 | patients = await aidbox_client.resources("Patient").fetch_all() 105 | assert len(patients) == 3 106 | 107 | 108 | @pytest.mark.asyncio() 109 | async def test_database_isolation__2(aidbox_client, safe_db): 110 | patients = await aidbox_client.resources("Patient").fetch_all() 111 | assert len(patients) == 2 112 | 113 | patient = aidbox_client.resource("Patient") 114 | await patient.save() 115 | 116 | patient = aidbox_client.resource("Patient") 117 | await patient.save() 118 | 119 | patients = await aidbox_client.resources("Patient").fetch_all() 120 | assert len(patients) == 4 121 | -------------------------------------------------------------------------------- /wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 148 | WAITFORIT_ISBUSY=1 149 | WAITFORIT_BUSYTIMEFLAG="-t" 150 | 151 | else 152 | WAITFORIT_ISBUSY=0 153 | WAITFORIT_BUSYTIMEFLAG="" 154 | fi 155 | 156 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 157 | wait_for 158 | WAITFORIT_RESULT=$? 159 | exit $WAITFORIT_RESULT 160 | else 161 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 162 | wait_for_wrapper 163 | WAITFORIT_RESULT=$? 164 | else 165 | wait_for 166 | WAITFORIT_RESULT=$? 167 | fi 168 | fi 169 | 170 | if [[ $WAITFORIT_CLI != "" ]]; then 171 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 172 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 173 | exit $WAITFORIT_RESULT 174 | fi 175 | exec "${WAITFORIT_CLI[@]}" 176 | else 177 | exit $WAITFORIT_RESULT 178 | fi --------------------------------------------------------------------------------