Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upgrade to Connexion3 #702

Merged
merged 34 commits into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4b4e621
Upgrade to Connexion 3 alpha-6
juhoinkinen Apr 28, 2023
57fcaa9
Make application/x-www-form-urlencoded request with axios
juhoinkinen May 9, 2023
f046c1f
Fix OpenAPI spec for /learn endpoint
juhoinkinen May 9, 2023
6d0a68f
Set content-types in response headers in rest methods
juhoinkinen May 3, 2023
c315633
Adapt tests for rest returning also status codes & content types
juhoinkinen May 10, 2023
e417e02
Adapt tests and fixtures for using Connexion app
juhoinkinen May 10, 2023
9cd6a60
Upgrade to Connexion 3.0.* (from alpha); remove direct Flask dependency
juhoinkinen Nov 15, 2023
5d7ec95
Merge branch 'main' into upgrade-to-connexion3
juhoinkinen Nov 15, 2023
067983b
Drop using flask-cors (TODO use CORSMiddleware)
juhoinkinen Nov 15, 2023
d485a3f
Drop using flask-cors (TODO use CORSMiddleware)
juhoinkinen Nov 15, 2023
9ca8d9a
Merge branch 'main' into upgrade-to-connexion3
juhoinkinen Dec 20, 2023
3902748
Add annif run CLI command for starting uvicorn dev server
juhoinkinen Dec 21, 2023
cb39aa0
Merge branch 'upgrade-to-connexion3' of github.com:NatLibFi/Annif int…
juhoinkinen Dec 21, 2023
aee036f
Require connexion version >= 3.0.5
juhoinkinen Jan 9, 2024
5aa1cf3
Merge branch 'main' into upgrade-to-connexion3
juhoinkinen Jan 9, 2024
a94f707
Merge branch 'main' into upgrade-to-connexion3
juhoinkinen Apr 4, 2024
beac55c
Re-enable CORS
juhoinkinen Apr 4, 2024
556a29a
Exclude fuzzy cases where path parameters contain newline "%0A"
juhoinkinen Apr 5, 2024
f4c28dc
Adapt tests for removed "annif routes" and customized "annif run" com…
juhoinkinen Apr 5, 2024
a0a7246
Fix slow fuzzy test by making it use cxapp and asgi test calls
juhoinkinen Apr 5, 2024
f12ca5e
Test with mocking that run command tries to start up server
juhoinkinen Apr 5, 2024
3703423
Update CustomRequestBodyValidator for Connexion 3 and re-enable its test
juhoinkinen Apr 8, 2024
89f9c3c
Use port 5000 by default like with Connexion 2
juhoinkinen Apr 11, 2024
7c91eba
Remove --env-file and --app options
juhoinkinen Apr 12, 2024
c57cc14
Allow only patch level updates on Connexion versions
juhoinkinen Apr 12, 2024
2f7aaa5
Fix hints for return types
juhoinkinen Apr 12, 2024
ab33375
Add test for empty RequestBody to suggest request
juhoinkinen Apr 12, 2024
60e95f6
Omit line from CodeCov covererage report because this case is tested
juhoinkinen Apr 12, 2024
8a2d0d5
Use the right annotation to exlude line from codecov report
juhoinkinen Apr 12, 2024
fc45493
Use uvicorn workers in docker-compose setup
juhoinkinen Apr 12, 2024
ad464e4
Set env in Dockerfile to make Gunicorn use Uvicorn workers
juhoinkinen Apr 17, 2024
9cc9e64
Merge branch 'main' into upgrade-to-connexion3
juhoinkinen Apr 17, 2024
5f02228
Adapt fixture from merged another PR to this PR
juhoinkinen Apr 17, 2024
e1e5d5a
Remove useless test and condition in validator
juhoinkinen Apr 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,6 @@ RUN groupadd -g 998 annif_user && \
chown -R annif_user:annif_user /annif-projects /Annif/tests/data
USER annif_user

ENV GUNICORN_CMD_ARGS="--worker-class uvicorn.workers.UvicornWorker"

CMD annif
38 changes: 27 additions & 11 deletions annif/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,20 @@
import os.path
from typing import TYPE_CHECKING

from flask import Flask

logging.basicConfig()
logger = logging.getLogger("annif")
logger.setLevel(level=logging.INFO)

import annif.backend # noqa

if TYPE_CHECKING:
from flask.app import Flask
from connexion.apps.flask import FlaskApp


def create_flask_app(config_name: str | None = None) -> Flask:
"""Create a Flask app to be used by the CLI."""
from flask import Flask

_set_tensorflow_loglevel()

Expand All @@ -31,29 +32,41 @@ def create_flask_app(config_name: str | None = None) -> Flask:
return app


def create_app(config_name: str | None = None) -> Flask:
def create_cx_app(config_name: str | None = None) -> FlaskApp:
"""Create a Connexion app to be used for the API."""
# 'cxapp' here is the Connexion application that has a normal Flask app
# as a property (cxapp.app)
import connexion
from flask_cors import CORS
from connexion.datastructures import MediaTypeDict
from connexion.middleware import MiddlewarePosition
from connexion.validators import FormDataValidator, MultiPartFormDataValidator
from starlette.middleware.cors import CORSMiddleware

import annif.registry
from annif.openapi.validation import CustomRequestBodyValidator

specdir = os.path.join(os.path.dirname(__file__), "openapi")
cxapp = connexion.App(__name__, specification_dir=specdir)
cxapp = connexion.FlaskApp(__name__, specification_dir=specdir)
config_name = _get_config_name(config_name)
logger.debug(f"creating connexion app with configuration {config_name}")
cxapp.app.config.from_object(config_name)
cxapp.app.config.from_envvar("ANNIF_SETTINGS", silent=True)

validator_map = {
"body": CustomRequestBodyValidator,
"body": MediaTypeDict(
{
"*/*json": CustomRequestBodyValidator,
"application/x-www-form-urlencoded": FormDataValidator,
"multipart/form-data": MultiPartFormDataValidator,
}
),
}
cxapp.add_api("annif.yaml", validator_map=validator_map)

# add CORS support
CORS(cxapp.app)
cxapp.add_middleware(
CORSMiddleware,
position=MiddlewarePosition.BEFORE_EXCEPTION,
allow_origins=["*"],
)

if cxapp.app.config["INITIALIZE_PROJECTS"]:
annif.registry.initialize_projects(cxapp.app)
Expand All @@ -64,8 +77,11 @@ def create_app(config_name: str | None = None) -> Flask:

cxapp.app.register_blueprint(bp)

# return the Flask app
return cxapp.app
# return the Connexion app
return cxapp


create_app = create_cx_app # Alias to allow starting directly with uvicorn run


def _get_config_name(config_name: str | None) -> str:
Expand Down
28 changes: 20 additions & 8 deletions annif/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,12 @@
logger = annif.logger
click_log.basic_config(logger)


if len(sys.argv) > 1 and sys.argv[1] in ("run", "routes"):
create_app = annif.create_app # Use Flask with Connexion
else:
# Connexion is not needed for most CLI commands, use plain Flask
create_app = annif.create_flask_app

cli = FlaskGroup(create_app=create_app, add_version_option=False)
create_app = annif.create_flask_app
cli = FlaskGroup(
create_app=create_app, add_default_commands=False, add_version_option=False
)
cli = click.version_option(message="%(version)s")(cli)
cli.params = [opt for opt in cli.params if opt.name not in ("env_file", "app")]


@cli.command("list-projects")
Expand Down Expand Up @@ -442,6 +439,21 @@ def run_eval(
)


@cli.command("run")
@click.option("--port", type=int, default=5000)
@click.option("--log-level")
@click_log.simple_verbosity_option(logger, default="ERROR")
def run_app(**kwargs):
"""
Run Annif in server mode for development.
\f
The server is for development purposes only.
"""
kwargs = {k: v for k, v in kwargs.items() if v is not None}
cxapp = annif.create_cx_app()
cxapp.run(**kwargs)


FILTER_BATCH_MAX_LIMIT = 15
OPTIMIZE_METRICS = ["Precision (doc avg)", "Recall (doc avg)", "F1 score (doc avg)"]

Expand Down
4 changes: 3 additions & 1 deletion annif/openapi/annif.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ paths:
responses:
"204":
description: successful operation
content: {}
content:
application/json:
{}
"404":
$ref: '#/components/responses/NotFound'
"503":
Expand Down
35 changes: 12 additions & 23 deletions annif/openapi/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,37 @@
from __future__ import annotations

import logging
from typing import Any

import jsonschema
from connexion import decorators
from connexion.exceptions import BadRequestProblem
from connexion.utils import is_null
from connexion.json_schema import format_error_with_path
from connexion.validators import JSONRequestBodyValidator
from jsonschema.exceptions import ValidationError

logger = logging.getLogger("openapi.validation")


class CustomRequestBodyValidator(decorators.validation.RequestBodyValidator):
class CustomRequestBodyValidator(JSONRequestBodyValidator):
"""Custom request body validator that overrides the default error message for the
'maxItems' validator for the 'documents' property."""
'maxItems' validator for the 'documents' property to prevent logging request body
with the contents of all documents."""

def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

def validate_schema(
self,
data: list | dict,
url: str,
) -> None:
"""Validate the request body against the schema."""

if self.is_null_value_valid and is_null(data):
return None # pragma: no cover

def _validate(self, body: Any) -> dict | None:
try:
self.validator.validate(data)
except jsonschema.ValidationError as exception:
return self._validator.validate(body)
except ValidationError as exception:
if exception.validator == "maxItems" and list(exception.schema_path) == [
"properties",
"documents",
"maxItems",
]:
exception.message = "too many items"

error_path_msg = self._error_path_message(exception=exception)
error_path_msg = format_error_with_path(exception=exception)
logger.error(
"{url} validation error: {error}{error_path_msg}".format(
url=url, error=exception.message, error_path_msg=error_path_msg
),
f"Validation error: {exception.message}{error_path_msg}",
extra={"validator": "body"},
)
raise BadRequestProblem(detail=f"{exception.message}{error_path_msg}")
return None
20 changes: 10 additions & 10 deletions annif/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
from annif.project import Access

if TYPE_CHECKING:
from datetime import datetime

from connexion.lifecycle import ConnexionResponse

from annif.corpus.subject import SubjectIndex
Expand Down Expand Up @@ -43,10 +41,11 @@ def server_error(
)


def show_info() -> dict[str, str]:
def show_info() -> tuple:
"""return version of annif and a title for the api according to OpenAPI spec"""

return {"title": "Annif REST API", "version": importlib.metadata.version("annif")}
result = {"title": "Annif REST API", "version": importlib.metadata.version("annif")}
return result, 200, {"Content-Type": "application/json"}


def language_not_supported_error(lang: str) -> ConnexionResponse:
Expand All @@ -59,15 +58,16 @@ def language_not_supported_error(lang: str) -> ConnexionResponse:
)


def list_projects() -> dict[str, list[dict[str, str | dict | bool | datetime | None]]]:
def list_projects() -> tuple:
"""return a dict with projects formatted according to OpenAPI spec"""

return {
result = {
"projects": [
proj.dump()
for proj in annif.registry.get_projects(min_access=Access.public).values()
]
}
return result, 200, {"Content-Type": "application/json"}


def show_project(
Expand All @@ -79,7 +79,7 @@ def show_project(
project = annif.registry.get_project(project_id, min_access=Access.hidden)
except ValueError:
return project_not_found_error(project_id)
return project.dump()
return project.dump(), 200, {"Content-Type": "application/json"}


def _suggestion_to_dict(
Expand Down Expand Up @@ -124,7 +124,7 @@ def suggest(

if _is_error(result):
return result
return result[0]
return result[0], 200, {"Content-Type": "application/json"}


def suggest_batch(
Expand All @@ -142,7 +142,7 @@ def suggest_batch(
return result
for document_results, document in zip(result, documents):
document_results["document_id"] = document.get("document_id")
return result
return result, 200, {"Content-Type": "application/json"}


def _suggest(
Expand Down Expand Up @@ -214,4 +214,4 @@ def learn(
except AnnifException as err:
return server_error(err)

return None, 204
return None, 204, {"Content-Type": "application/json"}
2 changes: 1 addition & 1 deletion annif/templates/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ <h2 class="mt-4" id="suggestions">Suggested subjects</h2>\
return;
}
var this_ = this;
var formData = new FormData();
var formData = new URLSearchParams();
formData.append('text', this_.text);
formData.append('limit', this_.limit);
this_.loading = true;
Expand Down
2 changes: 1 addition & 1 deletion docs/source/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Subject index administration

N/A

.. click:: flask.cli:run_command
.. click:: annif.cli:run_app
:prog: annif run

**REST equivalent**
Expand Down
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@ classifiers = [
[tool.poetry.dependencies]
python = ">=3.9,<3.12"

connexion = { version = "2.14.2", extras = ["swagger-ui"] }
flask = "2.2.*"
flask-cors = "4.0.*"
connexion = { version = "~3.0.5", extras = ["flask", "uvicorn", "swagger-ui"] }
click = "8.1.*"
click-log = "0.4.*"
joblib = "1.3.*"
Expand Down
25 changes: 15 additions & 10 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,39 @@


@pytest.fixture(scope="module")
def app():
def cxapp():
# make sure the dummy vocab is in place because many tests depend on it
subjfile = os.path.join(os.path.dirname(__file__), "corpora", "dummy-subjects.csv")
app = annif.create_app(config_name="annif.default_config.TestingConfig")
with app.app_context():
cxapp = annif.create_app(config_name="annif.default_config.TestingConfig")
with cxapp.app.app_context():
project = annif.registry.get_project("dummy-en")
# the vocab is needed for both English and Finnish language projects
vocab = annif.corpus.SubjectFileCSV(subjfile)
project.vocab.load_vocabulary(vocab)
return app
return cxapp


@pytest.fixture(scope="module")
def app(cxapp):
return cxapp.app


@pytest.fixture(scope="module")
def app_with_initialize():
app = annif.create_app(config_name="annif.default_config.TestingInitializeConfig")
return app
cxapp = annif.create_app(config_name="annif.default_config.TestingInitializeConfig")
return cxapp.app


@pytest.fixture(scope="module")
@unittest.mock.patch.dict(os.environ, {"ANNIF_PROJECTS_INIT": ".*-fi"})
def app_with_initialize_fi_projects():
app = annif.create_app(config_name="annif.default_config.TestingInitializeConfig")
return app
cxapp = annif.create_app(config_name="annif.default_config.TestingInitializeConfig")
return cxapp.app


@pytest.fixture
def app_client(app):
with app.test_client() as app_client:
def app_client(cxapp):
with cxapp.test_client() as app_client:
yield app_client


Expand Down
24 changes: 9 additions & 15 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1051,25 +1051,19 @@ def test_version_option():
assert result.output.strip() == version.strip()


def test_run():
result = runner.invoke(annif.cli.cli, ["run", "--help"])
@mock.patch("connexion.FlaskApp.run")
def test_run(run):
result = runner.invoke(annif.cli.cli, ["run"])
assert not result.exception
assert result.exit_code == 0
assert "Run a local development server." in result.output


def test_routes_with_flask_app():
# When using plain Flask only the static endpoint exists
result = runner.invoke(annif.cli.cli, ["routes"])
assert re.search(r"static\s+GET\s+\/static\/\<path:filename\>", result.output)
assert not re.search(r"app.home\s+GET\s+\/", result.output)
assert run.called


def test_routes_with_connexion_app():
# When using Connexion all endpoints exist
result = os.popen("python annif/cli.py routes").read()
assert re.search(r"static\s+GET\s+\/static\/<path:filename>", result)
assert re.search(r"app.home\s+GET\s+\/", result)
def test_run_help():
result = runner.invoke(annif.cli.cli, ["run", "--help"])
assert not result.exception
assert result.exit_code == 0
assert "Run Annif in server mode for development." in result.output


def test_completion_script_generation():
Expand Down
Loading
Loading