diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f92b3b3..88feae1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -9,7 +9,7 @@ name: Build
env:
MAJOR: 0
MINOR: 0
- PYTHON_VERSION: 3.11.0
+ PYTHON_VERSION: 3.13.0
#
# Establish when the workflow is run
@@ -39,12 +39,12 @@ jobs:
steps:
- name: Checkout out our code
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
- name: Calculate Build Context
run: |
MRMAT_VERSION="${MAJOR}.${MINOR}.${GITHUB_RUN_NUMBER}"
- if [ "$GITHUB_EVENT_NAME" == 'pull_request_target' -a "$GITHUB_BASE_REF" == 'main' ]; then
+ if [ "$GITHUB_EVENT_NAME" == 'pull_request_target' && GITHUB_BASE_REF == 'main']; then
MRMAT_IS_RELEASE=true
echo "::warning ::Building release ${MRMAT_VERSION}"
echo "MRMAT_IS_RELEASE=true" >> $GITHUB_ENV
@@ -60,7 +60,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Establish a cache for dependencies
- uses: actions/cache@v2
+ uses: actions/cache@v4
with:
path: |
~/.local
@@ -69,16 +69,17 @@ jobs:
- name: Build
run: |
- pip install --user -r requirements.txt
- pylint ${GITHUB_WORKSPACE}/src/python/mrmat_python_api_flask
- PYTHONPATH=${GITHUB_WORKSPACE}/src/python python -m pytest
- python -m build --wheel -n
+ export PYTHONUSERBASE=${HOME}/.local
+ pip install --user -r requirements.txt -r requirements.dev.txt
+ PYTHONPATH=${GITHUB_WORKSPACE}/src pytest
+ PYTHONPATH=${GITHUB_WORKSPACE}/src python -m build --wheel -n
- name: Upload test results
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: Test and Coverage
+
path: |
build/junit.xml
build/coverage.xml
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 71f34c9..a376666 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,6 +1,9 @@
-
+
+
+
+
diff --git a/.idea/modules.xml b/.idea/modules.xml
index 6ae7931..f0c38bc 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -3,6 +3,9 @@
+
+
+
\ No newline at end of file
diff --git a/.idea/mrmat-python-api-flask.iml b/.idea/mrmat-python-api-flask.iml
index 38dee3a..371ef45 100644
--- a/.idea/mrmat-python-api-flask.iml
+++ b/.idea/mrmat-python-api-flask.iml
@@ -1,24 +1,16 @@
-
+
+
-
-
-
-
-
-
-
-
-
-
+
-
+
diff --git a/.idea/runConfigurations/client.xml b/.idea/runConfigurations/client.xml
deleted file mode 100644
index cda8dbe..0000000
--- a/.idea/runConfigurations/client.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/cui.xml b/.idea/runConfigurations/cui.xml
deleted file mode 100644
index bd5122a..0000000
--- a/.idea/runConfigurations/cui.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/db_current.xml b/.idea/runConfigurations/db_current.xml
deleted file mode 100644
index 594b644..0000000
--- a/.idea/runConfigurations/db_current.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-<<<<<<<< HEAD:.idea/runConfigurations/build.xml
-
-========
-
->>>>>>>> develop:.idea/runConfigurations/db_current.xml
-
-
-
-
-
-
-
-<<<<<<<< HEAD:.idea/runConfigurations/build.xml
-
-========
-
->>>>>>>> develop:.idea/runConfigurations/db_current.xml
-
-
-
-
-
-<<<<<<<< HEAD:.idea/runConfigurations/build.xml
-
-
-
-
-========
-
-
-
-
->>>>>>>> develop:.idea/runConfigurations/db_current.xml
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/db_downgrade.xml b/.idea/runConfigurations/db_downgrade.xml
deleted file mode 100644
index e7c88f0..0000000
--- a/.idea/runConfigurations/db_downgrade.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/db_revision.xml b/.idea/runConfigurations/db_revision.xml
deleted file mode 100644
index 6ac4b7c..0000000
--- a/.idea/runConfigurations/db_revision.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/db_upgrade.xml b/.idea/runConfigurations/db_upgrade.xml
deleted file mode 100644
index 7bff3b9..0000000
--- a/.idea/runConfigurations/db_upgrade.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/lint.xml b/.idea/runConfigurations/lint.xml
index ed038d7..a1db76a 100644
--- a/.idea/runConfigurations/lint.xml
+++ b/.idea/runConfigurations/lint.xml
@@ -1,6 +1,7 @@
+
@@ -12,8 +13,8 @@
-
-
+
+
diff --git a/.idea/runConfigurations/mrmat_python_api_flask__local_infrastructure_.xml b/.idea/runConfigurations/mrmat_python_api_flask__local_infrastructure_.xml
deleted file mode 100644
index d451e35..0000000
--- a/.idea/runConfigurations/mrmat_python_api_flask__local_infrastructure_.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-
-<<<<<<<< HEAD:.idea/runConfigurations/mrmat_python_api_flask__prod_.xml
-
-========
-
->>>>>>>> develop:.idea/runConfigurations/mrmat_python_api_flask__local_infrastructure_.xml
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/mrmat_python_api_flask__no_infrastructure__debug_.xml b/.idea/runConfigurations/mrmat_python_api_flask__no_infrastructure__debug_.xml
deleted file mode 100644
index 1e68fed..0000000
--- a/.idea/runConfigurations/mrmat_python_api_flask__no_infrastructure__debug_.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/mrmat_python_api_flask__no_infrastructure_.xml b/.idea/runConfigurations/run__flask_.xml
similarity index 66%
rename from .idea/runConfigurations/mrmat_python_api_flask__no_infrastructure_.xml
rename to .idea/runConfigurations/run__flask_.xml
index 1064238..9551115 100644
--- a/.idea/runConfigurations/mrmat_python_api_flask__no_infrastructure_.xml
+++ b/.idea/runConfigurations/run__flask_.xml
@@ -1,16 +1,17 @@
-
+
+
+
-
+
-
-
+
-
+
diff --git a/.idea/runConfigurations/tests__no_infrastructure_.xml b/.idea/runConfigurations/tests_.xml
similarity index 73%
rename from .idea/runConfigurations/tests__no_infrastructure_.xml
rename to .idea/runConfigurations/tests_.xml
index 3534ce4..bf90c27 100644
--- a/.idea/runConfigurations/tests__no_infrastructure_.xml
+++ b/.idea/runConfigurations/tests_.xml
@@ -1,12 +1,9 @@
-
+
+
-
-
-
-
diff --git a/.idea/runConfigurations/tests__local_infrastructure_.xml b/.idea/runConfigurations/tests__local_infrastructure_.xml
deleted file mode 100644
index e149f59..0000000
--- a/.idea/runConfigurations/tests__local_infrastructure_.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml
index 200a4c5..6df4889 100644
--- a/.idea/sqldialects.xml
+++ b/.idea/sqldialects.xml
@@ -1,7 +1,6 @@
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 94a25f7..35eb1dd 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.pylintrc b/.pylintrc
deleted file mode 100644
index 89e53ac..0000000
--- a/.pylintrc
+++ /dev/null
@@ -1,430 +0,0 @@
-# This Pylint rcfile contains a best-effort configuration to uphold the
-# best-practices and style described in the Google Python style guide:
-# https://google.github.io/styleguide/pyguide.html
-#
-# Its canonical open-source location is:
-# https://google.github.io/styleguide/pylintrc
-
-[MASTER]
-
-# Files or directories to be skipped. They should be base names, not paths.
-ignore=third_party
-
-# Files or directories matching the regex patterns are skipped. The regex
-# matches against base names, not paths.
-ignore-patterns=
-
-# Pickle collected data for later comparisons.
-persistent=no
-
-# List of plugins (as comma separated values of python modules names) to load,
-# usually to register additional checkers.
-load-plugins=
-
-# Use multiple processes to speed up Pylint.
-jobs=4
-
-# Allow loading of arbitrary C extensions. Extensions are imported into the
-# active Python interpreter and may run arbitrary code.
-unsafe-load-any-extension=no
-
-
-[MESSAGES CONTROL]
-
-# Only show warnings with the listed confidence levels. Leave empty to show
-# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
-confidence=
-
-# Enable the message, report, category or checker with the given id(s). You can
-# either give multiple identifier separated by comma (,) or put this option
-# multiple time (only on the command line, not in the configuration file where
-# it should appear only once). See also the "--disable" option for examples.
-#enable=
-
-# Disable the message, report, category or checker with the given id(s). You
-# can either give multiple identifiers separated by comma (,) or put this
-# option multiple times (only on the command line, not in the configuration
-# file where it should appear only once).You can also use "--disable=all" to
-# disable everything first and then reenable specific checks. For example, if
-# you want to run only the similarities checker, you can use "--disable=all
-# --enable=similarities". If you want to run only the classes checker, but have
-# no Warning level messages displayed, use"--disable=all --enable=classes
-# --disable=W"
-disable=abstract-method,
- apply-builtin,
- arguments-differ,
- attribute-defined-outside-init,
- backtick,
- bad-option-value,
- basestring-builtin,
- buffer-builtin,
- c-extension-no-member,
- consider-using-enumerate,
- cmp-builtin,
- cmp-method,
- coerce-builtin,
- coerce-method,
- delslice-method,
- div-method,
- duplicate-code,
- eq-without-hash,
- execfile-builtin,
- file-builtin,
- filter-builtin-not-iterating,
- fixme,
- getslice-method,
- global-statement,
- hex-method,
- idiv-method,
- implicit-str-concat-in-sequence,
- import-error,
- import-self,
- import-star-module-level,
- inconsistent-return-statements,
- input-builtin,
- intern-builtin,
- invalid-str-codec,
- locally-disabled,
- long-builtin,
- long-suffix,
- map-builtin-not-iterating,
- misplaced-comparison-constant,
- missing-function-docstring,
- metaclass-assignment,
- next-method-called,
- next-method-defined,
- no-absolute-import,
- no-else-break,
- no-else-continue,
- no-else-raise,
- no-else-return,
- no-init, # added
- no-member,
- no-name-in-module,
- no-self-use,
- nonzero-method,
- oct-method,
- old-division,
- old-ne-operator,
- old-octal-literal,
- old-raise-syntax,
- parameter-unpacking,
- print-statement,
- raising-string,
- range-builtin-not-iterating,
- raw_input-builtin,
- rdiv-method,
- reduce-builtin,
- relative-import,
- reload-builtin,
- round-builtin,
- setslice-method,
- signature-differs,
- standarderror-builtin,
- suppressed-message,
- sys-max-int,
- too-few-public-methods,
- too-many-ancestors,
- too-many-arguments,
- too-many-boolean-expressions,
- too-many-branches,
- too-many-instance-attributes,
- too-many-locals,
- too-many-nested-blocks,
- too-many-public-methods,
- too-many-return-statements,
- too-many-statements,
- trailing-newlines,
- unichr-builtin,
- unicode-builtin,
- unnecessary-pass,
- unpacking-in-except,
- useless-else-on-loop,
- useless-object-inheritance,
- useless-suppression,
- using-cmp-argument,
- wrong-import-order,
- xrange-builtin,
- zip-builtin-not-iterating,
- missing-module-docstring
-
-
-[REPORTS]
-
-# Set the output format. Available formats are text, parseable, colorized, msvs
-# (visual studio) and html. You can also give a reporter class, eg
-# mypackage.mymodule.MyReporterClass.
-output-format=text
-
-# Tells whether to display a full report or only the messages
-reports=no
-
-# Python expression which should return a note less than 10 (10 is the highest
-# note). You have access to the variables errors warning, statement which
-# respectively contain the number of errors / warnings messages and the total
-# number of statements analyzed. This is used by the global evaluation report
-# (RP0004).
-evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
-
-# Template used to display messages. This is a python new-style format string
-# used to format the message information. See doc for all details
-#msg-template=
-
-
-[BASIC]
-
-# Good variable names which should always be accepted, separated by a comma
-good-names=main,_
-
-# Bad variable names which should always be refused, separated by a comma
-bad-names=
-
-# Colon-delimited sets of names that determine each other's naming style when
-# the name regexes allow several styles.
-name-group=
-
-# Include a hint for the correct naming format with invalid-name
-include-naming-hint=no
-
-# List of decorators that produce properties, such as abc.abstractproperty. Add
-# to this list to register other decorators that produce valid properties.
-property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl
-
-# Regular expression matching correct function names
-function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$
-
-# Regular expression matching correct variable names
-variable-rgx=^[a-z][a-z0-9_]*$
-
-# Regular expression matching correct constant names
-const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$
-
-# Regular expression matching correct attribute names
-attr-rgx=^_{0,2}[a-z][a-z0-9_]*$
-
-# Regular expression matching correct argument names
-argument-rgx=^[a-z][a-z0-9_]*$
-
-# Regular expression matching correct class attribute names
-class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$
-
-# Regular expression matching correct inline iteration names
-inlinevar-rgx=^[a-z][a-z0-9_]*$
-
-# Regular expression matching correct class names
-class-rgx=^_?[A-Z][a-zA-Z0-9]*$
-
-# Regular expression matching correct module names
-module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$
-
-# Regular expression matching correct method names
-method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$
-
-# Regular expression which should only match function or class names that do
-# not require a docstring.
-no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$
-
-# Minimum line length for functions/classes that require docstrings, shorter
-# ones are exempt.
-docstring-min-length=10
-
-
-[TYPECHECK]
-
-# List of decorators that produce context managers, such as
-# contextlib.contextmanager. Add to this list to register other decorators that
-# produce valid context managers.
-contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager
-
-# Tells whether missing members accessed in mixin class should be ignored. A
-# mixin class is detected if its name ends with "mixin" (case insensitive).
-ignore-mixin-members=yes
-
-# List of module names for which member attributes should not be checked
-# (useful for modules/projects where namespaces are manipulated during runtime
-# and thus existing member attributes cannot be deduced by static analysis. It
-# supports qualified module names, as well as Unix pattern matching.
-ignored-modules=
-
-# List of class names for which member attributes should not be checked (useful
-# for classes with dynamically set attributes). This supports the use of
-# qualified names.
-ignored-classes=optparse.Values,thread._local,_thread._local
-
-# List of members which are set dynamically and missed by pylint inference
-# system, and so shouldn't trigger E1101 when accessed. Python regular
-# expressions are accepted.
-generated-members=
-
-
-[FORMAT]
-
-# Maximum number of characters on a single line (Google has 80).
-max-line-length=120
-
-# TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt
-# lines made too long by directives to pytype.
-
-# Regexp for a line that is allowed to be longer than the limit.
-ignore-long-lines=(?x)(
- ^\s*(\#\ )??$|
- ^\s*(from\s+\S+\s+)?import\s+.+$)
-
-# Allow the body of an if to be on the same line as the test if there is no
-# else.
-single-line-if-stmt=yes
-
-# Maximum number of lines in a module
-max-module-lines=99999
-
-# String used as indentation unit. The internal Google style guide mandates 2
-# spaces. Google's externaly-published style guide says 4, consistent with
-# PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google
-# projects (like TensorFlow).
-indent-string=' '
-
-# Number of spaces of indent required inside a hanging or continued line.
-indent-after-paren=4
-
-# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
-expected-line-ending-format=
-
-
-[MISCELLANEOUS]
-
-# List of note tags to take in consideration, separated by a comma.
-notes=TODO
-
-
-[STRING]
-
-# This flag controls whether inconsistent-quotes generates a warning when the
-# character used as a quote delimiter is used inconsistently within a module.
-check-quote-consistency=yes
-
-
-[VARIABLES]
-
-# Tells whether we should check for unused import in __init__ files.
-init-import=no
-
-# A regular expression matching the name of dummy variables (i.e. expectedly
-# not used).
-dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_)
-
-# List of additional names supposed to be defined in builtins. Remember that
-# you should avoid to define new builtins when possible.
-additional-builtins=
-
-# List of strings which can identify a callback function by name. A callback
-# name must start or end with one of those strings.
-callbacks=cb_,_cb
-
-# List of qualified module names which can have objects that can redefine
-# builtins.
-redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools
-
-
-[LOGGING]
-
-# Logging modules to check that the string format arguments are in logging
-# function parameter format
-logging-modules=logging,absl.logging,tensorflow.io.logging
-
-
-[SIMILARITIES]
-
-# Minimum lines number of a similarity.
-min-similarity-lines=4
-
-# Ignore comments when computing similarities.
-ignore-comments=yes
-
-# Ignore docstrings when computing similarities.
-ignore-docstrings=yes
-
-# Ignore imports when computing similarities.
-ignore-imports=no
-
-
-[SPELLING]
-
-# Spelling dictionary name. Available dictionaries: none. To make it working
-# install python-enchant package.
-spelling-dict=
-
-# List of comma separated words that should not be checked.
-spelling-ignore-words=
-
-# A path to a file that contains private dictionary; one word per line.
-spelling-private-dict-file=
-
-# Tells whether to store unknown words to indicated private dictionary in
-# --spelling-private-dict-file option instead of raising a message.
-spelling-store-unknown-words=no
-
-
-[IMPORTS]
-
-# Deprecated modules which should not be used, separated by a comma
-deprecated-modules=regsub,
- TERMIOS,
- Bastion,
- rexec,
- sets
-
-# Create a graph of every (i.e. internal and external) dependencies in the
-# given file (report RP0402 must not be disabled)
-import-graph=
-
-# Create a graph of external dependencies in the given file (report RP0402 must
-# not be disabled)
-ext-import-graph=
-
-# Create a graph of internal dependencies in the given file (report RP0402 must
-# not be disabled)
-int-import-graph=
-
-# Force import order to recognize a module as part of the standard
-# compatibility libraries.
-known-standard-library=
-
-# Force import order to recognize a module as part of a third party library.
-known-third-party=enchant, absl
-
-# Analyse import fallback blocks. This can be used to support both Python 2 and
-# 3 compatible code, which means that the block might have code that exists
-# only in one or another interpreter, leading to false positives when analysed.
-analyse-fallback-blocks=no
-
-
-[CLASSES]
-
-# List of method names used to declare (i.e. assign) instance attributes.
-defining-attr-methods=__init__,
- __new__,
- setUp
-
-# List of member names, which should be excluded from the protected access
-# warning.
-exclude-protected=_asdict,
- _fields,
- _replace,
- _source,
- _make
-
-# List of valid names for the first argument in a class method.
-valid-classmethod-first-arg=cls,
- class_
-
-# List of valid names for the first argument in a metaclass class method.
-valid-metaclass-classmethod-first-arg=mcs
-
-
-[EXCEPTIONS]
-
-# Exceptions that will emit a warning when being caught. Defaults to
-# "Exception"
-overgeneral-exceptions=StandardError,
- Exception,
- BaseException
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..90af0c9
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,55 @@
+#
+# Convenience Makefile
+# Useful reference: https://makefiletutorial.com
+
+GIT_SHA := $(shell git rev-parse --short HEAD)
+VERSION ?= 0.0.0-dev0.${GIT_SHA}
+PYTHON_VERSION := $(shell echo "${VERSION}" | sed -e 's/-dev0\./-dev0+/')
+WHEEL_VERSION := $(shell echo "${VERSION}" | sed -e 's/-dev0\./.dev0+/')
+
+ROOT_PATH := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
+
+PYTHON_SOURCES := $(shell find src/mrmat_python_api_flask -name '*.py')
+PYTHON_TARGET := dist/mrmat_python_api_flask-${WHEEL_VERSION}-py3-none-any.whl
+CONTAINER_SOURCES := $(shell find var/container)
+HELM_SOURCES := $(shell find var/helm)
+HELM_TARGET := dist/mrmat-python-api-flask-$(VERSION).tgz
+
+all: python container helm
+python: $(PYTHON_TARGET)
+helm: $(HELM_TARGET)
+
+$(PYTHON_TARGET): $(PYTHON_SOURCES)
+ MRMAT_VERSION="${PYTHON_VERSION}" python -mbuild -n --wheel
+
+$(HELM_TARGET): $(HELM_SOURCES) container
+ helm package \
+ --app-version "$(VERSION)" \
+ --version $(VERSION) \
+ --destination dist/ \
+ var/helm
+
+container: $(PYTHON_TARGET) $(CONTAINER_SOURCES)
+ docker build \
+ -f var/container/Dockerfile \
+ -t localhost:5001/mrmat-python-api-flask:$(VERSION) \
+ --build-arg MRMAT_VERSION=$(VERSION) \
+ --build-arg WHEEL=$(PYTHON_TARGET) \
+ $(ROOT_PATH)
+ docker push localhost:5001/mrmat-python-api-flask:$(VERSION)
+
+helm-install: $(HELM_TARGET)
+ kubectl create ns mpaflask || true
+ kubectl label --overwrite ns mpaflask istio-injection=true
+ helm upgrade \
+ mrmat-python-api-flask \
+ ${HELM_TARGET} \
+ --install \
+ --force \
+ --namespace mpaflask
+
+helm-uninstall:
+ helm delete -n mpaflask mrmat-python-api-flask
+
+clean:
+ rm -rf build dist
diff --git a/README.md b/README.md
index 3891936..9e478be 100644
--- a/README.md
+++ b/README.md
@@ -2,357 +2,50 @@
[](https://github.com/MrMatAP/mrmat-python-api-flask/actions/workflows/build.yml)
-Boilerplate (and playground) for a code-first Python Flask API, with all the bells and whistles we've come to expect:
-
-* Pluggable APIs and multiple API versions
-* Database schema migration
-* API body serialisation
-* OIDC Authentication
-* Healthz API
-* Implementation of a generic resource API, showcasing how a service owner would manage slices of a wider offering
-* No TLS because this is intended to run behind a reverse proxy
-* Project auto-versioning
+Boilerplate (and playground) for a code-first Python Flask API.
## How to build this
-We use the [PEP517 build mechanism](https://www.python.org/dev/peps/pep-0517/) and build a wheel as follows:
-
-```bash
-$ pip install -r requirements.txt # Manually install dependencies (see note in requirements.txt!)
-$ export PYTHONPATH=`pwd` # In order to find the build-time ci module
-$ export MRMAT_VERSION=1.0.27 # Optional: To influence the version. Normally calculated and set by CI
-$ python -m build -n --wheel # Use -n in an interactive, virtual environment
-... lots of output omitted
-$ ls dist/
-mrmat_python_api_flask-1.0.27.dev0-py3-none-any.whl
-
-# Optional: Build a container image
-
-$ docker build -t mrmat-python-api-flask:${MRMAT_VERSION} -f var/docker/Dockerfile .
-
-# Optional: Install the package into your local environment
-
-$ pip install --force-reinstall ./dist/mrmat_python_api_flask-1.0.27.dev0-py3-none-any.whl
-```
-
-```powershell
-PS> pip install -r requirements.txt # Manually install dependencies (see note in requirements.txt!)
-PS> $Env:PYTHONPATH=(pwd) # In order to find the build-time ci module
-PS> $Env:MRMAT_VERSION = "1.0.27" # Optional: To influence the version. Normally calculated and set by CI
-PS> python -m build -n --wheel # Use -n in an interactive, virtual environment
-PS> ls dist/
-Mode LastWriteTime Length Name
----- ------------- ------ ----
--a---- 26/12/2021 11:24 33513 mrmat_python_api_flask-1.0.27-py3-none-any.whl
-
-# Optional: Build a container image
-
-$ docker build -t mrmat-python-api-flask:$Env:MRMAT_VERSION -f var/docker/Dockerfile .
-
-# Optional: Install the package into your local environment
-
-PS> pip install --force-reinstall ./dist/mrmat_python_api_flask-1.0.27.dev0-py3-none-any.whl
-```
-
-The project version is autogenerated based on MAJOR, MINOR and MICRO version numbers. MAJOR and MINOR are hardcoded
-numbers in the CI configuration (currently GitHub Actions, see `.github/workflows/build.yml`). The MICRO number is
-taken from the unique pipeline run injected by GitHub as `GITHUB_RUN_NUMBER`. This makes sure that we produce a unique
-project version for every build and especially for a release.
-
-The CI pipeline knows it's a release when it builds as part of a push to the main branch, which is technically meant
-to be a protected branch that can only be modified via a pull request. `MRMAT_VERSION` is therefore set to a
-concatenation of `${MAJOR}.${MINOR}.${GITHUB_RUN_NUMBER}` for a release artefact. For any other CI build, the calculated
-version will be `${MAJOR}.${MINOR}.${GITHUB_RUN_NUMBER}.dev0`.
-
-The calculated `MRMAT_VERSION` is picked up by the special build-time only Python module `ci`, which is specifically
-excluded from becoming part of the final artefact. See how the `ci` module is used by the build system in `setup.cfg`.
-
-If you are building locally, then your version will **always** be `0.0.0.dev0` (unless you explicitly set the
-`MRMAT_VERSION` environment variable to something different).
-
-## How to run this
-
-You have the choice of running this
-
-* as a Flask app straight out of the project directory
-* as a CLI app
-* as a WSGI app
-* as a container image
-
-### To run as a Flask app
-
-You can, of course, run this as a Flask app straight from the project directory:
+Create a virtual environment, then:
```shell
-$ cd src/python
-$ FLASK_APP=mrmat_python_api_flask flask run
- * Serving Flask app 'mrmat_python_api_flask' (lazy loading)
- * Environment: production
- WARNING: This is a development server. Do not use it in a production deployment.
- Use a production WSGI server instead.
- * Debug mode: off
-[12/26/21 13:06:45] INFO INFO:mrmat_python_api_flask:Using existing instance path at
- /home/imfeldma/projects/mrmat-python-api-flask/instance
-[12/26/21 13:06:45] WARNING WARNING:mrmat_python_api_flask:Running without any authentication/authorisation
-[12/26/21 13:06:45] INFO INFO:werkzeug: * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
+(venv) $ pip install -r requirements.txt
+(venv) $ python -m build -n --wheel
```
-```powershell
-$ $Env:FLASK_APP=mrmat_python_api_flask
-$ flask run
- * Serving Flask app 'mrmat_python_api_flask' (lazy loading)
- * Environment: production
- WARNING: This is a development server. Do not use it in a production deployment.
- Use a production WSGI server instead.
- * Debug mode: off
-[12/26/21 13:00:57] INFO INFO:mrmat_python_api_flask:Using existing instance path at C:\Users\imfeldma\Projects\mrmat-python-api-flask\instance
-[12/26/21 13:00:57] WARNING WARNING:mrmat_python_api_flask:Running without any authentication/authorisation
-[12/26/21 13:00:57] INFO INFO:werkzeug: * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
-```
-
-### To run as a CLI app
-
-A simple CLI app is provided for your convenience once you have installed the wheel:
-
-```shell
-usage: mrmat-python-api-flask [-h] [-d] [--host HOST] [--port PORT] [--instance-path INSTANCE_PATH] [--dsn DSN] [--oidc-secrets OIDC_SECRETS]
-
-mrmat-python-api-flask - 1.0.27
-
-options:
- -h, --help show this help message and exit
- -d, --debug Debug
- --host HOST Host interface to bind to
- --port PORT Port to bind to
- --instance-path INSTANCE_PATH
- Fully qualified path to instance directory
- --dsn DSN Database DSN
- --oidc-secrets OIDC_SECRETS
- Path to file containing OIDC registration
-```
+If you intend to run the testsuite or work on the code, then also install the requirements from `requirements.dev.txt`. You can run the testsuite using
```shell
-$ mrmat-python-api-flask
-[2021-06-06 15:30:18,005] INFO: Using instance path at /opt/dyn/python/mrmat-python-api-flask/var/mrmat_python_api_flask-instance
-[2021-06-06 15:30:18,005] WARNING: Running without any authentication/authorisation
- * Serving Flask app "mrmat_python_api_flask" (lazy loading)
- * Environment: production
- WARNING: This is a development server. Do not use it in a production deployment.
- Use a production WSGI server instead.
- * Debug mode: off
-INFO [werkzeug] * Running on http://localhost:8080/ (Press CTRL+C to quit)
-
-
-```
-
->**IMPORTANT**:
-> If you run on Windows, the default port 8080 is considered privileged. Use the `--port` option to override this
-> to 8090, for example.
-
-The instance directory defaults to `var/instance/` but you can override that to be a fully qualified path via the
-`--instance-path` option. Any database supported by SQLAlchemy and for which a driver is installed in the local
-environment can be provided via the `--dsn` option. The default database is a SQLite instance within the instance
-directory.
-
-### To run as a WSGI app
-
-```
-$ flask db upgrade
-$ gunicorn --workers 2 'mrmat_python_api_flask:create_app()'
+(venv) $ PYTHONPATH=src pytest tests
```
-### To run as a container
-
-To run as a container, first build a wheel as explained above, then build a container image:
+The resulting wheel is installable and knows its runtime dependencies. Any locally produced wheel will have version 0.0.0.dev0. This is intentional to distinguish local versions from those that are produced as releases in GitHub. You can override this behaviour by setting the `MRMAT_VERSION` environment variable to the desired version.
-```
-$ docker build -t mrmat-python-api-flask:0.0.1 -f var/docker/Dockerfile .
-...
-$ docker run --rm mrmat-python-api-flask:0.0.1
-```
-
->You may be tempted by Alpine, but most of the Python wheels do not work for it. Go for slim-buster instead
-
-## Configuration
-
-You can provide configuration in a JSON file pointed to by the APP_CONFIG environment variable, which defaults to
-`~/etc/mrmat-python-api-flask.json`. The configuration file can contain anything the app picks up (see
-`mrmat_python_api_flask/__init__.py`) but should typically contain the following three items:
-
-```json
-{
- "SECRET_KEY": "4tbBVKc6gk1eZIhnt-9Igg",
- "OIDC_CLIENT_SECRETS": "/path/to/oidc-secrets.json",
- "SQLALCHEMY_DATABASE_URI": "postgresql://mpaf:secret@localhost:5432/localdb"
-}
-```
-
-* SECRET_KEY secures the session cookie. If not specified, this will be randomised at every start which will
- invalidate any existing session. While this does not make a difference when running without OIDC authentication,
- it is strongly recommended to persist the session key. Create one using `import secrets; secrets.token_urlsafe(16)`
-* OIDC_CLIENT_SECRETS configures OIDC and should point to a separate configuration file. See the OIDC section for
- information about its contents
-* SQLALCHEMY_DATABASE_URI configures the database to use. The default SQLite is at most appropriate for quick testing.
-
-
-## How to use this
-
-Once started, you can curl towards the APIs mounted at various places. See the invocations of `app.register_blueprint`
-within `mrmat_python_api_flask/__.init__.py` to find out where.
-
->Note that omitting the last slash will cause a redirect that you can follow using curls -L option. We can probably
->get rid of that by using a more clever versioning scheme that doesn't make the root resource listen on / (e.g. `/greeting`).
-
-## How to test this
-
-Unit tests are within the `tests` directory. You can use the built-in Pycharm test configuration or do it on the CLI.
-
-```
-$ pytest
-```
-
-You will find that the test suite operates in two modes. Completely on its own without local infrastructure and
-with local infrastructure present. When no local infrastructure to test with is present, tests that require
-OIDC are skipped. If local infrastructure is present then PostgreSQL and Keycloak are used for full integration testing.
-
-Whether local infrastructure is present is discovered via the `TI_CONFIG` environment variable which is expected to
-point to a JSON configuration file of the following structure:
-
-```json
-{
- "pg": {
- "admin_dsn": "postgresql://postgres:secret@localhost:5432/localdb"
- },
- "keycloak": {
- "admin_url": "http://localhost:8080/auth/",
- "admin_user": "admin",
- "admin_password": "secret",
- "admin_realm": "master"
- }
-}
-```
-
-Note how the config file contains credentials for administrative access. The test suite will establish roles, users,
-client_ids and schemata for testing using these credentials and tear them down if so desired. See the classes in
-`tests/conftest.py`.
-
-## Database Setup
-
-A simple SQLite database is used by default for persistence needs. The project has been tested with PostgreSQL, but in
-theory you can use any database supported by SQLAlchemy. It is a matter of (my personal) preference to have a single
-database with dedicated schemas per role in PostgreSQL, like so:
-
-```postgresql
-CREATE ROLE mpaf ENCRYPTED PASSWORD 'secret' LOGIN;
-CREATE SCHEMA mpaf;
-ALTER SCHEMA mpaf OWNER TO mpaf;
-ALTER ROLE mpaf SET SEARCH_PATH TO mpaf;
-```
-
-With a database set up this way, you can then use the `--dsn` CLI parameter to configure the application so it connects
-to real PostgreSQL database rather than the built-in, small-scale SQLite. **Do note that a database migration is
-performed before the service is started.**
+You can produce a container image and associated Helm chart using the provided Makefile:
```shell
-$ mrmat-python-api-flask --dsn postgresql://mpaf:secret@dbserver:5432/dbname
-[12/26/21 13:19:40] INFO INFO:mrmat_python_api_flask:Using existing instance path at /home/imfeldma/projects/mrmat-python-api-flask\instance
-[12/26/21 13:19:40] WARNING WARNING:mrmat_python_api_flask:Running without any authentication/authorisation
-INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
-INFO [alembic.runtime.migration] Will assume transactional DDL.
-INFO [alembic.runtime.migration] Running upgrade -> 1bea80b612cc, initial
- * Serving Flask app 'mrmat_python_api_flask' (lazy loading)
- * Environment: production
- WARNING: This is a development server. Do not use it in a production deployment.
- Use a production WSGI server instead.
- * Debug mode: off
-INFO [werkzeug] * Running on http://localhost:8090/ (Press CTRL+C to quit)
-```
-
-## OIDC Setup
-
-No authentication/authorisation is enforced by default. Token-based authentication via OpenID Connect (OIDC) can be
-enforced by configuring connectivity to such extra central infrastructure. For this to happen, you must register the
-app in your OIDC IdP and create an OIDC secrets configuration file (json) of the following structure, which you
-subsequently point to using the `--oidc-secrets` option of the CLI or the `OIDC_CLIENT_SECRETS` key of the configuration
-file pointed to by the `APP_CONFIG` environment variable.
+$ make container
-```json
-{
- "web": {
- "client_id": "CLIENT_ID",
- "client_secret": "CLIENT_SECRET",
- "auth_uri": "AUTHORIZATION_ENDPOINT",
- "token_uri": "TOKEN_ENDPOINT",
- "userinfo_uri": "USERINFO_ENDPOINT",
- "token_introspection_uri": "INTROSPECTION_ENDPOINT",
- "issuer": "ISSUER",
- "redirect_uris": "REDIRECT_URIS"
- }
-}
+# Optionally install the produced container image in the current Kubernetes context
+$ make helm-install
```
-The client_id and associated client_secret are for the server-side app itself and must be set up as follows in the IdP:
-
-* Access Type: confidential
-* Authorization Code Flow: enabled (Keycloak calls this: 'Standard Flow Enabled')
-* Resource Owner Password Credentials Grant: enabled (Keycloak calls this 'Direct Access Grants Enabled')
-
-The app knows two scopes for authorisation in the Resource API:
-
-* mpaf-read - Grants read access
-* mpaf-write - Grants write access
-
-### OIDC Client
+## How to run this
-A very rudimentary client to demonstrate the OIDC device code flow is provided. The client currently exclusively
-demonstrates calling the Greeting API v3.
+To run from an installed wheel:
```shell
-$ mrmat-python-api-flask-client -h
-
-usage: mrmat-python-api-flask-client [-h] [-q] [-d] [--host HOST] [--port PORT] [--config CONFIG] [--client-id CLIENT_ID] [--client-secret CLIENT_SECRET] [--discovery-url DISCOVERY_URL]
-
-mrmat-python-api-flask-client - 1.0.30
-
-options:
- -h, --help show this help message and exit
- -q, --quiet Silent Operation
- -d, --debug Debug
- --host HOST Host interface to connect to
- --port PORT Port to connect to
-
-File Configuration:
- Configure the client via a config file
-
- --config CONFIG, -c CONFIG
- Path to the configuration file for the flask client
-
-Manual Configuration:
- Configure the client manually
-
- --client-id CLIENT_ID
- The client_id of this CLI itself (not yours!)
- --client-secret CLIENT_SECRET
- The client_secret of the CLI itself. Not required for AAD, required for Keycloak
- --discovery-url DISCOVERY_URL
- Discovery of endpoints in the authentication platform
+$ gunicorn --bind 0.0.0.0:8000 mrmat_python_api_flask:app
```
-You must have an actual user account configured in the IdP so the client can actually log you in. The client itself
-requires its own client-id and secret with the following parameters in the IdP:
+Or you can just start the container image or Helm chart. Both are declared in `var/container` and `var/helm` respectively and used by the top-level Makefile.
-* Access Type: confidential
-* Device Authorization Grant: enabled (Keycloak calls this 'OAuth 2.0 Device Authorization Grant Enabled')
+## How to configure this
-The client can be configured via the CLI parameters on its own, but also supports a configuration file of the
-following form. The DISCOVERY_URL must point to the URL where the IdP publishes its well-known endpoints.
+When you do not explicitly configure anything the app will use an ephemeral in-memory SQLite database. You can change this to PostgreSQL by:
-```json
-{
- "client_id": "CLIENT_ID",
- "client_secret": "CLIENT_SECRET",
- "discovery_url": "DISCOVERY_URL"
-}
-```
+* overriding the `config.db_url` variable of the Helm chart, or
+* setting the `APP_CONFIG_DB_URL` environment variable, or
+* creating a config file in JSON setting `db_url`
->The client requires configuration with OIDC secrets and currently implements the Device code flow
+The app will pick up the config file from the path set in the `APP_CONFIG` environment variable, if it is set. Note that the `APP_CONFIG_DB_URL` environment variable overrides the setting in the configuration file.
diff --git a/pyproject.toml b/pyproject.toml
index c7ad76f..9f713bc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,20 +1,15 @@
[build-system]
requires = [
- 'setuptools>=42.0.0',
- 'wheel >= 0.36.0',
- 'pylint~=2.15.5', # MIT
- 'pytest~=7.2.0', # GPL-2.0-or-later
- 'pytest-cov~=4.0.0', # MIT
- 'pyjwt~=2.6.0', # MIT
- 'python-keycloak~=2.6.0' # MIT
+ 'setuptools==76.0.0',
+ 'wheel==0.45.1'
]
build-backend = 'setuptools.build_meta'
[project]
name = "mrmat-python-api-flask"
-description = "Boilerplate code for an API using Flask"
-urls = { "Sources" = "https://github.com/MrMatAP/mrmat-python-api-flask" }
-keywords = ["experimental"]
+description = "A Python API using Flask"
+urls = { "Sources" = "https://github.com/MrMatAP/mrmat-python-api-flask.git" }
+keywords = ["api", "python", "flask"]
readme = "README.md"
license = { text = "MIT" }
authors = [
@@ -26,45 +21,29 @@ maintainers = [
classifiers = [
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: MIT",
- "Programming Language :: Python :: 3.11"
+ "Programming Language :: Python :: 3.12"
]
-requires-python = ">=3.10"
-dependencies = [
- "rich~=12.6.0",
- "Flask~=2.2.2",
- "Flask-SQLAlchemy~=3.0.2",
- "Flask-Migrate~=4.0.0",
- "flask-smorest~=0.40.0",
- "Flask-Marshmallow~=0.14.0",
- "marshmallow-sqlalchemy~=0.28.1",
- "psycopg2-binary~=2.9.5",
- "Flask-OIDC~=1.4.0"
-]
-dynamic = ["version"]
+requires-python = ">=3.12"
+dynamic = ["version", "dependencies", "optional-dependencies"]
[tool.setuptools.dynamic]
-version = { attr = "ci.version "}
+version = { attr = "ci.version"}
+dependencies = {file = ["requirements.txt"]}
+optional-dependencies = { dev = {file = ["requirements.dev.txt"] } }
[tool.setuptools.packages.find]
-where = ["src/python"]
+where = ["src"]
include = ["mrmat_python_api_flask*"]
namespaces = true
[tool.setuptools.package-data]
-"*" = ["*.mo", "migrations/*", "templates/*", "static/*"]
-
-[project.scripts]
-mrmat-python-api-flask = "mrmat_python_api_flask.cui:main"
-mrmat-python-api-flask-client = "mrmat_python_api_flask.client:main"
+"*" = [".mo", "*.yml", "*.yaml", "*.md", "inventory", "*.j2", "*.html", "*.ico", "*.css", "*.js", "*.svg", "*.woff", "*.eot", "*.ttf"]
-# If you are debugging your tests using PyCharm then comment out the coverage options
-# in addopts
[tool.pytest.ini_options]
-minversion = "6.0"
-addopts = "--cov=mrmat_python_api_flask --cov-report=term --cov-report=xml:build/coverage.xml --junit-xml=build/junit.xml"
-testpaths = ["tests"]
-junit_family = "xunit2"
+testpaths = 'tests'
+addopts = '--cov=mrmat_python_api_flask --cov-report=term --cov-report=xml:build/coverage.xml --junit-xml=build/junit.xml'
+junit_family = 'xunit2'
log_cli = 1
-log_cli_level = "INFO"
-log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
-log_cli_date_format="%Y-%m-%d %H:%M:%S"
+log_cli_level = 'INFO'
+log_cli_format = '%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)'
+log_cli_date_format = '%Y-%m-%d %H:%M:%S'
diff --git a/requirements.dev.txt b/requirements.dev.txt
new file mode 100644
index 0000000..86e21ac
--- /dev/null
+++ b/requirements.dev.txt
@@ -0,0 +1,11 @@
+#
+# Build/Test requirements
+
+setuptools==76.0.0
+build==1.2.2.post1 # MIT
+wheel==0.45.1 # MIT
+
+pytest==8.3.5 # MIT
+pytest-cov==6.0.0 # MIT
+mypy==1.15.0 # MIT
+types-PyYAML==6.0.12.20241230 # Apache 2.0
diff --git a/requirements.txt b/requirements.txt
index 93e5063..e7ea751 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,31 +1,12 @@
#
-# IMPORTANT: The requirements that actually count are in setup.cfg. This file is here for convenience so you can
-# quickly install both build- and test-requirements from within your development environment. DO MAKE
-# SURE to update setup.cfg whenever you make changes here.
-
-# Build/Test requirements
-
-setuptools>=42.0.0
-build>=0.9.0 # MIT
-wheel>=0.36.0 # MIT
-pylint~=2.15.5 # MIT
-pytest~=7.2.0 # GPL-2.0-or-later
-pytest-cov~=4.0.0 # MIT
-pyjwt~=2.6.0 # MIT
-python-keycloak~=2.6.0 # MIT
-
# Runtime requirements
-rich~=12.6.0 # MIT
-Flask~=2.2.2 # BSD 3-Clause
-Flask-SQLAlchemy~=3.0.2 # BSD 3-Clause
-Flask-Migrate~=4.0.0 # MIT
-flask-smorest~=0.40.0 # MIT
-Flask-Marshmallow~=0.14.0 # MIT
-marshmallow-sqlalchemy~=0.28.1 # MIT
-psycopg2-binary~=2.9.5 # LGPL with exceptions
-Flask-OIDC~=1.4.0 # MIT
+Flask==3.1.0 # BSD 3-Clause
+Flask-SQLAlchemy==3.1.1 # BSD 3-Clause
+flask-smorest==0.46.1 # MIT
+Flask-Marshmallow==1.3.0 # MIT
+marshmallow-sqlalchemy==1.4.2 # MIT
+#Flask-OIDC~=1.4.0 # MIT
-# Do we need these?
-#requests_oauthlib~=1.3.1 # ISC
-itsdangerous<=2.0.1 # BSD 3-Clause Must affix so JSONWebSignatureSerializer is known
+gunicorn==23.0.0 # MIT
+psycopg2-binary==2.9.10 # LGPL with exceptions
diff --git a/src/python/ci/__init__.py b/src/ci/__init__.py
similarity index 100%
rename from src/python/ci/__init__.py
rename to src/ci/__init__.py
diff --git a/src/mrmat_python_api_flask/__init__.py b/src/mrmat_python_api_flask/__init__.py
new file mode 100644
index 0000000..7123a3e
--- /dev/null
+++ b/src/mrmat_python_api_flask/__init__.py
@@ -0,0 +1,81 @@
+# MIT License
+#
+# Copyright (c) 2021 MrMat
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import importlib.metadata
+import sqlalchemy.orm
+import flask
+import flask_sqlalchemy
+import flask_marshmallow
+import flask_smorest
+from .config import Config
+
+try:
+ __version__ = importlib.metadata.version('mrmat-python-api-flask')
+except importlib.metadata.PackageNotFoundError:
+ # You have not yet installed this as a package, likely because you're hacking on it in some IDE
+ __version__ = '0.0.0.dev0'
+
+
+class ORMBase(sqlalchemy.orm.DeclarativeBase):
+ pass
+
+app_config = Config.from_context()
+
+app = flask.Flask(__name__)
+app.config.setdefault('SQLALCHEMY_DATABASE_URI',app_config.db_url)
+app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False)
+app.config.setdefault('SECRET_KEY', app_config.secret_key)
+app.config.setdefault('API_TITLE', 'MrMat :: Python API :: Flask')
+app.config.setdefault('API_VERSION', __version__)
+app.config.setdefault('OPENAPI_VERSION', '3.0.2')
+app.config.setdefault('OPENAPI_URL_PREFIX', '/')
+app.config.setdefault('OPENAPI_SWAGGER_UI_PATH', '/swagger-ui')
+app.config.setdefault('OPENAPI_SWAGGER_UI_URL', "https://cdn.jsdelivr.net/npm/swagger-ui-dist/")
+app.config.setdefault('OPENAPI_REDOC_PATH', '/redoc')
+app.config.setdefault('OPENAPI_REDOC_URL', "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js")
+app.config.setdefault('OPENAPI_RAPIDOC_PATH', '/rapidoc')
+app.config.setdefault('OPENAPI_RAPIDOC_URL', "https://unpkg.com/rapidoc/dist/rapidoc-min.js")
+
+db = flask_sqlalchemy.SQLAlchemy(app, model_class=ORMBase)
+ma = flask_marshmallow.Marshmallow(app)
+api = flask_smorest.Api(app)
+
+#
+# Register APIs
+
+from mrmat_python_api_flask.apis.healthz import api_healthz
+api.register_blueprint(api_healthz, url_prefix='/api/healthz')
+
+from mrmat_python_api_flask.apis.greeting.v1 import api_greeting_v1
+api.register_blueprint(api_greeting_v1, url_prefix='/api/greeting/v1')
+
+from mrmat_python_api_flask.apis.greeting.v2 import api_greeting_v2
+api.register_blueprint(api_greeting_v2, url_prefix='/api/greeting/v2')
+
+from mrmat_python_api_flask.apis.platform.v1 import api_platform_v1
+api.register_blueprint(api_platform_v1, url_prefix='/api/platform/v1')
+
+#
+# Initialise the database
+
+with app.app_context():
+ db.create_all()
diff --git a/src/python/mrmat_python_api_flask/apis/greeting/v2/model.py b/src/mrmat_python_api_flask/apis/__init__.py
similarity index 64%
rename from src/python/mrmat_python_api_flask/apis/greeting/v2/model.py
rename to src/mrmat_python_api_flask/apis/__init__.py
index a8f8365..535e3a4 100644
--- a/src/python/mrmat_python_api_flask/apis/greeting/v2/model.py
+++ b/src/mrmat_python_api_flask/apis/__init__.py
@@ -20,38 +20,39 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-"""Greeting API v2 Model"""
+"""
+Code that can be re-used by all APIs
+"""
-from marshmallow import fields
+import dataclasses
+from marshmallow import fields, post_load
from mrmat_python_api_flask import ma
+@dataclasses.dataclass
+class Status:
+ code: int = dataclasses.field(default=500)
+ msg: str = dataclasses.field(default='An unknown error occurred')
-class GreetingV2Input(ma.Schema):
- class Meta:
- fields: ('name',)
-
- name = fields.Str(
- required=False,
- load_only=True,
- missing='Stranger',
+class StatusSchema(ma.Schema):
+ """
+ A generic message class
+ """
+ code = fields.Int(
+ required=True,
metadata={
- 'description': 'The name to greet'
- }
- )
-
-
-class GreetingV2Output(ma.Schema):
- class Meta:
- fields = ('message',)
+ 'description': 'An integer status code which will typically match the HTTP status code'
+ })
- message = fields.Str(
+ msg = fields.Str(
required=True,
- dump_only=True,
metadata={
- 'description': 'A greeting message'
+ 'description': 'A human-readable message'
}
)
+ @post_load
+ def as_object(self, data, **kwargs) -> Status:
+ return Status(**data)
-greeting_v2_output = GreetingV2Output()
+status_schema = StatusSchema()
diff --git a/src/python/mrmat_python_api_flask/apis/greeting/__init__.py b/src/mrmat_python_api_flask/apis/greeting/__init__.py
similarity index 100%
rename from src/python/mrmat_python_api_flask/apis/greeting/__init__.py
rename to src/mrmat_python_api_flask/apis/greeting/__init__.py
diff --git a/src/python/mrmat_python_api_flask/apis/greeting/v1/__init__.py b/src/mrmat_python_api_flask/apis/greeting/v1/__init__.py
similarity index 90%
rename from src/python/mrmat_python_api_flask/apis/greeting/v1/__init__.py
rename to src/mrmat_python_api_flask/apis/greeting/v1/__init__.py
index d7ead16..d93ef43 100644
--- a/src/python/mrmat_python_api_flask/apis/greeting/v1/__init__.py
+++ b/src/mrmat_python_api_flask/apis/greeting/v1/__init__.py
@@ -23,5 +23,5 @@
"""Pluggable blueprint of the Greeting API v1
"""
-from .model import GreetingV1Output # noqa: F401
-from .api import bp as api_greeting_v1 # noqa: F401
+from .model import GreetingV1, GreetingV1Schema, greeting_v1_schema
+from .api import bp as api_greeting_v1
diff --git a/src/python/mrmat_python_api_flask/apis/greeting/v1/api.py b/src/mrmat_python_api_flask/apis/greeting/v1/api.py
similarity index 78%
rename from src/python/mrmat_python_api_flask/apis/greeting/v1/api.py
rename to src/mrmat_python_api_flask/apis/greeting/v1/api.py
index aad319d..ac9503c 100644
--- a/src/python/mrmat_python_api_flask/apis/greeting/v1/api.py
+++ b/src/mrmat_python_api_flask/apis/greeting/v1/api.py
@@ -24,24 +24,21 @@
Blueprint for the Greeting API in V1
"""
-import logging
-
from flask_smorest import Blueprint
-from mrmat_python_api_flask.apis.greeting.v1.model import greeting_v1_output, GreetingV1Output
+from .model import GreetingV1, GreetingV1Schema, greeting_v1_schema
-bp = Blueprint('greeting_v1', __name__, description='Greeting V1 API')
-log = logging.getLogger('api')
+bp = Blueprint('greeting_v1',
+ __name__,
+ url_prefix='/',
+ description='Greeting V1 API')
@bp.route('/', methods=['GET'])
-@bp.response(200, GreetingV1Output)
+@bp.response(200, GreetingV1Schema)
@bp.doc(summary='Get an anonymous greeting',
description='This version of the greeting API does not have a means to determine who you are')
def get_greeting():
"""
Receive a Hello World message
- Returns:
- A plain-text hello world message
"""
- log.info('v1/helloworld')
- return greeting_v1_output.dump({'message': 'Hello World'}), 200
+ return greeting_v1_schema.dump(obj=GreetingV1(message='Hello World'))
diff --git a/src/python/mrmat_python_api_flask/apis/greeting/v1/model.py b/src/mrmat_python_api_flask/apis/greeting/v1/model.py
similarity index 72%
rename from src/python/mrmat_python_api_flask/apis/greeting/v1/model.py
rename to src/mrmat_python_api_flask/apis/greeting/v1/model.py
index cd77038..955f6bb 100644
--- a/src/python/mrmat_python_api_flask/apis/greeting/v1/model.py
+++ b/src/mrmat_python_api_flask/apis/greeting/v1/model.py
@@ -22,22 +22,33 @@
"""Greeting API v1 Model"""
-from marshmallow import fields
+import dataclasses
+from marshmallow import fields, post_load
from mrmat_python_api_flask import ma
+@dataclasses.dataclass
+class GreetingV1:
+ message: str = dataclasses.field(default='Hello World')
-class GreetingV1Output(ma.Schema):
+class GreetingV1Schema(ma.Schema):
+ """
+ The GreetingV1 Output Schema
+ """
class Meta:
fields = ('message',)
message = fields.Str(
required=True,
- dump_only=True,
- metadata={
- 'description': 'A greeting message'
+ dump_default='Hello World',
+ metadata = {
+ 'description': 'A generic greeting message'
}
)
+ @post_load
+ def as_object(self, data, **kwargs) -> GreetingV1:
+ return GreetingV1(**data)
-greeting_v1_output = GreetingV1Output()
+
+greeting_v1_schema = GreetingV1Schema()
diff --git a/src/python/mrmat_python_api_flask/apis/greeting/v2/__init__.py b/src/mrmat_python_api_flask/apis/greeting/v2/__init__.py
similarity index 81%
rename from src/python/mrmat_python_api_flask/apis/greeting/v2/__init__.py
rename to src/mrmat_python_api_flask/apis/greeting/v2/__init__.py
index f1e1072..35504d0 100644
--- a/src/python/mrmat_python_api_flask/apis/greeting/v2/__init__.py
+++ b/src/mrmat_python_api_flask/apis/greeting/v2/__init__.py
@@ -23,5 +23,7 @@
"""Pluggable blueprint of the Greeting API v2
"""
-from .model import GreetingV2Output, GreetingV2Input # noqa: F401
-from .api import bp as api_greeting_v2 # noqa: F401
+from .model import (
+ GreetingV2Input, GreetingV2InputSchema, greeting_v2_input_schema,
+ GreetingV2, GreetingV2Schema, greeting_v2_schema)
+from .api import bp as api_greeting_v2 # noqa: F401
diff --git a/src/python/mrmat_python_api_flask/apis/greeting/v2/api.py b/src/mrmat_python_api_flask/apis/greeting/v2/api.py
similarity index 77%
rename from src/python/mrmat_python_api_flask/apis/greeting/v2/api.py
rename to src/mrmat_python_api_flask/apis/greeting/v2/api.py
index d3337ba..f2f5e64 100644
--- a/src/python/mrmat_python_api_flask/apis/greeting/v2/api.py
+++ b/src/mrmat_python_api_flask/apis/greeting/v2/api.py
@@ -25,27 +25,27 @@
"""
from flask_smorest import Blueprint
-from .model import greeting_v2_output, GreetingV2Output, GreetingV2Input
+from .model import (
+ GreetingV2Input, GreetingV2InputSchema, greeting_v2_input_schema,
+ GreetingV2, GreetingV2Schema, greeting_v2_schema
+)
bp = Blueprint('greeting_v2', __name__, description='Greeting V2 API')
@bp.route('/', methods=['GET'])
-@bp.arguments(GreetingV2Input,
+@bp.arguments(GreetingV2InputSchema,
description='The name to greet',
location='query',
required=False)
-@bp.response(200, GreetingV2Output)
+@bp.response(200, GreetingV2Schema)
@bp.doc(summary='Get a greeting for a given name',
description='This version of the greeting API allows you to specify who to greet')
-def get(greeting_input):
+def get(greeting_input: GreetingV2Input) -> GreetingV2Schema:
"""
Get a named greeting
Returns:
A named greeting in JSON
- ---
- It is possible to place logic here like we do for safe_name, but if we parse
- the GreetingV2Input via MarshMallow then we can also set a 'default' or 'missing' there.
"""
- safe_name: str = greeting_input['name'] or 'World'
- return greeting_v2_output.dump({'message': f'Hello {safe_name}'}), 200
+ safe_name: str = greeting_input.name or 'Stranger'
+ return greeting_v2_schema.dump(obj=GreetingV2(message=f'Hello {safe_name}'))
diff --git a/src/python/mrmat_python_api_flask/apis/__init__.py b/src/mrmat_python_api_flask/apis/greeting/v2/model.py
similarity index 55%
rename from src/python/mrmat_python_api_flask/apis/__init__.py
rename to src/mrmat_python_api_flask/apis/greeting/v2/model.py
index d5a36d1..52f0276 100644
--- a/src/python/mrmat_python_api_flask/apis/__init__.py
+++ b/src/mrmat_python_api_flask/apis/greeting/v2/model.py
@@ -20,55 +20,59 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-"""
-Code that can be re-used by all APIs
-"""
+"""Greeting API v2 Model"""
-from typing import Optional
-from marshmallow import fields
+import dataclasses
+from marshmallow import fields, post_load
from mrmat_python_api_flask import ma
+@dataclasses.dataclass
+class GreetingV2Input:
+ name: str = dataclasses.field(default='Stranger')
-class StatusOutputSchema(ma.Schema):
+class GreetingV2InputSchema(ma.Schema):
"""
- A schema for a generic status message returned via HTTP
+ The GreetingV2 Input Schema
"""
class Meta:
- fields = ('code', 'message')
+ fields: ('name',)
- code = fields.Int(
- default=200,
+ name = fields.Str(
+ required=False,
+ load_only=True,
+ load_default='Stranger',
metadata={
- 'description': 'An integer status code which will typically match the HTTP status code'
+ 'description': 'The optional name to greet'
}
)
+
+ @post_load
+ def as_object(self, data, **kwargs) -> GreetingV2Input:
+ return GreetingV2Input(**data)
+
+@dataclasses.dataclass
+class GreetingV2:
+ message: str = dataclasses.field(default='Hello Stranger')
+
+class GreetingV2Schema(ma.Schema):
+ """
+ The GreetingV2 Output Schema
+ """
+ class Meta:
+ fields = ('message',)
+
message = fields.Str(
required=True,
- dump_only=True,
metadata={
- 'description': 'A human-readable message'
+ 'description': 'A customizable greeting message'
}
)
- def __init__(self, code: Optional[int] = 200, message: Optional[str] = 'OK'):
- super().__init__()
- self.code = code
- self.message = message
-
-
-status_output = StatusOutputSchema()
+ @post_load
+ def as_object(self, data, **kwargs) -> GreetingV2:
+ return GreetingV2(**data)
-def status(code: Optional[int] = 200, message: Optional[str] = 'OK') -> dict:
- """
- A utility to return a standardised HTTP status message
- Args:
- code: Status code, typically matches the HTTP status code
- message: Human-readable message
-
- Returns:
- A dict to be rendered into JSON
- """
- status_message = StatusOutputSchema(code=code, message=message)
- return status_output.dump(status_message)
+greeting_v2_input_schema = GreetingV2InputSchema()
+greeting_v2_schema = GreetingV2Schema()
diff --git a/src/python/mrmat_python_api_flask/apis/greeting/v3/__init__.py b/src/mrmat_python_api_flask/apis/greeting/v3/__init__.py
similarity index 85%
rename from src/python/mrmat_python_api_flask/apis/greeting/v3/__init__.py
rename to src/mrmat_python_api_flask/apis/greeting/v3/__init__.py
index 069084e..4b75c3e 100644
--- a/src/python/mrmat_python_api_flask/apis/greeting/v3/__init__.py
+++ b/src/mrmat_python_api_flask/apis/greeting/v3/__init__.py
@@ -23,4 +23,5 @@
"""Pluggable blueprint of the Greeting API v3
"""
-from .api import bp as api_greeting_v3 # noqa: F401
+from .model import GreetingV3, GreetingV3OutputSchema, greeting_v3_output_schema # noqa: F401
+from .api import bp as api_greeting_v3 # noqa: F401
diff --git a/src/python/mrmat_python_api_flask/apis/greeting/v3/api.py b/src/mrmat_python_api_flask/apis/greeting/v3/api.py
similarity index 75%
rename from src/python/mrmat_python_api_flask/apis/greeting/v3/api.py
rename to src/mrmat_python_api_flask/apis/greeting/v3/api.py
index 2821ed8..e47e6da 100644
--- a/src/python/mrmat_python_api_flask/apis/greeting/v3/api.py
+++ b/src/mrmat_python_api_flask/apis/greeting/v3/api.py
@@ -26,23 +26,29 @@
from flask import g
from flask_smorest import Blueprint
+from authlib.integrations.flask_oauth2 import ResourceProtector, current_token
+from authlib.oauth2.rfc6750 import BearerTokenValidator
-from mrmat_python_api_flask import oidc
-from .model import greeting_v3_output, GreetingV3Output
+from .model import GreetingV3, GreetingV3OutputSchema, greeting_v3_output_schema
bp = Blueprint('greeting_v3', __name__, description='Greeting V3 API')
+require_oauth = ResourceProtector()
+require_oauth.register_token_validator(BearerTokenValidator())
@bp.route('/', methods=['GET'])
-@bp.response(200, schema=GreetingV3Output)
+@bp.response(200, schema=GreetingV3OutputSchema)
@bp.doc(summary='Get a greeting for the authenticated name',
description='This version of the greeting API knows who you are',
security=[{'openId': ['profile']}])
-@oidc.accept_token(require_token=True)
+@require_oauth('openid')
def get():
"""
Get a named greeting for the authenticated user
Returns:
A named greeting in JSON
"""
- return greeting_v3_output.dump(dict(message=f'Hello {g.oidc_token_info["username"]}')), 200
+ name = current_token.name
+ return greeting_v3_output_schema.dump(
+ GreetingV3(message=f'Hello {g.oidc_token_info["username"]}')
+ ), 200
diff --git a/src/python/mrmat_python_api_flask/apis/greeting/v3/model.py b/src/mrmat_python_api_flask/apis/greeting/v3/model.py
similarity index 83%
rename from src/python/mrmat_python_api_flask/apis/greeting/v3/model.py
rename to src/mrmat_python_api_flask/apis/greeting/v3/model.py
index a543ea7..e1659c4 100644
--- a/src/python/mrmat_python_api_flask/apis/greeting/v3/model.py
+++ b/src/mrmat_python_api_flask/apis/greeting/v3/model.py
@@ -22,22 +22,33 @@
"""Greeting API v3 Model"""
+import dataclasses
from marshmallow import fields
from mrmat_python_api_flask import ma
-class GreetingV3Output(ma.Schema):
+@dataclasses.dataclass
+class GreetingV3:
+ """
+ A dataclass containing the v3 greeting
+ """
+ message: str
+
+
+class GreetingV3OutputSchema(ma.Schema):
+ """
+ The GreetingV3 OutputSchema
+ """
class Meta:
fields = ('message',)
message = fields.Str(
required=True,
- dump_only=True,
metadata={
'description': 'The message returned'
}
)
-greeting_v3_output = GreetingV3Output()
+greeting_v3_output_schema = GreetingV3OutputSchema()
diff --git a/src/python/mrmat_python_api_flask/apis/healthz/__init__.py b/src/mrmat_python_api_flask/apis/healthz/__init__.py
similarity index 85%
rename from src/python/mrmat_python_api_flask/apis/healthz/__init__.py
rename to src/mrmat_python_api_flask/apis/healthz/__init__.py
index 60b7b26..2e40ca6 100644
--- a/src/python/mrmat_python_api_flask/apis/healthz/__init__.py
+++ b/src/mrmat_python_api_flask/apis/healthz/__init__.py
@@ -23,4 +23,9 @@
"""Pluggable blueprint of the Health API
"""
-from .api import bp as api_healthz # noqa: F401
+from .model import (
+ Healthz, HealthzSchema, healthz_schema,
+ Liveness, LivenessSchema, liveness_schema,
+ Readiness, ReadinessSchema, readiness_schema
+)
+from .api import bp as api_healthz
diff --git a/src/python/mrmat_python_api_flask/apis/healthz/api.py b/src/mrmat_python_api_flask/apis/healthz/api.py
similarity index 56%
rename from src/python/mrmat_python_api_flask/apis/healthz/api.py
rename to src/mrmat_python_api_flask/apis/healthz/api.py
index 5390c42..140305d 100644
--- a/src/python/mrmat_python_api_flask/apis/healthz/api.py
+++ b/src/mrmat_python_api_flask/apis/healthz/api.py
@@ -26,19 +26,48 @@
from flask_smorest import Blueprint
-from mrmat_python_api_flask.apis import status, StatusOutputSchema
+from .model import (
+ Healthz, HealthzSchema, healthz_schema,
+ Liveness, LivenessSchema, liveness_schema,
+ Readiness, ReadinessSchema, readiness_schema
+)
bp = Blueprint('healthz', __name__, description='Health API')
@bp.route('/', methods=['GET'])
-@bp.response(200, StatusOutputSchema)
-@bp.doc(summary='Get an indication of application health',
+@bp.response(200, HealthzSchema)
+@bp.doc(summary='Get an indication of overall application health',
description='Assess application health')
-def get():
+def healthz() -> HealthzSchema:
"""
Respond with the app health status
Returns:
A status response
"""
- return status(code=200, message='OK'), 200
+ return healthz_schema.dump(Healthz(status='OK'))
+
+
+@bp.route('/liveness', methods=['GET'])
+@bp.response(200, LivenessSchema)
+@bp.doc(summary='Get an indication of application liveness',
+ description='Assess application liveness')
+def liveness() -> LivenessSchema:
+ """
+ Respond with the app health status
+ Returns:
+ A status response
+ """
+ return liveness_schema.dump(Liveness(status='OK'))
+
+@bp.route('/readiness', methods=['GET'])
+@bp.response(200, ReadinessSchema)
+@bp.doc(summary='Get an indication of application readiness',
+ description='Assess application liveness')
+def readiness() -> ReadinessSchema:
+ """
+ Respond with the app health status
+ Returns:
+ A status response
+ """
+ return readiness_schema.dump(Readiness(status='OK'))
diff --git a/src/mrmat_python_api_flask/apis/healthz/model.py b/src/mrmat_python_api_flask/apis/healthz/model.py
new file mode 100644
index 0000000..a698b4b
--- /dev/null
+++ b/src/mrmat_python_api_flask/apis/healthz/model.py
@@ -0,0 +1,75 @@
+# MIT License
+#
+# Copyright (c) 2025 MrMat
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import dataclasses
+from marshmallow import fields, post_load
+
+from mrmat_python_api_flask import ma
+
+@dataclasses.dataclass
+class Healthz:
+ status: str = dataclasses.field(default='Unknown')
+
+class HealthzSchema(ma.Schema):
+ status = fields.Str(
+ required=True,
+ metadata={
+ 'description': 'The overall health of the service'
+ })
+
+ @post_load
+ def as_object(self, data, **kwargs) -> Healthz:
+ return Healthz(**data)
+
+@dataclasses.dataclass
+class Liveness:
+ status: str = dataclasses.field(default='Unknown')
+
+class LivenessSchema(ma.Schema):
+ status = fields.Str(
+ required=True,
+ metadata={
+ 'description': 'The liveness of the service'
+ })
+
+ @post_load
+ def as_object(self, data, **kwargs) -> Liveness:
+ return Liveness(**data)
+
+@dataclasses.dataclass
+class Readiness:
+ status: str = dataclasses.field(default='Unknown')
+
+class ReadinessSchema(HealthzSchema):
+ status = fields.Str(
+ required=True,
+ metadata={
+ 'description': 'The readiness of the service'
+ })
+
+ @post_load
+ def as_object(self, data, **kwargs) -> Readiness:
+ return Readiness(**data)
+
+healthz_schema = HealthzSchema()
+liveness_schema = LivenessSchema()
+readiness_schema = ReadinessSchema()
diff --git a/src/python/mrmat_python_api_flask/apis/resource/__init__.py b/src/mrmat_python_api_flask/apis/platform/__init__.py
similarity index 100%
rename from src/python/mrmat_python_api_flask/apis/resource/__init__.py
rename to src/mrmat_python_api_flask/apis/platform/__init__.py
diff --git a/src/python/mrmat_python_api_flask/apis/resource/v1/__init__.py b/src/mrmat_python_api_flask/apis/platform/v1/__init__.py
similarity index 79%
rename from src/python/mrmat_python_api_flask/apis/resource/v1/__init__.py
rename to src/mrmat_python_api_flask/apis/platform/v1/__init__.py
index 52bde28..f15b16f 100644
--- a/src/python/mrmat_python_api_flask/apis/resource/v1/__init__.py
+++ b/src/mrmat_python_api_flask/apis/platform/v1/__init__.py
@@ -23,5 +23,10 @@
"""Pluggable blueprint of the Resource API v1
"""
-from .api import bp as api_resource_v1 # noqa: F401
-from .model import Owner, Resource, OwnerSchema, ResourceSchema # noqa: F401
+from .api import bp as api_platform_v1
+from .model import (
+ OwnerInput, OwnerInputSchema, owner_input_schema,
+ OwnerSchema, owner_schema, owners_schema,
+ ResourceInput, ResourceInputSchema, resource_input_schema,
+ ResourceSchema, resource_schema, resources_schema, Owner, Resource
+)
diff --git a/src/mrmat_python_api_flask/apis/platform/v1/api.py b/src/mrmat_python_api_flask/apis/platform/v1/api.py
new file mode 100644
index 0000000..23b20a9
--- /dev/null
+++ b/src/mrmat_python_api_flask/apis/platform/v1/api.py
@@ -0,0 +1,195 @@
+# MIT License
+#
+# Copyright (c) 2021 MrMat
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+"""Blueprint for the Resource API in V1
+"""
+import uuid
+from typing import Tuple
+
+from flask import g, jsonify
+from flask_smorest import Blueprint
+from sqlalchemy.exc import SQLAlchemyError
+
+from mrmat_python_api_flask import db
+from mrmat_python_api_flask.apis import Status, status_schema
+from .model import (
+ Owner, Resource,
+ OwnerInput, OwnerInputSchema,
+ OwnerSchema, owner_schema, owners_schema,
+ ResourceInput, ResourceInputSchema,
+ ResourceSchema, resource_schema, resources_schema
+)
+
+bp = Blueprint('platform_v1', __name__, description='Platform V1 API')
+
+@bp.errorhandler(SQLAlchemyError)
+def db_error(e):
+ return jsonify(error=str(e)), 500
+
+
+def _extract_identity() -> Tuple:
+ return g.oidc_token_info['client_id'], g.oidc_token_info['username']
+
+
+@bp.route('/resources', methods=['GET'])
+@bp.doc(summary='Get all known resources',
+ description='Returns all currently known resources and their metadata',
+ security=[{'openId': ['mpaflask-read']}])
+@bp.response(200, schema=ResourceSchema(many=True))
+def get_resources():
+ #(client_id, name) = _extract_identity()
+ resources = db.session.query(Resource).all()
+ return resources_schema.dump(
+ [Resource(uid=r.uid, name=r.name, owner_uid=r.owner_uid) for r in resources]
+ ), 200
+
+
+@bp.route('/resources/', methods=['GET'])
+@bp.doc(summary='Get a single resource',
+ description='Return a single resource identified by its resource id',
+ security=[{'openId': ['mpaflask-read']}])
+@bp.response(200, schema=ResourceSchema)
+def get_resource(uid: str):
+ #(client_id, name) = _extract_identity()
+ resource = db.session.get(Resource, uid)
+ if not resource:
+ return status_schema.dump(Status(code=404, msg='No such resource')), 404
+ return resource_schema.dump(resource), 200
+
+
+@bp.route('/resources', methods=['POST'])
+@bp.doc(summary='Create a resource',
+ description='Create a resource owned by the authenticated user',
+ security=[{'openId': ['mpaflask-write']}])
+@bp.arguments(ResourceInputSchema,
+ location='json',
+ required=True,
+ description='The resource to create')
+@bp.response(201, schema=ResourceSchema)
+def create_resource(data: ResourceInput):
+ #(client_id, name) = _extract_identity()
+ resource = Resource(uid=str(uuid.uuid4()), name=data.name, owner_uid=str(data.owner_uid))
+ db.session.add(resource)
+ db.session.commit()
+ return resource_schema.dump(resource), 201
+
+@bp.route('/resources/', methods=['PUT'])
+@bp.doc(summary='Modify a resource',
+ description='Modify a resource owned by the authenticated user',
+ security=[{'openId': ['mpaflask-write']}])
+@bp.arguments(ResourceInputSchema,
+ location='json',
+ required=True,
+ description='The resource with updated contents')
+@bp.response(200, schema=ResourceSchema)
+def modify_resource(data: ResourceInput, uid: str):
+ #(client_id, name) = _extract_identity()
+ resource = db.session.get(Resource, uid)
+ if not resource:
+ return status_schema.dump(Status(code=404, msg='No such resource')), 404
+ resource.name = data.name
+ db.session.add(resource)
+ db.session.commit()
+ return resource_schema.dump(resource), 200
+
+@bp.route('/resources/', methods=['DELETE'])
+@bp.doc(summary='Remove a resource',
+ description='Remove a resource owned by the authenticated user',
+ security=[{'openId': ['mpaflask-write']}])
+def remove_resource(uid: str):
+ #(client_id, name) = _extract_identity()
+ resource = db.session.get(Resource, uid)
+ if not resource:
+ return status_schema.dump(Status(code=410, msg='The resource was already gone')), 410
+ db.session.delete(resource)
+ db.session.commit()
+ return {}, 204
+
+@bp.route('/owners', methods=['GET'])
+@bp.doc(summary='Get all owners',
+ description='Get all currently known owners',
+ security=[{'openId': ['mpaflask-read']}])
+@bp.response(200, schema=OwnerSchema(many=True))
+def get_owners():
+ #(client_id, name) = _extract_identity()
+ owners = db.session.query(Owner).all()
+ return owners_schema.dump([Owner(uid=r.uid, name=r.name) for r in owners]), 200
+
+@bp.route('/owners/', methods=['GET'])
+@bp.doc(summary='Get a single owner',
+ description='Return a single owner identified by its owner id',
+ security=[{'openId': ['mpaflask-read']}])
+@bp.response(200, schema=OwnerSchema)
+def get_owner(uid: str):
+ #(client_id, name) = _extract_identity()
+ owner = db.session.get(Owner, uid)
+ if not owner:
+ return status_schema.dump(Status(code=404, msg='No such owner')), 404
+ return owner_schema.dump(owner), 200
+
+@bp.route('/owners', methods=['POST'])
+@bp.doc(summary='Create an owner',
+ description='Create an owner',
+ security=[{'openId': ['mpaflask-write']}])
+@bp.arguments(OwnerInputSchema,
+ location='json',
+ required=True,
+ description='The owner to create')
+@bp.response(201, schema=OwnerSchema)
+def create_owner(data: OwnerInput):
+ #(client_id, name) = _extract_identity()
+ owner = Owner(uid=str(uuid.uuid4()), name=data.name)
+ db.session.add(owner)
+ db.session.commit()
+ return owner_schema.dump(owner), 201
+
+@bp.route('/owners/', methods=['PUT'])
+@bp.doc(summary='Modify an owner',
+ description='Modify an owner',
+ security=[{'openId': ['mpaflask-write']}])
+@bp.arguments(OwnerInputSchema,
+ location='json',
+ required=True,
+ description='The owner with updated contents')
+@bp.response(200, schema=OwnerSchema)
+def modify_owner(data: OwnerInput, uid: str):
+ #(client_id, name) = _extract_identity()
+ owner = db.session.get(Owner, uid)
+ if not owner:
+ return status_schema.dump(Status(code=404, msg='No such owner')), 404
+ owner.name = data.name
+ db.session.add(owner)
+ db.session.commit()
+ return owner_schema.dump(owner), 200
+
+@bp.route('/owners/', methods=['DELETE'])
+@bp.doc(summary='Remove an owner',
+ description='Remove an owner',
+ security=[{'openId': ['mpaflask-write']}])
+def remove_owner(uid: str):
+ #(client_id, name) = _extract_identity()
+ owner = db.session.get(Owner, uid)
+ if not owner:
+ return status_schema.dump(Status(code=410, msg='The owner was already gone')), 410
+ db.session.delete(owner)
+ db.session.commit()
+ return {}, 204
diff --git a/src/mrmat_python_api_flask/apis/platform/v1/model.py b/src/mrmat_python_api_flask/apis/platform/v1/model.py
new file mode 100644
index 0000000..44e5d42
--- /dev/null
+++ b/src/mrmat_python_api_flask/apis/platform/v1/model.py
@@ -0,0 +1,115 @@
+# MIT License
+#
+# Copyright (c) 2021 MrMat
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import dataclasses
+
+from marshmallow import fields, post_load
+from sqlalchemy import String, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from mrmat_python_api_flask import ma, ORMBase
+
+
+class Owner(ORMBase):
+ __tablename__ = 'owners'
+ __schema__ = 'mrmat-python-api-flask'
+ uid: Mapped[str] = mapped_column(String, primary_key=True)
+
+ client_id: Mapped[str] = mapped_column(String(255), nullable=True, unique=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ resources: Mapped[list["Resource"]] = relationship('Resource', back_populates='owner')
+
+class Resource(ORMBase):
+ __tablename__ = 'resources'
+ __schema__ = 'mrmat-python-api-flask'
+ uid: Mapped[str] = mapped_column(String, primary_key=True)
+ owner_uid: Mapped[str] = mapped_column(String, ForeignKey('owners.uid'), nullable=False)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+
+ owner: Mapped["Owner"] = relationship('Owner', back_populates='resources')
+ __table_args__ = (UniqueConstraint('owner_uid', 'name', name='no_duplicate_names_per_owner'),)
+
+
+class OwnerSchema(ma.SQLAlchemyAutoSchema):
+ class Meta:
+ model = Owner
+
+ uid = ma.auto_field()
+ client_id = ma.auto_field()
+ name = ma.auto_field()
+
+ @post_load
+ def as_object(self, data, **kwargs):
+ return Owner(**data)
+
+@dataclasses.dataclass
+class OwnerInput:
+ name: str
+
+class OwnerInputSchema(ma.Schema):
+ name = fields.Str(
+ required=True,
+ metadata={
+ 'description': 'The owner\'s name'
+ })
+
+ @post_load
+ def as_object(self, data, **kwargs):
+ return OwnerInput(**data)
+
+class ResourceSchema(ma.SQLAlchemyAutoSchema):
+ class Meta:
+ model = Resource
+ include_fk = True
+ uid = ma.auto_field()
+ owner_uid = ma.auto_field()
+ name = ma.auto_field()
+
+ @post_load
+ def as_object(self, data, **kwargs):
+ return Resource(**data)
+
+@dataclasses.dataclass
+class ResourceInput:
+ name: str
+ owner_uid: str
+
+class ResourceInputSchema(ma.Schema):
+ name = fields.String(
+ required=True,
+ metadata={
+ 'description': 'The resource name'
+ })
+ owner_uid = fields.Str(
+ required=True,
+ metadata={
+ 'description': 'The owner UID'
+ })
+
+ @post_load
+ def as_object(self, data, **kwargs):
+ return ResourceInput(**data)
+owner_schema = OwnerSchema()
+owners_schema = OwnerSchema(many=True)
+owner_input_schema = OwnerInputSchema()
+resource_schema = ResourceSchema()
+resources_schema = ResourceSchema(many=True)
+resource_input_schema = ResourceInputSchema()
diff --git a/tests/test_greeting_v3.py b/src/mrmat_python_api_flask/config.py
similarity index 53%
rename from tests/test_greeting_v3.py
rename to src/mrmat_python_api_flask/config.py
index 5695cad..5698b1c 100644
--- a/tests/test_greeting_v3.py
+++ b/src/mrmat_python_api_flask/config.py
@@ -1,6 +1,6 @@
# MIT License
#
-# Copyright (c) 2021 MrMat
+# Copyright (c) 2022 Mathieu Imfeld
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -20,26 +20,28 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-"""
-Tests for the Greeting V3 API
-"""
+import os
+import json
+import secrets
-import pytest
-from flask import Response
-
-@pytest.mark.usefixtures('local_test_infrastructure')
-class TestWithLocalInfrastructure:
+class Config:
"""
- Tests for the Greeting V3 API using locally available infrastructure
+ A class to deal with application configuration
"""
+ secret_key: str = secrets.token_urlsafe(16)
+ db_url: str = 'sqlite:///'
- def test_greeting_v3(self, tmpdir, local_test_infrastructure):
- with local_test_infrastructure.app_client(tmpdir) as client:
- with local_test_infrastructure.user_token() as user_token:
- rv: Response = client.get('/api/greeting/v3/',
- headers={'Authorization': f'Bearer {user_token["access_token"]}'})
- assert rv.status_code == 200
- json_body = rv.get_json()
- assert 'message' in json_body
- assert json_body['message'] == f'Hello {user_token["user_id"]}'
+ @staticmethod
+ def from_context(file: str | None = os.getenv('APP_CONFIG')):
+ runtime_config = Config()
+ if file and os.path.exists(file):
+ with open(file, 'r', encoding='UTF-8') as c:
+ file_config = json.load(c)
+ runtime_config.secret_key = file_config.get('secret_key', secrets.token_urlsafe(16))
+ runtime_config.db_url = file_config.get('db_url', 'sqlite:///')
+ if 'APP_CONFIG_SECRET_KEY' in os.environ:
+ runtime_config.secret_key = os.getenv('APP_CONFIG_SECRET_KEY', secrets.token_urlsafe(16))
+ if 'APP_CONFIG_DB_URL' in os.environ:
+ runtime_config.db_url = os.getenv('APP_CONFIG_DB_URL', '')
+ return runtime_config
diff --git a/mrmat_python_api_flask/apis/healthz/model.py b/src/mrmat_python_api_flask/db.py
similarity index 63%
rename from mrmat_python_api_flask/apis/healthz/model.py
rename to src/mrmat_python_api_flask/db.py
index d3c305b..181efe2 100644
--- a/mrmat_python_api_flask/apis/healthz/model.py
+++ b/src/mrmat_python_api_flask/db.py
@@ -1,6 +1,6 @@
# MIT License
#
-# Copyright (c) 2021 MrMat
+# Copyright (c) 2022 Mathieu Imfeld
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -20,23 +20,21 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-"""Healthz API Model"""
+from functools import lru_cache
-from marshmallow import fields
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session, sessionmaker
-from mrmat_python_api_flask import ma
+from mrmat_python_api_flask import app_config, ORMBase
-class HealthzOutput(ma.Schema):
- class Meta:
- fields = ('status',)
-
- status = fields.Str(
- required=True,
- dump_only=True,
- metadata={
- 'description': 'An indication of application health'
- })
-
-
-healthz_output = HealthzOutput()
+@lru_cache
+def get_db() -> Session:
+ if app_config.db_url.startswith('sqlite'):
+ engine = create_engine(url=app_config.db_url, connect_args={'check_same_thread': False})
+ else:
+ engine = create_engine(url=app_config.db_url)
+ session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+ with session_local():
+ ORMBase.metadata.create_all(bind=engine)
+ return session_local()
diff --git a/src/python/mrmat_python_api_flask/templates/swagger-ui-redirect.html b/src/mrmat_python_api_flask/templates/swagger-ui-redirect.html
similarity index 100%
rename from src/python/mrmat_python_api_flask/templates/swagger-ui-redirect.html
rename to src/mrmat_python_api_flask/templates/swagger-ui-redirect.html
diff --git a/src/python/mrmat_python_api_flask/__init__.py b/src/python/mrmat_python_api_flask/__init__.py
deleted file mode 100644
index 50e0baf..0000000
--- a/src/python/mrmat_python_api_flask/__init__.py
+++ /dev/null
@@ -1,170 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-"""Main entry point when executing this application as a WSGI app
-"""
-
-import sys
-import os
-import json
-import logging.config
-import secrets
-import importlib.metadata
-
-import flask
-from flask import Flask, has_request_context, request
-from flask_sqlalchemy import SQLAlchemy
-from flask_migrate import Migrate
-from flask_marshmallow import Marshmallow
-from flask_oidc import OpenIDConnect
-from flask_smorest import Api
-
-log = logging.getLogger(__name__)
-
-#
-# Establish consistent logging
-
-#
-# Determine the version we're at and a version header we add to each response
-
-try:
- __version__ = importlib.metadata.version('mrmat-python-api-flask')
-except importlib.metadata.PackageNotFoundError:
- # You have not yet installed this as a package, likely because you're hacking on it in some IDE
- __version__ = '0.0.0.dev0'
-
-#
-# Initialize supporting services
-
-db = SQLAlchemy()
-ma = Marshmallow()
-migrate = Migrate()
-oidc = OpenIDConnect()
-api = Api()
-
-
-def create_app(config_override=None, instance_path=None):
- """Factory method to create a Flask app.
-
- Allows configuration overrides by providing the optional test_config dict as well as a `config.py`
- within the instance_path. Will create the instance_path if it does not exist. instance_path is meant
- to be outside the package directory.
-
- Args:
- config_override: Optional dict to override configuration
- instance_path: Optional fully qualified path to instance directory (for configuration etc)
-
- Returns: an initialised Flask app object
-
- """
- app = Flask(__name__, instance_relative_config=True, instance_path=instance_path)
-
- #
- # Set configuration defaults. If a config file is present then load it. If we have overrides, apply them
-
- app.config.setdefault('SQLALCHEMY_DATABASE_URI',
- 'sqlite+pysqlite:///' + os.path.join(app.instance_path, 'mrmat-python-api-flask.sqlite'))
- app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False)
- app.config.setdefault('OIDC_USER_INFO_ENABLED', True)
- app.config.setdefault('OIDC_RESOURCE_SERVER_ONLY', True)
- app.config.setdefault('API_TITLE', 'MrMat :: Python :: API :: Flask')
- app.config.setdefault('API_VERSION', __version__)
- app.config.setdefault('OPENAPI_VERSION', '3.0.2')
- app.config.setdefault('OPENAPI_URL_PREFIX', '/doc')
- app.config.setdefault('OPENAPI_JSON_PATH', '/openapi.json')
- app.config.setdefault('OPENAPI_SWAGGER_UI_PATH', '/swagger-ui')
- app.config.setdefault('OPENAPI_SWAGGER_UI_URL', 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@4.5.0/')
- app_config_file = os.path.expanduser(os.environ.get('APP_CONFIG', '~/etc/mrmat-python-api-flask.json'))
- if os.path.exists(app_config_file):
- log.info('Applying configuration from %s', app_config_file)
- with open(app_config_file, 'r', encoding='UTF-8') as c:
- config = json.load(c)
- app.config.from_object(config)
- #app.config.from_json(app_config_file)
- if config_override is not None:
- for override in config_override:
- log.info('Overriding configuration for %s from the command line', override)
- app.config.from_mapping(config_override)
- if app.config['SECRET_KEY'] is None:
- log.warning('Generating new secret key')
- app.config['SECRET_KEY'] = secrets.token_urlsafe(16)
-
- #
- # Create the instance folder if it does not exist
-
- try:
- if not os.path.exists(app.instance_path):
- log.info('Creating new instance path at %s', app.instance_path)
- os.makedirs(app.instance_path)
- else:
- log.info('Using existing instance path at %s', app.instance_path)
- except OSError:
- log.error('Failed to create new instance path at %s', app.instance_path)
- sys.exit(1)
-
- # When using Flask-SQLAlchemy, there is no need to explicitly import DAO classes because they themselves
- # inherit from the SQLAlchemy model
-
- db.init_app(app)
- migrate.init_app(app, db)
- ma.init_app(app)
- api.init_app(app)
- if 'OIDC_CLIENT_SECRETS' in app.config.keys():
- oidc.init_app(app)
- else:
- log.warning('Running without any authentication/authorisation')
-
- #
- # Security Schemes
-
- api.spec.components.security_scheme('openId', dict(
- type='openIdConnect',
- description='MrMat OIDC',
- openIdConnectUrl='http://localhost:8080/auth/realms/master'
- '/.well-known/openid-configuration'
- ))
-
- #
- # Import and register our APIs here
-
- from mrmat_python_api_flask.apis.healthz import api_healthz # pylint: disable=import-outside-toplevel
- from mrmat_python_api_flask.apis.greeting.v1 import api_greeting_v1 # pylint: disable=import-outside-toplevel
- from mrmat_python_api_flask.apis.greeting.v2 import api_greeting_v2 # pylint: disable=import-outside-toplevel
- from mrmat_python_api_flask.apis.greeting.v3 import api_greeting_v3 # pylint: disable=import-outside-toplevel
- from mrmat_python_api_flask.apis.resource.v1 import api_resource_v1 # pylint: disable=import-outside-toplevel
- api.register_blueprint(api_healthz, url_prefix='/healthz')
- api.register_blueprint(api_greeting_v1, url_prefix='/api/greeting/v1')
- api.register_blueprint(api_greeting_v2, url_prefix='/api/greeting/v2')
- api.register_blueprint(api_greeting_v3, url_prefix='/api/greeting/v3')
- api.register_blueprint(api_resource_v1, url_prefix='/api/resource/v1')
-
- #
- # Postprocess the request
- # Log its result and add our version header to it
-
- @app.after_request
- def after_request(response: flask.Response) -> flask.Response:
- log.info('[%s]', response.status_code)
- response.headers.add('X-MrMat-Python-API-Flask-Version', __version__)
- return response
-
- return app
diff --git a/src/python/mrmat_python_api_flask/apis/resource/v1/api.py b/src/python/mrmat_python_api_flask/apis/resource/v1/api.py
deleted file mode 100644
index 35f231b..0000000
--- a/src/python/mrmat_python_api_flask/apis/resource/v1/api.py
+++ /dev/null
@@ -1,153 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-"""Blueprint for the Resource API in V1
-"""
-
-from typing import Tuple
-
-from werkzeug.local import LocalProxy
-from flask import request, g, current_app
-from flask_smorest import Blueprint
-from marshmallow import ValidationError
-
-from mrmat_python_api_flask import db, oidc
-from mrmat_python_api_flask.apis import status
-from .model import Owner, Resource, ResourceSchema, resource_schema, resources_schema
-
-bp = Blueprint('resource_v1', __name__, description='Resource V1 API')
-logger = LocalProxy(lambda: current_app.logger)
-
-
-def _extract_identity() -> Tuple:
- return g.oidc_token_info['client_id'], \
- g.oidc_token_info['username']
-
-
-@bp.route('/', methods=['GET'])
-@bp.doc(summary='Get all known resources',
- description='Returns all currently known resources and their metadata',
- security=[{'openId': ['mpaf-read']}])
-@bp.response(200, schema=ResourceSchema(many=True))
-@oidc.accept_token(require_token=True, scopes_required=['mpaf-read'])
-def get_all():
- (client_id, name) = _extract_identity()
- logger.info(f'Called by {client_id} for {name}')
- a = Resource.query.all()
- return {'resources': resources_schema.dump(a)}, 200
-
-
-@bp.route('/', methods=['GET'])
-@bp.doc(summary='Get a single resource',
- description='Return a single resource identified by its resource id',
- security=[{'openId': ['mpaf-read']}])
-@bp.response(200, schema=ResourceSchema)
-@oidc.accept_token(require_token=True, scopes_required=['mpaf-read'])
-def get_one(i: int):
- (client_id, name) = _extract_identity()
- logger.info(f'Called by {client_id} for {name}')
- resource = Resource.query.filter(Resource.id == i).one_or_none()
- if resource is None:
- return status(code=404, message='Unable to find a resource with this id'), 404
- return resource_schema.dump(resource), 200
-
-
-@bp.route('/', methods=['POST'])
-@bp.doc(summary='Create a resource',
- description='Create a resource owned by the authenticated user',
- security=[{'openId': ['mpaf-write']}])
-@bp.arguments(ResourceSchema,
- location='json',
- required=True,
- description='The resource to create')
-@bp.response(200, schema=ResourceSchema)
-@oidc.accept_token(require_token=True, scopes_required=['mpaf-write'])
-def create():
- (client_id, name) = _extract_identity()
- logger.info(f'Called by {client_id} for {name}')
- try:
- json_body = request.get_json()
- if not json_body:
- return status(code=400, message='Missing required input data'), 400
- body = resource_schema.load(request.get_json())
- except ValidationError as ve:
- return ve.messages, 422
-
- #
- # Check if we have a resource with the same name and owner already
-
- resource = Resource.query\
- .filter(Resource.name == body['name'] and Resource.owner.client_id == client_id)\
- .one_or_none()
- if resource is not None:
- # TODO: Allow turning this off because it can be used as an enumeration attack
- return status(code=409, message='This resource already exists'), 409
-
- #
- # Look up the owner and create one if necessary
-
- owner = Owner.query.filter(Owner.client_id == client_id).one_or_none()
- if owner is None:
- owner = Owner(client_id=client_id, name=name)
- db.session.add(owner)
-
- resource = Resource(owner=owner, name=body['name'])
- db.session.add(resource)
- db.session.commit()
- return resource_schema.dump(resource), 201
-
-
-@bp.route('/', methods=['PUT'])
-@oidc.accept_token(require_token=True, scopes_required=['mpaf-write'])
-def modify(i: int):
- (client_id, name) = _extract_identity()
- logger.info(f'Called by {client_id} for {name}')
- body = resource_schema.load(request.get_json())
-
- resource = Resource.query.filter(Resource.id == i).one_or_none()
- if resource is None:
- return status(code=404, message='Unable to find a resources with this id'), 404
- if resource.owner.client_id != client_id:
- return status(code=401, message='You are not authorised to modify this resource'), 401
- resource.name = body['name']
-
- db.session.add(resource)
- db.session.commit()
- return resource_schema.dump(resource), 200
-
-
-@bp.route('/', methods=['DELETE'])
-@oidc.accept_token(require_token=True, scopes_required=['mpaf-write'])
-def remove(i: int):
- (client_id, name) = _extract_identity()
- logger.info(f'Called by {client_id} for {name}')
-
- resource = Resource.query.filter(Resource.id == i).one_or_none()
- if resource is None:
- # TODO: Allow turning this off because it can be used as an enumeration attack
- return status(code=410, message='This resource is gone'), 410
- if resource.owner.client_id != client_id:
- return status(code=401, message='You are not authorised to remove this resource'), 401
-
- db.session.delete(resource)
- db.session.commit()
- return {}, 204
diff --git a/src/python/mrmat_python_api_flask/apis/resource/v1/model.py b/src/python/mrmat_python_api_flask/apis/resource/v1/model.py
deleted file mode 100644
index 2dec9e5..0000000
--- a/src/python/mrmat_python_api_flask/apis/resource/v1/model.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-"""Resource API SQLAlchemy model
-"""
-
-from sqlalchemy import ForeignKey, Column, Integer, String, UniqueConstraint, BigInteger
-from sqlalchemy.orm import relationship
-
-from mrmat_python_api_flask import db, ma
-
-
-class Owner(db.Model):
- __tablename__ = 'owners'
- id = Column(BigInteger().with_variant(Integer, 'sqlite'), primary_key=True)
- client_id = Column(String(255), nullable=False, unique=True)
- name = Column(String(255), nullable=False)
- resources = relationship('Resource', back_populates='owner')
-
-
-class Resource(db.Model):
- __tablename__ = 'resources'
- id = Column(BigInteger().with_variant(Integer, 'sqlite'), primary_key=True)
- owner_id = Column(Integer, ForeignKey('owners.id'), nullable=False)
- name = Column(String(255), nullable=False)
-
- owner = relationship('Owner', back_populates='resources')
- UniqueConstraint('owner_id', 'name', name='no_duplicate_names_per_owner')
-
-
-class OwnerSchema(ma.SQLAlchemyAutoSchema):
- class Meta:
- fields = ('id', 'client_id', 'name')
-
-
-class ResourceSchema(ma.SQLAlchemyAutoSchema):
- class Meta:
- fields = ('id', 'owner', 'name')
-
-
-owner_schema = OwnerSchema()
-owners_schema = OwnerSchema(many=True)
-resource_schema = ResourceSchema()
-resources_schema = ResourceSchema(many=True)
diff --git a/src/python/mrmat_python_api_flask/client.py b/src/python/mrmat_python_api_flask/client.py
deleted file mode 100644
index 859966c..0000000
--- a/src/python/mrmat_python_api_flask/client.py
+++ /dev/null
@@ -1,230 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-"""Main entry point of the interactive client application
-"""
-
-import os.path
-import sys
-import json
-from time import sleep
-from argparse import ArgumentParser, Namespace
-from typing import List, Optional, Dict
-import requests
-
-from mrmat_python_api_flask import __version__, log
-
-
-class ClientException(Exception):
- """A simple exception subclass
-
- Just so we can distinguish ourselves from whatever else may be thrown and capture an ultimate exit code and
- error message
- """
- exit_code: int
- msg: str
-
- def __init__(self, exit_code: int = 1, msg: str = 'Unknown Exception'):
- Exception.__init__()
- self.exit_code = exit_code
- self.msg = msg
-
-
-def oidc_discovery(config: Dict) -> Dict:
- resp = requests.get(config['discovery_url'], timeout=60)
- if resp.status_code != 200:
- raise ClientException(exit_code=1, msg=f'Unexpected response {resp.status_code} from discovery endpoint')
- try:
- data = resp.json()
- except ValueError as ve:
- raise ClientException(exit_code=1, msg='Unable to parse response from discovery endpoint into JSON') from ve
- return data
-
-
-def oidc_device_auth(config: Dict, discovery: Dict) -> Dict:
- resp = requests.post(url=discovery['device_authorization_endpoint'],
- data={'client_id': config['client_id'],
- 'client_secret': config['client_secret'],
- 'scope': ['openid', 'profile']},
- timeout=60)
- if resp.status_code != 200:
- raise ClientException(exit_code=1, msg=f'Unexpected response {resp.status_code} from device endpoint')
- try:
- data = resp.json()
- except ValueError as ve:
- raise ClientException(exit_code=1, msg='Unable to parse response from device endpoint into JSON') from ve
- return data
-
-
-def oidc_check_auth(config: Dict, discovery: Dict, device_auth: Dict):
- wait = 5
- stop = False
- while not stop:
- resp = requests.post(url=discovery['token_endpoint'],
- data={'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
- 'device_code': device_auth['device_code'],
- 'client_id': config['client_id'],
- 'client_secret': config['client_secret']},
- timeout=60) # client_secret only for keycloak
- if resp.status_code == 400:
- body = resp.json()
- if body['error'] == 'authorization_pending':
- continue
- elif body['error'] == 'slow_down':
- wait += 5
- continue
- elif body['error'] == 'access_denied':
- raise ClientException(msg='Access denied')
- elif body['error'] == 'expired_token':
- raise ClientException(msg='Token expired')
- else:
- raise ClientException(msg=body['error_description'])
- elif resp.status_code == 200:
- return resp.json()
- log.info('Waiting for %s seconds', wait)
- sleep(wait)
-
-
-def parse_args(argv: List[str]) -> Optional[Namespace]:
- """A dedicated function to parse the command line arguments.
-
- Makes it a lot easier to test CLI parameters.
-
- Args:
- argv: The command line arguments, minus the name of the script
-
- Returns:
- The Namespace object as defined by the argparse module built-in to Python
- """
- parser = ArgumentParser(description=f'mrmat-python-api-flask-client - {__version__}')
- parser.add_argument('-q', '--quiet', action='store_true', dest='quiet', help='Silent Operation')
- parser.add_argument('-d', '--debug', action='store_true', dest='debug', help='Debug')
-
- parser.add_argument('--host',
- dest='host',
- required=False,
- default='localhost',
- help='Host interface to connect to')
- parser.add_argument('--port',
- dest='port',
- required=False,
- default=8080,
- help='Port to connect to')
-
- config_group_file = parser.add_argument_group(title='File Configuration',
- description='Configure the client via a config file')
- config_group_file.add_argument('--config', '-c',
- dest='config',
- required=False,
- default=os.path.expanduser(os.path.join('~',
- 'etc',
- 'mrmat-python-api-flask-client.json')),
- help='Path to the configuration file for the flask client')
-
- config_group_manual = parser.add_argument_group(title='Manual Configuration',
- description='Configure the client manually')
- config_group_manual.add_argument('--client-id',
- dest='client_id',
- required=False,
- help='The client_id of this CLI itself (not yours!)')
- config_group_manual.add_argument('--client-secret',
- dest='client_secret',
- required=False,
- help='The client_secret of the CLI itself. Not required for AAD, required for '
- 'Keycloak')
- config_group_manual.add_argument('--discovery-url',
- dest='discovery_url',
- required=False,
- help='Discovery of endpoints in the authentication platform')
- return parser.parse_args(argv)
-
-
-def main(argv=None) -> int:
- """Main entry point for the CLI
-
- Args:
- argv: The command line arguments. These default to None and if so, the function will fall back to use the
- command line arguments without the application name used to invoke (i.e. sys.argv[1:])
-
- Returns:
- Exit code 0 for success. Any other integer is a failure.
- """
- args = parse_args(argv if argv is not None else sys.argv[1:])
- if args is None:
- return 0
-
- #
- # Read from the config file by default, but allow overrides via the CLI
-
- config = {}
- if os.path.exists(os.path.expanduser(args.config)):
- with open(os.path.expanduser(args.config), encoding='UTF-8') as c:
- config = json.load(c)
- config_override = vars(args)
- for key in config_override.keys() & config_override.keys():
- if config_override[key] is not None:
- config[key] = config_override[key]
-
- try:
- discovery = oidc_discovery(config)
- if 'device_authorization_endpoint' not in discovery:
- raise ClientException(msg='No device_authorization_endpoint in discovery')
- if 'token_endpoint' not in discovery:
- raise ClientException(msg='No token_endpoint in discovery')
-
- device_auth = oidc_device_auth(config, discovery)
- if 'device_code' not in device_auth:
- raise ClientException(msg='No device_code in device auth')
- if 'user_code' not in device_auth:
- raise ClientException(msg='No user_code in device auth')
- if 'verification_uri' not in device_auth:
- raise ClientException(msg='No verification_uri in device_auth')
- if 'expires_in' not in device_auth:
- raise ClientException(msg='No expires_in in device_auth')
-
- # Adding the user code to the URL is convenient, but not as secure as it could be
- log.info('Please visit %s within %s seconds and enter code %s. Or just visit %s',
- device_auth['verification_uri'],
- device_auth['expires_in'],
- device_auth['user_code'],
- device_auth['verification_uri_complete'])
-
- auth = oidc_check_auth(config, discovery, device_auth)
- log.info('Authenticated')
-
- #
- # We're using requests directly here because requests_oauthlib doesn't support device code flow directly
-
- resp = requests.get(f'http://{args.host}:{args.port}/api/greeting/v3/',
- headers={'Authorization': f'Bearer {auth["id_token"]}'},
- timeout=60)
- log.info('Status Code: %s', resp.status_code)
- log.info(resp.content)
-
- return 0
- except ClientException as ce:
- log.error(ce.msg)
- return ce.exit_code
-
-
-if __name__ == '__main__':
- sys.exit(main(sys.argv[1:]))
diff --git a/src/python/mrmat_python_api_flask/cui.py b/src/python/mrmat_python_api_flask/cui.py
deleted file mode 100644
index f1354ef..0000000
--- a/src/python/mrmat_python_api_flask/cui.py
+++ /dev/null
@@ -1,88 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-"""Main entry point when executing this application from the CLI
-"""
-
-import os
-import sys
-import argparse
-
-from flask_migrate import upgrade
-
-from mrmat_python_api_flask import __version__, create_app
-
-
-def main() -> int:
- """
- Main entry point
- :return: Exit code
- """
- parser = argparse.ArgumentParser(description=f'mrmat-python-api-flask - {__version__}')
- parser.add_argument('-d', '--debug', action='store_true', dest='debug', help='Debug')
- parser.add_argument('--host',
- dest='host',
- required=False,
- default='localhost',
- help='Host interface to bind to')
- parser.add_argument('--port',
- dest='port',
- required=False,
- default=8080,
- help='Port to bind to')
- parser.add_argument('--instance-path',
- dest='instance_path',
- required=False,
- default=None,
- help='Fully qualified path to instance directory')
- parser.add_argument('--dsn',
- dest='dsn',
- required=False,
- default=None,
- help='Database DSN')
- parser.add_argument('--oidc-secrets',
- dest='oidc_secrets',
- required=False,
- help='Path to file containing OIDC registration')
-
- args = parser.parse_args()
-
- overrides = {'DEBUG': args.debug}
- if args.oidc_secrets is not None:
- oidc_secrets_config = os.path.expanduser(args.oidc_secrets)
- if not os.path.exists(oidc_secrets_config):
- print(f'ERROR: {oidc_secrets_config} does not exist')
- return 1
- overrides['OIDC_CLIENT_SECRETS'] = oidc_secrets_config
- if args.dsn is not None:
- overrides['SQLALCHEMY_DATABASE_URI'] = args.dsn
-
- app = create_app(config_override=overrides, instance_path=args.instance_path)
- with app.app_context():
- upgrade()
- app.run(host=args.host, port=args.port, debug=args.debug)
-
- return 0
-
-
-if __name__ == '__main__':
- sys.exit(main())
diff --git a/src/python/mrmat_python_api_flask/migrations/README b/src/python/mrmat_python_api_flask/migrations/README
deleted file mode 100644
index 98e4f9c..0000000
--- a/src/python/mrmat_python_api_flask/migrations/README
+++ /dev/null
@@ -1 +0,0 @@
-Generic single-database configuration.
\ No newline at end of file
diff --git a/src/python/mrmat_python_api_flask/migrations/alembic.ini b/src/python/mrmat_python_api_flask/migrations/alembic.ini
deleted file mode 100644
index ec9d45c..0000000
--- a/src/python/mrmat_python_api_flask/migrations/alembic.ini
+++ /dev/null
@@ -1,50 +0,0 @@
-# A generic, single database configuration.
-
-[alembic]
-# template used to generate migration files
-# file_template = %%(rev)s_%%(slug)s
-
-# set to 'true' to run the environment during
-# the 'revision' command, regardless of autogenerate
-# revision_environment = false
-
-
-# Logging configuration
-[loggers]
-keys = root,sqlalchemy,alembic,flask_migrate
-
-[handlers]
-keys = console
-
-[formatters]
-keys = generic
-
-[logger_root]
-level = WARN
-handlers = console
-qualname =
-
-[logger_sqlalchemy]
-level = WARN
-handlers =
-qualname = sqlalchemy.engine
-
-[logger_alembic]
-level = INFO
-handlers =
-qualname = alembic
-
-[logger_flask_migrate]
-level = INFO
-handlers =
-qualname = flask_migrate
-
-[handler_console]
-class = StreamHandler
-args = (sys.stderr,)
-level = NOTSET
-formatter = generic
-
-[formatter_generic]
-format = %(levelname)-5.5s [%(name)s] %(message)s
-datefmt = %H:%M:%S
diff --git a/src/python/mrmat_python_api_flask/migrations/env.py b/src/python/mrmat_python_api_flask/migrations/env.py
deleted file mode 100644
index ee59fb1..0000000
--- a/src/python/mrmat_python_api_flask/migrations/env.py
+++ /dev/null
@@ -1,106 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-from __future__ import with_statement
-
-import logging
-from logging.config import fileConfig
-from alembic import context
-
-from flask import current_app
-
-config = context.config
-fileConfig(config.config_file_name)
-logger = logging.getLogger('alembic.env')
-
-# add your model's MetaData object here
-# for 'autogenerate' support
-# from myapp import mymodel
-# target_metadata = mymodel.Base.metadata
-config.set_main_option(
- 'sqlalchemy.url',
- str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%'))
-target_metadata = current_app.extensions['migrate'].db.metadata
-
-# other values from the config, defined by the needs of env.py,
-# can be acquired:
-# my_important_option = config.get_main_option("my_important_option")
-# ... etc.
-
-
-def run_migrations_offline():
- """Run migrations in 'offline' mode.
-
- This configures the context with just a URL
- and not an Engine, though an Engine is acceptable
- here as well. By skipping the Engine creation
- we don't even need a DBAPI to be available.
-
- Calls to context.execute() here emit the given string to the
- script output.
-
- """
- url = config.get_main_option("sqlalchemy.url")
- context.configure(
- url=url, target_metadata=target_metadata, literal_binds=True
- )
-
- with context.begin_transaction():
- context.run_migrations()
-
-
-def run_migrations_online():
- """Run migrations in 'online' mode.
-
- In this scenario we need to create an Engine
- and associate a connection with the context.
-
- """
-
- # this callback is used to prevent an auto-migration from being generated
- # when there are no changes to the schema
- # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
- def process_revision_directives(context, revision, directives):
- if getattr(config.cmd_opts, 'autogenerate', False):
- script = directives[0]
- if script.upgrade_ops.is_empty():
- directives[:] = []
- logger.info('No changes in schema detected.')
-
- connectable = current_app.extensions['migrate'].db.engine
-
- with connectable.connect() as connection:
- context.configure(
- connection=connection,
- target_metadata=target_metadata,
- process_revision_directives=process_revision_directives,
- **current_app.extensions['migrate'].configure_args
- )
-
- with context.begin_transaction():
- context.run_migrations()
-
-
-if context.is_offline_mode():
- run_migrations_offline()
-else:
- run_migrations_online()
diff --git a/src/python/mrmat_python_api_flask/migrations/script.py.mako b/src/python/mrmat_python_api_flask/migrations/script.py.mako
deleted file mode 100644
index 2c01563..0000000
--- a/src/python/mrmat_python_api_flask/migrations/script.py.mako
+++ /dev/null
@@ -1,24 +0,0 @@
-"""${message}
-
-Revision ID: ${up_revision}
-Revises: ${down_revision | comma,n}
-Create Date: ${create_date}
-
-"""
-from alembic import op
-import sqlalchemy as sa
-${imports if imports else ""}
-
-# revision identifiers, used by Alembic.
-revision = ${repr(up_revision)}
-down_revision = ${repr(down_revision)}
-branch_labels = ${repr(branch_labels)}
-depends_on = ${repr(depends_on)}
-
-
-def upgrade():
- ${upgrades if upgrades else "pass"}
-
-
-def downgrade():
- ${downgrades if downgrades else "pass"}
diff --git a/src/python/mrmat_python_api_flask/migrations/versions/2c4268119c7e_initial.py b/src/python/mrmat_python_api_flask/migrations/versions/2c4268119c7e_initial.py
deleted file mode 100644
index 96546d3..0000000
--- a/src/python/mrmat_python_api_flask/migrations/versions/2c4268119c7e_initial.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""newest
-
-Revision ID: 2c4268119c7e
-Revises:
-Create Date: 2021-12-31 09:46:33.167275
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '2c4268119c7e'
-down_revision = None
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- # ### commands auto generated by Alembic - please adjust! ###
- op.create_table('owners',
- sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
- sa.Column('client_id', sa.String(length=255), nullable=False),
- sa.Column('name', sa.String(length=255), nullable=False),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('client_id')
- )
- op.create_table('resources',
- sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
- sa.Column('owner_id', sa.Integer(), nullable=False),
- sa.Column('name', sa.String(length=255), nullable=False),
- sa.ForeignKeyConstraint(['owner_id'], ['owners.id'], ),
- sa.PrimaryKeyConstraint('id')
- )
- # ### end Alembic commands ###
-
-
-def downgrade():
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_table('resources')
- op.drop_table('owners')
- # ### end Alembic commands ###
diff --git a/tests/conftest.py b/tests/conftest.py
index c4eba45..9d9c4a3 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,6 @@
# MIT License
#
-# Copyright (c) 2021 MrMat
+# Copyright (c) 2025 Mathieu Imfeld
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -20,317 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-"""
-Code available to the entire testsuite
-"""
-
-import os
-import logging
-import json
-import pathlib
-import contextlib
-import abc
-from typing import Optional, List, Dict
-
import pytest
-import secrets
-import psycopg2
-import psycopg2.sql
-import psycopg2.extensions
-
-from flask_migrate import upgrade
-from keycloak.keycloak_admin import KeycloakOpenID, KeycloakAdmin
-from keycloak.exceptions import KeycloakOperationError
-
-from mrmat_python_api_flask import create_app, db
-
-LOGGER = logging.getLogger(__name__)
-
-
-class TIException(Exception):
- """
- An dedicated exception raised when issues occur establishing the test infrastructure
- """
- skip: bool = False
- msg: str = 'An unexpected exception occurred'
-
- def __init__(self, msg: str, skip: Optional[bool] = False):
- super().__init__()
- self.skip = skip
- self.msg = msg
-
-
-class AbstractTestInfrastructure(abc.ABC):
- """
- An abstract class dedicated to any local test infrastructure implementation
- """
-
- _app = None
-
- @abc.abstractmethod
- def app(self):
- pass
-
- @abc.abstractmethod
- def app_client(self):
- pass
-
-
-class NoTestInfrastructure(AbstractTestInfrastructure):
- """
- An implementation of test infrastructure that does not rely on anything (e.g. there is nothing
- available except what we have right here)
- """
-
- @contextlib.contextmanager
- def app(self):
- self._app = create_app({
- 'TESTING': True,
- 'SECRET_KEY': secrets.token_hex(16)
- })
- with self._app.app_context():
- upgrade(directory=os.path.join(os.path.dirname(__file__),
- '..',
- 'src',
- 'python',
- 'mrmat_python_api_flask',
- 'migrations'))
- db.create_all()
- yield self._app
-
- @contextlib.contextmanager
- def app_client(self):
- with self.app() as app:
- yield app.test_client()
-
-
-class LocalTestInfrastructure(object):
- """
- A class for administration of the available local test infrastructure
- """
-
- _ti_config_path: pathlib.Path = None
- _ti_config: Dict = {}
-
- _pg_admin = None
- _keycloak_admin: KeycloakAdmin = None
- _auth_info: Dict = {}
- _app = None
-
- def __init__(self, ti_config_path: pathlib.Path):
- if not ti_config_path.exists():
- raise TIException(skip=True, msg=f'Configuration at {ti_config_path} is not readable or does not exist')
- self._ti_config_path = ti_config_path
- self._ti_config = json.loads(self._ti_config_path.read_text(encoding='UTF-8'))
- if 'pg' not in self._ti_config or 'keycloak' not in self._ti_config:
- raise TIException(skip=True, msg='Missing configuration for local test infrastructure')
- try:
- self._pg_admin = psycopg2.connect(self._ti_config['pg'].get('admin_dsn'))
- self._keycloak_admin = KeycloakAdmin(server_url=self._ti_config['keycloak'].get('admin_url'),
- username=self._ti_config['keycloak'].get('admin_user'),
- password=self._ti_config['keycloak'].get('admin_password'),
- realm_name=self._ti_config['keycloak'].get('admin_realm'))
- except psycopg2.OperationalError as oe:
- raise TIException(skip=True, msg='Failed to obtain an administrative connection to PG') from oe
- except KeycloakOperationError as koe:
- raise TIException(skip=True, msg='Failed to obtain an administrative connection to KeyCloak') from koe
- except Exception as e:
- raise TIException(skip=True, msg='Unknown exception') from e
-
- @contextlib.contextmanager
- def app_dsn(self,
- role: str = 'test',
- password: str = 'test',
- schema: str = 'test',
- drop_finally: bool = False):
- try:
- cur = self._pg_admin.cursor()
- cur.execute('SELECT COUNT(rolname) FROM pg_roles WHERE rolname = %(role_name)s;', {'role_name': role})
- role_count = cur.fetchone()
- if role_count[0] == 0:
- cur.execute(
- psycopg2.sql.SQL('CREATE ROLE {} ENCRYPTED PASSWORD %(password)s LOGIN').format(
- psycopg2.sql.Identifier(role)),
- {'password': password})
- cur.execute(psycopg2.sql.SQL('CREATE SCHEMA IF NOT EXISTS {} AUTHORIZATION {}').format(
- psycopg2.sql.Identifier(schema),
- psycopg2.sql.Identifier(role)))
- cur.execute(psycopg2.sql.SQL('ALTER ROLE {} SET search_path TO {}').format(
- psycopg2.sql.Identifier(role),
- psycopg2.sql.Identifier(schema)))
- self._pg_admin.commit()
- cur.close()
-
- dsn_info = psycopg2.extensions.ConnectionInfo = psycopg2.extensions.parse_dsn(self._ti_config['pg'].
- get('admin_dsn'))
- app_dsn = f"postgresql://{role}:{password}@{dsn_info['host']}:{dsn_info['port']}/{dsn_info['dbname']}"
- yield app_dsn
-
- except psycopg2.Error as e:
- raise TIException(msg=f'Failed to create role {role} on schema {schema}') from e
- finally:
- if drop_finally:
- LOGGER.info('Dropping schema %s and associated role %s', schema, role)
- cur = self._pg_admin.cursor()
- cur.execute(
- psycopg2.sql.SQL('DROP SCHEMA {} CASCADE').format(psycopg2.sql.Identifier(schema)))
- cur.execute(
- psycopg2.sql.SQL('DROP ROLE {}').format(psycopg2.sql.Identifier(role)))
- self._pg_admin.commit()
- cur.close()
-
- @contextlib.contextmanager
- def app_auth(self,
- tmpdir,
- client_id: str = 'test-client',
- ti_id: str = 'ti-client',
- scopes: List[str] = None,
- redirect_uris: List = None,
- drop_finally: bool = False):
- try:
- if scopes is None:
- scopes = []
- # existing_scopes = self._keycloak_admin.get_client_scopes()
- # existing_scopes = [e.name for e in existing_scopes]
- for desired_scope in scopes:
- self._keycloak_admin.create_client_scope(skip_exists=True, payload={
- 'id': desired_scope,
- 'name': desired_scope,
- 'description': f'Test {desired_scope}',
- 'protocol': 'openid-connect'})
- if not self._keycloak_admin.get_client_id(client_id):
- self._keycloak_admin.create_client({
- 'id': client_id,
- 'name': client_id,
- 'publicClient': False,
- 'optionalClientScopes': scopes
- })
- client_secret = self._keycloak_admin.generate_client_secrets(client_id)
- if not self._keycloak_admin.get_client_id(ti_id):
- self._keycloak_admin.create_client({
- 'id': ti_id,
- 'name': ti_id,
- 'publicClient': False,
- 'redirectUris': ['http://localhost'],
- 'directAccessGrantsEnabled': True,
- 'optionalClientScopes': scopes
- })
- ted_secret = self._keycloak_admin.generate_client_secrets(ti_id)
-
- keycloak = KeycloakOpenID(server_url=self._keycloak_admin.server_url,
- client_id=ti_id,
- client_secret_key=ted_secret['value'],
- realm_name='master',
- verify=True)
- discovery = keycloak.well_know()
- with open(f'{tmpdir}/client_secrets.json', 'w', encoding='UTF-8') as cs:
- json.dump({
- 'web': {
- 'client_id': client_id,
- 'client_secret': client_secret['value'],
- 'auth_uri': discovery['authorization_endpoint'],
- 'token_uri': discovery['token_endpoint'],
- 'userinfo_uri': discovery['userinfo_endpoint'],
- 'token_introspection_uri': discovery['introspection_endpoint'],
- 'issuer': discovery['issuer'],
- 'redirect_uris': redirect_uris
- }
- }, cs)
- self._auth_info = {
- 'client_secrets_file': f'{tmpdir}/client_secrets.json',
- 'client_id': client_id,
- 'client_secret': client_secret['value'],
- 'ti_id': ti_id,
- 'ti_secret': ted_secret['value']
- }
- yield self._auth_info
-
- except KeycloakOperationError as koe:
- LOGGER.exception(koe)
- finally:
- if drop_finally:
- LOGGER.info('Deleting client_id %s', client_id)
- self._keycloak_admin.delete_client(client_id)
- LOGGER.info('Deleting client_id %s', ti_id)
- self._keycloak_admin.delete_client(ti_id)
-
- @contextlib.contextmanager
- def app(self,
- tmpdir,
- pg_role: str = 'mpaf-test',
- pg_password: str = 'mpaf-test',
- pg_schema: str = 'mpaf-test',
- drop_finally: bool = False):
- with self.app_dsn(role=pg_role, password=pg_password, schema=pg_schema, drop_finally=drop_finally) as dsn, \
- self.app_auth(tmpdir, scopes=['mpaf-read', 'mpaf-write']) as auth:
- self._app = create_app({
- 'TESTING': True,
- 'SECRET_KEY': secrets.token_hex(16),
- 'SQLALCHEMY_DATABASE_URI': dsn,
- 'OIDC_CLIENT_SECRETS': auth['client_secrets_file']
- })
- with self._app.app_context():
- upgrade(directory=os.path.join(os.path.dirname(__file__), '..', 'var', 'migrations'))
- db.create_all()
- yield self._app
-
- @contextlib.contextmanager
- def app_client(self, app_dir):
- with self.app(app_dir) as app:
- yield app.test_client()
-
- @contextlib.contextmanager
- def user_token(self,
- user_id: str = 'mpaf-test-user',
- user_password: str = 'mpaf-test-user',
- scopes: List[str] = None,
- drop_finally: bool = False):
- try:
- self._keycloak_admin.create_user({
- 'id': user_id,
- 'emailVerified': True,
- 'enabled': True,
- 'firstName': 'Test',
- 'lastName': 'User',
- 'username': user_id,
- 'credentials': [
- {'value': user_password}
- ]
- }, exist_ok=True)
-
- keycloak = KeycloakOpenID(server_url=self._keycloak_admin.server_url,
- client_id=self._auth_info['ti_id'],
- client_secret_key=self._auth_info['ti_secret'],
- realm_name='master',
- verify=True)
- token = keycloak.token(user_id, user_password, scope=scopes)
- token['user_id'] = user_id
- yield token
- finally:
- if drop_finally:
- LOGGER.info('Deleting user %s', user_id)
- self._keycloak_admin.delete_user(user_id)
-
-
-@pytest.fixture(scope='module', autouse=False, )
-def no_test_infrastructure():
- """
- Class-wide fixture for when no test infrastructure is available
- Yields: An initialised NoTestInfrastructure object
- """
- yield NoTestInfrastructure()
-
-
-@pytest.fixture(scope='class', autouse=False)
-def local_test_infrastructure():
- """
- Class-wide fixture to read the configuration of locally available test infrastructure from the `TI_CONFIG`
- environment variable.
-
- Yields: An initialised TI object
+from mrmat_python_api_flask import app
- """
- if 'TI_CONFIG' not in os.environ:
- pytest.skip('There is TI_CONFIG environment variable configuring local infrastructure to test with')
- yield LocalTestInfrastructure(ti_config_path=pathlib.Path(os.path.expanduser(os.getenv('TI_CONFIG'))))
+@pytest.fixture(scope='session')
+def client():
+ app.config.update({'TESTING': True})
+ return app.test_client()
diff --git a/tests/resource_api_client.py b/tests/resource_api_client.py
deleted file mode 100644
index 12c99cf..0000000
--- a/tests/resource_api_client.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-"""
-Resource API test utilities
-"""
-
-from flask import Response
-from flask.testing import FlaskClient
-from typing import Tuple, Dict, Optional
-
-
-class ResourceAPIClient:
- """
- A client class for the resource API
- """
- client: FlaskClient
- token: Dict
-
- _headers: Dict = {}
-
- def __init__(self, client: FlaskClient, token: Optional[Dict]):
- self.client = client
- self.token = token
- if token is not None:
- self._headers = {'Authorization': f'Bearer {token}'}
-
- def get_all(self) -> Tuple:
- resp: Response = self.client.get('/api/resource/v1/', headers=self._headers)
- resp_body = self._parse_body(resp)
- return resp, resp_body
-
- def get_one(self, i: Optional[int]) -> Tuple:
- resp: Response = self.client.get(f'/api/resource/v1/{i}', headers=self._headers)
- resp_body = self._parse_body(resp)
- return resp, resp_body
-
- def create(self, name: str) -> Tuple:
- req_body = {'name': name}
- resp: Response = self.client.post('/api/resource/v1/', json=req_body, headers=self._headers)
- resp_body = self._parse_body(resp)
- return resp, resp_body
-
- def modify(self, i: Optional[int], name: str) -> Tuple:
- req_body = {'name': name}
- resp: Response = self.client.put(f'/api/resource/v1/{i}', json=req_body, headers=self._headers)
- resp_body = self._parse_body(resp)
- return resp, resp_body
-
- def remove(self, i: Optional[int]) -> Tuple:
- resp: Response = self.client.delete(f'/api/resource/v1/{i}', headers=self._headers)
- resp_body = self._parse_body(resp)
- return resp, resp_body
-
- @staticmethod
- def _parse_body(resp: Optional[Response]) -> Dict:
- # TODO: silent will return None if parsing fails. We may wish to know if the JSON is invalid vs no body/mime
- body = resp.get_json(silent=True)
- return body
diff --git a/tests/test_greeting.py b/tests/test_greeting.py
new file mode 100644
index 0000000..ff57185
--- /dev/null
+++ b/tests/test_greeting.py
@@ -0,0 +1,51 @@
+# MIT License
+#
+# Copyright (c) 2025 Mathieu Imfeld
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import pytest
+import flask.testing
+
+from mrmat_python_api_flask.apis.greeting.v1 import GreetingV1, greeting_v1_schema
+from mrmat_python_api_flask.apis.greeting.v2 import (
+ GreetingV2, greeting_v2_schema,
+)
+
+def test_greeting_v1(client: flask.testing.Client):
+ response = client.get("/api/greeting/v1/")
+ assert response.status_code == 200
+ greeting = greeting_v1_schema.load(response.json)
+ assert isinstance(greeting, GreetingV1)
+ assert greeting.message == 'Hello World'
+
+def test_greeting_v2(client: flask.testing.Client):
+ response = client.get("/api/greeting/v2/")
+ assert response.status_code == 200
+ greeting = greeting_v2_schema.load(response.json)
+ assert isinstance(greeting, GreetingV2)
+ assert greeting.message == 'Hello Stranger'
+
+@pytest.mark.parametrize('name', ['MrMat', 'Chris', 'Michal', 'Alexandre', 'Jerome'])
+def test_greeting_v2_custom(client: flask.testing.Client, name: str):
+ response = client.get("/api/greeting/v2/", query_string={'name': name})
+ assert response.status_code == 200
+ greeting = greeting_v2_schema.load(response.json)
+ assert isinstance(greeting, GreetingV2)
+ assert greeting.message == f'Hello {name}'
diff --git a/tests/test_greeting_v1.py b/tests/test_greeting_v1.py
deleted file mode 100644
index b4fbecb..0000000
--- a/tests/test_greeting_v1.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-"""
-Tests for the greeting v1 API
-"""
-
-import pytest
-
-from flask import Response
-
-
-@pytest.mark.usefixtures('no_test_infrastructure')
-class TestWithoutInfrastructure:
-
- def test_greeting_v1(self, no_test_infrastructure):
- with no_test_infrastructure.app_client() as client:
- rv: Response = client.get('/api/greeting/v1/')
- json_body = rv.get_json()
- assert 'message' in json_body
- assert json_body['message'] == 'Hello World'
diff --git a/tests/test_greeting_v2.py b/tests/test_greeting_v2.py
deleted file mode 100644
index 2b46f43..0000000
--- a/tests/test_greeting_v2.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-"""
-Tests for the Greeting V2 API
-"""
-
-import pytest
-
-from flask import Response
-
-
-@pytest.mark.usefixtures('no_test_infrastructure')
-class TestWithoutInfrastructure:
-
- def test_greeting_v2(self, no_test_infrastructure):
- with no_test_infrastructure.app_client() as client:
- rv: Response = client.get('/api/greeting/v2/?name=MrMat')
- json_body = rv.get_json()
- assert 'message' in json_body
- assert json_body['message'] == 'Hello MrMat'
diff --git a/tests/test_healthz.py b/tests/test_healthz.py
index 1fc3cb6..3b89522 100644
--- a/tests/test_healthz.py
+++ b/tests/test_healthz.py
@@ -1,6 +1,6 @@
# MIT License
#
-# Copyright (c) 2021 MrMat
+# Copyright (c) 2025 Mathieu Imfeld
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -20,22 +20,30 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-"""
-Tests for the Healthz API
-"""
+import flask.testing
+from mrmat_python_api_flask.apis.healthz.api import (
+ Healthz, healthz_schema,
+ Liveness, liveness_schema,
+ Readiness, readiness_schema,
+)
-import pytest
+def test_healthz(client: flask.testing.Client):
+ response = client.get("/api/healthz/")
+ assert response.status_code == 200
+ healthz = healthz_schema.load(data=response.json)
+ assert isinstance(healthz, Healthz)
+ assert healthz.status == 'OK'
-from flask import Response
+def test_liveness(client: flask.testing.Client):
+ response = client.get("/api/healthz/liveness")
+ assert response.status_code == 200
+ liveness = liveness_schema.load(data=response.json)
+ assert isinstance(liveness, Liveness)
+ assert liveness.status == 'OK'
-
-@pytest.mark.usefixtures('no_test_infrastructure')
-class TestWithoutInfrastructure:
-
- def test_healthz(self, no_test_infrastructure):
- with no_test_infrastructure.app_client() as client:
- rv: Response = client.get('/healthz/')
- json_body = rv.get_json()
- assert rv.status_code == 200
- assert json_body['code'] == 200
- assert json_body['message'] == 'OK'
+def test_readiness(client: flask.testing.Client):
+ response = client.get("/api/healthz/readiness")
+ assert response.status_code == 200
+ readiness = readiness_schema.load(data=response.json)
+ assert isinstance(readiness, Readiness)
+ assert readiness.status == 'OK'
diff --git a/tests/test_platform.py b/tests/test_platform.py
new file mode 100644
index 0000000..023c3e6
--- /dev/null
+++ b/tests/test_platform.py
@@ -0,0 +1,121 @@
+# MIT License
+#
+# Copyright (c) 2021 Mathieu Imfeld
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import flask.testing
+
+from mrmat_python_api_flask.apis.platform.v1 import (
+ Owner,
+ OwnerInput, owner_input_schema,
+ owner_schema, owners_schema,
+ Resource,
+ ResourceInput, resource_input_schema,
+ resource_schema, resources_schema
+)
+
+def test_platform_v1(client: flask.testing.Client):
+ response = client.get('/api/platform/v1/owners')
+ assert response.status_code == 200
+ owners = owners_schema.load(response.json, many=True)
+ assert isinstance(owners, list)
+ assert len(owners) == 0
+
+ owner = owner_input_schema.dump(OwnerInput(name='test-owner'))
+ response = client.post('/api/platform/v1/owners', json=owner)
+ assert response.status_code == 201
+ owner_created = owner_schema.load(response.json)
+ assert isinstance(owner_created, Owner)
+ assert owner_created.uid is not None
+
+ response = client.get(f'/api/platform/v1/owners/{owner_created.uid}')
+ assert response.status_code == 200
+ owner_retrieved = owner_schema.load(response.json)
+ assert isinstance(owner_retrieved, Owner)
+ assert owner_created.uid == owner_retrieved.uid
+ assert owner_created.name == owner_retrieved.name
+
+ response = client.get('/api/platform/v1/resources')
+ assert response.status_code == 200
+ resources = resources_schema.load(response.json)
+ assert isinstance(resources, list)
+ assert len(resources) == 0
+
+ resource = resource_input_schema.dump(
+ Resource(name='test-resource', owner_uid=owner_created.uid))
+ response = client.post('/api/platform/v1/resources', json=resource)
+ assert response.status_code == 201
+ resource_created = resource_schema.load(response.json)
+ assert isinstance(resource_created, Resource)
+ assert resource_created.uid is not None
+ assert resource_created.owner_uid == owner_created.uid
+
+ response = client.get(f'/api/platform/v1/resources/{resource_created.uid}')
+ assert response.status_code == 200
+ resource_retrieved = resource_schema.load(response.json)
+ assert isinstance(resource_retrieved, Resource)
+ assert resource_created.uid == resource_retrieved.uid
+ assert resource_created.name == resource_retrieved.name
+ assert resource_created.owner_uid == resource_retrieved.owner_uid
+
+ response = client.get('/api/platform/v1/owners')
+ assert response.status_code == 200
+ owners = owners_schema.load(response.json, many=True)
+ assert isinstance(owners, list)
+ assert len(owners) == 1
+
+ response = client.get('/api/platform/v1/resources')
+ assert response.status_code == 200
+ resources = resources_schema.load(response.json)
+ assert isinstance(resources, list)
+ assert len(resources) == 1
+
+ owner = owner_input_schema.dump(OwnerInput(name='modified-owner'))
+ response = client.put(f'/api/platform/v1/owners/{owner_created.uid}', json=owner)
+ assert response.status_code == 200
+ owner_updated = owner_schema.load(response.json)
+ assert isinstance(owner_updated, Owner)
+ assert owner_updated.uid == owner_created.uid
+ assert owner_updated.name == 'modified-owner'
+
+ response = client.get(f'/api/platform/v1/resources/{resource_created.uid}')
+ assert response.status_code == 200
+ resource_retrieved = resource_schema.load(response.json)
+ assert isinstance(resource_retrieved, Resource)
+ assert resource_retrieved.owner_uid == owner_updated.uid
+
+ resource = resource_input_schema.dump(
+ ResourceInput(name='modified-resource', owner_uid=owner_updated.uid))
+ response = client.put(f'/api/platform/v1/resources/{resource_created.uid}', json=resource)
+ assert response.status_code == 200
+ resource_updated = resource_schema.load(response.json)
+ assert isinstance(resource_updated, Resource)
+ assert resource_updated.uid == resource_created.uid
+ assert resource_updated.owner_uid == owner_updated.uid
+
+ response = client.delete(f'/api/platform/v1/resources/{resource_created.uid}')
+ assert response.status_code == 204
+ response = client.delete(f'/api/platform/v1/resources/{resource_created.uid}')
+ assert response.status_code == 410
+
+ response = client.delete(f'/api/platform/v1/owners/{owner_created.uid}')
+ assert response.status_code == 204
+ response = client.delete(f'/api/platform/v1/owners/{owner_created.uid}')
+ assert response.status_code == 410
diff --git a/tests/test_resource_v1.py b/tests/test_resource_v1.py
deleted file mode 100644
index b8e3d53..0000000
--- a/tests/test_resource_v1.py
+++ /dev/null
@@ -1,133 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-"""
-Tests for the Resource API
-"""
-
-import pytest
-from datetime import datetime
-
-from resource_api_client import ResourceAPIClient
-
-
-@pytest.mark.usefixtures('local_test_infrastructure')
-class TestWithLocalInfrastructure:
- """
- Tests for the Resource API using locally available infrastructure
- """
-
- def test_resource_lifecycle(self, tmpdir, local_test_infrastructure):
- with local_test_infrastructure.app_client(tmpdir) as client:
- with local_test_infrastructure.user_token(scopes=['mpaf-read', 'mpaf-write']) as user_token:
- rac = ResourceAPIClient(client, token=user_token['access_token'])
-
- resource_name = f'Test Resource {datetime.utcnow()}'
- (resp, resp_body) = rac.create(name=resource_name)
- assert resp.status_code == 201
- assert resp_body['id'] is not None
- assert resp_body['name'] == resource_name
- resource_id = resp_body['id']
-
- (resp, resp_body) = rac.get_all()
- assert resp.status_code == 200
- assert 'resources' in resp_body
- assert len(resp_body['resources']) == 0
-
- (resp, resp_body) = rac.get_one(resource_id)
- assert resp.status_code == 200
- assert resp_body['id'] == resource_id
- assert resp_body['name'] == resource_name
-
- resource_name_modified = f'Modified Resource {datetime.utcnow()}'
- (resp, resp_body) = rac.modify(resource_id, name=resource_name_modified)
- assert resp.status_code == 200
- assert resp_body['id'] == resource_id
- assert resp_body['name'] == resource_name_modified
-
- (resp, resp_body) = rac.remove(resource_id)
- assert resp.status_code == 204
- assert resp_body is None
-
- (resp, resp_body) = rac.remove(resource_id)
- assert resp.status_code == 410
- assert resp_body['code'] == 410
- assert resp_body['message'] == 'The requested resource is permanently deleted'
-
- def test_insufficient_scope(self, tmpdir, local_test_infrastructure):
- with local_test_infrastructure.app_client(tmpdir) as client:
- with local_test_infrastructure.user_token(scopes=['mpaf-read']) as user_token:
- rac = ResourceAPIClient(client, token=user_token['access_token'])
-
- (resp, resp_body) = rac.create(name='Unauthorised')
- assert resp.status_code == 401
- assert resp_body == {}
-
- (resp, resp_body) = rac.modify(1, name='Unauthorised')
- assert resp.status_code == 401
-
- (resp, resp_body) = rac.remove(1)
- assert resp.status_code == 401
- assert resp_body is None
-
- def test_duplicate_creation_fails(self, tmpdir, local_test_infrastructure):
- with local_test_infrastructure.app_client(tmpdir) as client:
- with local_test_infrastructure.user_token(scopes=['mpaf-read', 'mpaf-write']) as user_token:
- rac = ResourceAPIClient(client, token=user_token['access_token'])
-
- resource_name = f'Test Resource {datetime.utcnow()}'
- (resp, resp_body) = rac.create(name=resource_name)
- assert resp.status_code == 201
- assert resp_body['id'] is not None
- assert resp_body['name'] == resource_name
-
- (resp, resp_body) = rac.create(name=resource_name)
- assert resp.status_code == 409
- assert resp_body['code'] == 409
- assert resp_body['message'] == 'This resource already exists'
-
- def test_ownership_is_maintained(self, tmpdir, local_test_infrastructure):
- with local_test_infrastructure.app_client(tmpdir) as client, \
- local_test_infrastructure.user_token(user_id='test-user1', scopes=['mpaf-read', 'mpaf-write']) as token1, \
- local_test_infrastructure.user_token(user_id='test-user2', scopes=['mpaf-read', 'mpaf-write']) as token2:
-
- rac1 = ResourceAPIClient(client, token=token1['access_token'])
- rac2 = ResourceAPIClient(client, token=token2['access_token'])
-
- resource_name = f'Test Resource {datetime.utcnow()}'
- (resp, resp_body) = rac1.create(name=resource_name)
- assert resp.status_code == 201
-
- (resp, resp_body) = rac2.create(name=resource_name)
- assert resp.status_code == 201
- user2_resource = resp_body['id']
-
- (resp, resp_body) = rac1.modify(user2_resource, name='Test')
- assert resp.status_code == 401
- assert resp_body['code'] == 401
- assert resp_body['message'] == 'You are not authorised to modify this resource'
-
- (resp, resp_body) = rac1.remove(user2_resource)
- assert resp.status_code == 401
- assert resp_body['code'] == 401
- assert resp_body['message'] == 'You are not authorised to remove this resource'
-
diff --git a/var/client-localhost.http b/var/client-localhost.http
new file mode 100644
index 0000000..dd1d009
--- /dev/null
+++ b/var/client-localhost.http
@@ -0,0 +1,30 @@
+### Healthz
+GET http://localhost:5000/api/healthz
+
+### Liveness
+GET http://localhost:5000/api/healthz/liveness
+
+### Readiness
+GET http://localhost:5000/api/healthz/readiness
+
+### Greeting V1 API
+GET http://localhost:5000/api/greeting/v1
+
+### Greeting V2 API
+GET http://localhost:5000/api/greeting/v2
+
+### Greeting V2 API with custom name
+GET http://localhost:5000/api/greeting/v2?name=MrMat
+
+### Platform API V1 - Create an Owner
+POST http://localhost:5000/api/platform/v1/owner
+Content-Type: application/json
+
+{
+ "name": "MrMat"
+}
+
+### Platform API V1 - Get owners
+GET http://localhost:5000/api/platform/v1/owner
+
+
diff --git a/var/container/Dockerfile b/var/container/Dockerfile
new file mode 100644
index 0000000..b902e84
--- /dev/null
+++ b/var/container/Dockerfile
@@ -0,0 +1,20 @@
+FROM python:3.12-alpine AS build
+ARG MRMAT_VERSION="0.0.0.dev0"
+ARG WHEEL=""
+ADD "$WHEEL" /
+RUN pip install --user /mrmat_python_api_flask-*.whl
+
+FROM python:3.12-alpine
+ARG MRMAT_VERSION="0.0.0.dev0"
+LABEL VERSION=$MRMAT_VERSION
+RUN addgroup -g 1000 app && \
+ adduser -g 'App User' -u 1000 -G app -D app
+COPY --from=build /root/.local /home/app/.local
+RUN chown -R 1000:1000 /home/app/.local
+
+USER app:app
+EXPOSE 8000
+#CMD ["/home/app/.local/bin/opentelemetry-instrument", \
+# "/home/app/.local/bin/gunicorn", "--host", "0.0.0.0", "--port", "8000", "mrmat_python_api_flask:app"]
+CMD [ \
+ "/home/app/.local/bin/gunicorn", "--bind", "0.0.0.0:8000", "mrmat_python_api_flask:app"]
\ No newline at end of file
diff --git a/var/docker/Dockerfile b/var/docker/Dockerfile
deleted file mode 100644
index 3cd3a78..0000000
--- a/var/docker/Dockerfile
+++ /dev/null
@@ -1,26 +0,0 @@
-FROM python:3.10.5-slim
-
-USER 0:0
-COPY dist/mrmat_python_api_flask-*.whl /
-RUN \
- groupadd -g 1000 flask \
- && useradd -u 1000 -g 1000 -c "Flask User" -M flask \
- && mkdir -p /app/instance \
- && chown -R 1000:1000 /app /app/instance
-
-USER 1000:1000
-ENV FLASK_APP=mrmat_python_api_flask
-RUN \
- python -mvenv /app/venv \
- && . /app/venv/bin/activate \
- && python -m pip install --no-cache-dir -U pip setuptools wheel \
- && pip install --no-cache-dir gunicorn \
- && pip install --no-cache-dir /mrmat_python_api_flask-*.whl
-
-USER 0:0
-RUN \
- rm -rf /mrmat_python_api_flask-*.whl
-
-EXPOSE 8080
-USER 1000:1000
-ENTRYPOINT /app/venv/bin/gunicorn -w 4 -b 0.0.0.0:8080 --error-logfile /app/instance/error.log --access-logfile /app/instance/access.log 'mrmat_python_api_flask:create_app()'
diff --git a/var/helm/.helmignore b/var/helm/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/var/helm/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/var/helm/Chart.yaml b/var/helm/Chart.yaml
new file mode 100644
index 0000000..e938cd9
--- /dev/null
+++ b/var/helm/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: mrmat-python-api-flask
+description: A Helm chart for MrMat Python API Flask
+type: application
+version: "0.0.0"
+appVersion: "0.0.0.dev0"
diff --git a/var/helm/templates/NOTES.txt b/var/helm/templates/NOTES.txt
new file mode 100644
index 0000000..061061d
--- /dev/null
+++ b/var/helm/templates/NOTES.txt
@@ -0,0 +1 @@
+# MrMat :: Python :: API :: Flask installed
diff --git a/var/helm/templates/_helpers.tpl b/var/helm/templates/_helpers.tpl
new file mode 100644
index 0000000..153ebaf
--- /dev/null
+++ b/var/helm/templates/_helpers.tpl
@@ -0,0 +1,8 @@
+
+{{/* Common labels */}}
+{{ define "common.labels" }}
+app: mpaflask
+version: {{ .Chart.AppVersion }}
+app.kubernetes.io/part-of: mpaflask
+app.kubernetes.io/version: {{ .Chart.AppVersion }}
+{{ end }}
diff --git a/var/helm/templates/deployment.yaml b/var/helm/templates/deployment.yaml
new file mode 100644
index 0000000..e2ec8d2
--- /dev/null
+++ b/var/helm/templates/deployment.yaml
@@ -0,0 +1,103 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: mpaflask
+ labels:
+ app.kubernetes.io/name: mpaflask
+ {{ include "common.labels" . | nindent 4 }}
+spec:
+ replicas: {{ .Values.pod.replicas | int }}
+ selector:
+ matchLabels:
+ {{ include "common.labels" . | nindent 6 }}
+ template:
+ metadata:
+ labels:
+ {{ include "common.labels" . | nindent 8 }}
+ annotations:
+ prometheus.io/scrape: "true"
+ prometheus.io/scheme: http
+ prometheus.io/port: "8000"
+ prometheus.io/path: /metrics
+ spec:
+ serviceAccountName: {{ .Values.sa.name }}
+ volumes:
+ - name: config-volume
+ secret:
+ secretName: config-mpaflask
+ - name: data-volume
+ emptyDir:
+ sizeLimit: 10Mi
+ medium: Memory
+ - name: tmp-volume
+ emptyDir:
+ sizeLimit: 1Mi
+ - name: var-volume
+ emptyDir:
+ sizeLimit: 1Mi
+ containers:
+ - name: mpaflask
+ image: {{ .Values.pod.repository }}:{{ .Chart.AppVersion }}
+ imagePullPolicy: {{ .Values.pod.imagePullPolicy }}
+ env:
+ - name: APP_CONFIG
+ value: /config/app_config.json
+ - name: OTEL_SERVICE_NAME
+ value: "mrmat-python-api-flask"
+ - name: OTEL_TRACES_EXPORTER
+ value: "otlp"
+ - name: OTEL_METRICS_EXPORTER
+ value: "none"
+ - name: OTEL_LOGS_EXPORTER
+ value: "none"
+ - name: OTEL_EXPORTER_OTLP_ENDPOINT
+ value: "jaeger-collector.stack.svc.cluster.local:4317"
+ - name: OTEL_EXPORTER_OTLP_INSECURE
+ value: "true"
+ - name: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
+ value: "jaeger-collector.stack.svc.cluster.local:4317"
+ - name: OTEL_EXPORTER_OTLP_TRACES_INSECURE
+ value: "true"
+ ports:
+ - name: http
+ containerPort: {{ .Values.pod.port }}
+ protocol: TCP
+ volumeMounts:
+ - name: config-volume
+ mountPath: /config
+ readOnly: true
+ - name: data-volume
+ mountPath: /data
+ - name: tmp-volume
+ mountPath: /tmp
+ - name: var-volume
+ mountPath: /home/app/.local/var
+ securityContext:
+ capabilities:
+ drop:
+ - ALL
+ readOnlyRootFilesystem: true
+ runAsNonRoot: true
+ runAsUser: 1000
+ livenessProbe:
+ periodSeconds: 10
+ httpGet:
+ path: /api/healthz/liveness/
+ port: {{ .Values.pod.port }}
+ scheme: HTTP
+ readinessProbe:
+ periodSeconds: 10
+ httpGet:
+ path: /api/healthz/readiness/
+ port: {{ .Values.pod.port }}
+ scheme: HTTP
+ affinity:
+ podAntiAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ - topologyKey: "kubernetes.io/hostname"
+ labelSelector:
+ matchExpressions:
+ - key: app
+ operator: In
+ values:
+ - {{ .Values.pod.name }}
diff --git a/var/helm/templates/route.yaml b/var/helm/templates/route.yaml
new file mode 100644
index 0000000..9a7a047
--- /dev/null
+++ b/var/helm/templates/route.yaml
@@ -0,0 +1,31 @@
+{{- if .Values.route.enabled -}}
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+ name: {{ .Values.route.name }}
+ labels:
+ {{ include "common.labels" . | nindent 4 }}
+spec:
+ hostnames:
+ {{- range .Values.route.hostnames }}
+ - {{ . | quote }}
+ {{- end }}
+ parentRefs:
+ {{- range .Values.route.parents }}
+ - group: gateway.networking.k8s.io
+ kind: Gateway
+ name: {{ .name }}
+ namespace: {{ .namespace }}
+ sectionName: mpaflask
+ {{- end }}
+ rules:
+ - backendRefs:
+ - kind: Service
+ name: {{ .Values.svc.name }}
+ port: {{ .Values.svc.port }}
+ weight: 1
+ matches:
+ - path:
+ type: PathPrefix
+ value: /
+{{- end -}}
diff --git a/var/helm/templates/secret.yaml b/var/helm/templates/secret.yaml
new file mode 100644
index 0000000..85ad56a
--- /dev/null
+++ b/var/helm/templates/secret.yaml
@@ -0,0 +1,10 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: config-mpaflask
+ labels:
+ app.kubernetes.io/name: config-mpaflask
+ {{ include "common.labels" . | nindent 4 }}
+stringData:
+ app_config.json: |-
+ { "db_url": {{ .Values.config.db_url | quote }} }
diff --git a/var/helm/templates/service.yaml b/var/helm/templates/service.yaml
new file mode 100644
index 0000000..4738ee5
--- /dev/null
+++ b/var/helm/templates/service.yaml
@@ -0,0 +1,16 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Values.svc.name }}
+ labels:
+ {{ include "common.labels" . | nindent 4 }}
+spec:
+ type: ClusterIP
+ ports:
+ - port: {{ .Values.svc.port }}
+ targetPort: {{ .Values.pod.port }}
+ protocol: TCP
+ name: http
+ selector:
+ app: {{ .Values.pod.name }}
+ version: {{ .Chart.AppVersion }}
diff --git a/var/helm/templates/serviceaccount.yaml b/var/helm/templates/serviceaccount.yaml
new file mode 100644
index 0000000..3df429c
--- /dev/null
+++ b/var/helm/templates/serviceaccount.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ .Values.sa.name }}
+ labels:
+ {{ include "common.labels" . | nindent 4 }}
+automountServiceAccountToken: true
diff --git a/var/helm/values.yaml b/var/helm/values.yaml
new file mode 100644
index 0000000..529067e
--- /dev/null
+++ b/var/helm/values.yaml
@@ -0,0 +1,30 @@
+#
+# Default values for MrMat :: Python API Flask
+
+sa:
+ name: sa-mpaflask
+
+pod:
+ name: mpaflask
+ replicas: 1
+ repository: registry:5000/mrmat-python-api-flask
+ imagePullPolicy: Always
+ port: 8000
+
+svc:
+ name: svc-mpaflask
+ port: 80
+
+route:
+ enabled: true
+ name: route-mpaflask
+ hostnames:
+ - mpaflask.covenant.local
+ parents:
+ - name: edge-ingress
+ namespace: edge
+
+config:
+ db_url: "sqlite:///"
+ #db_url: "sqlite:///data/db.sqlite"
+