├── .dockerignore ├── .github └── workflows │ └── checks.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── docker └── Dockerfile ├── docs ├── access_control │ └── README.md └── api │ ├── conf.py │ └── index.rst ├── examples ├── petstore-access-control │ ├── .dockerignore │ ├── Dockerfile │ ├── README.md │ ├── app.py │ ├── config.yaml │ ├── controllers.py │ ├── docker-compose.yaml │ ├── exceptions.py │ ├── petstore-access-control.yaml │ └── petstore_policies.conf └── petstore │ ├── .dockerignore │ ├── Dockerfile │ ├── README.md │ ├── app.py │ ├── config.yaml │ ├── controllers.py │ ├── docker-compose.yaml │ ├── exceptions.py │ ├── petstore.yaml │ └── petstore_policies.conf ├── foca ├── __init__.py ├── api │ ├── __init__.py │ └── register_openapi.py ├── config │ ├── __init__.py │ └── config_parser.py ├── database │ ├── __init__.py │ └── register_mongodb.py ├── errors │ ├── __init__.py │ └── exceptions.py ├── factories │ ├── __init__.py │ ├── celery_app.py │ └── connexion_app.py ├── foca.py ├── models │ ├── __init__.py │ └── config.py ├── security │ ├── __init__.py │ ├── access_control │ │ ├── __init__.py │ │ ├── access_control_server.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── access-control-specs.yaml │ │ │ └── default_model.conf │ │ ├── constants.py │ │ ├── foca_casbin_adapter │ │ │ ├── __init__.py │ │ │ ├── adapter.py │ │ │ └── casbin_rule.py │ │ └── register_access_control.py │ ├── auth.py │ └── cors.py ├── utils │ ├── __init__.py │ ├── db.py │ ├── logging.py │ └── misc.py └── version.py ├── images ├── casbin_model.jpeg ├── foca_favicon.svg ├── foca_logo.svg ├── foca_logo_192px.png ├── foca_logo_round.svg ├── foca_seal.svg ├── hint.svg ├── hint.svg.2021_02_16_09_54_34.0.svg └── logo-banner.svg ├── py.typed ├── requirements.txt ├── requirements_dev.txt ├── requirements_docs.txt ├── setup.cfg ├── setup.py ├── templates └── config.yaml └── tests ├── __init__.py ├── api ├── controllers │ └── __init__.py └── test_register_openapi.py ├── config └── test_config_parser.py ├── database └── test_register_mongodb.py ├── errors └── test_errors.py ├── factories ├── test_celery_app.py └── test_connexion_app.py ├── integration_tests.py ├── mock_data.py ├── models └── test_config.py ├── security ├── access_control │ ├── foca_casbin_adapter │ │ ├── test_adapter.py │ │ ├── test_casbin_rule.py │ │ └── test_files │ │ │ ├── rbac_model.conf │ │ │ └── rbac_with_resources_roles.conf │ ├── test_access_control_server.py │ └── test_register_access_control.py ├── test_auth.py └── test_cors.py ├── test_files ├── __init__.py ├── conf_api.yaml ├── conf_db.yaml ├── conf_invalid_access_control.yaml ├── conf_invalid_jobs.yaml ├── conf_invalid_log_level.yaml ├── conf_jobs.yaml ├── conf_log.yaml ├── conf_log_invalid.yaml ├── conf_no_jobs.yaml ├── conf_no_yaml.txt ├── conf_valid.yaml ├── conf_valid_access_control.yaml ├── conf_valid_cors_disabled.yaml ├── conf_valid_cors_enabled.yaml ├── conf_valid_custom_invalid.yaml ├── empty_conf.yaml ├── invalid.json ├── invalid.openapi.yaml ├── invalid_conf.yaml ├── invalid_conf_db.yaml ├── model_valid.py ├── models_petstore.py ├── openapi_2_petstore.addition.yaml ├── openapi_2_petstore.modified.yaml ├── openapi_2_petstore.original.json ├── openapi_2_petstore.original.yaml ├── openapi_3_petstore.modified.yaml ├── openapi_3_petstore.original.yaml ├── openapi_3_petstore_pathitemparam.modified.yaml └── openapi_3_petstore_pathitemparam.original.yaml ├── test_foca.py ├── test_version.py └── utils ├── test_db.py ├── test_logging.py └── test_misc.py /.dockerignore: -------------------------------------------------------------------------------- 1 | examples/ 2 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build the project with multiple Python versions, lint, run 2 | # tests, and build and push Docker images. 3 | # For more information see: 4 | # https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 5 | 6 | name: CI 7 | 8 | on: 9 | push: 10 | branches: [ dev ] 11 | pull_request: 12 | branches: [ dev ] 13 | 14 | jobs: 15 | Test: 16 | name: Run linting and unit tests 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: true 20 | matrix: 21 | py-version-img: [ 22 | ["3.9", "3.9-slim-bookworm"], 23 | ["3.10", "3.10-slim-bookworm"], 24 | ["3.11", "3.11-slim-bookworm"], 25 | ["3.12", "3.12-slim-bookworm"], 26 | ] 27 | mongodb-version: ["4.4", "5.0", "6.0", "7.0"] 28 | mongodb-port: [12345] 29 | 30 | steps: 31 | - name: Checkout Repository 32 | uses: actions/checkout@v4 33 | - name: Set up Python ${{ matrix.py-version-img[0] }} 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: ${{ matrix.py-version-img[0] }} 37 | - name: Install requirements 38 | run: | 39 | pip install -e .[dev] 40 | pip install setuptools 41 | - name: Lint with flake8 42 | run: flake8 43 | - name: Run mypy 44 | run: mypy foca 45 | - name: Start MongoDB 46 | uses: supercharge/mongodb-github-action@1.7.0 47 | with: 48 | mongodb-version: ${{ matrix.mongodb-version }} 49 | mongodb-port: ${{ matrix.mongodb-port }} 50 | - name: Calculate unit test coverage 51 | run: | 52 | coverage run --source foca -m pytest -W ignore::DeprecationWarning 53 | coverage xml 54 | - name: Upload coverage to Codecov 55 | uses: codecov/codecov-action@v4 56 | with: 57 | token: ${{ secrets.CODECOV_TOKEN }} 58 | flags: test_unit 59 | files: ./coverage.xml 60 | fail_ci_if_error: true 61 | verbose: true 62 | - name: Run tests on petstore app 63 | env: 64 | DOCKERHUB_ORG: ${{ secrets.DOCKERHUB_ORG }} 65 | REPO_NAME: ${{ github.event.repository.name }} 66 | run: | 67 | export PY_VERSION=${{ matrix.py-version-img[0] }} 68 | export PY_IMAGE=${{ matrix.py-version-img[1] }} 69 | docker build \ 70 | -t ${DOCKERHUB_ORG}/${REPO_NAME}:petstore \ 71 | -f docker/Dockerfile \ 72 | --build-arg PY_IMAGE=${PY_IMAGE} \ 73 | . 74 | cd ./examples/petstore 75 | docker-compose up --build -d 76 | cd ../.. 77 | sleep 10 78 | pytest ./tests/integration_tests.py 79 | 80 | Docker: 81 | runs-on: ubuntu-latest 82 | needs: [Test] 83 | strategy: 84 | fail-fast: true 85 | matrix: 86 | py-version-img-tag: [ 87 | ["3.9", "3.9-slim-bookworm", ""], 88 | ["3.10", "3.10-slim-bookworm", ""], 89 | ["3.11", "3.11-slim-bookworm", ""], 90 | ["3.12", "3.12-slim-bookworm", "latest"], 91 | ] 92 | 93 | steps: 94 | - name: Checkout Repository 95 | uses: actions/checkout@v4 96 | - name: Build & Publish image to DockerHub 97 | env: 98 | DOCKERHUB_ORG: ${{ secrets.DOCKERHUB_ORG }} 99 | DOCKERHUB_LOGIN: ${{ secrets.DOCKERHUB_LOGIN }} 100 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} 101 | REPO_NAME: ${{ github.event.repository.name }} 102 | run: | 103 | set -x 104 | export ON_LATEST=FALSE 105 | export ON_DEFAULT=FALSE 106 | export PY_VERSION=${{ matrix.py-version-img-tag[0] }} 107 | export PY_IMAGE=${{ matrix.py-version-img-tag[1] }} 108 | export PY_TAG=${{ matrix.py-version-img-tag[2] }} 109 | export DEFAULT_BRANCH=${{ github.event.repository.default_branch }} 110 | if [[ "$PY_TAG" == "latest" ]]; then 111 | export ON_LATEST=TRUE 112 | fi 113 | if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then 114 | export BRANCH_NAME=${GITHUB_REF##*/} 115 | elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then 116 | export BRANCH_NAME=${GITHUB_HEAD_REF##*/} 117 | else 118 | export BRANCH_NAME=INVALID_EVENT_BRANCH_UNKNOWN 119 | fi 120 | if [[ "$BRANCH_NAME" == "$DEFAULT_BRANCH" ]]; then 121 | export TAG=$(date '+%Y%m%d') 122 | export ON_DEFAULT=TRUE 123 | else 124 | export TAG=$BRANCH_NAME 125 | fi 126 | export TAG="${TAG}-py${PY_VERSION}" 127 | echo "TAG: ${TAG}" 128 | docker build \ 129 | -t ${DOCKERHUB_ORG}/${REPO_NAME}:${TAG} \ 130 | -f docker/Dockerfile \ 131 | --build-arg PY_IMAGE=${PY_IMAGE} \ 132 | . 133 | echo $DOCKERHUB_TOKEN | \ 134 | docker login -u $DOCKERHUB_LOGIN --password-stdin 135 | docker push ${DOCKERHUB_ORG}/${REPO_NAME}:${TAG} 136 | # if on default branch, we also want to update the "latest" tag 137 | if [[ "$ON_LATEST" = "TRUE" && "$ON_DEFAULT" == "TRUE" ]]; then 138 | docker tag \ 139 | ${DOCKERHUB_ORG}/${REPO_NAME}:${TAG} \ 140 | ${DOCKERHUB_ORG}/${REPO_NAME}:latest 141 | docker push ${DOCKERHUB_ORG}/${REPO_NAME}:latest 142 | fi 143 | rm ${HOME}/.docker/config.json 144 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/vim,code,emacs,python,pycharm,virtualenv,sublimetext 3 | # Edit at https://www.gitignore.io/?templates=vim,code,emacs,python,pycharm,virtualenv,sublimetext 4 | 5 | ### Code ### 6 | .vscode/* 7 | !.vscode/settings.json 8 | !.vscode/tasks.json 9 | !.vscode/launch.json 10 | !.vscode/extensions.json 11 | 12 | ### Emacs ### 13 | # -*- mode: gitignore; -*- 14 | *~ 15 | \#*\# 16 | /.emacs.desktop 17 | /.emacs.desktop.lock 18 | *.elc 19 | auto-save-list 20 | tramp 21 | .\#* 22 | 23 | # Org-mode 24 | .org-id-locations 25 | *_archive 26 | 27 | # flymake-mode 28 | *_flymake.* 29 | 30 | # eshell files 31 | /eshell/history 32 | /eshell/lastdir 33 | 34 | # elpa packages 35 | /elpa/ 36 | 37 | # reftex files 38 | *.rel 39 | 40 | # AUCTeX auto folder 41 | /auto/ 42 | 43 | # cask packages 44 | .cask/ 45 | dist/ 46 | 47 | # Flycheck 48 | flycheck_*.el 49 | 50 | # server auth directory 51 | /server/ 52 | 53 | # projectiles files 54 | .projectile 55 | 56 | # directory configuration 57 | .dir-locals.el 58 | 59 | # network security 60 | /network-security.data 61 | 62 | 63 | ### PyCharm ### 64 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 65 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 66 | 67 | # User-specific stuff 68 | .idea/**/workspace.xml 69 | .idea/**/tasks.xml 70 | .idea/**/usage.statistics.xml 71 | .idea/**/dictionaries 72 | .idea/**/shelf 73 | 74 | # Generated files 75 | .idea/**/contentModel.xml 76 | 77 | # Sensitive or high-churn files 78 | .idea/**/dataSources/ 79 | .idea/**/dataSources.ids 80 | .idea/**/dataSources.local.xml 81 | .idea/**/sqlDataSources.xml 82 | .idea/**/dynamic.xml 83 | .idea/**/uiDesigner.xml 84 | .idea/**/dbnavigator.xml 85 | 86 | # Gradle 87 | .idea/**/gradle.xml 88 | .idea/**/libraries 89 | 90 | # Gradle and Maven with auto-import 91 | # When using Gradle or Maven with auto-import, you should exclude module files, 92 | # since they will be recreated, and may cause churn. Uncomment if using 93 | # auto-import. 94 | # .idea/modules.xml 95 | # .idea/*.iml 96 | # .idea/modules 97 | # *.iml 98 | # *.ipr 99 | 100 | # CMake 101 | cmake-build-*/ 102 | 103 | # Mongo Explorer plugin 104 | .idea/**/mongoSettings.xml 105 | 106 | # File-based project format 107 | *.iws 108 | 109 | # IntelliJ 110 | out/ 111 | 112 | # mpeltonen/sbt-idea plugin 113 | .idea_modules/ 114 | 115 | # JIRA plugin 116 | atlassian-ide-plugin.xml 117 | 118 | # Cursive Clojure plugin 119 | .idea/replstate.xml 120 | 121 | # Crashlytics plugin (for Android Studio and IntelliJ) 122 | com_crashlytics_export_strings.xml 123 | crashlytics.properties 124 | crashlytics-build.properties 125 | fabric.properties 126 | 127 | # Editor-based Rest Client 128 | .idea/httpRequests 129 | 130 | # Android studio 3.1+ serialized cache file 131 | .idea/caches/build_file_checksums.ser 132 | 133 | ### PyCharm Patch ### 134 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 135 | 136 | # *.iml 137 | # modules.xml 138 | # .idea/misc.xml 139 | # *.ipr 140 | 141 | # Sonarlint plugin 142 | .idea/**/sonarlint/ 143 | 144 | # SonarQube Plugin 145 | .idea/**/sonarIssues.xml 146 | 147 | # Markdown Navigator plugin 148 | .idea/**/markdown-navigator.xml 149 | .idea/**/markdown-navigator/ 150 | 151 | ### Python ### 152 | # Byte-compiled / optimized / DLL files 153 | __pycache__/ 154 | *.py[cod] 155 | *$py.class 156 | 157 | # C extensions 158 | *.so 159 | 160 | # Distribution / packaging 161 | .Python 162 | build/ 163 | develop-eggs/ 164 | downloads/ 165 | eggs/ 166 | .eggs/ 167 | lib/ 168 | lib64/ 169 | parts/ 170 | sdist/ 171 | var/ 172 | wheels/ 173 | pip-wheel-metadata/ 174 | share/python-wheels/ 175 | *.egg-info/ 176 | .installed.cfg 177 | *.egg 178 | MANIFEST 179 | 180 | # PyInstaller 181 | # Usually these files are written by a python script from a template 182 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 183 | *.manifest 184 | *.spec 185 | 186 | # Installer logs 187 | pip-log.txt 188 | pip-delete-this-directory.txt 189 | 190 | # Unit test / coverage reports 191 | htmlcov/ 192 | .tox/ 193 | .nox/ 194 | .coverage 195 | .coverage.* 196 | .cache 197 | nosetests.xml 198 | coverage.xml 199 | *.cover 200 | .hypothesis/ 201 | .pytest_cache/ 202 | 203 | # Translations 204 | *.mo 205 | *.pot 206 | 207 | # Scrapy stuff: 208 | .scrapy 209 | 210 | # Sphinx documentation 211 | docs/_build/ 212 | 213 | # PyBuilder 214 | target/ 215 | 216 | # pyenv 217 | .python-version 218 | 219 | # pipenv 220 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 221 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 222 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 223 | # install all needed dependencies. 224 | #Pipfile.lock 225 | 226 | # celery beat schedule file 227 | celerybeat-schedule 228 | 229 | # SageMath parsed files 230 | *.sage.py 231 | 232 | # Spyder project settings 233 | .spyderproject 234 | .spyproject 235 | 236 | # Rope project settings 237 | .ropeproject 238 | 239 | # Mr Developer 240 | .mr.developer.cfg 241 | .project 242 | .pydevproject 243 | 244 | # mkdocs documentation 245 | /site 246 | 247 | # mypy 248 | .mypy_cache/ 249 | .dmypy.json 250 | dmypy.json 251 | 252 | # Pyre type checker 253 | .pyre/ 254 | 255 | ### SublimeText ### 256 | # Cache files for Sublime Text 257 | *.tmlanguage.cache 258 | *.tmPreferences.cache 259 | *.stTheme.cache 260 | 261 | # Workspace files are user-specific 262 | *.sublime-workspace 263 | 264 | # Project files should be checked into the repository, unless a significant 265 | # proportion of contributors will probably not be using Sublime Text 266 | # *.sublime-project 267 | 268 | # SFTP configuration file 269 | sftp-config.json 270 | 271 | # Package control specific files 272 | Package Control.last-run 273 | Package Control.ca-list 274 | Package Control.ca-bundle 275 | Package Control.system-ca-bundle 276 | Package Control.cache/ 277 | Package Control.ca-certs/ 278 | Package Control.merged-ca-bundle 279 | Package Control.user-ca-bundle 280 | oscrypto-ca-bundle.crt 281 | bh_unicode_properties.cache 282 | 283 | # Sublime-github package stores a github token in this file 284 | # https://packagecontrol.io/packages/sublime-github 285 | GitHub.sublime-settings 286 | 287 | ### Vim ### 288 | # Swap 289 | [._]*.s[a-v][a-z] 290 | [._]*.sw[a-p] 291 | [._]s[a-rt-v][a-z] 292 | [._]ss[a-gi-z] 293 | [._]sw[a-p] 294 | 295 | # Session 296 | Session.vim 297 | Sessionx.vim 298 | 299 | # Temporary 300 | .netrwhist 301 | 302 | # Auto-generated tag files 303 | tags 304 | 305 | # Persistent undo 306 | [._]*.un~ 307 | 308 | # Coc configuration directory 309 | .vim 310 | 311 | ### VirtualEnv ### 312 | # Virtualenv 313 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 314 | pyvenv.cfg 315 | .env 316 | .venv 317 | env/ 318 | venv/ 319 | ENV/ 320 | env.bak/ 321 | venv.bak/ 322 | pip-selfcheck.json 323 | 324 | # End of https://www.gitignore.io/api/vim,code,emacs,python,pycharm,virtualenv,sublimetext 325 | 326 | # Custom additions 327 | .vscode 328 | examples/petstore/petstore.modified.yaml 329 | examples/petstore/data/ 330 | examples/petstore-access-control/petstore-access-control.modified.yaml 331 | examples/petstore-access-control/data/ 332 | *.modified.yaml 333 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.10" 12 | # You can also specify other tool versions: 13 | # nodejs: "20" 14 | # rust: "1.70" 15 | # golang: "1.20" 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/api/conf.py 20 | # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs 21 | # builder: "dirhtml" 22 | # Fail on all warnings to avoid broken references 23 | # fail_on_warning: true 24 | 25 | # Optionally build your docs in additional formats such as PDF and ePub 26 | # formats: 27 | # - pdf 28 | # - epub 29 | 30 | python: 31 | install: 32 | - method: pip 33 | path: . 34 | extra_requirements: 35 | - docs 36 | - dev 37 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Package setuptools-git will ensure that all files that are version-controlled 2 | # are packaged. Therefore, this file lists only those files that are not 3 | # supposed to end up in distributions. 4 | 5 | exclude .gitignore 6 | prune .github 7 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] Documentation update 15 | 16 | ## Checklist: 17 | 18 | - [ ] My code follows the [style guidelines](https://github.com/elixir-cloud-aai/elixir-cloud-aai/blob/dev/resources/contributing_guidelines.md#language-specific-guidelines) of this project 19 | - [ ] I have performed a self-review of my own code 20 | - [ ] I have commented my code, particularly in hard-to-understand areas 21 | - [ ] I have updated the documentation (or documentation does not need to be updated) 22 | - [ ] My changes generate no new warnings 23 | - [ ] I have added tests that prove my fix is effective or that my feature works 24 | - [ ] New and existing unit tests pass locally with my changes 25 | - [ ] I have not reduced the existing code coverage 26 | 27 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PY_IMAGE=3.12-slim-bookworm 2 | FROM python:$PY_IMAGE as base 3 | 4 | # Metadata 5 | LABEL software="FOCA" 6 | LABEL software.description="Kickstart OpenAPI-based microservice development with Flask & Connexion" 7 | LABEL software.website="https://github.com/elixir-cloud-aai/foca" 8 | LABEL software.license="https://spdx.org/licenses/Apache-2.0" 9 | LABEL maintainer="alexander.kanitz@alumni.ethz.ch" 10 | LABEL maintainer.organisation="ELIXIR Cloud & AAI" 11 | 12 | # Build image 13 | FROM base as builder 14 | 15 | # Install general dependencies 16 | ENV PACKAGES openssl git build-essential python3-dev curl jq 17 | RUN apt-get update && \ 18 | apt-get install -y --no-install-recommends ${PACKAGES} && \ 19 | rm -rf /var/lib/apt/lists/* 20 | WORKDIR /app 21 | 22 | # Install Python dependencies 23 | COPY requirements.txt ./ 24 | RUN pip install \ 25 | --no-warn-script-location \ 26 | --prefix="/install" \ 27 | -r requirements.txt 28 | 29 | # Install FOCA 30 | COPY setup.py README.md ./ 31 | COPY foca/ ./foca/ 32 | RUN pip install --upgrade pkginfo && \ 33 | pip install . \ 34 | --no-warn-script-location \ 35 | --prefix="/install" 36 | 37 | # Final image 38 | FROM base 39 | 40 | # Python UserID workaround for OpenShift/K8S 41 | ENV LOGNAME=ipython 42 | ENV USER=ipython 43 | ENV HOME=/tmp/user 44 | 45 | # Install general dependencies 46 | ENV PACKAGES openssl git build-essential python3-dev curl jq 47 | RUN apt-get update && \ 48 | apt-get install -y --no-install-recommends ${PACKAGES} && \ 49 | rm -rf /var/lib/apt/lists/* 50 | 51 | # Copy Python packages 52 | COPY --from=builder /install /usr/local 53 | -------------------------------------------------------------------------------- /docs/api/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | from pathlib import Path 14 | import sys 15 | 16 | from sphinx.ext import apidoc 17 | 18 | root_dir = Path(__file__).resolve().parents[2] 19 | sys.path.insert(0, str(root_dir)) 20 | 21 | exec(open(root_dir / "foca" / "version.py").read()) 22 | 23 | # -- Project information ----------------------------------------------------- 24 | 25 | project = 'FOCA' 26 | copyright = '2022, ELIXIR Cloud & AAI' 27 | author = 'ELIXIR Cloud & AAI' 28 | 29 | # The full version, including alpha/beta/rc tags 30 | release = __version__ # noqa: F821 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be 36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 37 | # ones. 38 | extensions = [ 39 | 'sphinx.ext.autodoc', 40 | 'sphinx.ext.napoleon', 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # List of patterns, relative to source directory, that match files and 47 | # directories to ignore when looking for source files. 48 | # This pattern also affects html_static_path and html_extra_path. 49 | exclude_patterns = ['_build'] 50 | 51 | 52 | # Default doc to search for 53 | master_doc = 'index' 54 | 55 | # -- Options for HTML output ------------------------------------------------- 56 | 57 | # The theme to use for HTML and HTML Help pages. See the documentation for 58 | # a list of builtin themes. 59 | # 60 | html_theme = 'sphinx_rtd_theme' 61 | 62 | # Add any paths that contain custom static files (such as style sheets) here, 63 | # relative to this directory. They are copied after the builtin static files, 64 | # so a file named "default.css" will overwrite the builtin "default.css". 65 | html_static_path = [] 66 | 67 | 68 | # -- Automation ------------------------------------------------------------- 69 | 70 | # Auto-generate API doc 71 | def run_apidoc(_): 72 | ignore_paths = [ 73 | ] 74 | argv = [ 75 | "--force", 76 | "--module-first", 77 | "-o", "./modules", 78 | "../../foca" 79 | ] + ignore_paths 80 | apidoc.main(argv) 81 | 82 | 83 | def setup(app): 84 | app.connect('builder-inited', run_apidoc) 85 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | .. Foca documentation master file, created by 2 | sphinx-quickstart on Thu Jun 4 16:04:23 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | FOCA API docs 7 | ============= 8 | 9 | .. toctree:: 10 | :caption: Modules 11 | 12 | modules/modules 13 | 14 | Indices and tables 15 | ================== 16 | 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | 20 | -------------------------------------------------------------------------------- /examples/petstore-access-control/.dockerignore: -------------------------------------------------------------------------------- 1 | /data 2 | -------------------------------------------------------------------------------- /examples/petstore-access-control/Dockerfile: -------------------------------------------------------------------------------- 1 | ### BASE IMAGE ### 2 | FROM foca-petstore-access-control-root:latest 3 | 4 | # Metadata 5 | LABEL software="Petstore access control application" 6 | LABEL software.description="Example application for FOCA microservice archetype with access control" 7 | LABEL software.website="https://github.com/elixir-cloud-aai/foca" 8 | LABEL software.documentation="https://github.com/elixir-cloud-aai/foca" 9 | LABEL software.license="https://spdx.org/licenses/Apache-2.0" 10 | LABEL maintainer="alexander.kanitz@alumni.ethz.ch" 11 | LABEL maintainer.organisation="ELIXIR Cloud & AAI" 12 | 13 | ## Set working directory 14 | WORKDIR /app 15 | 16 | ## Copy app files 17 | COPY ./ /app -------------------------------------------------------------------------------- /examples/petstore-access-control/app.py: -------------------------------------------------------------------------------- 1 | """Entry point for petstore example app.""" 2 | 3 | from foca import Foca 4 | 5 | if __name__ == '__main__': 6 | foca = Foca( 7 | config_file="config.yaml" 8 | ) 9 | app = foca.create_app() 10 | app.run() 11 | -------------------------------------------------------------------------------- /examples/petstore-access-control/config.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | host: '0.0.0.0' 3 | port: 8080 4 | debug: True 5 | environment: development 6 | testing: False 7 | use_reloader: True 8 | 9 | security: 10 | auth: 11 | required: True 12 | add_key_to_claims: True 13 | allow_expired: False 14 | audience: null 15 | claim_identity: sub 16 | claim_issuer: iss 17 | algorithms: 18 | - RS256 19 | validation_methods: 20 | - userinfo 21 | - public_key 22 | validation_checks: all 23 | access_control: 24 | owner_headers: ['user_id'] 25 | user_headers: ['user_id'] 26 | 27 | api: 28 | specs: 29 | - path: petstore-access-control.yaml 30 | append: 31 | - security: 32 | - bearerAuth: [] 33 | add_security_fields: 34 | x-bearerInfoFunc: foca.security.auth.validate_token 35 | add_operation_fields: 36 | x-openapi-router-controller: controllers 37 | connexion: 38 | strict_validation: True 39 | validate_responses: False 40 | options: 41 | swagger_ui: True 42 | serve_spec: True 43 | 44 | db: 45 | host: mongodb 46 | port: 27017 47 | dbs: 48 | petstore-access-control: 49 | collections: 50 | pets: 51 | indexes: null 52 | 53 | exceptions: 54 | required_members: [['message'], ['code']] 55 | status_member: ['code'] 56 | exceptions: exceptions.exceptions 57 | -------------------------------------------------------------------------------- /examples/petstore-access-control/controllers.py: -------------------------------------------------------------------------------- 1 | """Petstore access control controllers.""" 2 | 3 | import logging 4 | 5 | from flask import (current_app, make_response) 6 | from pymongo.collection import Collection 7 | 8 | from exceptions import NotFound 9 | from foca.security.access_control.register_access_control import ( 10 | check_permissions 11 | ) 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @check_permissions 17 | def findPets(limit=None, tags=None): 18 | db_collection: Collection = ( 19 | current_app.config.foca.db.dbs['petstore-access-control'] 20 | .collections['pets'].client 21 | ) 22 | filter_dict = {} if tags is None else {'tag': {'$in': tags}} 23 | if not limit: 24 | limit = 0 25 | records = db_collection.find( 26 | filter_dict, 27 | {'_id': False} 28 | ).sort([('$natural', -1)]).limit(limit) 29 | return list(records) 30 | 31 | 32 | @check_permissions 33 | def addPet(pet): 34 | db_collection: Collection = ( 35 | current_app.config.foca.db.dbs['petstore-access-control'] 36 | .collections['pets'].client 37 | ) 38 | counter = 0 39 | ctr = db_collection.find({}).sort([('$natural', -1)]) 40 | if not db_collection.count_documents({}) == 0: 41 | counter = ctr[0].get('id') + 1 42 | record = { 43 | "id": counter, 44 | "name": pet['name'], 45 | "tag": pet['tag'] 46 | } 47 | db_collection.insert_one(record) 48 | del record['_id'] 49 | return record 50 | 51 | 52 | @check_permissions 53 | def findPetById(id): 54 | db_collection: Collection = ( 55 | current_app.config.foca.db.dbs['petstore-access-control'] 56 | .collections['pets'].client 57 | ) 58 | record = db_collection.find_one( 59 | {"id": id}, 60 | {'_id': False}, 61 | ) 62 | if record is None: 63 | raise NotFound 64 | return record 65 | 66 | 67 | @check_permissions 68 | def deletePet(id): 69 | db_collection: Collection = ( 70 | current_app.config.foca.db.dbs['petstore-access-control'] 71 | .collections['pets'].client 72 | ) 73 | record = db_collection.find_one( 74 | {"id": id}, 75 | {'_id': False}, 76 | ) 77 | if record is None: 78 | raise NotFound 79 | db_collection.delete_one( 80 | {"id": id}, 81 | ) 82 | response = make_response('', 204) 83 | return response 84 | -------------------------------------------------------------------------------- /examples/petstore-access-control/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | 4 | # build app image based on current FOCA root image 5 | foca-petstore-access-control-root: 6 | build: 7 | context: ../../ 8 | dockerfile: docker/Dockerfile 9 | args: 10 | PY_IMAGE: ${PETSTORE_PY_IMAGE:-3.10-slim-buster} 11 | image: foca-petstore-access-control-root:latest 12 | restart: "no" 13 | 14 | app: 15 | image: elixircloud/foca-petstore-access-control:latest 16 | depends_on: 17 | - foca-petstore-access-control-root 18 | build: 19 | context: . 20 | dockerfile: Dockerfile 21 | restart: unless-stopped 22 | links: 23 | - mongodb 24 | command: bash -c "python app.py" 25 | ports: 26 | - "80:8080" 27 | 28 | mongodb: 29 | image: mongo:7.0 30 | restart: unless-stopped 31 | volumes: 32 | - ./data/petstore-access-control/db:/data/db 33 | ports: 34 | - "27017:27017" 35 | -------------------------------------------------------------------------------- /examples/petstore-access-control/exceptions.py: -------------------------------------------------------------------------------- 1 | """Petstore exceptions.""" 2 | 3 | from connexion.exceptions import ( 4 | BadRequestProblem, 5 | ExtraParameterProblem, 6 | Forbidden, 7 | Unauthorized, 8 | OAuthProblem, 9 | ) 10 | from werkzeug.exceptions import ( 11 | BadRequest, 12 | InternalServerError, 13 | NotFound, 14 | ) 15 | 16 | exceptions = { 17 | Exception: { 18 | "message": "Oops, something unexpected happened.", 19 | "code": 500, 20 | }, 21 | BadRequestProblem: { 22 | "message": "We don't quite understand what it is you are looking for.", 23 | "code": 400, 24 | }, 25 | BadRequest: { 26 | "message": "We don't quite understand what it is you are looking for.", 27 | "code": 400, 28 | }, 29 | ExtraParameterProblem: { 30 | "message": "We don't quite understand what it is you are looking for.", 31 | "code": 400, 32 | }, 33 | OAuthProblem: { 34 | "message": ( 35 | "I will need to see some identification first, before I let you " 36 | "play with the pets." 37 | ), 38 | "code": 401, 39 | }, 40 | Unauthorized: { 41 | "message": ( 42 | "We will need to see some identification before we let you play " 43 | "with the pets." 44 | ), 45 | "code": 401, 46 | }, 47 | Forbidden: { 48 | "message": ( 49 | "Sorry, but you don't have permission to play with the pets." 50 | ), 51 | "code": 403, 52 | }, 53 | NotFound: { 54 | "message": "We have never heard of this pet! :-(", 55 | "code": 404, 56 | }, 57 | InternalServerError: { 58 | "message": "We seem to be having a problem here in the petstore.", 59 | "code": 500, 60 | }, 61 | } 62 | -------------------------------------------------------------------------------- /examples/petstore-access-control/petstore-access-control.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.2 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification 6 | termsOfService: http://swagger.io/terms/ 7 | license: 8 | name: Apache 2.0 9 | url: https://www.apache.org/licenses/LICENSE-2.0.html 10 | servers: 11 | - url: http://localhost/ 12 | paths: 13 | /pets: 14 | get: 15 | description: | 16 | Returns all pets from the system that the user has access to. 17 | operationId: findPets 18 | parameters: 19 | - name: tags 20 | in: query 21 | description: tags to filter by 22 | required: false 23 | style: form 24 | schema: 25 | type: array 26 | items: 27 | type: string 28 | - name: limit 29 | in: query 30 | description: maximum number of results to return 31 | required: false 32 | schema: 33 | type: integer 34 | format: int32 35 | responses: 36 | '200': 37 | description: pet response 38 | content: 39 | application/json: 40 | schema: 41 | type: array 42 | items: 43 | $ref: '#/components/schemas/Pet' 44 | '401': 45 | description: The request is unauthorized. 46 | content: 47 | application/json: 48 | schema: 49 | $ref: '#/components/schemas/Error' 50 | '403': 51 | description: The requester is not authorized to perform this action. 52 | content: 53 | application/json: 54 | schema: 55 | $ref: '#/components/schemas/Error' 56 | default: 57 | description: unexpected error 58 | content: 59 | application/json: 60 | schema: 61 | $ref: '#/components/schemas/Error' 62 | post: 63 | description: Creates a new pet in the store. Duplicates are allowed 64 | operationId: addPet 65 | requestBody: 66 | description: Pet to add to the store 67 | required: true 68 | content: 69 | application/json: 70 | schema: 71 | x-body-name: pet 72 | $ref: '#/components/schemas/NewPet' 73 | responses: 74 | '200': 75 | description: pet response 76 | content: 77 | application/json: 78 | schema: 79 | $ref: '#/components/schemas/Pet' 80 | '401': 81 | description: The request is unauthorized. 82 | content: 83 | application/json: 84 | schema: 85 | $ref: '#/components/schemas/Error' 86 | '403': 87 | description: The requester is not authorized to perform this action. 88 | content: 89 | application/json: 90 | schema: 91 | $ref: '#/components/schemas/Error' 92 | default: 93 | description: unexpected error 94 | content: 95 | application/json: 96 | schema: 97 | $ref: '#/components/schemas/Error' 98 | /pets/{id}: 99 | get: 100 | description: Returns a user based on a single ID, if the user does not have access to the pet 101 | operationId: findPetById 102 | parameters: 103 | - name: id 104 | in: path 105 | description: ID of pet to fetch 106 | required: true 107 | schema: 108 | type: integer 109 | format: int64 110 | responses: 111 | '200': 112 | description: pet response 113 | content: 114 | application/json: 115 | schema: 116 | $ref: '#/components/schemas/Pet' 117 | '401': 118 | description: The request is unauthorized. 119 | content: 120 | application/json: 121 | schema: 122 | $ref: '#/components/schemas/Error' 123 | '403': 124 | description: The requester is not authorized to perform this action. 125 | content: 126 | application/json: 127 | schema: 128 | $ref: '#/components/schemas/Error' 129 | default: 130 | description: unexpected error 131 | content: 132 | application/json: 133 | schema: 134 | $ref: '#/components/schemas/Error' 135 | delete: 136 | description: deletes a single pet based on the ID supplied 137 | operationId: deletePet 138 | parameters: 139 | - name: id 140 | in: path 141 | description: ID of pet to delete 142 | required: true 143 | schema: 144 | type: integer 145 | format: int64 146 | responses: 147 | '204': 148 | description: pet deleted 149 | '401': 150 | description: The request is unauthorized. 151 | content: 152 | application/json: 153 | schema: 154 | $ref: '#/components/schemas/Error' 155 | '403': 156 | description: The requester is not authorized to perform this action. 157 | content: 158 | application/json: 159 | schema: 160 | $ref: '#/components/schemas/Error' 161 | default: 162 | description: unexpected error 163 | content: 164 | application/json: 165 | schema: 166 | $ref: '#/components/schemas/Error' 167 | components: 168 | securitySchemes: 169 | bearerAuth: 170 | type: http 171 | scheme: bearer 172 | bearerFormat: JWT 173 | schemas: 174 | Pet: 175 | allOf: 176 | - $ref: '#/components/schemas/NewPet' 177 | - type: object 178 | required: 179 | - id 180 | properties: 181 | id: 182 | type: integer 183 | format: int64 184 | 185 | NewPet: 186 | type: object 187 | required: 188 | - name 189 | properties: 190 | name: 191 | type: string 192 | tag: 193 | type: string 194 | 195 | Error: 196 | type: object 197 | required: 198 | - code 199 | - message 200 | properties: 201 | code: 202 | type: integer 203 | format: int32 204 | message: 205 | type: string 206 | -------------------------------------------------------------------------------- /examples/petstore-access-control/petstore_policies.conf: -------------------------------------------------------------------------------- 1 | # Request definition 2 | [request_definition] 3 | r = sub, obj, act 4 | 5 | # Policy definition 6 | [policy_definition] 7 | p = sub, obj, act 8 | 9 | # Policy effect 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | # Matchers 14 | [matchers] 15 | m = r.sub == p.sub && r.obj == p.obj && r.act == p.act -------------------------------------------------------------------------------- /examples/petstore/.dockerignore: -------------------------------------------------------------------------------- 1 | /data 2 | -------------------------------------------------------------------------------- /examples/petstore/Dockerfile: -------------------------------------------------------------------------------- 1 | ### BASE IMAGE ### 2 | FROM foca-petstore-root:latest 3 | 4 | # Metadata 5 | LABEL software="Petstore application" 6 | LABEL software.description="Example application for FOCA microservice archetype" 7 | LABEL software.website="https://github.com/elixir-cloud-aai/foca" 8 | LABEL software.documentation="https://github.com/elixir-cloud-aai/foca" 9 | LABEL software.license="https://spdx.org/licenses/Apache-2.0" 10 | LABEL maintainer="alexander.kanitz@alumni.ethz.ch" 11 | LABEL maintainer.organisation="ELIXIR Cloud & AAI" 12 | 13 | ## Set working directory 14 | WORKDIR /app 15 | 16 | ## Copy app files 17 | COPY ./ /app -------------------------------------------------------------------------------- /examples/petstore/README.md: -------------------------------------------------------------------------------- 1 | # FOCA-Petstore 2 | 3 | Dockerized [Petstore][res-petstore] example application implemented using 4 | [FOCA][res-foca]. 5 | 6 | ## Description 7 | 8 | The example demonstrates how FOCA sets up a fully configured [Flask][res-flask] 9 | app when passed an appropriately formatted [configuration 10 | file][docs-config-file]. 11 | 12 | FOCA makes sure that 13 | 14 | * the returned app instance contains all [configuration parameters][app-config] 15 | * FOCA configuration parameters are validated 16 | * requests and responses sent to/from the API endpoints configured in the 17 | [Petstore][app-specs] [OpenAPI][res-openapi] specification are validated 18 | * a [MongoDB][res-mongo-db] collection to store pets in is registered with the 19 | app 20 | * [CORS][res-cors] is enabled 21 | * exceptions are handled according to the definitions in the `exceptions` 22 | dictionary in module [`exceptions`][app-exceptions] 23 | 24 | Apart from writing the configuration file, all that was left for us to do to 25 | set up this example app was to write a _very_ simple app [entry point 26 | module][app-entrypoint], implement the [endpoint controller 27 | logic][app-controllers] and prepare the [`Dockerfile`][app-dockerfile] and 28 | [Docker Compose][res-docker-compose] [configuration][app-docker-compose] for 29 | easy shipping/installation! 30 | 31 | ![Hint][img-hint] _**Check the [FOCA documentation][docs] for further 32 | details.**_ 33 | 34 | ## Installation 35 | 36 | ### Requirements 37 | 38 | Ensure you have the following software installed: 39 | 40 | * Docker (19.03.4, build 9013bf583a) 41 | * docker-compose (1.25.5) 42 | * Git (2.17.1) 43 | 44 | > Indicated versions were used for developing/testing. Other versions may or 45 | > may not work. Please let us know if you encounter any issues with versions 46 | > _newer_ than the listed ones. 47 | 48 | ### Deploy app 49 | 50 | Clone repository: 51 | 52 | ```bash 53 | git clone https://github.com/elixir-cloud-aai/foca.git 54 | ``` 55 | 56 | Traverse to the Petstore app (_this_) directory: 57 | 58 | ```bash 59 | cd foca/examples/petstore 60 | ``` 61 | 62 | Build and run services in detached/daemonized mode: 63 | 64 | ```bash 65 | docker-compose up -d --build 66 | ``` 67 | 68 | Some notes: 69 | 70 | > * This will build the app based on the _current_ state of your FOCA clone. 71 | > That is, any changes that you may have introduced to FOCA will be reflected 72 | > in your build. 73 | > * By default, the app will be build based on the latest Python version that 74 | > FOCA supports. If you would like to build the FOCA image based on a different 75 | > version of Python, you can set the Python image to be used as FOCA's base 76 | > image by defining the environment variable `PETSTORE_PY_IMAGE` before 77 | > executing the build command. For example: 78 | > 79 | > ```bash 80 | > export PETSTORE_PY_IMAGE=3.12-slim-bookworm 81 | > ``` 82 | > 83 | > * In case Docker complains about port conflicts or if any of the used ports 84 | > are blocked by a firewall, you will need to re-map the conflicting port(s) in 85 | > the [Docker Compose config][app-docker-compose]. In particular, for each of 86 | > the services that failed to start because of a port conflict, you will need 87 | > to change the **first** of the two numbers listed below the corresponding 88 | > `ports` keyword to some unused/open port. 89 | 90 | That's it, you can now visit the application's [Swagger UI][res-swagger-ui] in 91 | your browser, e.g.,: 92 | 93 | ```bash 94 | firefox http://localhost/ui # or use your browser of choice 95 | ``` 96 | 97 | Some notes: 98 | 99 | > * Mac users may need to replace `localhost` with `0.0.0.0`. 100 | > * If you have changed the mapped port for the `app` service you will need to 101 | > manually append it to `localhost` when you access the API (or the Swagger UI) 102 | > in subsequent steps. For example, assuming you would like to run the app on 103 | > port `8080` instead of the default of `80`, then the app will be availabe 104 | > at `http://localhost:8080/`. 105 | 106 | ## Explore app 107 | 108 | In the [Swagger UI][res-swagger-ui], you may use the `GET`/`POST` endpoints by 109 | providing the required/desired values based on the indicated descriptions, then 110 | hit the `Try it out!` button! 111 | 112 | Alternatively, you can access the API endpoints programmatically, e.g., via 113 | [`curl`][res-curl]: 114 | 115 | * To **register a new pet**: 116 | 117 | ```console 118 | curl -X POST \ 119 | --header 'Accept: application/json' \ 120 | --header 'Content-Type: application/json' \ 121 | -d '{"name":"You","tag":"cat"}' \ 122 | 'http://localhost/pets' 123 | ``` 124 | 125 | * To **retrieve all registered pets**: 126 | 127 | ```console 128 | curl -X GET \ 129 | --header 'Accept: application/json' \ 130 | 'http://localhost/pets' 131 | ``` 132 | 133 | * To **retrieve information on a specific pet**: 134 | 135 | ```console 136 | curl -X GET \ 137 | --header 'Accept: application/json' \ 138 | 'http://localhost/pets/0' # replace 0 with the the pet ID of choice 139 | ``` 140 | 141 | * To **delete a pet**: :-( 142 | 143 | ```console 144 | curl -X DELETE \ 145 | --header 'Accept: application/json' \ 146 | 'http://localhost/pets/0' # replace 0 with the the pet ID of choice 147 | ``` 148 | 149 | ## Modify app 150 | 151 | You can make use of this example to create your own app. Just modify any or all 152 | of the following: 153 | 154 | * [FOCA configuration file][app-config] 155 | * [Main application module][app-entrypoint] 156 | * [API specification][app-specs] 157 | * [Endpoint controller module][app-controllers] 158 | * [Registered exceptions][app-exceptions] 159 | * [`Dockerfile`][app-dockerfile] 160 | * [`docker-compose` configuration][app-docker-compose] 161 | 162 | [app-config]: config.yaml 163 | [app-controllers]: controllers.py 164 | [app-dockerfile]: Dockerfile 165 | [app-docker-compose]: docker-compose.yaml 166 | [app-exceptions]: exceptions.py 167 | [app-entrypoint]: app.py 168 | [app-specs]: petstore.yaml 169 | [docs]: 170 | [docs-config-file]: ../../README.md#configuration-file 171 | [img-hint]: ../../images/hint.svg 172 | [res-cors]: 173 | [res-curl]: 174 | [res-docker-compose]: 175 | [res-flask]: 176 | [res-foca]: 177 | [res-mongo-db]: 178 | [res-openapi]: 179 | [res-petstore]: 180 | [res-swagger-ui]: 181 | -------------------------------------------------------------------------------- /examples/petstore/app.py: -------------------------------------------------------------------------------- 1 | """Entry point for petstore example app.""" 2 | 3 | from foca import Foca 4 | 5 | if __name__ == '__main__': 6 | foca = Foca( 7 | config_file="config.yaml" 8 | ) 9 | app = foca.create_app() 10 | app.run() 11 | -------------------------------------------------------------------------------- /examples/petstore/config.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | host: '0.0.0.0' 3 | port: 8080 4 | debug: True 5 | environment: development 6 | testing: False 7 | use_reloader: True 8 | 9 | security: 10 | auth: 11 | required: True 12 | add_key_to_claims: True 13 | allow_expired: False 14 | audience: null 15 | claim_identity: sub 16 | claim_issuer: iss 17 | algorithms: 18 | - RS256 19 | validation_methods: 20 | - userinfo 21 | - public_key 22 | validation_checks: all 23 | 24 | api: 25 | specs: 26 | - path: petstore.yaml 27 | append: null 28 | add_operation_fields: 29 | x-openapi-router-controller: controllers 30 | connexion: 31 | strict_validation: True 32 | validate_responses: False 33 | options: 34 | swagger_ui: True 35 | serve_spec: True 36 | 37 | db: 38 | host: mongodb 39 | port: 27017 40 | dbs: 41 | petstore: 42 | collections: 43 | pets: 44 | indexes: null 45 | 46 | exceptions: 47 | required_members: [['message'], ['code']] 48 | status_member: ['code'] 49 | exceptions: exceptions.exceptions 50 | -------------------------------------------------------------------------------- /examples/petstore/controllers.py: -------------------------------------------------------------------------------- 1 | """Petstore controllers.""" 2 | 3 | import logging 4 | 5 | from flask import (current_app, make_response) 6 | from pymongo.collection import Collection 7 | 8 | from exceptions import NotFound 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def findPets(limit=None, tags=None): 14 | db_collection: Collection = ( 15 | current_app.config.foca.db.dbs['petstore'] 16 | .collections['pets'].client 17 | ) 18 | filter_dict = {} if tags is None else {'tag': {'$in': tags}} 19 | if not limit: 20 | limit = 0 21 | records = db_collection.find( 22 | filter_dict, 23 | {'_id': False} 24 | ).sort([('$natural', -1)]).limit(limit) 25 | return list(records) 26 | 27 | 28 | def addPet(pet): 29 | db_collection: Collection = ( 30 | current_app.config.foca.db.dbs['petstore'] 31 | .collections['pets'].client 32 | ) 33 | counter = 0 34 | ctr = db_collection.find({}).sort([('$natural', -1)]) 35 | if not db_collection.count_documents({}) == 0: 36 | counter = ctr[0].get('id') + 1 37 | record = { 38 | "id": counter, 39 | "name": pet['name'], 40 | "tag": pet['tag'] 41 | } 42 | db_collection.insert_one(record) 43 | del record['_id'] 44 | return record 45 | 46 | 47 | def findPetById(id): 48 | db_collection: Collection = ( 49 | current_app.config.foca.db.dbs['petstore'] 50 | .collections['pets'].client 51 | ) 52 | record = db_collection.find_one( 53 | {"id": id}, 54 | {'_id': False}, 55 | ) 56 | if record is None: 57 | raise NotFound 58 | return record 59 | 60 | 61 | def deletePet(id): 62 | db_collection: Collection = ( 63 | current_app.config.foca.db.dbs['petstore'] 64 | .collections['pets'].client 65 | ) 66 | record = db_collection.find_one( 67 | {"id": id}, 68 | {'_id': False}, 69 | ) 70 | if record is None: 71 | raise NotFound 72 | db_collection.delete_one( 73 | {"id": id}, 74 | ) 75 | response = make_response('', 204) 76 | return response 77 | -------------------------------------------------------------------------------- /examples/petstore/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | 4 | # build app image based on current FOCA root image 5 | foca-petstore-root: 6 | build: 7 | context: ../../ 8 | dockerfile: docker/Dockerfile 9 | args: 10 | PY_IMAGE: ${PETSTORE_PY_IMAGE:-3.10-slim-buster} 11 | image: foca-petstore-root:latest 12 | restart: "no" 13 | 14 | app: 15 | image: elixircloud/foca-petstore:latest 16 | depends_on: 17 | - foca-petstore-root 18 | build: 19 | context: . 20 | dockerfile: Dockerfile 21 | restart: unless-stopped 22 | links: 23 | - mongodb 24 | command: bash -c "python app.py" 25 | ports: 26 | - "80:8080" 27 | 28 | mongodb: 29 | image: mongo:7.0 30 | restart: unless-stopped 31 | volumes: 32 | - ./data/petstore/db:/data/db 33 | ports: 34 | - "27017:27017" 35 | -------------------------------------------------------------------------------- /examples/petstore/exceptions.py: -------------------------------------------------------------------------------- 1 | """Petstore exceptions.""" 2 | 3 | from connexion.exceptions import BadRequestProblem 4 | from werkzeug.exceptions import ( 5 | InternalServerError, 6 | NotFound, 7 | ) 8 | 9 | exceptions = { 10 | Exception: { 11 | "message": "Oops, something unexpected happened.", 12 | "code": 500, 13 | }, 14 | BadRequestProblem: { 15 | "message": "We don't quite understand what it is you are looking for.", 16 | "code": 400, 17 | }, 18 | NotFound: { 19 | "message": "We have never heard of this pet! :-(", 20 | "code": 404, 21 | }, 22 | InternalServerError: { 23 | "message": "We seem to be having a problem here in the petstore.", 24 | "code": 500, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /examples/petstore/petstore.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.2 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification 6 | termsOfService: http://swagger.io/terms/ 7 | license: 8 | name: Apache 2.0 9 | url: https://www.apache.org/licenses/LICENSE-2.0.html 10 | servers: 11 | - url: http://localhost/ 12 | paths: 13 | /pets: 14 | get: 15 | description: | 16 | Returns all pets from the system that the user has access to. 17 | operationId: findPets 18 | parameters: 19 | - name: tags 20 | in: query 21 | description: tags to filter by 22 | required: false 23 | style: form 24 | schema: 25 | type: array 26 | items: 27 | type: string 28 | - name: limit 29 | in: query 30 | description: maximum number of results to return 31 | required: false 32 | schema: 33 | type: integer 34 | format: int32 35 | responses: 36 | '200': 37 | description: pet response 38 | content: 39 | application/json: 40 | schema: 41 | type: array 42 | items: 43 | $ref: '#/components/schemas/Pet' 44 | default: 45 | description: unexpected error 46 | content: 47 | application/json: 48 | schema: 49 | $ref: '#/components/schemas/Error' 50 | post: 51 | description: Creates a new pet in the store. Duplicates are allowed 52 | operationId: addPet 53 | requestBody: 54 | description: Pet to add to the store 55 | required: true 56 | content: 57 | application/json: 58 | schema: 59 | x-body-name: pet 60 | $ref: '#/components/schemas/NewPet' 61 | responses: 62 | '200': 63 | description: pet response 64 | content: 65 | application/json: 66 | schema: 67 | $ref: '#/components/schemas/Pet' 68 | default: 69 | description: unexpected error 70 | content: 71 | application/json: 72 | schema: 73 | $ref: '#/components/schemas/Error' 74 | /pets/{id}: 75 | get: 76 | description: Returns a user based on a single ID, if the user does not have access to the pet 77 | operationId: findPetById 78 | parameters: 79 | - name: id 80 | in: path 81 | description: ID of pet to fetch 82 | required: true 83 | schema: 84 | type: integer 85 | format: int64 86 | responses: 87 | '200': 88 | description: pet response 89 | content: 90 | application/json: 91 | schema: 92 | $ref: '#/components/schemas/Pet' 93 | default: 94 | description: unexpected error 95 | content: 96 | application/json: 97 | schema: 98 | $ref: '#/components/schemas/Error' 99 | delete: 100 | description: deletes a single pet based on the ID supplied 101 | operationId: deletePet 102 | parameters: 103 | - name: id 104 | in: path 105 | description: ID of pet to delete 106 | required: true 107 | schema: 108 | type: integer 109 | format: int64 110 | responses: 111 | '204': 112 | description: pet deleted 113 | default: 114 | description: unexpected error 115 | content: 116 | application/json: 117 | schema: 118 | $ref: '#/components/schemas/Error' 119 | components: 120 | schemas: 121 | Pet: 122 | allOf: 123 | - $ref: '#/components/schemas/NewPet' 124 | - type: object 125 | required: 126 | - id 127 | properties: 128 | id: 129 | type: integer 130 | format: int64 131 | 132 | NewPet: 133 | type: object 134 | required: 135 | - name 136 | properties: 137 | name: 138 | type: string 139 | tag: 140 | type: string 141 | 142 | Error: 143 | type: object 144 | required: 145 | - code 146 | - message 147 | properties: 148 | code: 149 | type: integer 150 | format: int32 151 | message: 152 | type: string 153 | -------------------------------------------------------------------------------- /examples/petstore/petstore_policies.conf: -------------------------------------------------------------------------------- 1 | # Request definition 2 | [request_definition] 3 | r = sub, obj, act 4 | 5 | # Policy definition 6 | [policy_definition] 7 | p = sub, obj, act 8 | 9 | # Policy effect 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | # Matchers 14 | [matchers] 15 | m = r.sub == p.sub && r.obj == p.obj && r.act == p.act -------------------------------------------------------------------------------- /foca/__init__.py: -------------------------------------------------------------------------------- 1 | """FOCA root package.""" 2 | 3 | from foca.foca import Foca # noqa: F401 4 | -------------------------------------------------------------------------------- /foca/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/api/__init__.py -------------------------------------------------------------------------------- /foca/api/register_openapi.py: -------------------------------------------------------------------------------- 1 | """Register and modify OpenAPI specifications.""" 2 | 3 | import logging 4 | from pathlib import Path 5 | from typing import Dict, List 6 | 7 | from connexion import App 8 | 9 | from foca.models.config import SpecConfig 10 | from foca.config.config_parser import ConfigParser 11 | 12 | # Get logger instance 13 | logger = logging.getLogger(__name__) 14 | 15 | # Path Item object fields which contain an Operation object (ie: HTTP verbs). 16 | # Reference: https://swagger.io/specification/v3/#path-item-object 17 | _OPERATION_OBJECT_FIELDS = frozenset({ 18 | "get", "put", "post", "delete", "options", "head", "patch", "trace", 19 | }) 20 | 21 | 22 | def register_openapi( 23 | app: App, 24 | specs: List[SpecConfig], 25 | ) -> App: 26 | """ 27 | Register OpenAPI specifications with Connexion application instance. 28 | 29 | Args: 30 | app: Connexion application instance. 31 | specs: Sequence of :py:class:`foca.models.config.SpecConfig` instances 32 | describing OpenAPI 2.x and/or 3.x specifications to be registered 33 | with `app`. 34 | 35 | Returns: 36 | Connexion application instance with registered OpenAPI specifications. 37 | 38 | Raises: 39 | OSError: Modified specification cannot be written. 40 | yaml.YAMLError: Modified specification cannot be serialized. 41 | """ 42 | # Iterate over OpenAPI specs 43 | for spec in specs: 44 | 45 | # Merge specs 46 | list_specs = [spec.path] if isinstance(spec.path, Path) else spec.path 47 | spec_parsed: Dict = ConfigParser.merge_yaml(*list_specs) 48 | logger.debug(f"Parsed spec: {list_specs}") 49 | 50 | # Add/replace root objects 51 | if spec.append is not None: 52 | for item in spec.append: 53 | spec_parsed.update(item) 54 | logger.debug(f"Appended spec: {spec.append}") 55 | 56 | # Add/replace fields to Operation Objects 57 | if spec.add_operation_fields is not None: 58 | for key, val in spec.add_operation_fields.items(): 59 | for path_item_object in spec_parsed.get('paths', {}).values(): 60 | for operation, operation_object in path_item_object.items(): 61 | if operation not in _OPERATION_OBJECT_FIELDS: 62 | continue 63 | operation_object[key] = val 64 | logger.debug( 65 | f"Added operation fields: {spec.add_operation_fields}" 66 | ) 67 | 68 | # Add fields to security definitions/schemes 69 | if not spec.disable_auth and spec.add_security_fields is not None: 70 | for key, val in spec.add_security_fields.items(): 71 | # OpenAPI 2 72 | sec_defs = spec_parsed.get('securityDefinitions', {}) 73 | for sec_def in sec_defs.values(): 74 | sec_def[key] = val 75 | # OpenAPI 3 76 | sec_schemes = spec_parsed.get( 77 | 'components', {'securitySchemes': {}} 78 | ).get('securitySchemes', {}) # type: ignore 79 | for sec_scheme in sec_schemes.values(): 80 | sec_scheme[key] = val 81 | logger.debug(f"Added security fields: {spec.add_security_fields}") 82 | 83 | # Remove security definitions/schemes and fields 84 | elif spec.disable_auth: 85 | # Open API 2 86 | spec_parsed.pop('securityDefinitions', None) 87 | # Open API 3 88 | spec_parsed.get('components', {}).pop('securitySchemes', None) 89 | # Open API 2/3 90 | spec_parsed.pop('security', None) 91 | for path_item_object in spec_parsed.get('paths', {}).values(): 92 | for operation_object in path_item_object.values(): 93 | operation_object.pop('security', None) 94 | logger.debug("Removed security fields") 95 | 96 | # Attach specs to connexion App 97 | logger.debug(f"Modified specs: {spec_parsed}") 98 | spec.connexion = {} if spec.connexion is None else spec.connexion 99 | app.add_api( 100 | specification=spec_parsed, 101 | **spec.model_dump().get('connexion', {}), 102 | ) 103 | logger.info(f"API endpoints added from spec: {spec.path_out}") 104 | 105 | return app 106 | -------------------------------------------------------------------------------- /foca/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/config/__init__.py -------------------------------------------------------------------------------- /foca/config/config_parser.py: -------------------------------------------------------------------------------- 1 | """Parser for YAML-based app configuration.""" 2 | 3 | from importlib import import_module 4 | import logging 5 | from logging.config import dictConfig 6 | from pathlib import Path 7 | from typing import (Dict, Optional) 8 | 9 | from addict import Dict as Addict 10 | from pydantic import BaseModel 11 | import yaml 12 | 13 | from foca.models.config import (Config, LogConfig) 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class ConfigParser(): 19 | """Parse FOCA config files. 20 | 21 | Args: 22 | config_file: Path to config file in YAML format. 23 | custom_config_model: Path to model to be used for custom config 24 | parameter validation, supplied in "dot notation", e.g., 25 | ``myapp.config.models.CustomConfig`, where ``CustomConfig`` is the 26 | actual importable name of a `pydantic` model for your custom 27 | configuration, deriving from ``BaseModel``. FOCA will attempt to 28 | instantiate the model with the values passed to the ``custom`` 29 | section in the application's configuration, if present. Wherever 30 | possible, make sure that default values are supplied for each 31 | config parameters, so as to make it easier for others to 32 | write/modify their app configuration. 33 | format_logs: Whether log formatting should be configured. 34 | 35 | Attributes: 36 | config_file: Path to config file in YAML format. 37 | custom_config_model: Path to model to be used for custom config 38 | parameter validation, supplied in "dot notation", e.g., 39 | ``myapp.config.models.CustomConfig`, where ``CustomConfig`` is the 40 | actual importable name of a `pydantic` model for your custom 41 | configuration, deriving from ``BaseModel``. FOCA will attempt to 42 | instantiate the model with the values passed to the ``custom`` 43 | section in the application's configuration, if present. Wherever 44 | possible, make sure that default values are supplied for each 45 | config parameters, so as to make it easier for others to 46 | write/modify their app configuration. 47 | format_logs: Whether log formatting should be configured. 48 | """ 49 | 50 | def __init__( 51 | self, 52 | config_file: Optional[Path] = None, 53 | custom_config_model: Optional[str] = None, 54 | format_logs: bool = True 55 | ) -> None: 56 | """Constructor method.""" 57 | if config_file is not None: 58 | self.config = Config(**self.parse_yaml(config_file)) 59 | else: 60 | self.config = Config() 61 | if custom_config_model is not None: 62 | setattr( 63 | self.config, 64 | 'custom', 65 | self.parse_custom_config( 66 | model=custom_config_model, 67 | ) 68 | ) 69 | if format_logs: 70 | self._configure_logging() 71 | logger.debug(f"Parsed config: {self.config.model_dump(by_alias=True)}") 72 | 73 | def _configure_logging(self) -> None: 74 | """Configure logging.""" 75 | try: 76 | dictConfig(self.config.log.model_dump(by_alias=True)) 77 | except Exception as e: 78 | dictConfig(LogConfig().model_dump(by_alias=True)) 79 | logger.warning( 80 | f"Failed to configure logging. Falling back to default " 81 | f"settings. Original error: {type(e).__name__}: {e}" 82 | ) 83 | 84 | @staticmethod 85 | def parse_yaml(conf: Path) -> Dict: 86 | """Parse YAML file. 87 | 88 | Args: 89 | conf: Path to YAML file. 90 | 91 | Returns: 92 | Dictionary of `conf` contents. 93 | 94 | Raises: 95 | OSError: File cannot be accessed. 96 | ValueError: File contents cannot be parsed. 97 | """ 98 | try: 99 | with open(conf) as config_file: 100 | try: 101 | return yaml.safe_load(config_file) 102 | except yaml.YAMLError as exc: 103 | raise ValueError( 104 | f"file '{conf}' is not valid YAML" 105 | ) from exc 106 | except OSError as exc: 107 | raise OSError( 108 | f"file '{conf}' could not be read" 109 | ) from exc 110 | 111 | @staticmethod 112 | def merge_yaml(*args: Path) -> Dict: 113 | """Parse and merge a set of YAML files. 114 | 115 | Merging is done iteratively, from the first, second to the n-th 116 | argument. Dictionary items are updated, not overwritten. For exact 117 | behavior cf. https://github.com/mewwts/addict. 118 | 119 | Args: 120 | *args: One or more paths to YAML files. 121 | 122 | Returns: 123 | Dictionary of merged YAML file contents, or ``None`` if no 124 | arguments have been supplied; if only a single YAML file path is 125 | provided, no merging is done. 126 | """ 127 | args_list = list(args) 128 | if not args_list: 129 | return {} 130 | yaml_dict = Addict(ConfigParser.parse_yaml(args_list.pop(0))) 131 | 132 | for arg in args_list: 133 | yaml_dict.update(Addict(ConfigParser.parse_yaml(arg))) 134 | 135 | return yaml_dict.to_dict() 136 | 137 | def parse_custom_config(self, model: str) -> BaseModel: 138 | """Parse custom configuration and validate against a model. 139 | 140 | The method will attempt to instantiate the model with the parameters 141 | provided in the application configuration's ``custom`` section, if 142 | provided. Any required configuration parameters for which no defaults 143 | are provided in the model indeed will have to be listed in such a 144 | section. 145 | 146 | Args: 147 | model: Path to model to be used for configuration validation, 148 | supplied in "dot notation", e.g., 149 | ``myapp.config.models.CustomConfig`, where ``CustomConfig`` is 150 | the actual importable name of a `pydantic` model for your 151 | custom configuration, deriving from ``BaseModel``. 152 | 153 | Returns: 154 | Custom configuration model instantiated with the parameters listed 155 | in the app configuration's ``custom``. 156 | """ 157 | module = Path(model).stem 158 | model_class = Path(model).suffix[1:] 159 | try: 160 | model_class = getattr(import_module(module), model_class) 161 | except ModuleNotFoundError: 162 | raise ValueError( 163 | f"failed validating custom configuration: module '{module}' " 164 | "not available" 165 | ) 166 | except (AttributeError, ImportError): 167 | raise ValueError( 168 | f"failed validating custom configuration: module '{module}' " 169 | f"has no class {model_class} or could not be imported" 170 | ) 171 | try: 172 | custom_config = model_class( # type: ignore[operator] 173 | **self.config.custom) # type: ignore[attr-defined] 174 | except Exception as exc: 175 | raise ValueError( 176 | "failed validating custom configuration: provided custom " 177 | f"configuration does not match model class in '{model}'" 178 | ) from exc 179 | return custom_config 180 | -------------------------------------------------------------------------------- /foca/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/database/__init__.py -------------------------------------------------------------------------------- /foca/database/register_mongodb.py: -------------------------------------------------------------------------------- 1 | """Register MongoDB database and collections.""" 2 | 3 | import logging 4 | import os 5 | 6 | from flask import Flask 7 | from flask_pymongo import PyMongo 8 | from foca.models.config import MongoConfig, DBConfig 9 | 10 | # Get logger instance 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def register_mongodb( 15 | app: Flask, 16 | conf: MongoConfig, 17 | ) -> MongoConfig: 18 | """Register MongoDB databases and collections with Flask application 19 | instance. 20 | 21 | Args: 22 | app: Flask application instance. 23 | conf: :py:class:`foca.models.config.MongoConfig` instance describing 24 | databases and collections to be registered with `app`. 25 | 26 | Returns: 27 | Flask application instance with registered MongoDB databases and 28 | collections. 29 | """ 30 | # Iterate over databases 31 | if conf.dbs is not None: 32 | for db_name, db_conf in conf.dbs.items(): 33 | 34 | # Instantiate PyMongo client 35 | mongo = _create_mongo_client( 36 | app=app, 37 | host=conf.host, 38 | port=conf.port, 39 | db=db_name, 40 | ) 41 | 42 | # Add database 43 | db_conf.client = mongo.db 44 | 45 | # Add collections 46 | if db_conf.collections is not None and db_conf.client is not None: 47 | for coll_name, coll_conf in db_conf.collections.items(): 48 | 49 | coll_conf.client = db_conf.client[coll_name] 50 | logger.info( 51 | f"Added database collection '{coll_name}'." 52 | ) 53 | 54 | # Add indexes 55 | if ( 56 | coll_conf.indexes is not None 57 | and coll_conf.client is not None 58 | ): 59 | # Remove already created indexes if any 60 | coll_conf.client.drop_indexes() 61 | for index in coll_conf.indexes: 62 | if index.keys is not None: 63 | coll_conf.client.create_index( 64 | index.keys, **index.options) 65 | logger.info( 66 | f"Indexes created for collection '{coll_name}'." 67 | ) 68 | 69 | return conf 70 | 71 | 72 | def add_new_database( 73 | app: Flask, 74 | conf: MongoConfig, 75 | db_conf: DBConfig, 76 | db_name: str 77 | ): 78 | """Register an additional db to database config. 79 | 80 | Args: 81 | app: Flask application instance. 82 | conf: :py:class:`foca.models.config.MongoConfig` instance describing 83 | databases and collections to be registered with `app`. 84 | db_conf: :py:class:`foca.models.config.DBConfig` instance describing 85 | new databases configuration to be registered with `app`. 86 | db_name: Name of the database being added. 87 | """ 88 | # Instantiate PyMongo client 89 | mongo = _create_mongo_client( 90 | app=app, 91 | host=conf.host, 92 | port=conf.port, 93 | db=db_name, 94 | ) 95 | 96 | # Add database 97 | db_conf.client = mongo.db 98 | 99 | # Add collections 100 | if db_conf.collections is not None and db_conf.client is not None: 101 | for coll_name, coll_conf in db_conf.collections.items(): 102 | 103 | coll_conf.client = db_conf.client[coll_name] 104 | logger.info( 105 | f"Added database collection '{coll_name}'." 106 | ) 107 | 108 | 109 | def _create_mongo_client( 110 | app: Flask, 111 | host: str = 'mongodb', 112 | port: int = 27017, 113 | db: str = 'database', 114 | ) -> PyMongo: 115 | """Create MongoDB client for Flask application instance. 116 | 117 | Args: 118 | app: Flask application instance. 119 | host: Host at which MongoDB database is exposed. 120 | port: Port at which MongoDB database is exposed. 121 | db: Name of database to be accessed/created. 122 | 123 | Returns: 124 | MongoDB client for Flask application instance. 125 | """ 126 | auth = '' 127 | user = os.environ.get('MONGO_USERNAME') 128 | if user is not None and user != "": 129 | auth = '{username}:{password}@'.format( 130 | username=os.environ.get('MONGO_USERNAME'), 131 | password=os.environ.get('MONGO_PASSWORD'), 132 | ) 133 | 134 | app.config['MONGO_URI'] = 'mongodb://{auth}{host}:{port}/{db}'.format( 135 | host=os.environ.get('MONGO_HOST', host), 136 | port=os.environ.get('MONGO_PORT', port), 137 | db=os.environ.get('MONGO_DBNAME', db), 138 | auth=auth 139 | ) 140 | 141 | mongo = PyMongo(app) 142 | logger.info( 143 | ( 144 | "Registered database '{db}' at URI '{host}':'{port}' with Flask " 145 | 'application.' 146 | ).format( 147 | db=os.environ.get('MONGO_DBNAME', db), 148 | host=os.environ.get('MONGO_HOST', host), 149 | port=os.environ.get('MONGO_PORT', port) 150 | ) 151 | ) 152 | return mongo 153 | -------------------------------------------------------------------------------- /foca/errors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/errors/__init__.py -------------------------------------------------------------------------------- /foca/errors/exceptions.py: -------------------------------------------------------------------------------- 1 | """Define and register exceptions raised in app context with Connexion app.""" 2 | 3 | from copy import deepcopy 4 | import logging 5 | from traceback import format_exception 6 | from typing import (Dict, List) 7 | 8 | from connexion import App 9 | from connexion.exceptions import ( 10 | ExtraParameterProblem, 11 | Forbidden, 12 | OAuthProblem, 13 | Unauthorized, 14 | ) 15 | from flask import (current_app, Response) 16 | from json import dumps 17 | from werkzeug.exceptions import ( 18 | BadRequest, 19 | BadGateway, 20 | GatewayTimeout, 21 | InternalServerError, 22 | NotFound, 23 | ServiceUnavailable, 24 | ) 25 | 26 | from foca.models.config import _get_by_path 27 | 28 | # Get logger instance 29 | logger = logging.getLogger(__name__) 30 | 31 | # Default exceptions 32 | exceptions = { 33 | Exception: { 34 | "title": "Internal Server Error", 35 | "status": 500, 36 | }, 37 | BadRequest: { 38 | "title": "Bad Request", 39 | "status": 400, 40 | }, 41 | ExtraParameterProblem: { 42 | "title": "Bad Request", 43 | "status": 400, 44 | }, 45 | Unauthorized: { 46 | "title": "Unauthorized", 47 | "status": 401, 48 | }, 49 | OAuthProblem: { 50 | "title": "Unauthorized", 51 | "status": 401, 52 | }, 53 | Forbidden: { 54 | "title": "Forbidden", 55 | "status": 403, 56 | }, 57 | NotFound: { 58 | "title": "Not Found", 59 | "status": 404, 60 | }, 61 | InternalServerError: { 62 | "title": "Internal Server Error", 63 | "status": 500, 64 | }, 65 | BadGateway: { 66 | "title": "Bad Gateway", 67 | "status": 502, 68 | }, 69 | ServiceUnavailable: { 70 | "title": "Service Unavailable", 71 | "status": 502, 72 | }, 73 | GatewayTimeout: { 74 | "title": "Gateway Timeout", 75 | "status": 504, 76 | } 77 | } 78 | 79 | 80 | def register_exception_handler(app: App) -> App: 81 | """Register generic JSON problem handler with Connexion application 82 | instance. 83 | 84 | Args: 85 | app: Connexion application instance. 86 | 87 | Returns: 88 | Connexion application instance with registered generic JSON problem 89 | handler. 90 | """ 91 | app.add_error_handler(Exception, _problem_handler_json) 92 | logger.debug("Registered generic JSON problem handler with Connexion app.") 93 | return app 94 | 95 | 96 | def _exc_to_str( 97 | exc: BaseException, 98 | delimiter: str = "\\n", 99 | ) -> str: 100 | """Convert exception, including traceback, to string representation. 101 | 102 | Args: 103 | exc: The exception to convert to a string. 104 | delimiter: The delimiter used to join different lines of the exception 105 | stack. 106 | 107 | Returns: 108 | String representation of exception. 109 | """ 110 | exc_lines = format_exception( 111 | exc.__class__, 112 | exc, 113 | exc.__traceback__ 114 | ) 115 | exc_stripped = [e.rstrip('\n') for e in exc_lines] 116 | exc_split = [] 117 | for item in exc_stripped: 118 | exc_split.extend(item.splitlines()) 119 | return delimiter.join(exc_split) 120 | 121 | 122 | def _log_exception( 123 | exc: BaseException, 124 | format: str = 'oneline', 125 | ) -> None: 126 | """Log exception with indicated format. 127 | 128 | Requires a `logging` logger to be set up and configured. 129 | 130 | Args: 131 | exc: The exception to log. 132 | format: One of ``oneline`` (exception, including traceback logged to 133 | single line), ``minimal`` (log only exception title and message), 134 | or ``regular`` (exception logged with entire trace stack, typically 135 | across multiple lines). 136 | """ 137 | exc_str = '' 138 | valid_formats = [ 139 | 'oneline', 140 | 'minimal', 141 | 'regular', 142 | ] 143 | if format in valid_formats: 144 | if format == "oneline": 145 | exc_str = _exc_to_str(exc=exc) 146 | elif format == "minimal": 147 | exc_str = f"{type(exc).__name__}: {str(exc)}" 148 | else: 149 | exc_str = _exc_to_str( 150 | exc=exc, 151 | delimiter='\n' 152 | ) 153 | logger.error(exc_str) 154 | else: 155 | logger.error("Error logging is misconfigured.") 156 | 157 | 158 | def _subset_nested_dict( 159 | obj: Dict, 160 | key_sequence: List, 161 | ) -> Dict: 162 | """Subset nested dictionary. 163 | 164 | Args: 165 | obj: (Nested) dictionary. 166 | key_sequence: Sequence of keys, to be applied from outside to inside, 167 | pointing to the key (and descendants) to keep. 168 | 169 | Returns: 170 | Subset of `obj`. 171 | """ 172 | filt = {} 173 | if len(key_sequence): 174 | key = key_sequence.pop(0) 175 | filt[key] = obj[key] 176 | if len(key_sequence): 177 | filt[key] = _subset_nested_dict(filt[key], key_sequence) 178 | return filt 179 | 180 | 181 | def _exclude_key_nested_dict( 182 | obj: Dict, 183 | key_sequence: List, 184 | ) -> Dict: 185 | """Exclude key from nested dictionary. 186 | 187 | Args: 188 | obj: (Nested) dictionary. 189 | key_sequence: Sequence of keys, to be applied from outside to inside, 190 | pointing to the key (and descendants) to delete. 191 | 192 | Returns: 193 | `obj` stripped of excluded key. 194 | """ 195 | if len(key_sequence): 196 | key = key_sequence.pop(0) 197 | if len(key_sequence): 198 | _exclude_key_nested_dict(obj[key], key_sequence) 199 | else: 200 | del obj[key] 201 | return obj 202 | 203 | 204 | def _problem_handler_json(exception: Exception) -> Response: 205 | """Generic JSON problem handler. 206 | 207 | Args: 208 | exception: Raised exception. 209 | 210 | Returns: 211 | JSON-formatted error response. 212 | """ 213 | # Look up exception & get status code 214 | conf = current_app.config.foca.exceptions # type: ignore[attr-defined] 215 | exc = type(exception) 216 | if exc not in conf.mapping: 217 | exc = Exception 218 | try: 219 | status = int(_get_by_path( 220 | obj=conf.mapping[exc], 221 | key_sequence=conf.status_member, 222 | )) 223 | except KeyError: 224 | if conf.logging.value != "none": 225 | _log_exception( 226 | exc=exception, 227 | format=conf.logging.value 228 | ) 229 | return Response( 230 | status=500, 231 | mimetype="application/problem+json", 232 | ) 233 | # Log exception JSON & traceback 234 | if conf.logging.value != "none": 235 | logger.error(conf.mapping[exc]) 236 | _log_exception( 237 | exc=exception, 238 | format=conf.logging.value 239 | ) 240 | # Filter members to be returned to user 241 | keep = deepcopy(conf.mapping[exc]) 242 | if conf.public_members is not None: 243 | keep = {} 244 | for member in deepcopy(conf.public_members): 245 | keep.update(_subset_nested_dict( 246 | obj=conf.mapping[exc], 247 | key_sequence=member, 248 | )) 249 | elif conf.private_members is not None: 250 | for member in deepcopy(conf.private_members): 251 | keep.update(_exclude_key_nested_dict( 252 | obj=keep, 253 | key_sequence=member, 254 | )) 255 | # Return response 256 | return Response( 257 | response=dumps(keep), 258 | status=status, 259 | mimetype="application/problem+json", 260 | ) 261 | -------------------------------------------------------------------------------- /foca/factories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/factories/__init__.py -------------------------------------------------------------------------------- /foca/factories/celery_app.py: -------------------------------------------------------------------------------- 1 | """Factory for creating and configuring Celery application instances.""" 2 | 3 | from inspect import stack 4 | import logging 5 | 6 | from flask import Flask 7 | from celery import Celery 8 | 9 | # Get logger instance 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def create_celery_app(app: Flask) -> Celery: 14 | """Create and configure Celery application instance. 15 | 16 | Args: 17 | app: Flask application instance. 18 | 19 | Returns: 20 | Celery application instance. 21 | """ 22 | conf = app.config.foca.jobs # type: ignore[attr-defined] 23 | 24 | # Instantiate Celery app 25 | celery = Celery( 26 | app=__name__, 27 | broker=f"pyamqp://{conf.host}:{conf.port}//", 28 | backend=conf.backend, 29 | include=conf.include, 30 | ) 31 | calling_module = ':'.join([stack()[1].filename, stack()[1].function]) 32 | logger.debug(f"Celery app created from '{calling_module}'.") 33 | 34 | # Update Celery app configuration with Flask app configuration 35 | setattr(celery.conf, 'foca', app.config.foca) # type: ignore[attr-defined] 36 | logger.debug('Celery app configured.') 37 | 38 | class ContextTask(celery.Task): # type: ignore 39 | # https://github.com/python/mypy/issues/4284) 40 | """Create subclass of task that wraps task execution in application 41 | context. 42 | """ 43 | def __call__(self, *args, **kwargs): 44 | """Wrap task execution in application context.""" 45 | with app.app_context(): # pragma: no cover 46 | return self.run(*args, **kwargs) 47 | 48 | celery.Task = ContextTask 49 | logger.debug("App context added to 'celery.Task' class.") 50 | 51 | return celery 52 | -------------------------------------------------------------------------------- /foca/factories/connexion_app.py: -------------------------------------------------------------------------------- 1 | """Factory for creating and configuring Connexion application instances.""" 2 | 3 | from inspect import stack 4 | import logging 5 | from typing import Optional 6 | 7 | from connexion import App 8 | 9 | from foca.models.config import Config 10 | 11 | # Get logger instance 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def create_connexion_app(config: Optional[Config] = None) -> App: 16 | """Create and configure Connexion application instance. 17 | 18 | Args: 19 | config: Application configuration. 20 | 21 | Returns: 22 | Connexion application instance. 23 | """ 24 | # Instantiate Connexion app 25 | app = App( 26 | __name__, 27 | skip_error_handlers=True, 28 | ) 29 | 30 | calling_module = ':'.join([stack()[1].filename, stack()[1].function]) 31 | logger.debug(f"Connexion app created from '{calling_module}'.") 32 | 33 | # Configure Connexion app 34 | if config is not None: 35 | app = __add_config_to_connexion_app( 36 | app=app, 37 | config=config, 38 | ) 39 | 40 | return app 41 | 42 | 43 | def __add_config_to_connexion_app( 44 | app: App, 45 | config: Config, 46 | ) -> App: 47 | """Replace default Flask and Connexion settings with FOCA configuration 48 | parameters. 49 | 50 | Args: 51 | app: Connexion application instance. 52 | config: Application configuration. 53 | 54 | Returns: 55 | Connexion application instance with updated configuration. 56 | """ 57 | conf = config.server 58 | 59 | # replace Connexion app settings 60 | app.host = conf.host 61 | app.port = conf.port 62 | app.debug = conf.debug 63 | 64 | # replace Flask app settings 65 | app.app.config['DEBUG'] = conf.debug 66 | app.app.config['ENV'] = conf.environment 67 | app.app.config['TESTING'] = conf.testing 68 | 69 | logger.debug('Flask app settings:') 70 | for (key, value) in app.app.config.items(): 71 | logger.debug('* {}: {}'.format(key, value)) 72 | 73 | # Add user configuration to Flask app config 74 | setattr(app.app.config, 'foca', config) 75 | 76 | logger.debug('Connexion app configured.') 77 | return app 78 | -------------------------------------------------------------------------------- /foca/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/models/__init__.py -------------------------------------------------------------------------------- /foca/security/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/security/__init__.py -------------------------------------------------------------------------------- /foca/security/access_control/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/security/access_control/__init__.py -------------------------------------------------------------------------------- /foca/security/access_control/access_control_server.py: -------------------------------------------------------------------------------- 1 | """"Controllers for permission management endpoints.""" 2 | 3 | import logging 4 | 5 | from typing import (Dict, List) 6 | 7 | from flask import (request, current_app) 8 | from pymongo.collection import Collection 9 | from werkzeug.exceptions import (InternalServerError, NotFound) 10 | 11 | from foca.utils.logging import log_traffic 12 | from foca.errors.exceptions import BadRequest 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | @log_traffic 18 | def postPermission() -> str: 19 | """Method to register new permissions. 20 | 21 | Returns: 22 | Identifier of the new permission added. 23 | """ 24 | request_json = request.json 25 | if isinstance(request_json, dict): 26 | try: 27 | access_control_adapter = current_app.config["casbin_adapter"] 28 | rule = request_json.get("rule", {}) 29 | permission_data = [ 30 | rule.get("v0", None), 31 | rule.get("v1", None), 32 | rule.get("v2", None), 33 | rule.get("v3", None), 34 | rule.get("v4", None), 35 | rule.get("v5", None) 36 | ] 37 | permission_id = access_control_adapter.save_policy_line( 38 | ptype=request_json.get("policy_type", None), 39 | rule=permission_data 40 | ) 41 | logger.info("New policy added.") 42 | return permission_id 43 | except Exception as e: 44 | logger.error(f"{type(e).__name__}: {e}") 45 | raise InternalServerError 46 | else: 47 | logger.error("Invalid request payload.") 48 | raise BadRequest 49 | 50 | 51 | @log_traffic 52 | def putPermission( 53 | id: str, 54 | ) -> str: 55 | """Update permission with a user-supplied ID. 56 | 57 | Args: 58 | id: Identifier of permission to be updated. 59 | 60 | Returns: 61 | Identifier of updated permission. 62 | """ 63 | request_json = request.json 64 | if isinstance(request_json, dict): 65 | app_config = current_app.config 66 | try: 67 | security_conf = \ 68 | app_config.foca.security # type: ignore[attr-defined] 69 | access_control_config = \ 70 | security_conf.access_control # type: ignore[attr-defined] 71 | db_coll_permission: Collection = ( 72 | app_config.foca.db.dbs[ # type: ignore[attr-defined] 73 | access_control_config.db_name] 74 | .collections[access_control_config.collection_name].client 75 | ) 76 | 77 | permission_data = request_json.get("rule", {}) 78 | permission_data["id"] = id 79 | permission_data["ptype"] = request_json.get("policy_type", None) 80 | db_coll_permission.replace_one( 81 | filter={"id": id}, 82 | replacement=permission_data, 83 | upsert=True 84 | ) 85 | logger.info("Policy updated.") 86 | return id 87 | except Exception as e: 88 | logger.error(f"{type(e).__name__}: {e}") 89 | raise InternalServerError 90 | else: 91 | logger.error("Invalid request payload.") 92 | raise BadRequest 93 | 94 | 95 | @log_traffic 96 | def getAllPermissions(limit=None) -> List[Dict]: 97 | """Method to fetch all permissions. 98 | 99 | Args: 100 | limit: Number of objects requested. 101 | 102 | Returns: 103 | List of permission dicts. 104 | """ 105 | app_config = current_app.config 106 | access_control_config = \ 107 | app_config.foca.security.access_control # type: ignore[attr-defined] 108 | db_coll_permission: Collection = ( 109 | app_config.foca.db.dbs[ # type: ignore[attr-defined] 110 | access_control_config.db_name 111 | ].collections[access_control_config.collection_name].client 112 | ) 113 | 114 | if not limit: 115 | limit = 0 116 | permissions = list( 117 | db_coll_permission.find( 118 | filter={}, 119 | projection={'_id': False} 120 | ).sort([('$natural', -1)]).limit(limit) 121 | ) 122 | user_permission_list = [] 123 | for _permission in permissions: 124 | policy_type = _permission.get("ptype", None) 125 | id = _permission.get("id", None) 126 | del _permission["ptype"] 127 | del _permission["id"] 128 | rule = _permission 129 | user_permission_list.append({ 130 | "policy_type": policy_type, 131 | "rule": rule, 132 | "id": id 133 | }) 134 | return user_permission_list 135 | 136 | 137 | @log_traffic 138 | def getPermission( 139 | id: str, 140 | ) -> Dict: 141 | """Method to fetch a particular permission. 142 | 143 | Args: 144 | id: Permission identifier. 145 | 146 | Returns: 147 | Permission data for the given id. 148 | """ 149 | app_config = current_app.config 150 | access_control_config = \ 151 | app_config.foca.security.access_control # type: ignore[attr-defined] 152 | db_coll_permission: Collection = ( 153 | app_config.foca.db.dbs[ # type: ignore[attr-defined] 154 | access_control_config.db_name 155 | ].collections[access_control_config.collection_name].client 156 | ) 157 | 158 | permission = db_coll_permission.find_one(filter={"id": id}) 159 | if permission is None: 160 | raise NotFound 161 | del permission["_id"] 162 | policy_type = permission.get("ptype", None) 163 | id = permission.get("id", None) 164 | del permission["ptype"] 165 | del permission["id"] 166 | return { 167 | "id": id, 168 | "rule": permission, 169 | "policy_type": policy_type 170 | } 171 | 172 | 173 | @log_traffic 174 | def deletePermission( 175 | id: str, 176 | ) -> str: 177 | """Method to delete a particular permission. 178 | 179 | Args: 180 | id: Permission identifier. 181 | 182 | Returns: 183 | Delete permission identifier. 184 | """ 185 | app_config = current_app.config 186 | access_control_config = \ 187 | app_config.foca.security.access_control # type: ignore[attr-defined] 188 | db_coll_permission: Collection = ( 189 | app_config.foca.db.dbs[ # type: ignore[attr-defined] 190 | access_control_config.db_name 191 | ].collections[access_control_config.collection_name].client 192 | ) 193 | 194 | del_obj_permission = db_coll_permission.delete_one({'id': id}) 195 | 196 | if del_obj_permission.deleted_count: 197 | return id 198 | else: 199 | raise NotFound 200 | -------------------------------------------------------------------------------- /foca/security/access_control/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/security/access_control/api/__init__.py -------------------------------------------------------------------------------- /foca/security/access_control/api/default_model.conf: -------------------------------------------------------------------------------- 1 | # For detailed explaination on how to write policy models: 2 | # visit: https://casbin.org/docs/en/syntax-for-models 3 | 4 | [request_definition] 5 | # This is the definition for the access request. 6 | # sub - accessing entity (Subject) 7 | # obj - accessed resource (Object) 8 | # act - access method (Action) 9 | r = sub, obj, act 10 | 11 | [policy_definition] 12 | # This is the definition of the policy. 13 | # Each policy rule starts with a policy type, e.g., p, p2. 14 | # It is used to match the policy definition if there are multiple definitions. 15 | p = sub, obj, act 16 | 17 | [role_definition] 18 | # This is the definition for the RBAC role inheritance relation. 19 | # This role definition shows that g is a RBAC system, and g2 is another RBAC system. 20 | # _, _ means there are two parties inside an inheritance relation. 21 | g = _, _ 22 | g2 = _, _ 23 | 24 | [policy_effect] 25 | # This is the definition for the policy effect. It defines whether the access request 26 | # should be approved if multiple policy rules match the request. 27 | # The above policy effect means if there's any matched policy rule of allow, the final 28 | # effect is allow (aka allow-override). p.eft is the effect for a policy, it can be 29 | # allow or deny. It's optional and the default value is allow. 30 | e = some(where (p.eft == allow)) 31 | 32 | [matchers] 33 | # This is the definition for policy matchers. The matchers are expressions. It defines 34 | # how the policy rules are evaluated against the request. 35 | # The above matcher is the simplest, it means that the subject, object and action in 36 | # a request should match the ones in a policy rule. 37 | # You can use arithmetic like +, -, *, / and logical operators like &&, ||, ! in 38 | # matchers. 39 | m = g(r.sub, p.sub) && g2(r.obj, p.obj) && r.act == p.act 40 | -------------------------------------------------------------------------------- /foca/security/access_control/constants.py: -------------------------------------------------------------------------------- 1 | """File to store permission based constants. 2 | """ 3 | 4 | DEFAULT_ACCESS_CONTROL_DB_NAME = "access_control_db" 5 | DEFAULT_ACESS_CONTROL_COLLECTION_NAME = "policy_rules" 6 | DEFAULT_API_SPEC_PATH = "access-control-specs.yaml" 7 | DEFAULT_MODEL_FILE = "default_model.conf" 8 | DEFAULT_SPEC_CONTROLLER = "foca.security.access_control.access_control_server" 9 | 10 | ACCESS_CONTROL_BASE_PATH = "foca.security.access_control.api" 11 | -------------------------------------------------------------------------------- /foca/security/access_control/foca_casbin_adapter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/security/access_control/foca_casbin_adapter/__init__.py -------------------------------------------------------------------------------- /foca/security/access_control/foca_casbin_adapter/adapter.py: -------------------------------------------------------------------------------- 1 | """Casbin rule adapter.""" 2 | 3 | from casbin import persist 4 | from casbin.model import Model 5 | from typing import (List, Optional) 6 | from pymongo import MongoClient 7 | 8 | from foca.security.access_control.foca_casbin_adapter.casbin_rule import ( 9 | CasbinRule 10 | ) 11 | from foca.utils.misc import generate_id 12 | 13 | 14 | class Adapter(persist.Adapter): 15 | """Interface for casbin adapters. This is utilized for interacting casbin 16 | and mongodb. 17 | 18 | Args: 19 | uri: This should be the same requiement as pymongo Client's 'uri' 20 | parameter. See https://pymongo.readthedocs.io/en/stable/api/pymong\ 21 | o/mongo_client.html#pymongo.mongo_client.MongoClient. 22 | dbname: Database to store policy. 23 | collection: Collection of the choosen database. Defaults to 24 | "casbin_rule". 25 | 26 | Attributes: 27 | uri: This should be the same requiement as pymongo Client's 'uri' 28 | parameter. See https://pymongo.readthedocs.io/en/stable/api/pymong\ 29 | o/mongo_client.html#pymongo.mongo_client.MongoClient. 30 | dbname: Database to store policy. 31 | collection: Collection of the choosen database. Defaults to 32 | "casbin_rule". 33 | """ 34 | 35 | def __init__( 36 | self, uri: str, dbname: str, collection: Optional[str] = "casbin_rule" 37 | ): 38 | """Create an adapter for Mongodb.""" 39 | client: MongoClient = MongoClient(uri) 40 | db = client[dbname] 41 | assert collection is not None 42 | self._collection = db[collection] 43 | 44 | def load_policy(self, model: CasbinRule): 45 | """Implementing add Interface for casbin. 46 | 47 | Load all policy rules from MongoDB. 48 | 49 | Args: 50 | model: CasbinRule object. 51 | """ 52 | 53 | for line in self._collection.find(): 54 | rule = CasbinRule(line["ptype"]) 55 | for key, value in line.items(): 56 | setattr(rule, key, value) 57 | 58 | persist.load_policy_line(str(rule), model) 59 | 60 | def save_policy_line(self, ptype: str, rule: List[str]): 61 | """Method to save a policy. 62 | 63 | Args: 64 | ptype: Policy type for the given rule based on the given conf file. 65 | rule: List of policy attributes. 66 | """ 67 | line = CasbinRule(ptype=ptype) 68 | for index, value in enumerate(rule): 69 | setattr(line, f"v{index}", value) 70 | rule_dict: dict = line.dict() 71 | rule_dict["id"] = generate_id() 72 | self._collection.insert_one(rule_dict) 73 | return rule_dict["id"] 74 | 75 | def _delete_policy_lines(self, ptype: str, rule: List[str]) -> int: 76 | """Method to find a delete policies given a list of policy attributes. 77 | 78 | Args: 79 | ptype: Policy type for the given rule based on the given conf file. 80 | rule: List of policy attributes. 81 | 82 | Returns: 83 | Count of policies deleted. 84 | """ 85 | 86 | line = CasbinRule(ptype=ptype) 87 | for index, value in enumerate(rule): 88 | setattr(line, f"v{index}", value) 89 | 90 | # if rule is empty, do nothing 91 | # else find all given rules and delete them 92 | if len(rule) == 0: 93 | return 0 94 | else: 95 | line_dict = line.dict() 96 | line_dict_keys_len = len(line_dict) 97 | results = self._collection.find( 98 | filter=line_dict, 99 | projection={"id": False} 100 | ) 101 | to_delete = [ 102 | result["_id"] 103 | for result in results 104 | if line_dict_keys_len == len(result.keys()) - 1 105 | ] 106 | return self._collection.delete_many( 107 | {"_id": {"$in": to_delete}} 108 | ).deleted_count 109 | 110 | def save_policy(self, model: Model) -> bool: 111 | """Method to save a casbin model. 112 | 113 | Args: 114 | model: Casbin Model which loads from .conf file. For model 115 | description, cf. https://github.com/casbin/pycasbin/blob/72571\ 116 | 5fc04b3f37f26eb4be1ba7007ddf55d1e75/casbin/model/model.py#L23 117 | 118 | Returns: 119 | True if successfully created. 120 | """ 121 | for section_type in ["p", "g"]: 122 | for ptype, ast in model.model[section_type].items(): 123 | for rule in ast.policy: 124 | self.save_policy_line(ptype, rule) 125 | return True 126 | 127 | def add_policy(self, sec: str, ptype: str, rule: List[str]) -> bool: 128 | """Add policy rules to mongodb 129 | 130 | Args: 131 | sec: Section corresponding which the rule will be added. 132 | ptype: Policy type for which casbin rule will be added. 133 | rule: Casbin rule list definition to be added. 134 | 135 | Returns: 136 | True if succeed else False. 137 | """ 138 | self.save_policy_line(ptype, rule) 139 | return True 140 | 141 | def remove_policy(self, sec: str, ptype: str, rule: List[str]): 142 | """Remove policy rules from mongodb(duplicate rules are also removed). 143 | 144 | Args: 145 | sec: Section corresponding which the rule will be added. 146 | ptype: Policy type for which casbin rule will be removed. 147 | rule: Casbin rule list definition to be removed. 148 | 149 | Returns: 150 | Number of policies removed. 151 | """ 152 | deleted_count = self._delete_policy_lines(ptype, rule) 153 | return deleted_count > 0 154 | 155 | def remove_filtered_policy( 156 | self, sec: str, ptype: str, 157 | field_index: int, *field_values: List[str] 158 | ): 159 | """Remove policy rules that match the filter from the storage. 160 | This is part of the Auto-Save feature. 161 | 162 | Args: 163 | sec: Section corresponding which the rule will be added. 164 | ptype: Policy type for which casbin rule will be removed. 165 | field_index: The policy index at which the field_values begin 166 | filtering. Its range is [0, 5] 167 | field_values: A list of rules to filter policy. 168 | 169 | Returns: 170 | True if success. 171 | """ 172 | if not (0 <= field_index <= 5): 173 | return False 174 | if not (1 <= field_index + len(field_values) <= 6): 175 | return False 176 | 177 | query = {} 178 | for index, value in enumerate(field_values): 179 | query[f"v{index + field_index}"] = value 180 | 181 | query["ptype"] = ptype # type: ignore[assignment] 182 | results = self._collection.delete_many(query) 183 | return results.deleted_count > 0 184 | -------------------------------------------------------------------------------- /foca/security/access_control/foca_casbin_adapter/casbin_rule.py: -------------------------------------------------------------------------------- 1 | """Casbin rule class.""" 2 | 3 | from typing import (Dict, Optional) 4 | 5 | 6 | class CasbinRule: 7 | """This class defines the basic structuring of a casbin rule object. 8 | 9 | Args: 10 | ptype: Policy type for the given rule based on the given conf file. 11 | v0: Policy parameter. 12 | v1: Policy parameter. 13 | v2: Policy parameter. 14 | v3: Policy parameter. 15 | v4: Policy parameter. 16 | v5: Policy parameter. 17 | 18 | Attributes: 19 | ptype: Policy type for the given rule based on the given conf file. 20 | v0: Policy parameter. 21 | v1: Policy parameter. 22 | v2: Policy parameter. 23 | v3: Policy parameter. 24 | v4: Policy parameter. 25 | v5: Policy parameter. 26 | """ 27 | 28 | def __init__( 29 | self, 30 | ptype: Optional[str] = None, 31 | v0: Optional[str] = None, 32 | v1: Optional[str] = None, 33 | v2: Optional[str] = None, 34 | v3: Optional[str] = None, 35 | v4: Optional[str] = None, 36 | v5: Optional[str] = None 37 | ): 38 | """Casbin rule object initializer.""" 39 | self.ptype = ptype 40 | self.v0 = v0 41 | self.v1 = v1 42 | self.v2 = v2 43 | self.v3 = v3 44 | self.v4 = v4 45 | self.v5 = v5 46 | 47 | def dict(self) -> Dict: 48 | """Method to convert params into casbin rule object. 49 | 50 | Returns: 51 | Casbin rule object. 52 | """ 53 | rule_dict = {"ptype": self.ptype} 54 | 55 | for value in dir(self): 56 | if ( 57 | getattr(self, value) is not None 58 | and value.startswith("v") 59 | and value[1:].isnumeric() 60 | ): 61 | rule_dict[value] = getattr(self, value) 62 | 63 | return rule_dict 64 | 65 | def __str__(self): 66 | return ", ".join(self.dict().values()) 67 | 68 | def __repr__(self): 69 | print("self ", self) 70 | return ''.format(str(self)) 71 | -------------------------------------------------------------------------------- /foca/security/cors.py: -------------------------------------------------------------------------------- 1 | """Resources for cross-origin resource sharing (CORS).""" 2 | 3 | import logging 4 | from flask import Flask 5 | 6 | from flask_cors import CORS 7 | 8 | # Get logger instance 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def enable_cors(app: Flask) -> None: 13 | """Enables cross-origin resource sharing (CORS) for Flask application 14 | instance. 15 | 16 | Args: 17 | app: Flask application instance. 18 | """ 19 | CORS(app) 20 | logger.debug('Enabled CORS for Flask app.') 21 | -------------------------------------------------------------------------------- /foca/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/foca/utils/__init__.py -------------------------------------------------------------------------------- /foca/utils/db.py: -------------------------------------------------------------------------------- 1 | """Utility functions for interacting with a MongoDB database collection.""" 2 | 3 | from typing import (Any, Mapping, Optional) 4 | 5 | from bson.objectid import ObjectId 6 | from pymongo.collection import Collection 7 | 8 | 9 | def find_one_latest(collection: Collection) -> Optional[Mapping[Any, Any]]: 10 | """Return newest document, stripped of `ObjectId`. 11 | 12 | Args: 13 | collection: MongoDB collection from which the document is to be 14 | retrieved. 15 | 16 | Returns: 17 | Newest document or ``None``, if no document exists. 18 | """ 19 | try: 20 | return collection.find( 21 | {}, 22 | {'_id': False} 23 | ).sort([('_id', -1)]).limit(1).next() 24 | except StopIteration: 25 | return None 26 | 27 | 28 | def find_id_latest(collection: Collection) -> Optional[ObjectId]: 29 | """Return `ObjectId` of newest document. 30 | 31 | Args: 32 | collection: MongoDB collection from which `ObjectId` is to be 33 | retrieved. 34 | 35 | Returns: 36 | `ObjectId` of newest document or ``None``, if no document exists. 37 | """ 38 | try: 39 | return collection.find().sort([('_id', -1)]).limit(1).next()['_id'] 40 | except StopIteration: 41 | return None 42 | -------------------------------------------------------------------------------- /foca/utils/logging.py: -------------------------------------------------------------------------------- 1 | """Utility functions for logging.""" 2 | 3 | import logging 4 | from connexion import request 5 | from functools import wraps 6 | from typing import (Callable, Optional) 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def log_traffic( 12 | _fn: Optional[Callable] = None, 13 | log_request: bool = True, 14 | log_response: bool = True, 15 | log_level: int = logging.INFO, 16 | ) -> Callable: 17 | """Decorator for logging service requests and responses. 18 | 19 | Args: 20 | log_request: Whether or not the request should be logged. 21 | log_response: Whether or not the response should be logged. 22 | log_level: Logging level, cf. 23 | https://docs.python.org/3/library/logging.html#logging-levels 24 | 25 | Returns: 26 | The decorated function. 27 | """ 28 | 29 | def _decorator_log_traffic(fn): 30 | """Logging decorator. Used to facilitate optional decorator arguments. 31 | 32 | Args: 33 | fn: The function to be decorated. 34 | 35 | Returns: 36 | The response returned from the input function. 37 | """ 38 | @wraps(fn) 39 | def _wrapper(*args, **kwargs): 40 | """Wrapper for logging decorator. 41 | 42 | Args: 43 | args: positional arguments passed through from `log_traffic`. 44 | kwargs: keyword arguments passed through from `log_traffic`. 45 | 46 | Returns: 47 | Wrapper function. 48 | """ 49 | req = ( 50 | f"\"{request.environ['REQUEST_METHOD']} " 51 | f"{request.environ['PATH_INFO']} " 52 | f"{request.environ['SERVER_PROTOCOL']}\" from " 53 | f"{request.environ['REMOTE_ADDR']}" 54 | ) 55 | if log_request: 56 | logger.log( 57 | level=log_level, 58 | msg=f"Incoming request: {req}", 59 | ) 60 | 61 | response = fn(*args, **kwargs) 62 | if log_response: 63 | logger.log( 64 | level=log_level, 65 | msg=f"Response to request {req}: {response}", 66 | ) 67 | return response 68 | 69 | return _wrapper 70 | 71 | if _fn is None: 72 | return _decorator_log_traffic 73 | else: 74 | return _decorator_log_traffic(_fn) 75 | -------------------------------------------------------------------------------- /foca/utils/misc.py: -------------------------------------------------------------------------------- 1 | """Miscellaneous utility functions.""" 2 | 3 | from random import choice 4 | import string 5 | 6 | 7 | def generate_id( 8 | charset: str = ''.join([string.ascii_letters, string.digits]), 9 | length: int = 6 10 | ) -> str: 11 | """Generate random string composed of specified character set. 12 | 13 | Args: 14 | charset: A string of allowed characters or an expression evaluating to 15 | a string of allowed characters. 16 | length: Length of returned string. 17 | 18 | Returns: 19 | Random string of specified length and composed of specified character 20 | set. 21 | 22 | Raises: 23 | TypeError: Raised if `charset` cannot be evaluated to a string or if 24 | `length` is not a positive integer. 25 | """ 26 | try: 27 | charset = eval(charset) 28 | except (NameError, SyntaxError): 29 | pass 30 | except Exception as e: 31 | raise TypeError(f"Could not evaluate 'charset': {charset}") from e 32 | if not isinstance(charset, str) or charset == "": 33 | raise TypeError( 34 | f"Could not evaluate 'charset' to non-empty string: {charset}" 35 | ) 36 | if not isinstance(length, int) or not length > 0: 37 | raise TypeError( 38 | f"Argument to 'length' is not a positive integer: {length}" 39 | ) 40 | charset = ''.join(sorted(set(charset))) 41 | return ''.join(choice(charset) for __ in range(length)) 42 | -------------------------------------------------------------------------------- /foca/version.py: -------------------------------------------------------------------------------- 1 | """Single source of truth for package version.""" 2 | 3 | __version__ = '0.13.0' 4 | -------------------------------------------------------------------------------- /images/casbin_model.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/images/casbin_model.jpeg -------------------------------------------------------------------------------- /images/foca_logo_192px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/images/foca_logo_192px.png -------------------------------------------------------------------------------- /images/logo-banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 63 | 67 | 73 | 79 | 84 | 90 | 96 | 101 | 102 | 107 | 108 | 109 | 114 | 115 | -------------------------------------------------------------------------------- /py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/py.typed -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | addict~=2.2 2 | celery~=5.2 3 | connexion~=2.11 4 | cryptography~=42.0 5 | Flask~=2.2 6 | flask-authz~=2.5.1 7 | Flask-Cors~=4.0 8 | Flask-PyMongo~=2.3 9 | pydantic~=2.7 10 | PyJWT~=2.4 11 | pymongo~=4.7 12 | PyYAML~=6.0 13 | requests~=2.31 14 | swagger-ui-bundle~=0.0 15 | toml~=0.10 16 | typing~=3.7 17 | Werkzeug~=2.2 18 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | coverage>=6.5 2 | flake8>=6.1 3 | mongomock>=4.1 4 | mypy>=0.991 5 | mypy-extensions>=0.4.4 6 | pylint>=3.2 7 | pytest>=7.4 8 | python-semantic-release>=9.7 9 | types-PyYAML 10 | types-requests 11 | types-setuptools 12 | types-urllib3 13 | typing_extensions>=4.11 14 | -------------------------------------------------------------------------------- /requirements_docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=7.2 2 | readthedocs-sphinx-ext>=2.2 3 | sphinx-rtd-theme>=1.3 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: foca.__version__ 3 | 4 | [flake8] 5 | exclude = .git,.eggs,build,venv,env 6 | max-line-length = 79 7 | 8 | [semantic_release] 9 | ; documentation: https://python-semantic-release.readthedocs.io/en/latest/configuration.html 10 | branch = master 11 | changelog_components = semantic_release.changelog.changelog_headers,semantic_release.changelog.compare_url 12 | check_build_status = false 13 | major_on_zero = true 14 | repository = pypi 15 | upload_to_pypi = true 16 | upload_to_release = true 17 | version_variable = foca/__init__.py:__version__ 18 | 19 | [mypy] 20 | ignore_missing_imports = True -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Package setup.""" 2 | 3 | from pathlib import Path 4 | from setuptools import setup, find_packages 5 | 6 | root_dir = Path(__file__).parent.resolve() 7 | 8 | exec(open(root_dir / "foca" / "version.py").read()) 9 | 10 | file_name = root_dir / "README.md" 11 | with open(file_name, "r") as _file: 12 | long_description = _file.read() 13 | 14 | install_requires = [] 15 | req = root_dir / 'requirements.txt' 16 | with open(req, "r") as _file: 17 | install_requires = _file.read().splitlines() 18 | 19 | docs_require = [] 20 | req = root_dir / 'requirements_docs.txt' 21 | try: 22 | with open(req, "r") as _file: 23 | docs_require = _file.read().splitlines() 24 | except FileNotFoundError: 25 | "Docs requirements unavailable." 26 | 27 | dev_requires = [] 28 | req = root_dir / 'requirements_dev.txt' 29 | try: 30 | with open(req, "r") as _file: 31 | dev_requires = _file.read().splitlines() 32 | except FileNotFoundError: 33 | "Docs requirements unavailable." 34 | 35 | setup( 36 | name="foca", 37 | version=__version__, # noqa: F821 38 | description=( 39 | "Archetype for OpenAPI microservices based on Flask and Connexion" 40 | ), 41 | long_description=long_description, 42 | long_description_content_type="text/markdown", 43 | url="https://github.com/elixir-cloud-aai/foca", 44 | author="ELIXIR Cloud & AAI", 45 | author_email="alexander.kanitz@alumni.ethz.ch", 46 | maintainer="Alexander Kanitz", 47 | maintainer_email="alexander.kanitz@alumnni.ethz.ch", 48 | classifiers=[ 49 | "Development Status :: 3 - Alpha", 50 | "Environment :: Console", 51 | "Environment :: Web Environment", 52 | "Framework :: Flask", 53 | "Intended Audience :: Developers", 54 | "Intended Audience :: Information Technology", 55 | "Intended Audience :: System Administrators", 56 | "License :: OSI Approved :: Apache Software License", 57 | "Natural Language :: English", 58 | "Programming Language :: Python :: 3.9", 59 | "Programming Language :: Python :: 3.10", 60 | "Programming Language :: Python :: 3.11", 61 | "Programming Language :: Python :: 3.12", 62 | "Topic :: Internet :: WWW/HTTP", 63 | "Topic :: System :: Systems Administration", 64 | "Topic :: Utilities", 65 | "Typing :: Typed", 66 | ], 67 | keywords=( 68 | 'rest api app openapi python microservice' 69 | ), 70 | project_urls={ 71 | "Repository": "https://github.com/elixir-cloud-aai/foca", 72 | "ELIXIR Cloud & AAI": "https://elixir-europe.github.io/cloud/", 73 | "Tracker": "https://github.com/elixir-cloud-aai/foca/issues", 74 | }, 75 | packages=find_packages(), 76 | setup_requires=[ 77 | "setuptools_git>=1.2", 78 | "twine>=3.8.0" 79 | ], 80 | install_requires=install_requires, 81 | extras_require={ 82 | "dev": dev_requires, 83 | "docs": docs_require, 84 | }, 85 | include_package_data=True, 86 | package_data={ 87 | "foca.security.access_control.api": ["*.yaml", "*.conf"], 88 | "": ["py.typed"], 89 | }, 90 | ) 91 | -------------------------------------------------------------------------------- /templates/config.yaml: -------------------------------------------------------------------------------- 1 | # FOCA CONFIGURATION 2 | 3 | # Available in app context as attributes of `current_app.config.foca` 4 | # Automatically validated via FOCA 5 | # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html 6 | 7 | # SERVER CONFIGURATION 8 | # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.ServerConfig 9 | server: 10 | host: '0.0.0.0' 11 | port: 8080 12 | debug: True 13 | environment: development 14 | testing: False 15 | use_reloader: False 16 | 17 | # EXCEPTION CONFIGURATION 18 | # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.ExceptionConfig 19 | exceptions: 20 | required_members: [['msg'], ['status']] 21 | status_member: ['status'] 22 | exceptions: my_app.exceptions.exceptions 23 | 24 | # SECURITY CONFIGURATION 25 | # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.SecurityConfig 26 | security: 27 | auth: 28 | add_key_to_claims: True 29 | algorithms: 30 | - RS256 31 | allow_expired: False 32 | audience: null 33 | validation_methods: 34 | - userinfo 35 | - public_key 36 | validation_checks: any 37 | 38 | # API CONFIGURATION 39 | # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.APIConfig 40 | api: 41 | specs: 42 | - path: 43 | - path/to/my/openapi/specs.yaml 44 | add_operation_fields: 45 | x-openapi-router-controller: myapi.controllers 46 | add_security_fields: 47 | x-bearerInfoFunc: app.validate_token 48 | disable_auth: False 49 | connexion: 50 | strict_validation: True 51 | validate_responses: True 52 | options: 53 | swagger_ui: True 54 | serve_spec: True 55 | 56 | # DATABASE CONFIGURATION 57 | # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.DBConfig 58 | db: 59 | host: mongodb 60 | port: 27017 61 | dbs: 62 | myDb: 63 | collections: 64 | myCollection: 65 | indexes: 66 | - keys: 67 | id: 1 68 | options: 69 | 'unique': True 70 | 71 | # WORKER CONFIGURATION 72 | # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.JobsConfig 73 | jobs: 74 | host: rabbitmq 75 | port: 5672 76 | backend: 'rpc://' 77 | include: 78 | - my_app.tasks.my_task_1 79 | - my_app.tasks.my_task_2 80 | 81 | # LOGGING CONFIGURATION 82 | # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.LogConfig 83 | log: 84 | version: 1 85 | disable_existing_loggers: False 86 | formatters: 87 | standard: 88 | class: logging.Formatter 89 | style: "{" 90 | format: "[{asctime}: {levelname:<8}] {message} [{name}]" 91 | handlers: 92 | console: 93 | class: logging.StreamHandler 94 | level: 20 95 | formatter: standard 96 | stream: ext://sys.stderr 97 | root: 98 | level: 10 99 | handlers: [console] 100 | 101 | 102 | # CUSTOM APP CONFIGURATION 103 | # Available in app context as attributes of `current_app.config.foca` 104 | 105 | # Can be validated by FOCA by passing a Pydantic model class to the 106 | # `custom_config_model` parameter in the `foca.Foca()` constructor 107 | custom: 108 | my_param: 'some_value' 109 | 110 | # Any other sections/parameters are *not* validated by FOCA; if desired, 111 | # validate parameters in app 112 | custom_params_not_validated: 113 | my_other_param: 'some_other_value' 114 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/tests/__init__.py -------------------------------------------------------------------------------- /tests/api/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from foca.security.auth import validate_token # as vt # noqa: F401 2 | 3 | 4 | def listPets(): 5 | return {} 6 | 7 | 8 | def createPets(): 9 | return {} 10 | 11 | 12 | def showPetById(): 13 | return {} 14 | 15 | 16 | def putPetsById(): 17 | return {} 18 | -------------------------------------------------------------------------------- /tests/api/test_register_openapi.py: -------------------------------------------------------------------------------- 1 | """Tests for the registration of OpenAPI specifications with a Connexion app 2 | instance. 3 | """ 4 | 5 | from copy import deepcopy 6 | from pathlib import Path 7 | 8 | from connexion import App 9 | from connexion.exceptions import InvalidSpecification 10 | import pytest 11 | from yaml import YAMLError 12 | 13 | from foca.api.register_openapi import register_openapi 14 | from foca.models.config import SpecConfig 15 | 16 | # Define mock data 17 | DIR = Path(__file__).parents[1].resolve() / "test_files" 18 | PATH_SPECS_2_YAML_ORIGINAL = DIR / "openapi_2_petstore.original.yaml" 19 | PATH_SPECS_2_YAML_MODIFIED = DIR / "openapi_2_petstore.modified.yaml" 20 | PATH_SPECS_2_JSON_ORIGINAL = DIR / "openapi_2_petstore.original.json" 21 | PATH_SPECS_2_YAML_ADDITION = DIR / "openapi_2_petstore.addition.yaml" 22 | PATH_SPECS_3_YAML_ORIGINAL = DIR / "openapi_3_petstore.original.yaml" 23 | PATH_SPECS_3_YAML_MODIFIED = DIR / "openapi_3_petstore.modified.yaml" 24 | PATH_SPECS_3_PATHITEMPARAM_YAML_ORIGINAL = DIR / "openapi_3_petstore_pathitemparam.original.yaml" 25 | PATH_SPECS_3_PATHITEMPARAM_YAML_MODIFIED = DIR / "openapi_3_petstore_pathitemparam.modified.yaml" 26 | PATH_SPECS_INVALID_JSON = DIR / "invalid.json" 27 | PATH_SPECS_INVALID_YAML = DIR / "invalid.openapi.yaml" 28 | PATH_NOT_FOUND = DIR / "does/not/exist.yaml" 29 | OPERATION_FIELDS_2 = {"x-swagger-router-controller": "controllers"} 30 | OPERATION_FIELDS_2_NO_RESOLVE = {"x-swagger-router-controller": YAMLError} 31 | OPERATION_FIELDS_3 = {"x-openapi-router-controller": "controllers"} 32 | SECURITY_FIELDS_2 = {"x-apikeyInfoFunc": "controllers.validate_token"} 33 | SECURITY_FIELDS_3 = {"x-bearerInfoFunc": "controllers.validate_token"} 34 | APPEND = { 35 | "info": { 36 | "version": "1.0.0", 37 | "title": "Swagger Petstore", 38 | "license": { 39 | "name": "MIT" 40 | } 41 | } 42 | } 43 | CONNEXION_CONFIG = { 44 | "strict_validation": True, 45 | "validate_responses": False, 46 | "options": { 47 | "swagger_ui": True, 48 | "serve_spec": True, 49 | } 50 | } 51 | SPEC_CONFIG_2 = { 52 | "path": PATH_SPECS_2_YAML_ORIGINAL, 53 | "path_out": PATH_SPECS_2_YAML_MODIFIED, 54 | "append": [APPEND], 55 | "add_operation_fields": OPERATION_FIELDS_2, 56 | "add_security_fields": SECURITY_FIELDS_2, 57 | "disable_auth": False, 58 | "connexion": CONNEXION_CONFIG, 59 | } 60 | SPEC_CONFIG_3 = { 61 | "path": PATH_SPECS_3_YAML_ORIGINAL, 62 | "path_out": PATH_SPECS_3_YAML_MODIFIED, 63 | "append": [APPEND], 64 | "add_operation_fields": OPERATION_FIELDS_3, 65 | "add_security_fields": SECURITY_FIELDS_3, 66 | "disable_auth": False, 67 | "connexion": CONNEXION_CONFIG, 68 | } 69 | SPEC_CONFIG_3_PATHITEMPARAM = { 70 | "path": PATH_SPECS_3_PATHITEMPARAM_YAML_ORIGINAL, 71 | "path_out": PATH_SPECS_3_PATHITEMPARAM_YAML_MODIFIED, 72 | "append": [APPEND], 73 | "add_operation_fields": OPERATION_FIELDS_3, 74 | "add_security_fields": SECURITY_FIELDS_3, 75 | "disable_auth": False, 76 | "connexion": CONNEXION_CONFIG, 77 | } 78 | SPEC_CONFIG_2_JSON = deepcopy(SPEC_CONFIG_2) 79 | SPEC_CONFIG_2_JSON['path'] = PATH_SPECS_2_JSON_ORIGINAL 80 | SPEC_CONFIG_2_LIST = deepcopy(SPEC_CONFIG_2) 81 | SPEC_CONFIG_2_LIST['path'] = [PATH_SPECS_2_YAML_ORIGINAL] 82 | SPEC_CONFIG_2_MULTI = deepcopy(SPEC_CONFIG_2_LIST) 83 | SPEC_CONFIG_2_MULTI['path'].append(PATH_SPECS_2_YAML_ADDITION) 84 | SPEC_CONFIG_2_DISABLE_AUTH = deepcopy(SPEC_CONFIG_2) 85 | SPEC_CONFIG_2_DISABLE_AUTH['disable_auth'] = True 86 | SPEC_CONFIG_3_DISABLE_AUTH = deepcopy(SPEC_CONFIG_3) 87 | SPEC_CONFIG_3_DISABLE_AUTH['disable_auth'] = True 88 | 89 | 90 | class TestRegisterOpenAPI: 91 | 92 | def test_openapi_2_yaml(self): 93 | """Successfully register OpenAPI 2 YAML specs with Connexion app.""" 94 | app = App(__name__) 95 | spec_configs = [SpecConfig(**SPEC_CONFIG_2)] 96 | res = register_openapi(app=app, specs=spec_configs) 97 | assert isinstance(res, App) 98 | 99 | def test_openapi_3_yaml(self): 100 | """Successfully register OpenAPI 3 YAML specs with Connexion app.""" 101 | app = App(__name__) 102 | spec_configs = [SpecConfig(**SPEC_CONFIG_3)] 103 | res = register_openapi(app=app, specs=spec_configs) 104 | assert isinstance(res, App) 105 | 106 | def test_openapi_3_pathitemparam_yaml(self): 107 | """ 108 | Successfully register OpenAPI 3 YAML specs with PathItem.parameters 109 | field with Connexion app. 110 | """ 111 | app = App(__name__) 112 | spec_configs = [SpecConfig(**SPEC_CONFIG_3_PATHITEMPARAM)] 113 | res = register_openapi(app=app, specs=spec_configs) 114 | assert isinstance(res, App) 115 | 116 | def test_openapi_2_json(self): 117 | """Successfully register OpenAPI 2 JSON specs with Connexion app.""" 118 | app = App(__name__) 119 | spec_configs = [SpecConfig(**SPEC_CONFIG_2_JSON)] 120 | res = register_openapi(app=app, specs=spec_configs) 121 | assert isinstance(res, App) 122 | 123 | def test_openapi_2_json_and_3_yaml(self): 124 | """Successfully register both OpenAPI2 JSON and OpenAPI3 YAML specs 125 | with Connexion app. 126 | """ 127 | app = App(__name__) 128 | spec_configs = [ 129 | SpecConfig(**SPEC_CONFIG_2_JSON), 130 | SpecConfig(**SPEC_CONFIG_3), 131 | ] 132 | res = register_openapi(app=app, specs=spec_configs) 133 | assert isinstance(res, App) 134 | 135 | def test_openapi_2_invalid(self): 136 | """Registration failing because of invalid OpenAPI 2 spec file.""" 137 | app = App(__name__) 138 | spec_configs = [SpecConfig(path=PATH_SPECS_INVALID_YAML)] 139 | with pytest.raises(InvalidSpecification): 140 | register_openapi(app=app, specs=spec_configs) 141 | 142 | def test_openapi_2_json_invalid(self): 143 | """Registration failing because of invalid JSON spec file.""" 144 | app = App(__name__) 145 | spec_configs = [SpecConfig(path=PATH_SPECS_INVALID_JSON)] 146 | with pytest.raises(ValueError): 147 | register_openapi(app=app, specs=spec_configs) 148 | 149 | def test_openapi_not_found(self): 150 | """Registration failing because spec file is unavailable.""" 151 | app = App(__name__) 152 | spec_configs = [SpecConfig(path=PATH_NOT_FOUND)] 153 | with pytest.raises(OSError): 154 | register_openapi(app=app, specs=spec_configs) 155 | 156 | def test_openapi_2_list(self): 157 | """Successfully register OpenAPI 2 JSON specs with Connexion app; 158 | specs provided as list. 159 | """ 160 | app = App(__name__) 161 | spec_configs = [SpecConfig(**SPEC_CONFIG_2_LIST)] 162 | res = register_openapi(app=app, specs=spec_configs) 163 | assert isinstance(res, App) 164 | 165 | def test_openapi_2_fragments(self): 166 | """Successfully register OpenAPI 2 JSON specs with Connexion app; 167 | specs provided as multiple fragments. 168 | """ 169 | app = App(__name__) 170 | spec_configs = [SpecConfig(**SPEC_CONFIG_2_MULTI)] 171 | res = register_openapi(app=app, specs=spec_configs) 172 | assert isinstance(res, App) 173 | 174 | def test_openapi_2_yaml_no_auth(self): 175 | """Successfully register OpenAPI 2 YAML specs with Connexion app; 176 | no security definitions/fields. 177 | """ 178 | app = App(__name__) 179 | spec_configs = [SpecConfig(**SPEC_CONFIG_2_DISABLE_AUTH)] 180 | res = register_openapi(app=app, specs=spec_configs) 181 | assert isinstance(res, App) 182 | 183 | def test_openapi_3_yaml_no_auth(self): 184 | """Successfully register OpenAPI 3 YAML specs with Connexion app; 185 | no security schemes/fields. 186 | """ 187 | app = App(__name__) 188 | spec_configs = [SpecConfig(**SPEC_CONFIG_3_DISABLE_AUTH)] 189 | res = register_openapi(app=app, specs=spec_configs) 190 | assert isinstance(res, App) 191 | -------------------------------------------------------------------------------- /tests/config/test_config_parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for config_parser.py 3 | """ 4 | 5 | from pathlib import Path 6 | from unittest import mock 7 | 8 | from pydantic import ( 9 | BaseModel, 10 | ValidationError, 11 | ) 12 | import pytest 13 | 14 | from foca.config.config_parser import ConfigParser 15 | from foca.models.config import Config 16 | 17 | DIR = Path(__file__).parent.parent / "test_files" 18 | PATH = str(DIR / "openapi_2_petstore.original.yaml") 19 | PATH_ADDITION = str(DIR / "openapi_2_petstore.addition.yaml") 20 | TEST_CONFIG_INSTANCE = Config() 21 | TEST_CONFIG_MODEL = 'tests.test_files.model_valid.CustomConfig' 22 | TEST_CONFIG_MODEL_NOT_EXISTS = 'tests.test_files.model_valid.NotExists' 23 | TEST_CONFIG_MODEL_MODULE_NOT_EXISTS = 'tests.test_files.not_a_module.NotExists' 24 | TEST_DICT = {} 25 | TEST_FILE = "tests/test_files/conf_valid.yaml" 26 | TEST_FILE_CUSTOM_INVALID = "tests/test_files/conf_valid_custom_invalid.yaml" 27 | TEST_FILE_INVALID = "tests/test_files/conf_invalid_log_level.yaml" 28 | TEST_FILE_INVALID_YAML = "tests/test_files/conf_no_yaml.txt" 29 | TEST_FILE_INVALID_LOG = "tests/test_files/conf_log_invalid.yaml" 30 | 31 | 32 | def test_config_parser_valid_config_file(): 33 | """Test valid YAML parsing.""" 34 | conf = ConfigParser(Path(TEST_FILE)) 35 | assert type(conf.config.model_dump()) == type(TEST_DICT) 36 | assert isinstance(conf.config, type(TEST_CONFIG_INSTANCE)) 37 | 38 | 39 | def test_config_parser_invalid_config_file(): 40 | """Test invalid YAML parsing.""" 41 | with pytest.raises(ValidationError): 42 | ConfigParser(Path(TEST_FILE_INVALID)) 43 | 44 | 45 | def test_config_parser_invalid_file_path(): 46 | """Test invalid file path.""" 47 | conf = ConfigParser(Path(TEST_FILE)) 48 | with pytest.raises(OSError): 49 | assert conf.parse_yaml(Path("")) is not None 50 | 51 | 52 | def test_config_parser_invalid_log_config(): 53 | """Test invalid log config YAML.""" 54 | conf = ConfigParser(Path(TEST_FILE_INVALID_LOG)) 55 | assert type(conf.config.model_dump()) == type(TEST_DICT) 56 | assert isinstance(conf.config, type(TEST_CONFIG_INSTANCE)) 57 | 58 | 59 | def test_config_parser_with_custom_config_model(): 60 | """Test with valid custom config model class.""" 61 | conf = ConfigParser( 62 | config_file=Path(TEST_FILE), 63 | custom_config_model=TEST_CONFIG_MODEL, 64 | ) 65 | assert isinstance(conf.config.custom.param, str) 66 | assert conf.config.custom.param == "STRING" 67 | 68 | 69 | def test_process_yaml_valid_config_file(): 70 | """Test process_yaml with valid YAML file.""" 71 | result = ConfigParser.parse_yaml(Path(TEST_FILE)) 72 | assert isinstance(result, dict) 73 | 74 | 75 | def test_process_yaml_invalid_config_file(): 76 | """Test process_yaml with invalid YAML file.""" 77 | with pytest.raises(ValueError): 78 | ConfigParser.parse_yaml(Path(TEST_FILE_INVALID_YAML)) 79 | 80 | 81 | def test_process_yaml_missing_file(): 82 | """Test process_yaml when file cannot be opened.""" 83 | with mock.patch("foca.config.config_parser.open") as mock_open: 84 | mock_open.side_effect = OSError 85 | with pytest.raises(OSError): 86 | ConfigParser.parse_yaml(Path(TEST_FILE)) 87 | 88 | 89 | def test_merge_yaml_with_no_args(): 90 | """Test merge_yaml with no arguments.""" 91 | empty_list = [] 92 | res = ConfigParser.merge_yaml(*empty_list) 93 | assert res == {} 94 | 95 | 96 | def test_merge_yaml_with_two_args(): 97 | """Test merge_yaml with no arguments.""" 98 | yaml_list = [Path(PATH), Path(PATH_ADDITION)] 99 | res = ConfigParser.merge_yaml(*yaml_list) 100 | assert 'put' in res['paths']['/pets/{petId}'] 101 | 102 | 103 | def test_parse_custom_config_valid_model(): 104 | """Test ``.parse_custom_config()`` with a valid model class.""" 105 | conf = ConfigParser(config_file=Path(TEST_FILE)) 106 | result = conf.parse_custom_config(model=TEST_CONFIG_MODEL) 107 | assert isinstance(result, BaseModel) 108 | assert isinstance(result.param, str) 109 | assert result.param == "STRING" 110 | 111 | 112 | def test_parse_custom_config_model_module_not_exists(): 113 | """Test ``.parse_custom_config()`` when module does not exist.""" 114 | conf = ConfigParser(config_file=Path(TEST_FILE)) 115 | with pytest.raises(ValueError): 116 | conf.parse_custom_config(model=TEST_CONFIG_MODEL_MODULE_NOT_EXISTS) 117 | 118 | 119 | def test_parse_custom_config_model_not_exists(): 120 | """Test ``.parse_custom_config()`` when model class does not exist.""" 121 | conf = ConfigParser(config_file=Path(TEST_FILE)) 122 | with pytest.raises(ValueError): 123 | conf.parse_custom_config(model=TEST_CONFIG_MODEL_NOT_EXISTS) 124 | 125 | 126 | def test_parse_custom_config_invalid(): 127 | """Test ``.parse_custom_config()`` when model class does not exist.""" 128 | conf = ConfigParser(config_file=Path(TEST_FILE_CUSTOM_INVALID)) 129 | with pytest.raises(ValueError): 130 | conf.parse_custom_config(model=TEST_CONFIG_MODEL) 131 | -------------------------------------------------------------------------------- /tests/database/test_register_mongodb.py: -------------------------------------------------------------------------------- 1 | """Tests for register_mongodb.py""" 2 | 3 | from flask import Flask 4 | from flask_pymongo import PyMongo 5 | 6 | from foca.database.register_mongodb import ( 7 | _create_mongo_client, 8 | register_mongodb, 9 | ) 10 | from foca.models.config import MongoConfig 11 | 12 | MONGO_DICT_MIN = { 13 | 'host': 'mongodb', 14 | 'port': 27017, 15 | } 16 | DB_DICT_NO_COLL = { 17 | 'my_db': { 18 | 'collections': None 19 | } 20 | } 21 | DB_DICT_DEF_COLL = { 22 | 'my_db': { 23 | 'collections': { 24 | 'my_collection': { 25 | 'indexes': None, 26 | } 27 | } 28 | } 29 | } 30 | DB_DICT_CUST_COLL = { 31 | 'my_db': { 32 | 'collections': { 33 | 'my_collection': { 34 | 'indexes': [{ 35 | 'keys': {'indexed_field': 1}, 36 | 'options': {'sparse': False} 37 | }] 38 | } 39 | } 40 | } 41 | } 42 | MONGO_CONFIG_MINIMAL = MongoConfig(**MONGO_DICT_MIN, dbs=None) 43 | MONGO_CONFIG_NO_COLL = MongoConfig(**MONGO_DICT_MIN, dbs=DB_DICT_NO_COLL) 44 | MONGO_CONFIG_DEF_COLL = MongoConfig(**MONGO_DICT_MIN, dbs=DB_DICT_DEF_COLL) 45 | MONGO_CONFIG_CUST_COLL = MongoConfig(**MONGO_DICT_MIN, dbs=DB_DICT_CUST_COLL) 46 | 47 | 48 | def test__create_mongo_client(monkeypatch): 49 | """When MONGO_USERNAME environement variable is NOT defined""" 50 | monkeypatch.setenv("MONGO_USERNAME", 'None') 51 | app = Flask(__name__) 52 | res = _create_mongo_client( 53 | app=app, 54 | ) 55 | assert isinstance(res, PyMongo) 56 | 57 | 58 | def test__create_mongo_client_auth(monkeypatch): 59 | """When MONGO_USERNAME environement variable IS defined""" 60 | monkeypatch.setenv("MONGO_USERNAME", "TestingUser") 61 | app = Flask(__name__) 62 | res = _create_mongo_client(app) 63 | assert isinstance(res, PyMongo) 64 | 65 | 66 | def test__create_mongo_client_auth_empty(monkeypatch): 67 | """When MONGO_USERNAME environment variable IS defined but empty""" 68 | monkeypatch.setenv("MONGO_USERNAME", '') 69 | app = Flask(__name__) 70 | res = _create_mongo_client(app) 71 | assert isinstance(res, PyMongo) 72 | 73 | 74 | def test_register_mongodb_no_database(): 75 | """Skip MongoDB client registration""" 76 | app = Flask(__name__) 77 | res = register_mongodb( 78 | app=app, 79 | conf=MONGO_CONFIG_MINIMAL, 80 | ) 81 | assert isinstance(res, MongoConfig) 82 | 83 | 84 | def test_register_mongodb_no_collections(): 85 | """Register MongoDB database without any collections""" 86 | app = Flask(__name__) 87 | res = register_mongodb( 88 | app=app, 89 | conf=MONGO_CONFIG_NO_COLL, 90 | ) 91 | assert isinstance(res, MongoConfig) 92 | 93 | 94 | def test_register_mongodb_def_collections(): 95 | """Register MongoDB with collection and default index""" 96 | app = Flask(__name__) 97 | res = register_mongodb( 98 | app=app, 99 | conf=MONGO_CONFIG_DEF_COLL, 100 | ) 101 | assert isinstance(res, MongoConfig) 102 | 103 | 104 | def test_register_mongodb_cust_collections(monkeypatch): 105 | """Register MongoDB with collections and custom indexes""" 106 | monkeypatch.setattr( 107 | 'pymongo.collection.Collection.create_index', 108 | lambda *args, **kwargs: None, 109 | ) 110 | monkeypatch.setattr( 111 | 'pymongo.collection.Collection.drop_indexes', 112 | lambda *args, **kwargs: None, 113 | ) 114 | app = Flask(__name__) 115 | res = register_mongodb( 116 | app=app, 117 | conf=MONGO_CONFIG_CUST_COLL, 118 | ) 119 | assert isinstance(res, MongoConfig) 120 | -------------------------------------------------------------------------------- /tests/errors/test_errors.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for exceptions.py 3 | """ 4 | 5 | from copy import deepcopy 6 | import json 7 | 8 | from flask import (Flask, Response) 9 | from connexion import App 10 | import pytest 11 | 12 | from foca.errors.exceptions import ( 13 | _exc_to_str, 14 | _exclude_key_nested_dict, 15 | _problem_handler_json, 16 | _log_exception, 17 | register_exception_handler, 18 | _subset_nested_dict, 19 | ) 20 | from foca.models.config import Config 21 | 22 | EXCEPTION_INSTANCE = Exception() 23 | INVALID_LOG_FORMAT = 'unknown_log_format' 24 | TEST_DICT = { 25 | "title": "MyException", 26 | "details": { 27 | "code": 400, 28 | "description": "Some exception", 29 | }, 30 | "status": 400, 31 | } 32 | TEST_KEYS = ['details', 'code'] 33 | EXPECTED_SUBSET_RESULT = { 34 | "details": { 35 | "code": 400, 36 | }, 37 | } 38 | EXPECTED_EXCLUDE_RESULT = { 39 | "title": "MyException", 40 | "details": { 41 | "description": "Some exception", 42 | }, 43 | "status": 400, 44 | } 45 | PUBLIC_MEMBERS = [['title']] 46 | PRIVATE_MEMBERS = [['status']] 47 | 48 | 49 | class UnknownException(Exception): 50 | pass 51 | 52 | 53 | def test_register_exception_handler(): 54 | """Test exception handler registration with Connexion app.""" 55 | app = App(__name__) 56 | ret = register_exception_handler(app) 57 | assert isinstance(ret, App) 58 | 59 | 60 | def test__exc_to_str(): 61 | """Test exception reformatter function.""" 62 | res = _exc_to_str(exc=EXCEPTION_INSTANCE) 63 | assert isinstance(res, str) 64 | 65 | 66 | @pytest.mark.parametrize("format", ['oneline', 'minimal', 'regular']) 67 | def test__log_exception(caplog, format): 68 | """Test exception reformatter function.""" 69 | _log_exception( 70 | exc=EXCEPTION_INSTANCE, 71 | format=format, 72 | ) 73 | assert "Exception" in caplog.text 74 | 75 | 76 | def test__log_exception_invalid_format(caplog): 77 | """Test exception reformatter function with invalid format argument.""" 78 | _log_exception( 79 | exc=EXCEPTION_INSTANCE, 80 | format=INVALID_LOG_FORMAT, 81 | ) 82 | assert "logging is misconfigured" in caplog.text 83 | 84 | 85 | def test__subset_nested_dict(): 86 | """Test nested dictionary subsetting function.""" 87 | res = _subset_nested_dict( 88 | obj=TEST_DICT, 89 | key_sequence=deepcopy(TEST_KEYS) 90 | ) 91 | assert res == EXPECTED_SUBSET_RESULT 92 | 93 | 94 | def test__exclude_key_nested_dict(): 95 | """Test function to exclude a key from a nested dictionary.""" 96 | res = _exclude_key_nested_dict( 97 | obj=TEST_DICT, 98 | key_sequence=deepcopy(TEST_KEYS) 99 | ) 100 | assert res == EXPECTED_EXCLUDE_RESULT 101 | 102 | 103 | def test__problem_handler_json(): 104 | """Test problem handler with instance of custom, unlisted error.""" 105 | app = Flask(__name__) 106 | setattr(app.config, 'foca', Config()) 107 | EXPECTED_RESPONSE = app.config.foca.exceptions.mapping[Exception] 108 | with app.app_context(): 109 | res = _problem_handler_json(UnknownException()) 110 | assert isinstance(res, Response) 111 | assert res.status == '500 INTERNAL SERVER ERROR' 112 | assert res.mimetype == "application/problem+json" 113 | response = json.loads(res.data.decode('utf-8')) 114 | assert response == EXPECTED_RESPONSE 115 | 116 | 117 | def test__problem_handler_json_no_fallback_exception(): 118 | """Test problem handler; unlisted error without fallback.""" 119 | app = Flask(__name__) 120 | setattr(app.config, 'foca', Config()) 121 | del app.config.foca.exceptions.mapping[Exception] 122 | with app.app_context(): 123 | res = _problem_handler_json(UnknownException()) 124 | assert isinstance(res, Response) 125 | assert res.status == '500 INTERNAL SERVER ERROR' 126 | assert res.mimetype == "application/problem+json" 127 | response = res.data.decode("utf-8") 128 | assert response == "" 129 | 130 | 131 | def test__problem_handler_json_with_public_members(): 132 | """Test problem handler with public members.""" 133 | app = Flask(__name__) 134 | setattr(app.config, 'foca', Config()) 135 | app.config.foca.exceptions.public_members = PUBLIC_MEMBERS 136 | with app.app_context(): 137 | res = _problem_handler_json(UnknownException()) 138 | assert isinstance(res, Response) 139 | assert res.status == '500 INTERNAL SERVER ERROR' 140 | assert res.mimetype == "application/problem+json" 141 | 142 | 143 | def test__problem_handler_json_with_private_members(): 144 | """Test problem handler with private members.""" 145 | app = Flask(__name__) 146 | setattr(app.config, 'foca', Config()) 147 | app.config.foca.exceptions.private_members = PRIVATE_MEMBERS 148 | with app.app_context(): 149 | res = _problem_handler_json(UnknownException()) 150 | assert isinstance(res, Response) 151 | assert res.status == '500 INTERNAL SERVER ERROR' 152 | assert res.mimetype == "application/problem+json" 153 | -------------------------------------------------------------------------------- /tests/factories/test_celery_app.py: -------------------------------------------------------------------------------- 1 | """Unit tests for Celery app factory module.""" 2 | 3 | from celery import Celery 4 | 5 | from foca.factories.celery_app import create_celery_app 6 | from foca.factories.connexion_app import create_connexion_app 7 | from foca.models.config import (Config, JobsConfig) 8 | 9 | CONFIG = Config() 10 | CONFIG.jobs = JobsConfig() 11 | 12 | 13 | def test_create_celery_app(): 14 | """Test Connexion app creation.""" 15 | cnx_app = create_connexion_app(CONFIG) 16 | cel_app = create_celery_app(cnx_app.app) 17 | assert isinstance(cel_app, Celery) 18 | assert cel_app.conf.foca == CONFIG 19 | -------------------------------------------------------------------------------- /tests/factories/test_connexion_app.py: -------------------------------------------------------------------------------- 1 | """Tests for foca.factories.connexion_app.""" 2 | 3 | from connexion import App 4 | 5 | from foca.models.config import Config 6 | from foca.factories.connexion_app import ( 7 | __add_config_to_connexion_app, 8 | create_connexion_app, 9 | ) 10 | 11 | CONFIG = Config() 12 | ERROR_CODE = 400 13 | ERROR_ORIGINAL = { 14 | 'title': 'BAD REQUEST', 15 | 'status_code': str(ERROR_CODE), 16 | } 17 | ERROR_REWRITTEN = { 18 | "msg": "The request is malformed.", 19 | "status_code": str(ERROR_CODE), 20 | } 21 | 22 | 23 | def test_add_config_to_connexion_app(): 24 | """Test if app config is updated.""" 25 | cnx_app = App(__name__) 26 | cnx_app = __add_config_to_connexion_app(cnx_app, CONFIG) 27 | assert isinstance(cnx_app, App) 28 | assert cnx_app.app.config.foca == CONFIG 29 | 30 | 31 | def test_create_connexion_app_without_config(): 32 | """Test Connexion app creation without config.""" 33 | cnx_app = create_connexion_app() 34 | assert isinstance(cnx_app, App) 35 | 36 | 37 | def test_create_connexion_app_with_config(): 38 | """Test Connexion app creation with config.""" 39 | cnx_app = create_connexion_app(CONFIG) 40 | assert isinstance(cnx_app, App) 41 | -------------------------------------------------------------------------------- /tests/integration_tests.py: -------------------------------------------------------------------------------- 1 | """Integration tests for petstore app.""" 2 | 3 | import requests 4 | 5 | from tests.test_files.models_petstore import ( 6 | Error, 7 | Pet, 8 | Pets, 9 | ) 10 | 11 | PETSTORE_URL = "http://localhost:80" 12 | NAME_PET = "karl" 13 | TAG_PET = "frog" 14 | EXTRA_PARAM_ARG = "extra" 15 | BODY_PET_1 = { 16 | "name": NAME_PET, 17 | "tag": TAG_PET, 18 | } 19 | BODY_PET_2 = { 20 | "name": NAME_PET, 21 | "tag": TAG_PET, 22 | "extra_parameter": EXTRA_PARAM_ARG, 23 | } 24 | INVALID_ID = "X" 25 | 26 | 27 | def test_add_pet_200(): 28 | """Test `POST /pets` for successfully adding a new pet.""" 29 | response = requests.post( 30 | url=f"{PETSTORE_URL}/pets", 31 | json=BODY_PET_1, 32 | ) 33 | assert response.status_code == 200 34 | response_data = Pet(**response.json()) 35 | assert isinstance(response_data, Pet) 36 | assert response_data.name == NAME_PET 37 | assert response_data.tag == TAG_PET 38 | 39 | 40 | def test_add_pet_extra_parameter_200(): 41 | """Test `POST /pets` to ensure that extra parameter is ignored.""" 42 | response = requests.post( 43 | url=f"{PETSTORE_URL}/pets", 44 | json=BODY_PET_2, 45 | ) 46 | assert response.status_code == 200 47 | response_data = Pet(**response.json()) 48 | assert isinstance(response_data, Pet) 49 | assert response_data.name == NAME_PET 50 | assert response_data.tag == TAG_PET 51 | assert getattr(response_data, 'extra_parameter', None) is None 52 | 53 | 54 | def test_add_pet_required_arguments_missing_400(): 55 | """Test `POST /pets` with required arguments missing.""" 56 | response = requests.post( 57 | url=f"{PETSTORE_URL}/pets", 58 | json={}, 59 | ) 60 | assert response.status_code == 400 61 | print(response.json()) 62 | response_data = Error(**response.json()) 63 | assert response_data.code == 400 64 | assert response_data.message == ( 65 | "We don't quite understand what it is you are looking for." 66 | ) 67 | 68 | 69 | def test_get_all_pets_200(): 70 | """Test `GET /pets` for successfully fetching all pets.""" 71 | response = requests.get( 72 | url=f"{PETSTORE_URL}/pets", 73 | ) 74 | assert response.status_code == 200 75 | response_data = Pets(pets=response.json()) 76 | assert isinstance(response_data, Pets) 77 | 78 | 79 | def test_get_all_pets_check_record_number(): 80 | """Test `GET /pets` to ensure that the number of records increased after 81 | adding an additional pet. 82 | """ 83 | response = requests.get( 84 | url=f"{PETSTORE_URL}/pets", 85 | ) 86 | assert response.status_code == 200 87 | records = len(response.json()) 88 | requests.post( 89 | url=f"{PETSTORE_URL}/pets", 90 | json=BODY_PET_1, 91 | ) 92 | new_response = requests.get( 93 | url=f"{PETSTORE_URL}/pets", 94 | ) 95 | assert new_response.status_code == 200 96 | assert len(new_response.json()) == records + 1 97 | 98 | 99 | def test_get_pet_by_id_200(): 100 | """Test for `GET /pets/{id}` for successfully fetching a pet with a given 101 | id. 102 | """ 103 | post_response = requests.post( 104 | url=f"{PETSTORE_URL}/pets", 105 | json=BODY_PET_1, 106 | ) 107 | pet_id = Pet(**post_response.json()).id 108 | response = requests.get( 109 | url=f"{PETSTORE_URL}/pets/{pet_id}", 110 | ) 111 | assert response.status_code == 200 112 | response_data = Pet(**response.json()) 113 | assert isinstance(response_data, Pet) 114 | assert response_data.name == NAME_PET 115 | assert response_data.tag == TAG_PET 116 | 117 | 118 | def test_get_pet_by_id_404(): 119 | """Test for `GET /pets/{id}` for fetching a non-existent pet.""" 120 | response = requests.get( 121 | url=f"{PETSTORE_URL}/pets/{INVALID_ID}", 122 | ) 123 | assert response.status_code == 404 124 | response_data = Error(**response.json()) 125 | assert response_data.code == 404 126 | assert response_data.message == "We have never heard of this pet! :-(" 127 | 128 | 129 | def test_delete_pet_204(): 130 | """Test for `DELETE /pets/{id}` for successfully deleting a pet with a 131 | given id. 132 | """ 133 | post_response = requests.post( 134 | url=f"{PETSTORE_URL}/pets", 135 | json=BODY_PET_1, 136 | ) 137 | pet_id = Pet(**post_response.json()).id 138 | get_response_pre = requests.get( 139 | url=f"{PETSTORE_URL}/pets/{pet_id}", 140 | ) 141 | assert get_response_pre.status_code == 200 142 | response = requests.delete( 143 | url=f"{PETSTORE_URL}/pets/{pet_id}", 144 | ) 145 | assert response.status_code == 204 146 | get_response_post = requests.get( 147 | url=f"{PETSTORE_URL}/pets/{pet_id}", 148 | ) 149 | assert get_response_post.status_code == 404 150 | 151 | 152 | def test_delete_pet_404(): 153 | """Test for `DELETE /pets/{id}` for deleting a non-existent pet.""" 154 | response = requests.delete( 155 | url=f"{PETSTORE_URL}/pets/{INVALID_ID}", 156 | ) 157 | assert response.status_code == 404 158 | response_data = Error(**response.json()) 159 | assert response_data.code == 404 160 | assert response_data.message == "We have never heard of this pet! :-(" 161 | -------------------------------------------------------------------------------- /tests/mock_data.py: -------------------------------------------------------------------------------- 1 | """Mock data for testing.""" 2 | from pathlib import Path 3 | 4 | 5 | INDEX_CONFIG = { 6 | "keys": [("id", 1)] 7 | } 8 | COLLECTION_CONFIG = { 9 | "indexes": [INDEX_CONFIG], 10 | } 11 | DB_CONFIG = { 12 | "collections": { 13 | "policy_rules": COLLECTION_CONFIG, 14 | }, 15 | } 16 | MONGO_CONFIG = { 17 | "host": "mongodb", 18 | "port": 12345, 19 | "dbs": { 20 | "access_control_db": DB_CONFIG, 21 | }, 22 | } 23 | RELATIVE_PATH = "security/access_control/foca_casbin_adapter/test_files/" 24 | DIR = Path(__file__).parent / RELATIVE_PATH 25 | MODEL_CONF_FILE = str(DIR / "rbac_model.conf") 26 | ACCESS_CONTROL_CONFIG = { 27 | "db_name": "access_control_db", 28 | "collection_name": "policy_rules", 29 | "owner_headers": ["X-User", "X-Group"], 30 | "user_headers": ["X-User"], 31 | "model": MODEL_CONF_FILE 32 | } 33 | 34 | MOCK_ID = "mock_id" 35 | MOCK_RULE = { 36 | "ptype": "p1", 37 | "v0": "alice", 38 | "v1": "data1", 39 | "v2": "POST", 40 | "v3": "read" 41 | } 42 | MOCK_RULE_USER_INPUT_OUTPUT = { 43 | "policy_type": "p1", 44 | "rule": { 45 | "v0": "alice", 46 | "v1": "data1", 47 | "v2": "POST", 48 | "v3": "read" 49 | } 50 | } 51 | MOCK_RULE_INVALID = {"rule": []} 52 | MOCK_PERMISSION = ["alice", "/", "GET"] 53 | MOCK_REQUEST = { 54 | "REQUEST_METHOD": "GET", 55 | "PATH_INFO": "/", 56 | "SERVER_PROTOCOL": "HTTP/1.1", 57 | "REMOTE_ADDR": "192.168.1.1" 58 | } 59 | -------------------------------------------------------------------------------- /tests/security/access_control/foca_casbin_adapter/test_casbin_rule.py: -------------------------------------------------------------------------------- 1 | """Tests for initialising casbin rule object. 2 | """ 3 | 4 | from foca.security.access_control.foca_casbin_adapter.casbin_rule import ( 5 | CasbinRule 6 | ) 7 | 8 | # Define data 9 | BASE_RULE_OBJECT = { 10 | "ptype": "ptype", 11 | "v0": "v0", 12 | "v1": "v1", 13 | "v2": "v2", 14 | "v3": "v3", 15 | "v4": "v4", 16 | "v5": "v5" 17 | } 18 | BASE_RULE_STR_REPRESENTATION = "ptype, v0, v1, v2, v3, v4, v5" 19 | BASE_RULE_REPRESENTATION = f'' 20 | 21 | 22 | class TestCasbinRule: 23 | def test_initialise_object(self): 24 | test_rule = CasbinRule(**BASE_RULE_OBJECT) 25 | assert test_rule.ptype == "ptype" 26 | assert test_rule.v0 == "v0" 27 | assert test_rule.v1 == "v1" 28 | assert test_rule.v2 == "v2" 29 | assert test_rule.v3 == "v3" 30 | assert test_rule.v4 == "v4" 31 | assert test_rule.v5 == "v5" 32 | assert repr(test_rule) == BASE_RULE_REPRESENTATION 33 | assert test_rule.dict() == BASE_RULE_OBJECT 34 | assert test_rule.__str__() == BASE_RULE_STR_REPRESENTATION 35 | -------------------------------------------------------------------------------- /tests/security/access_control/foca_casbin_adapter/test_files/rbac_model.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [role_definition] 8 | g = _, _ 9 | 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | [matchers] 14 | m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act -------------------------------------------------------------------------------- /tests/security/access_control/foca_casbin_adapter/test_files/rbac_with_resources_roles.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [role_definition] 8 | g = _, _ 9 | g2 = _, _ 10 | 11 | [policy_effect] 12 | e = some(where (p.eft == allow)) 13 | 14 | [matchers] 15 | m = g(r.sub, p.sub) && g2(r.obj, p.obj) && r.act == p.act -------------------------------------------------------------------------------- /tests/security/access_control/test_register_access_control.py: -------------------------------------------------------------------------------- 1 | """Tests for registering access control""" 2 | 3 | from flask import Flask 4 | import mongomock 5 | from pymongo import MongoClient 6 | from unittest import TestCase 7 | import pytest 8 | 9 | from foca.security.access_control.register_access_control import ( 10 | check_permissions 11 | ) 12 | from foca.security.access_control.foca_casbin_adapter.adapter import Adapter 13 | from foca.errors.exceptions import Forbidden 14 | from foca.models.config import AccessControlConfig, Config, MongoConfig 15 | from tests.mock_data import ( 16 | ACCESS_CONTROL_CONFIG, 17 | MOCK_REQUEST, 18 | MONGO_CONFIG, 19 | MOCK_PERMISSION 20 | ) 21 | 22 | 23 | class TestRegisterAccessControl(TestCase): 24 | """Test class for register access control.""" 25 | 26 | def __init__(self, *args, **kwargs): 27 | super().__init__(*args, **kwargs) 28 | self.db = MongoConfig(**MONGO_CONFIG) 29 | self.access_control = AccessControlConfig(**ACCESS_CONTROL_CONFIG) 30 | self.access_db = self.access_control.db_name 31 | self.access_col = self.access_control.collection_name 32 | self.db_port = self.db.port 33 | 34 | def clear_db(self): 35 | client = MongoClient(f"mongodb://localhost:{self.db_port}") 36 | client.drop_database(self.access_db) 37 | 38 | def setUp(self): 39 | self.clear_db() 40 | 41 | def tearDown(self): 42 | self.clear_db() 43 | 44 | def test_check_permission_allowed(self): 45 | """Test to check only valid user requests are permitted via 46 | enforcer.""" 47 | app = Flask(__name__) 48 | app.config["FOCA"] = Config( 49 | db=self.db, 50 | access_control=self.access_control 51 | ) 52 | app.config["FOCA"].db.dbs[self.access_db].collections[self.access_col]\ 53 | .client = mongomock.MongoClient().db.collection 54 | app.config["casbin_adapter"] = Adapter( 55 | uri=f"mongodb://localhost:{self.db_port}/", 56 | dbname=self.access_db, 57 | collection=self.access_col 58 | ) 59 | app.config["casbin_adapter"].save_policy_line( 60 | ptype="p", 61 | rule=MOCK_PERMISSION 62 | ) 63 | app.config["CASBIN_MODEL"] = self.access_control.model 64 | app.config["CASBIN_OWNER_HEADERS"] = self.access_control.owner_headers 65 | app.config["CASBIN_USER_NAME_HEADERS"] = self.access_control.\ 66 | user_headers 67 | 68 | @check_permissions 69 | def mock_func(): 70 | return "pass" 71 | 72 | with app.test_request_context( 73 | environ_base=MOCK_REQUEST, 74 | headers={"X-User": "alice"} 75 | ): 76 | response = mock_func() 77 | assert response == "pass" 78 | 79 | def test_check_permission_not_allowed(self): 80 | """Test to check invalid user request is not allowed.""" 81 | assert check_permissions() is not None 82 | 83 | def test_check_permission_allowed_casbin_permission_not_found(self): 84 | """Test to check only user forbidden in case permission is not 85 | present.""" 86 | app = Flask(__name__) 87 | app.config["FOCA"] = Config( 88 | db=self.db, 89 | access_control=self.access_control 90 | ) 91 | app.config["FOCA"].db.dbs[self.access_db].collections[self.access_col]\ 92 | .client = mongomock.MongoClient().db.collection 93 | app.config["casbin_adapter"] = Adapter( 94 | uri=f"mongodb://localhost:{self.db_port}/", 95 | dbname=self.access_db, 96 | collection=self.access_col 97 | ) 98 | app.config["CASBIN_MODEL"] = self.access_control.model 99 | app.config["CASBIN_OWNER_HEADERS"] = self.access_control.owner_headers 100 | app.config["CASBIN_USER_NAME_HEADERS"] = self.access_control.\ 101 | user_headers 102 | 103 | @check_permissions 104 | def mock_func(): 105 | return "pass" 106 | 107 | with app.test_request_context( 108 | environ_base=MOCK_REQUEST, 109 | headers={"X-Admin": "alice"} 110 | ): 111 | with pytest.raises(Forbidden): 112 | mock_func() 113 | -------------------------------------------------------------------------------- /tests/security/test_cors.py: -------------------------------------------------------------------------------- 1 | """Unit test for security.cors.py""" 2 | 3 | from unittest.mock import patch 4 | 5 | from flask import Flask 6 | 7 | from foca.security.cors import enable_cors 8 | 9 | 10 | def test_enable_cors(): 11 | """Test that CORS is called with app as a parameter.""" 12 | app = Flask(__name__) 13 | with patch('foca.security.cors.CORS') as mock_cors: 14 | enable_cors(app) 15 | mock_cors.assert_called_once_with(app) 16 | -------------------------------------------------------------------------------- /tests/test_files/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-cloud-aai/foca/2cf6f815ea1a10ea5c6dfe9e3678ebb2672f633f/tests/test_files/__init__.py -------------------------------------------------------------------------------- /tests/test_files/conf_api.yaml: -------------------------------------------------------------------------------- 1 | api: 2 | specs: 3 | - path: openapi_2_petstore.yaml 4 | path_out: openapi_2_petstore.modified.yaml 5 | append: 6 | - securityDefinitions: 7 | jwt: 8 | type: apiKey 9 | name: Authorization 10 | in: header 11 | add_operation_fields: 12 | x-swagger-router-controller: controllers 13 | connexion: 14 | strict_validation: True 15 | validate_responses: False 16 | options: 17 | swagger_ui: True 18 | swagger_json: True -------------------------------------------------------------------------------- /tests/test_files/conf_db.yaml: -------------------------------------------------------------------------------- 1 | db: 2 | host: mongo 3 | port: 27017 4 | dbs: 5 | my-db: 6 | collections: 7 | my-col-1: 8 | indexes: null 9 | -------------------------------------------------------------------------------- /tests/test_files/conf_invalid_access_control.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | auth: 3 | required: False 4 | add_key_to_claims: True 5 | algorithms: 6 | - RS256 7 | allow_expired: False 8 | audience: null 9 | claim_identity: sub 10 | claim_issuer: iss 11 | validation_methods: 12 | - userinfo 13 | - public_key 14 | validation_checks: all 15 | cors: 16 | enabled: True 17 | access_control: 18 | api_specs: foca/security/access_control/api/access-control-specs.yaml 19 | api_controllers: foca/security/access_control/access_control_server.py 20 | db_name: test_db 21 | collection_name: test_collection 22 | model: foca/security/access_control/api/default_model.conf 23 | owner_headers: ["owner"] 24 | user_headers: ["user"] -------------------------------------------------------------------------------- /tests/test_files/conf_invalid_jobs.yaml: -------------------------------------------------------------------------------- 1 | jobs: 2 | host: rabbitmq 3 | port: some port 4 | backend: 'rpc://' 5 | include: 6 | - some.module -------------------------------------------------------------------------------- /tests/test_files/conf_invalid_log_level.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | host: '0.0.0.0' 3 | port: 8080 4 | debug: True 5 | environment: development 6 | testing: False 7 | use_reloader: True 8 | 9 | api: 10 | specs: 11 | - path: my_specs.yaml 12 | path_out: my_specs.modified.yaml 13 | append: null 14 | add_operation_fields: null 15 | connexion: null 16 | 17 | security: 18 | auth: 19 | required: False 20 | add_key_to_claims: True 21 | algorithms: 22 | - RS256 23 | allow_expired: False 24 | audience: null 25 | claim_identity: sub 26 | claim_issuer: iss 27 | claim_key_id: kid 28 | header_name: Authorization 29 | token_prefix: Bearer 30 | validation_methods: 31 | - userinfo 32 | - public_key 33 | validation_checks: all 34 | 35 | db: 36 | host: mongo 37 | port: 27017 38 | dbs: 39 | my_db: 40 | collections: 41 | my-col-1: 42 | indexes: null 43 | 44 | jobs: 45 | host: rabbitmq 46 | port: 5672 47 | backend: 'rpc://' 48 | include: 49 | - some.module 50 | 51 | custom: 52 | my_custom_field_1: my_custom_value_1 53 | my_custom_field_2: my_custom_value_2 54 | my_custom_field_3: my_custom_value_3 55 | 56 | log: 57 | version: 1 58 | disable_existing_loggers: False 59 | formatters: 60 | standard: 61 | class: logging.Formatter 62 | style: "{" 63 | format: "[{asctime}: {levelname:<8}] {message} [{name}]" 64 | handlers: 65 | console: 66 | class: logging.StreamHandler 67 | level: INFO 68 | formatter: standard 69 | stream: ext://sys.stderr 70 | root: 71 | level: 70 72 | handlers: [console] 73 | -------------------------------------------------------------------------------- /tests/test_files/conf_jobs.yaml: -------------------------------------------------------------------------------- 1 | jobs: 2 | host: rabbitmq 3 | port: 5672 4 | backend: 'rpc://' 5 | include: 6 | - some.module -------------------------------------------------------------------------------- /tests/test_files/conf_log.yaml: -------------------------------------------------------------------------------- 1 | log: 2 | version: 1 3 | disable_existing_loggers: False 4 | formatters: 5 | standard: 6 | class: logging.Formatter 7 | style: "{" 8 | format: "[{asctime}: {levelname:<8}] {message} [{name}]" 9 | handlers: 10 | console: 11 | class: logging.StreamHandler 12 | level: 20 13 | formatter: standard 14 | stream: ext://sys.stderr 15 | root: 16 | level: 10 17 | handlers: [console] 18 | -------------------------------------------------------------------------------- /tests/test_files/conf_log_invalid.yaml: -------------------------------------------------------------------------------- 1 | log: 2 | version: 1 3 | disable_existing_loggers: False 4 | formatters: 5 | a!@#$: 6 | class: logging.Formatter 7 | style: "{" 8 | format: "[{asctime}: {levelname:<8}] {message} [{name}]" 9 | handlers: 10 | console: 11 | class: logging.StreamHandler 12 | level: 20 13 | formatter: standard 14 | stream: ext://sys.stderr 15 | root: 16 | level: 10 17 | handlers: [console] 18 | -------------------------------------------------------------------------------- /tests/test_files/conf_no_jobs.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | host: '0.0.0.0' 3 | port: 8080 4 | debug: True 5 | environment: development 6 | testing: False 7 | use_reloader: True 8 | 9 | api: 10 | specs: 11 | - path: my_specs.yaml 12 | path_out: my_specs.modified.yaml 13 | append: null 14 | add_operation_fields: null 15 | disable_auth: False 16 | connexion: null 17 | 18 | security: 19 | auth: 20 | add_key_to_claims: True 21 | algorithms: 22 | - RS256 23 | allow_expired: False 24 | audience: null 25 | claim_identity: sub 26 | claim_issuer: iss 27 | validation_methods: 28 | - userinfo 29 | - public_key 30 | validation_checks: all 31 | 32 | db: 33 | host: mongo 34 | port: 27017 35 | dbs: 36 | my_db: 37 | collections: 38 | my-col-1: 39 | indexes: null 40 | 41 | custom: 42 | my_custom_field_1: my_custom_value_1 43 | my_custom_field_2: my_custom_value_2 44 | my_custom_field_3: my_custom_value_3 45 | 46 | log: 47 | version: 1 48 | disable_existing_loggers: False 49 | formatters: 50 | standard: 51 | class: logging.Formatter 52 | style: "{" 53 | format: "[{asctime}: {levelname:<8}] {message} [{name}]" 54 | handlers: 55 | console: 56 | class: logging.StreamHandler 57 | level: 20 58 | formatter: standard 59 | stream: ext://sys.stderr 60 | root: 61 | level: 10 62 | handlers: [console] 63 | -------------------------------------------------------------------------------- /tests/test_files/conf_no_yaml.txt: -------------------------------------------------------------------------------- 1 | bla: bla: 2 | -------------------------------------------------------------------------------- /tests/test_files/conf_valid.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | host: '0.0.0.0' 3 | port: 8080 4 | debug: True 5 | environment: development 6 | testing: False 7 | use_reloader: True 8 | 9 | api: 10 | specs: 11 | - path: my_specs.yaml 12 | path_out: my_specs.modified.yaml 13 | append: null 14 | add_operation_fields: null 15 | disable_auth: False 16 | connexion: null 17 | 18 | security: 19 | auth: 20 | add_key_to_claims: True 21 | algorithms: 22 | - RS256 23 | allow_expired: False 24 | audience: null 25 | claim_identity: sub 26 | claim_issuer: iss 27 | validation_methods: 28 | - userinfo 29 | - public_key 30 | validation_checks: all 31 | 32 | db: 33 | host: mongo 34 | port: 27017 35 | dbs: 36 | my_db: 37 | collections: 38 | my-col-1: 39 | indexes: null 40 | 41 | jobs: 42 | host: rabbitmq 43 | port: 5672 44 | backend: 'rpc://' 45 | include: 46 | - some.module 47 | 48 | custom: 49 | my_custom_field_1: my_custom_value_1 50 | my_custom_field_2: my_custom_value_2 51 | my_custom_field_3: my_custom_value_3 52 | 53 | log: 54 | version: 1 55 | disable_existing_loggers: False 56 | formatters: 57 | standard: 58 | class: logging.Formatter 59 | style: "{" 60 | format: "[{asctime}: {levelname:<8}] {message} [{name}]" 61 | handlers: 62 | console: 63 | class: logging.StreamHandler 64 | level: 20 65 | formatter: standard 66 | stream: ext://sys.stderr 67 | root: 68 | level: 10 69 | handlers: [console] 70 | -------------------------------------------------------------------------------- /tests/test_files/conf_valid_access_control.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | auth: 3 | add_key_to_claims: True 4 | algorithms: 5 | - RS256 6 | allow_expired: False 7 | audience: null 8 | claim_identity: sub 9 | claim_issuer: iss 10 | validation_methods: 11 | - userinfo 12 | - public_key 13 | validation_checks: all 14 | cors: 15 | enabled: True 16 | access_control: 17 | api_specs: foca/security/access_control/api/access-control-specs.yaml 18 | api_controllers: foca/security/access_control/access_control_server.py 19 | db_name: test_db 20 | collection_name: test_collection 21 | model: foca/security/access_control/api/default_model.conf 22 | owner_headers: ["owner"] 23 | user_headers: ["user"] -------------------------------------------------------------------------------- /tests/test_files/conf_valid_cors_disabled.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | auth: 3 | add_key_to_claims: True 4 | algorithms: 5 | - RS256 6 | allow_expired: False 7 | audience: null 8 | claim_identity: sub 9 | claim_issuer: iss 10 | validation_methods: 11 | - userinfo 12 | - public_key 13 | validation_checks: all 14 | cors: 15 | enabled: False -------------------------------------------------------------------------------- /tests/test_files/conf_valid_cors_enabled.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | auth: 3 | add_key_to_claims: True 4 | algorithms: 5 | - RS256 6 | allow_expired: False 7 | audience: null 8 | claim_identity: sub 9 | claim_issuer: iss 10 | validation_methods: 11 | - userinfo 12 | - public_key 13 | validation_checks: all 14 | cors: 15 | enabled: True -------------------------------------------------------------------------------- /tests/test_files/conf_valid_custom_invalid.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | host: '0.0.0.0' 3 | port: 8080 4 | debug: True 5 | environment: development 6 | testing: False 7 | use_reloader: True 8 | 9 | api: 10 | specs: 11 | - path: my_specs.yaml 12 | path_out: my_specs.modified.yaml 13 | append: null 14 | add_operation_fields: null 15 | disable_auth: False 16 | connexion: null 17 | 18 | security: 19 | auth: 20 | add_key_to_claims: True 21 | algorithms: 22 | - RS256 23 | allow_expired: False 24 | audience: null 25 | claim_identity: sub 26 | claim_issuer: iss 27 | validation_methods: 28 | - userinfo 29 | - public_key 30 | validation_checks: all 31 | 32 | db: 33 | host: mongo 34 | port: 27017 35 | dbs: 36 | my_db: 37 | collections: 38 | my-col-1: 39 | indexes: null 40 | 41 | jobs: 42 | host: rabbitmq 43 | port: 5672 44 | backend: 'rpc://' 45 | include: 46 | - some.module 47 | 48 | custom: 49 | my_custom_field_1: my_custom_value_1 50 | my_custom_field_2: my_custom_value_2 51 | my_custom_field_3: my_custom_value_3 52 | param: null 53 | 54 | log: 55 | version: 1 56 | disable_existing_loggers: False 57 | formatters: 58 | standard: 59 | class: logging.Formatter 60 | style: "{" 61 | format: "[{asctime}: {levelname:<8}] {message} [{name}]" 62 | handlers: 63 | console: 64 | class: logging.StreamHandler 65 | level: 20 66 | formatter: standard 67 | stream: ext://sys.stderr 68 | root: 69 | level: 10 70 | handlers: [console] 71 | -------------------------------------------------------------------------------- /tests/test_files/empty_conf.yaml: -------------------------------------------------------------------------------- 1 | db: 2 | # Any database parameters 3 | api: 4 | # Any OpenAPI specifications 5 | jobs: 6 | # Any Celery parameters for background jobs 7 | security: 8 | # Any protected endpoints/security parameters 9 | server: 10 | # Any general Flask/Connexion/Gunicorn parameters 11 | # (e.g., host, port...) 12 | service_specific_section_1: 13 | param_1: "my_param_1" 14 | param_2: "my_param_2" 15 | service_specific_section_2: 16 | param_1: "my_param_1" 17 | param_2: "my_param_2" -------------------------------------------------------------------------------- /tests/test_files/invalid.json: -------------------------------------------------------------------------------- 1 | "swagger": "2.0", 2 | "info": { 3 | "version": "1.0.0", 4 | "title": "Swagger Petstore", 5 | "license": { 6 | "name": "MIT" 7 | }, 8 | "host": "petstore.swagger.io", 9 | "basePath": "/v1", 10 | "schemes": [ 11 | "http" 12 | "consumes": [ 13 | "application/json" 14 | ], 15 | "produces": [ 16 | "application/json" 17 | ], 18 | "paths": { 19 | "/pets": { 20 | "get": { 21 | "summary": "List all pets", 22 | "operationId": "listPets", 23 | "tags": [ 24 | "pets" 25 | ], 26 | "parameters": [ 27 | { 28 | "name": "limit", 29 | "in": "query", 30 | "description": "How many items to return at one time (max 100)", 31 | "required": false, 32 | "type": "integer", 33 | "format": "int32" 34 | "responses": { 35 | "200": { 36 | "description": "An paged array of pets", 37 | "headers": { 38 | "x-next": { 39 | "type": "string", 40 | "description": "A link to the next page of responses" 41 | } 42 | }, 43 | "schema": { 44 | "$ref": "#/definitions/Pets" 45 | } 46 | }, 47 | "default": { 48 | "description": "unexpected error", 49 | "schema": { 50 | "$ref": "#/definitions/Error" 51 | } 52 | "post": { 53 | "summary": "Create a pet", 54 | "operationId": "createPets", 55 | "tags": [ 56 | "pets" 57 | "responses": { 58 | "201": { 59 | "description": "Null response" 60 | }, 61 | "default": { 62 | "description": "unexpected error", 63 | "schema": { 64 | "$ref": "#/definitions/Error" 65 | } 66 | } 67 | } 68 | } 69 | }, 70 | "/pets/{petId}": { 71 | "get": { 72 | "summary": "Info for a specific pet", 73 | "operationId": "showPetById", 74 | "tags": [ 75 | "pets" 76 | ], 77 | "parameters": [ 78 | { 79 | "name": "petId", 80 | "in": "path", 81 | "required": true, 82 | "description": "The id of the pet to retrieve", 83 | "type": "string" 84 | } 85 | ], 86 | "responses": { 87 | "200": { 88 | "description": "Expected response to a valid request", 89 | "schema": { 90 | "$ref": "#/definitions/Pets" 91 | } 92 | }, 93 | "default": { 94 | "description": "unexpected error", 95 | "schema": { 96 | "$ref": "#/definitions/Error" 97 | } 98 | } 99 | } 100 | } 101 | } 102 | }, 103 | "definitions": { 104 | "Pet": { 105 | "required": [ 106 | "id", 107 | "name" 108 | ], 109 | "properties": { 110 | "id": { 111 | "type": "integer", 112 | "format": "int64" 113 | }, 114 | "name": { 115 | "type": "string" 116 | }, 117 | "tag": { 118 | "type": "string" 119 | } 120 | } 121 | }, 122 | "Pets": { 123 | "type": "array", 124 | "items": { 125 | "$ref": "#/definitions/Pet" 126 | } 127 | }, 128 | "Error": { 129 | "required": [ 130 | "code", 131 | "message" 132 | ], 133 | "properties": { 134 | "code": { 135 | "type": "integer", 136 | "format": "int32" 137 | }, 138 | "message": { 139 | "type": "string" 140 | } 141 | } 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /tests/test_files/invalid.openapi.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | license: 6 | name: MIT 7 | host: petstore.swagger.io 8 | basePath: /v1 9 | schemes: 10 | - http 11 | consumes: 12 | - application/json 13 | produces: 14 | - application/json 15 | definitions: 16 | Pet: 17 | type: "object" 18 | required: 19 | - id 20 | - name 21 | properties: 22 | id: 23 | type: integer 24 | format: int64 25 | name: 26 | type: string 27 | tag: 28 | type: string 29 | Pets: 30 | type: array 31 | items: 32 | $ref: '#/definitions/Pet' 33 | Error: 34 | type: "object" 35 | required: 36 | - code 37 | - message 38 | properties: 39 | code: 40 | type: integer 41 | format: int32 42 | message: 43 | type: string 44 | -------------------------------------------------------------------------------- /tests/test_files/invalid_conf.yaml: -------------------------------------------------------------------------------- 1 | db: 2 | # Any database parameters 3 | api= 4 | # Any OpenAPI specifications 5 | jobs: 6 | # Any Celery parameters for background jobs 7 | security: 8 | # Any protected endpoints/security parameters 9 | server: 10 | # Any general Flask/Connexion/Gunicorn parameters 11 | # (e.g., host, port...) 12 | service_specific_section_1: 13 | param_1: "my_param_1" 14 | param_2: "my_param_2" 15 | service_specific_section_2: 16 | param_1: "my_param_1" 17 | param_2: "my_param_2" -------------------------------------------------------------------------------- /tests/test_files/invalid_conf_db.yaml: -------------------------------------------------------------------------------- 1 | db: 2 | host: mongodb 3 | port: some port 4 | name: cwl-wes-db 5 | -------------------------------------------------------------------------------- /tests/test_files/model_valid.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class CustomConfig(BaseModel): 5 | """Valid custom config model test class. 6 | 7 | Args: 8 | param: Test parameter. 9 | """ 10 | param: str = 'STRING' 11 | -------------------------------------------------------------------------------- /tests/test_files/models_petstore.py: -------------------------------------------------------------------------------- 1 | """Validation models for the petstore app.""" 2 | 3 | from typing import ( 4 | List, 5 | Optional, 6 | ) 7 | 8 | from pydantic import BaseModel 9 | 10 | 11 | class Pet(BaseModel): 12 | """Model instance for pet. 13 | 14 | Args: 15 | id: Unique identifier for pet. 16 | name: The pet's name. 17 | tag: Optional tag for the pet. 18 | 19 | Attributes: 20 | id: Unique identifier for pet. 21 | name: The pet's name. 22 | tag: Optional tag for the pet. 23 | """ 24 | id: int 25 | name: str 26 | tag: Optional[str] 27 | 28 | 29 | class Pets(BaseModel): 30 | """Model instance for a list of pets. 31 | 32 | Args: 33 | pets: List of pets. 34 | 35 | Attributes: 36 | pets: List of pets. 37 | """ 38 | pets: List[Pet] = [] 39 | 40 | 41 | class Error(BaseModel): 42 | """Model for petstore error response. 43 | 44 | Args: 45 | code: Status code. 46 | message: Error message. 47 | 48 | Attributes: 49 | code: Status code. 50 | message: Error message. 51 | """ 52 | code: int 53 | message: str 54 | -------------------------------------------------------------------------------- /tests/test_files/openapi_2_petstore.addition.yaml: -------------------------------------------------------------------------------- 1 | paths: 2 | /pets/{petId}: 3 | put: 4 | summary: Modifies the pet with the given id. 5 | operationId: putPetsById 6 | parameters: 7 | - name: petId 8 | in: path 9 | required: true 10 | description: The id of the pet to retrieve 11 | type: string 12 | - in: body 13 | name: bdy 14 | description: updated content 15 | required: true 16 | schema: 17 | $ref: "#/definitions/Pets" 18 | responses: 19 | "200": 20 | description: Expected response to a valid request 21 | schema: 22 | $ref: '#/definitions/Pets' 23 | default: 24 | description: unexpected error 25 | schema: 26 | $ref: '#/definitions/Error' 27 | -------------------------------------------------------------------------------- /tests/test_files/openapi_2_petstore.modified.yaml: -------------------------------------------------------------------------------- 1 | basePath: /v1 2 | consumes: 3 | - application/json 4 | definitions: 5 | Error: 6 | properties: 7 | code: 8 | format: int32 9 | type: integer 10 | message: 11 | type: string 12 | required: 13 | - code 14 | - message 15 | type: object 16 | Pet: 17 | properties: 18 | id: 19 | format: int64 20 | type: integer 21 | name: 22 | type: string 23 | tag: 24 | type: string 25 | required: 26 | - id 27 | - name 28 | type: object 29 | Pets: 30 | items: 31 | $ref: '#/definitions/Pet' 32 | type: array 33 | host: petstore.swagger.io 34 | info: 35 | license: 36 | name: MIT 37 | title: Swagger Petstore 38 | version: 1.0.0 39 | paths: 40 | /pets: 41 | get: 42 | operationId: listPets 43 | parameters: 44 | - description: How many items to return at one time (max 100) 45 | format: int32 46 | in: query 47 | name: limit 48 | required: false 49 | type: integer 50 | responses: 51 | '200': 52 | description: A paged array of pets 53 | headers: 54 | x-next: 55 | description: A link to the next page of responses 56 | type: string 57 | schema: 58 | $ref: '#/definitions/Pets' 59 | default: 60 | description: unexpected error 61 | schema: 62 | $ref: '#/definitions/Error' 63 | summary: List all pets 64 | tags: 65 | - pets 66 | x-swagger-router-controller: controllers 67 | post: 68 | operationId: createPets 69 | responses: 70 | '201': 71 | description: Null response 72 | default: 73 | description: unexpected error 74 | schema: 75 | $ref: '#/definitions/Error' 76 | summary: Create a pet 77 | tags: 78 | - pets 79 | x-swagger-router-controller: controllers 80 | /pets/{petId}: 81 | get: 82 | operationId: showPetById 83 | parameters: 84 | - description: The id of the pet to retrieve 85 | in: path 86 | name: petId 87 | required: true 88 | type: string 89 | responses: 90 | '200': 91 | description: Expected response to a valid request 92 | schema: 93 | $ref: '#/definitions/Pets' 94 | default: 95 | description: unexpected error 96 | schema: 97 | $ref: '#/definitions/Error' 98 | summary: Info for a specific pet 99 | tags: 100 | - pets 101 | x-swagger-router-controller: controllers 102 | produces: 103 | - application/json 104 | schemes: 105 | - http 106 | swagger: '2.0' 107 | -------------------------------------------------------------------------------- /tests/test_files/openapi_2_petstore.original.json: -------------------------------------------------------------------------------- 1 | {"swagger":"2.0","info":{"version":"1.0.0","title":"Swagger Petstore","license":{"name":"MIT"}},"host":"petstore.swagger.io","basePath":"/v1","schemes":["http"],"consumes":["application/json"],"produces":["application/json"],"paths":{"/pets":{"get":{"summary":"List all pets","operationId":"listPets","tags":["pets"],"parameters":[{"name":"limit","in":"query","description":"How many items to return at one time (max 100)","required":false,"type":"integer","format":"int32"}],"responses":{"200":{"description":"An paged array of pets","headers":{"x-next":{"type":"string","description":"A link to the next page of responses"}},"schema":{"$ref":"#/definitions/Pets"}},"default":{"description":"unexpected error","schema":{"$ref":"#/definitions/Error"}}}},"post":{"summary":"Create a pet","operationId":"createPets","tags":["pets"],"responses":{"201":{"description":"Null response"},"default":{"description":"unexpected error","schema":{"$ref":"#/definitions/Error"}}}}},"/pets/{petId}":{"get":{"summary":"Info for a specific pet","operationId":"showPetById","tags":["pets"],"parameters":[{"name":"petId","in":"path","required":true,"description":"The id of the pet to retrieve","type":"string"}],"responses":{"200":{"description":"Expected response to a valid request","schema":{"$ref":"#/definitions/Pets"}},"default":{"description":"unexpected error","schema":{"$ref":"#/definitions/Error"}}}}}},"definitions":{"Pet":{"required":["id","name"],"properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"tag":{"type":"string"}}},"Pets":{"type":"array","items":{"$ref":"#/definitions/Pet"}},"Error":{"required":["code","message"],"properties":{"code":{"type":"integer","format":"int32"},"message":{"type":"string"}}}}} 2 | -------------------------------------------------------------------------------- /tests/test_files/openapi_2_petstore.original.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | license: 6 | name: MIT 7 | host: petstore.swagger.io 8 | basePath: /v1 9 | schemes: 10 | - http 11 | consumes: 12 | - application/json 13 | produces: 14 | - application/json 15 | securityDefinitions: 16 | authToken: 17 | type: apiKey 18 | name: Authorization 19 | in: header 20 | security: 21 | - {} 22 | - authToken: [] 23 | paths: 24 | /pets: 25 | get: 26 | summary: List all pets 27 | operationId: listPets 28 | tags: 29 | - pets 30 | parameters: 31 | - name: limit 32 | in: query 33 | description: How many items to return at one time (max 100) 34 | required: false 35 | type: integer 36 | format: int32 37 | responses: 38 | "200": 39 | description: A paged array of pets 40 | headers: 41 | x-next: 42 | type: string 43 | description: A link to the next page of responses 44 | schema: 45 | $ref: '#/definitions/Pets' 46 | default: 47 | description: unexpected error 48 | schema: 49 | $ref: '#/definitions/Error' 50 | security: 51 | - {} 52 | - authToken: [] 53 | post: 54 | summary: Create a pet 55 | operationId: createPets 56 | tags: 57 | - pets 58 | responses: 59 | "201": 60 | description: Null response 61 | default: 62 | description: unexpected error 63 | schema: 64 | $ref: '#/definitions/Error' 65 | security: 66 | - {} 67 | - authToken: [] 68 | /pets/{petId}: 69 | get: 70 | summary: Info for a specific pet 71 | operationId: showPetById 72 | tags: 73 | - pets 74 | parameters: 75 | - name: petId 76 | in: path 77 | required: true 78 | description: The id of the pet to retrieve 79 | type: string 80 | responses: 81 | "200": 82 | description: Expected response to a valid request 83 | schema: 84 | $ref: '#/definitions/Pets' 85 | default: 86 | description: unexpected error 87 | schema: 88 | $ref: '#/definitions/Error' 89 | definitions: 90 | Pet: 91 | type: "object" 92 | required: 93 | - id 94 | - name 95 | properties: 96 | id: 97 | type: integer 98 | format: int64 99 | name: 100 | type: string 101 | tag: 102 | type: string 103 | Pets: 104 | type: array 105 | items: 106 | $ref: '#/definitions/Pet' 107 | Error: 108 | type: "object" 109 | required: 110 | - code 111 | - message 112 | properties: 113 | code: 114 | type: integer 115 | format: int32 116 | message: 117 | type: string 118 | -------------------------------------------------------------------------------- /tests/test_files/openapi_3_petstore.modified.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | Error: 4 | properties: 5 | code: 6 | format: int32 7 | type: integer 8 | message: 9 | type: string 10 | required: 11 | - code 12 | - message 13 | type: object 14 | Pet: 15 | properties: 16 | id: 17 | format: int64 18 | type: integer 19 | name: 20 | type: string 21 | tag: 22 | type: string 23 | required: 24 | - id 25 | - name 26 | type: object 27 | Pets: 28 | items: 29 | $ref: '#/components/schemas/Pet' 30 | type: array 31 | info: 32 | license: 33 | name: MIT 34 | title: Swagger Petstore 35 | version: 1.0.0 36 | openapi: 3.0.0 37 | paths: 38 | /pets: 39 | get: 40 | operationId: listPets 41 | parameters: 42 | - description: How many items to return at one time (max 100) 43 | in: query 44 | name: limit 45 | required: false 46 | schema: 47 | format: int32 48 | type: integer 49 | responses: 50 | '200': 51 | content: 52 | application/json: 53 | schema: 54 | $ref: '#/components/schemas/Pets' 55 | description: A paged array of pets 56 | headers: 57 | x-next: 58 | description: A link to the next page of responses 59 | schema: 60 | type: string 61 | default: 62 | content: 63 | application/json: 64 | schema: 65 | $ref: '#/components/schemas/Error' 66 | description: unexpected error 67 | summary: List all pets 68 | tags: 69 | - pets 70 | x-openapi-router-controller: controllers 71 | post: 72 | operationId: createPets 73 | responses: 74 | '201': 75 | description: Null response 76 | default: 77 | content: 78 | application/json: 79 | schema: 80 | $ref: '#/components/schemas/Error' 81 | description: unexpected error 82 | summary: Create a pet 83 | tags: 84 | - pets 85 | x-openapi-router-controller: controllers 86 | /pets/{petId}: 87 | get: 88 | operationId: showPetById 89 | parameters: 90 | - description: The id of the pet to retrieve 91 | in: path 92 | name: petId 93 | required: true 94 | schema: 95 | type: string 96 | responses: 97 | '200': 98 | content: 99 | application/json: 100 | schema: 101 | $ref: '#/components/schemas/Pet' 102 | description: Expected response to a valid request 103 | default: 104 | content: 105 | application/json: 106 | schema: 107 | $ref: '#/components/schemas/Error' 108 | description: unexpected error 109 | summary: Info for a specific pet 110 | tags: 111 | - pets 112 | x-openapi-router-controller: controllers 113 | servers: 114 | - url: http://petstore.swagger.io/v2 115 | -------------------------------------------------------------------------------- /tests/test_files/openapi_3_petstore.original.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | license: 6 | name: MIT 7 | servers: 8 | - url: http://petstore.swagger.io/v2 9 | security: 10 | - bearerAuth: [] 11 | paths: 12 | /pets: 13 | get: 14 | summary: List all pets 15 | operationId: listPets 16 | tags: 17 | - pets 18 | parameters: 19 | - name: limit 20 | in: query 21 | description: How many items to return at one time (max 100) 22 | required: false 23 | schema: 24 | type: integer 25 | format: int32 26 | responses: 27 | '200': 28 | description: A paged array of pets 29 | headers: 30 | x-next: 31 | description: A link to the next page of responses 32 | schema: 33 | type: string 34 | content: 35 | application/json: 36 | schema: 37 | $ref: "#/components/schemas/Pets" 38 | default: 39 | description: unexpected error 40 | content: 41 | application/json: 42 | schema: 43 | $ref: "#/components/schemas/Error" 44 | security: 45 | - bearerAuth: [] 46 | post: 47 | summary: Create a pet 48 | operationId: createPets 49 | tags: 50 | - pets 51 | responses: 52 | '201': 53 | description: Null response 54 | default: 55 | description: unexpected error 56 | content: 57 | application/json: 58 | schema: 59 | $ref: "#/components/schemas/Error" 60 | security: 61 | - bearerAuth: [] 62 | /pets/{petId}: 63 | get: 64 | summary: Info for a specific pet 65 | operationId: showPetById 66 | tags: 67 | - pets 68 | parameters: 69 | - name: petId 70 | in: path 71 | required: true 72 | description: The id of the pet to retrieve 73 | schema: 74 | type: string 75 | responses: 76 | '200': 77 | description: Expected response to a valid request 78 | content: 79 | application/json: 80 | schema: 81 | $ref: "#/components/schemas/Pet" 82 | default: 83 | description: unexpected error 84 | content: 85 | application/json: 86 | schema: 87 | $ref: "#/components/schemas/Error" 88 | components: 89 | securitySchemes: 90 | bearerAuth: 91 | type: http 92 | scheme: bearer 93 | bearerFormat: JWT 94 | schemas: 95 | Pet: 96 | type: object 97 | required: 98 | - id 99 | - name 100 | properties: 101 | id: 102 | type: integer 103 | format: int64 104 | name: 105 | type: string 106 | tag: 107 | type: string 108 | Pets: 109 | type: array 110 | items: 111 | $ref: "#/components/schemas/Pet" 112 | Error: 113 | type: object 114 | required: 115 | - code 116 | - message 117 | properties: 118 | code: 119 | type: integer 120 | format: int32 121 | message: 122 | type: string 123 | -------------------------------------------------------------------------------- /tests/test_files/openapi_3_petstore_pathitemparam.modified.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | Error: 4 | properties: 5 | code: 6 | format: int32 7 | type: integer 8 | message: 9 | type: string 10 | required: 11 | - code 12 | - message 13 | type: object 14 | Pet: 15 | properties: 16 | id: 17 | format: int64 18 | type: integer 19 | name: 20 | type: string 21 | tag: 22 | type: string 23 | required: 24 | - id 25 | - name 26 | type: object 27 | Pets: 28 | items: 29 | $ref: '#/components/schemas/Pet' 30 | type: array 31 | info: 32 | license: 33 | name: MIT 34 | title: Swagger Petstore 35 | version: 1.0.0 36 | openapi: 3.0.0 37 | paths: 38 | /pets: 39 | get: 40 | operationId: listPets 41 | parameters: 42 | - description: How many items to return at one time (max 100) 43 | in: query 44 | name: limit 45 | required: false 46 | schema: 47 | format: int32 48 | type: integer 49 | responses: 50 | '200': 51 | content: 52 | application/json: 53 | schema: 54 | $ref: '#/components/schemas/Pets' 55 | description: A paged array of pets 56 | headers: 57 | x-next: 58 | description: A link to the next page of responses 59 | schema: 60 | type: string 61 | default: 62 | content: 63 | application/json: 64 | schema: 65 | $ref: '#/components/schemas/Error' 66 | description: unexpected error 67 | summary: List all pets 68 | tags: 69 | - pets 70 | x-openapi-router-controller: controllers 71 | post: 72 | operationId: createPets 73 | responses: 74 | '201': 75 | description: Null response 76 | default: 77 | content: 78 | application/json: 79 | schema: 80 | $ref: '#/components/schemas/Error' 81 | description: unexpected error 82 | summary: Create a pet 83 | tags: 84 | - pets 85 | x-openapi-router-controller: controllers 86 | /pets/{petId}: 87 | parameters: 88 | - description: The id of the pet to retrieve 89 | in: path 90 | name: petId 91 | required: true 92 | schema: 93 | type: string 94 | get: 95 | operationId: showPetById 96 | responses: 97 | '200': 98 | content: 99 | application/json: 100 | schema: 101 | $ref: '#/components/schemas/Pet' 102 | description: Expected response to a valid request 103 | default: 104 | content: 105 | application/json: 106 | schema: 107 | $ref: '#/components/schemas/Error' 108 | description: unexpected error 109 | summary: Info for a specific pet 110 | tags: 111 | - pets 112 | x-openapi-router-controller: controllers 113 | servers: 114 | - url: http://petstore.swagger.io/v2 115 | -------------------------------------------------------------------------------- /tests/test_files/openapi_3_petstore_pathitemparam.original.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | license: 6 | name: MIT 7 | servers: 8 | - url: http://petstore.swagger.io/v2 9 | security: 10 | - bearerAuth: [] 11 | paths: 12 | /pets: 13 | get: 14 | summary: List all pets 15 | operationId: listPets 16 | tags: 17 | - pets 18 | parameters: 19 | - name: limit 20 | in: query 21 | description: How many items to return at one time (max 100) 22 | required: false 23 | schema: 24 | type: integer 25 | format: int32 26 | responses: 27 | '200': 28 | description: A paged array of pets 29 | headers: 30 | x-next: 31 | description: A link to the next page of responses 32 | schema: 33 | type: string 34 | content: 35 | application/json: 36 | schema: 37 | $ref: "#/components/schemas/Pets" 38 | default: 39 | description: unexpected error 40 | content: 41 | application/json: 42 | schema: 43 | $ref: "#/components/schemas/Error" 44 | security: 45 | - bearerAuth: [] 46 | post: 47 | summary: Create a pet 48 | operationId: createPets 49 | tags: 50 | - pets 51 | responses: 52 | '201': 53 | description: Null response 54 | default: 55 | description: unexpected error 56 | content: 57 | application/json: 58 | schema: 59 | $ref: "#/components/schemas/Error" 60 | security: 61 | - bearerAuth: [] 62 | /pets/{petId}: 63 | parameters: 64 | - name: petId 65 | in: path 66 | required: true 67 | description: The id of the pet to retrieve 68 | schema: 69 | type: string 70 | get: 71 | summary: Info for a specific pet 72 | operationId: showPetById 73 | tags: 74 | - pets 75 | responses: 76 | '200': 77 | description: Expected response to a valid request 78 | content: 79 | application/json: 80 | schema: 81 | $ref: "#/components/schemas/Pet" 82 | default: 83 | description: unexpected error 84 | content: 85 | application/json: 86 | schema: 87 | $ref: "#/components/schemas/Error" 88 | components: 89 | securitySchemes: 90 | bearerAuth: 91 | type: http 92 | scheme: bearer 93 | bearerFormat: JWT 94 | schemas: 95 | Pet: 96 | type: object 97 | required: 98 | - id 99 | - name 100 | properties: 101 | id: 102 | type: integer 103 | format: int64 104 | name: 105 | type: string 106 | tag: 107 | type: string 108 | Pets: 109 | type: array 110 | items: 111 | $ref: "#/components/schemas/Pet" 112 | Error: 113 | type: object 114 | required: 115 | - code 116 | - message 117 | properties: 118 | code: 119 | type: integer 120 | format: int32 121 | message: 122 | type: string 123 | -------------------------------------------------------------------------------- /tests/test_foca.py: -------------------------------------------------------------------------------- 1 | """Tests for `foca.py` module.""" 2 | 3 | from pathlib import Path 4 | import pytest 5 | import shutil 6 | 7 | from celery import Celery 8 | from connexion import App 9 | from pydantic import ValidationError 10 | from pymongo.collection import Collection 11 | from pymongo.database import Database 12 | from yaml import ( 13 | safe_load, 14 | safe_dump, 15 | ) 16 | 17 | from foca import Foca 18 | 19 | DIR = Path(__file__).parent / "test_files" 20 | PATH_SPECS_2_YAML_ORIGINAL = str(DIR / "openapi_2_petstore.original.yaml") 21 | PATH_SPECS_2_YAML_MODIFIED = str(DIR / "openapi_2_petstore.modified.yaml") 22 | EMPTY_CONF = DIR / "empty_conf.yaml" 23 | INVALID_CONF = DIR / "invalid_conf.yaml" 24 | VALID_DB_CONF = DIR / "conf_db.yaml" 25 | INVALID_DB_CONF = DIR / "invalid_conf_db.yaml" 26 | INVALID_LOG_CONF = DIR / "conf_invalid_log_level.yaml" 27 | JOBS_CONF = DIR / "conf_jobs.yaml" 28 | NO_JOBS_CONF = DIR / "conf_no_jobs.yaml" 29 | INVALID_JOBS_CONF = DIR / "conf_invalid_jobs.yaml" 30 | API_CONF = DIR / "conf_api.yaml" 31 | VALID_CORS_CONF_DISABLED = DIR / "conf_valid_cors_disabled.yaml" 32 | VALID_CORS_CONF_ENABLED = DIR / "conf_valid_cors_enabled.yaml" 33 | INVALID_ACCESS_CONTROL_CONF = DIR / "conf_invalid_access_control.yaml" 34 | VALID_ACCESS_CONTROL_CONF = DIR / "conf_valid_access_control.yaml" 35 | 36 | 37 | def create_modified_api_conf(path, temp_dir, api_specs_in, api_specs_out): 38 | """Create a copy of a configuration YAML file.""" 39 | temp_path = Path(temp_dir) / "temp_test.yaml" 40 | shutil.copy2(path, temp_path) 41 | with open(temp_path, "r+") as conf_file: 42 | conf = safe_load(conf_file) 43 | conf["api"]["specs"][0]["path"] = api_specs_in 44 | conf["api"]["specs"][0]["path_out"] = api_specs_out 45 | conf_file.seek(0) 46 | safe_dump(conf, conf_file) 47 | conf_file.truncate() 48 | return temp_path 49 | 50 | 51 | def test_foca_constructor_empty_conf(): 52 | """Empty config.""" 53 | with pytest.raises(ValidationError): 54 | Foca(config_file=EMPTY_CONF) 55 | 56 | 57 | def test_foca_constructor_invalid_conf(): 58 | """Invalid config file format.""" 59 | with pytest.raises(ValueError): 60 | Foca(config_file=INVALID_CONF) 61 | 62 | 63 | def test_foca_constructor_invalid_conf_log(): 64 | """Invalid 'log' field.""" 65 | with pytest.raises(ValidationError): 66 | Foca(config_file=INVALID_LOG_CONF) 67 | 68 | 69 | def test_foca_constructor_invalid_conf_jobs(): 70 | """Invalid 'jobs' field.""" 71 | with pytest.raises(ValidationError): 72 | Foca(config_file=INVALID_JOBS_CONF) 73 | 74 | 75 | def test_foca_constructor_invalid_conf_db(): 76 | """Invalid 'db' field.""" 77 | with pytest.raises(ValidationError): 78 | Foca(config_file=INVALID_DB_CONF) 79 | 80 | 81 | def test_foca_create_app_output_defaults(): 82 | """Ensure a Connexion app instance is returned; defaults only.""" 83 | foca = Foca() 84 | app = foca.create_app() 85 | assert isinstance(app, App) 86 | 87 | 88 | def test_foca_create_app_jobs(): 89 | """Ensure a Connexion app instance is returned; valid 'jobs' field.""" 90 | foca = Foca(config_file=JOBS_CONF) 91 | app = foca.create_app() 92 | assert isinstance(app, App) 93 | 94 | 95 | def test_foca_create_app_api(tmpdir): 96 | """Ensure a Connexion app instance is returned; valid 'api' field.""" 97 | temp_file = create_modified_api_conf( 98 | API_CONF, 99 | tmpdir, 100 | PATH_SPECS_2_YAML_ORIGINAL, 101 | PATH_SPECS_2_YAML_MODIFIED, 102 | ) 103 | foca = Foca(config_file=temp_file) 104 | app = foca.create_app() 105 | assert isinstance(app, App) 106 | 107 | 108 | def test_foca_db(): 109 | """Ensure a Connexion app instance is returned; valid 'db' field.""" 110 | foca = Foca(config_file=VALID_DB_CONF) 111 | app = foca.create_app() 112 | my_db = app.app.config.foca.db.dbs["my-db"] 113 | my_coll = my_db.collections["my-col-1"] 114 | assert isinstance(my_db.client, Database) 115 | assert isinstance(my_coll.client, Collection) 116 | assert isinstance(app, App) 117 | 118 | 119 | def test_foca_cors_config_flag_enabled(): 120 | """Ensures CORS config flag is set correctly (enabled).""" 121 | foca = Foca(config_file=VALID_CORS_CONF_ENABLED) 122 | app = foca.create_app() 123 | assert app.app.config.foca.security.cors.enabled is True 124 | 125 | 126 | def test_foca_CORS_disabled(): 127 | """Ensures CORS config flag is set correctly (disabled).""" 128 | foca = Foca(config_file=VALID_CORS_CONF_DISABLED) 129 | app = foca.create_app() 130 | assert app.app.config.foca.security.cors.enabled is False 131 | 132 | 133 | def test_foca_invalid_access_control(): 134 | """Ensures access control is not enabled if auth flag is disabled.""" 135 | foca = Foca(config_file=INVALID_ACCESS_CONTROL_CONF) 136 | app = foca.create_app() 137 | assert app.app.config.foca.db is None 138 | 139 | 140 | def test_foca_valid_access_control(): 141 | """Ensures access control settings are set correctly.""" 142 | foca = Foca(config_file=VALID_ACCESS_CONTROL_CONF) 143 | app = foca.create_app() 144 | my_db = app.app.config.foca.db.dbs["test_db"] 145 | my_coll = my_db.collections["test_collection"] 146 | assert isinstance(my_db.client, Database) 147 | assert isinstance(my_coll.client, Collection) 148 | assert isinstance(app, App) 149 | 150 | 151 | def test_foca_create_celery_app(): 152 | """Ensure a Celery app instance is returned.""" 153 | foca = Foca(config_file=JOBS_CONF) 154 | app = foca.create_celery_app() 155 | assert isinstance(app, Celery) 156 | 157 | 158 | def test_foca_create_celery_app_without_jobs_field(): 159 | """Ensure that Celery app creation fails if 'jobs' field missing.""" 160 | foca = Foca(config_file=NO_JOBS_CONF) 161 | with pytest.raises(ValueError): 162 | foca.create_celery_app() 163 | 164 | 165 | # INVALID_JOBS_CONF = DIR / "conf_invalid_jobs.yaml" 166 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | """Tests for `version.py` module.""" 2 | 3 | from foca.version import __version__ # noqa: F401 4 | -------------------------------------------------------------------------------- /tests/utils/test_db.py: -------------------------------------------------------------------------------- 1 | """Tests for the database utilties module.""" 2 | 3 | import mongomock 4 | 5 | from foca.utils.db import find_id_latest, find_one_latest 6 | 7 | 8 | def test_find_one_latest(): 9 | """Test that find_one_latest return recently added object without _id 10 | field. 11 | """ 12 | collection = mongomock.MongoClient().db.collection 13 | obj1 = {'_id': 1, 'name': 'first'} 14 | obj2 = {'_id': 2, 'name': 'seond'} 15 | obj3 = {'_id': 3, 'name': 'third'} 16 | 17 | collection.insert_many([obj1, obj2, obj3]) 18 | res = find_one_latest(collection) 19 | assert res == {'name': 'third'} 20 | 21 | 22 | def test_find_one_latest_returns_None(): 23 | """Test that find_one_latest return empty if collection is empty.""" 24 | collection = mongomock.MongoClient().db.collection 25 | assert find_one_latest(collection) is None 26 | 27 | 28 | def test_find_id_latest(): 29 | """Test that find_id_latest return recently added id.""" 30 | collection = mongomock.MongoClient().db.collection 31 | obj1 = {'_id': 1, 'name': 'first'} 32 | obj2 = {'_id': 2, 'name': 'seond'} 33 | obj3 = {'_id': 3, 'name': 'third'} 34 | 35 | collection.insert_many([obj1, obj2, obj3]) 36 | res = find_id_latest(collection) 37 | assert res == 3 38 | 39 | 40 | def test_find_id_latest_returns_None(): 41 | """Test that find_one_latest return empty if collection is empty.""" 42 | collection = mongomock.MongoClient().db.collection 43 | assert find_id_latest(collection) is None 44 | -------------------------------------------------------------------------------- /tests/utils/test_logging.py: -------------------------------------------------------------------------------- 1 | """Tests for the logging utilties module.""" 2 | 3 | import logging 4 | 5 | from flask import Flask 6 | 7 | from foca.utils.logging import log_traffic 8 | 9 | app = Flask(__name__) 10 | REQ = { 11 | 'REQUEST_METHOD': 'GET', 12 | 'PATH_INFO': '/', 13 | 'SERVER_PROTOCOL': 'HTTP/1.1', 14 | 'REMOTE_ADDR': '192.168.1.1', 15 | } 16 | 17 | # Get logger instance 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def test_logging_decorator(caplog): 22 | """Verify the default settings work properly""" 23 | caplog.set_level(logging.INFO) 24 | 25 | @log_traffic 26 | def mock_func(): 27 | return {'foo': 'bar'} 28 | 29 | with app.test_request_context(environ_base=REQ): 30 | mock_func() 31 | assert 'Incoming request' in caplog.text \ 32 | and 'Response to request' in caplog.text 33 | 34 | 35 | def test_logging_decorator_log_level(caplog): 36 | """Verify that the 'log_level argument modifies the log level properly""" 37 | 38 | @log_traffic(log_level=30) 39 | def mock_func(): 40 | return {'foo': 'bar'} 41 | 42 | with app.test_request_context(environ_base=REQ): 43 | mock_func() 44 | assert 'WARNING' in caplog.text 45 | 46 | 47 | def test_logging_decorator_req_only(caplog): 48 | """Verify that only the request gets logged""" 49 | caplog.set_level(logging.INFO) 50 | 51 | @log_traffic(log_response=False) 52 | def mock_func(): 53 | return {'foo': 'bar'} 54 | 55 | with app.test_request_context(environ_base=REQ): 56 | mock_func() 57 | assert 'Incoming request' in caplog.text \ 58 | and 'Response to request' not in caplog.text 59 | 60 | 61 | def test_logging_decorator_res_only(caplog): 62 | """Verify that only the response gets logged""" 63 | caplog.set_level(logging.INFO) 64 | 65 | @log_traffic(log_request=False) 66 | def mock_func(): 67 | return {'foo': 'bar'} 68 | 69 | with app.test_request_context(environ_base=REQ): 70 | mock_func() 71 | assert 'Incoming request' not in caplog.text \ 72 | and 'Response to request' in caplog.text 73 | -------------------------------------------------------------------------------- /tests/utils/test_misc.py: -------------------------------------------------------------------------------- 1 | """Tests for miscellaneous utility functions.""" 2 | 3 | import string 4 | 5 | import pytest 6 | 7 | from foca.utils.misc import generate_id 8 | 9 | 10 | class TestGenerateId: 11 | 12 | def test_default(self): 13 | """Use only default arguments.""" 14 | res = generate_id() 15 | assert isinstance(res, str) 16 | 17 | def test_charset_literal_string(self): 18 | """Argument to `charset` is non-default literal string.""" 19 | charset = string.digits 20 | res = generate_id(charset=charset) 21 | assert set(res) <= set(string.digits) 22 | 23 | def test_charset_literal_string_duplicates(self): 24 | """Argument to `charset` is non-default literal string with duplicates. 25 | """ 26 | charset = string.digits + string.digits 27 | res = generate_id(charset=charset) 28 | assert set(res) <= set(string.digits) 29 | 30 | def test_charset_evaluates_to_string(self): 31 | """Argument to `charset` evaluates to string.""" 32 | charset = "''.join([c for c in string.digits])" 33 | res = generate_id(charset=charset) 34 | assert set(res) <= set(string.digits) 35 | 36 | def test_charset_evaluates_to_empty_string(self): 37 | """Argument to `charset` evaluates to non-string.""" 38 | charset = "''.join([])" 39 | with pytest.raises(TypeError): 40 | generate_id(charset=charset) 41 | 42 | def test_charset_evaluates_to_non_string(self): 43 | """Argument to `charset` evaluates to non-string.""" 44 | charset = "1" 45 | with pytest.raises(TypeError): 46 | generate_id(charset=charset) 47 | 48 | def test_evaluation_error(self): 49 | """Evaulation of `length` raises an exception.""" 50 | charset = int 51 | with pytest.raises(TypeError): 52 | generate_id(charset=charset) # type: ignore 53 | 54 | def test_length(self): 55 | """Non-default argument to `length`.""" 56 | length = 1 57 | res = generate_id(length=length) 58 | assert len(res) == length 59 | 60 | def test_length_not_int(self): 61 | """Argument to `length` is not an integer.""" 62 | length = "" 63 | with pytest.raises(TypeError): 64 | generate_id(length=length) # type: ignore 65 | 66 | def test_length_not_positive(self): 67 | """Argument to `length` is not a positive integer.""" 68 | length = -1 69 | with pytest.raises(TypeError): 70 | generate_id(length=length) 71 | --------------------------------------------------------------------------------