├── .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 |
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 |
--------------------------------------------------------------------------------