diff --git a/src/database/BL_Python/database/engine/sqlite.py b/src/database/BL_Python/database/engine/sqlite.py index f3ca89a6..b9a2ce14 100644 --- a/src/database/BL_Python/database/engine/sqlite.py +++ b/src/database/BL_Python/database/engine/sqlite.py @@ -3,6 +3,7 @@ from sqlalchemy import create_engine, event from sqlalchemy.orm.scoping import ScopedSession from sqlalchemy.orm.session import sessionmaker +from sqlalchemy.pool import Pool, StaticPool class SQLiteScopedSession(ScopedSession): @@ -15,8 +16,22 @@ def create( """ Create a new session factory for SQLite. """ + poolclass: type[Pool] | None = None + # if the connection string is an SQLite in-memory database + # then make SQLAlchemy maintain a static pool of "connections" + # so that the in-memory database is not deallocated. Otherwise, + # the database would disappear when a thread is done with it. + # Note: SQLite will reject usage from other threads unless + # the connection string also contains `?check_same_thread=False`, + # e.g. `sqlite:///:memory:?check_same_thread=False` + if ":memory:" in connection_string: + poolclass = StaticPool + engine = create_engine( - connection_string, echo=echo, execution_options=execution_options or {} + connection_string, + echo=echo, + execution_options=execution_options or {}, + poolclass=poolclass, ) return SQLiteScopedSession( diff --git a/src/web/BL_Python/web/application.py b/src/web/BL_Python/web/application.py index 9d36f454..30b6be9a 100644 --- a/src/web/BL_Python/web/application.py +++ b/src/web/BL_Python/web/application.py @@ -3,7 +3,7 @@ Flask entry point. """ -import logging +from logging import Logger from os import environ, path from typing import Any, Optional, cast @@ -22,7 +22,7 @@ # from CAP.app.services.user.login_manager import LoginManager # from CAP.database.models.CAP import Base from connexion.apps.flask_app import FlaskApp -from flask import Flask +from flask import Flask, url_for from injector import Module from lib_programname import get_path_executed_script @@ -49,7 +49,7 @@ def create_app( # just grow and grow. # startup_builder: IStartupBuilder, # config: Config, -): +) -> Flask: """ Bootstrap the Flask applcation. @@ -115,6 +115,14 @@ def create_app( flask_injector = configure_dependencies(app, application_modules=modules) app.injector = flask_injector + if config.flask.openapi is not None and config.flask.openapi.use_swagger: + with app.app_context(): + # use this logger so we can control where output is sent. + # the default logger retrieved here logs to the console. + app.injector.injector.get(Logger).info( + f"Swagger UI can be accessed at {url_for('/./_swagger_ui_index', _external=True)}" + ) + return app @@ -166,11 +174,7 @@ def configure_openapi(config: Config, name: Optional[str] = None): # json_logging.config_root_logger() app.logger.setLevel(environ.get("LOGLEVEL", "INFO").upper()) - options: dict[str, bool] = {} - # TODO document that connexion[swagger-ui] must be installed - # for this to work - if config.flask.openapi.use_swagger: - options["swagger_ui"] = True + options: dict[str, bool] = {"swagger_ui": config.flask.openapi.use_swagger} connexion_app.add_api( f"{config.flask.app_name}/{config.flask.openapi.spec_path}", diff --git a/src/web/BL_Python/web/config.py b/src/web/BL_Python/web/config.py index d228a64f..b234d9a9 100644 --- a/src/web/BL_Python/web/config.py +++ b/src/web/BL_Python/web/config.py @@ -137,6 +137,7 @@ def _update_flask_config(self, flask_app_config: FlaskAppConfig): class ConfigObject: ENV = self.env + SERVER_NAME = f"{self.host}:{self.port}" flask_app_config.from_object(ConfigObject) diff --git a/src/web/BL_Python/web/scaffolding/templates/base/{{application_name}}/__init__.py.j2 b/src/web/BL_Python/web/scaffolding/templates/base/{{application_name}}/__init__.py.j2 index 81b16e36..e71589f4 100644 --- a/src/web/BL_Python/web/scaffolding/templates/base/{{application_name}}/__init__.py.j2 +++ b/src/web/BL_Python/web/scaffolding/templates/base/{{application_name}}/__init__.py.j2 @@ -1,4 +1,15 @@ +# isort: off from {{application_name}}._version import __version__ +# isort: on + +{% if module.database %} +from typing import Any, cast +from injector import Injector +from sqlalchemy import MetaData + +from sqlalchemy.orm import Session +from {{application_name}}.modules.database import Base +{% endif %} def create_app(): application_configs = [] @@ -13,8 +24,29 @@ def create_app(): from BL_Python.web.application import create_app as _create_app # fmt: off - return _create_app( + app = _create_app( application_configs=application_configs, application_modules=application_modules ) # fmt: on + + {% if module.database %} + # For now, create the database and tables + # when the application starts. This behavior + # will be removed when Alembic is integrated. + session = cast(Injector, app.injector.injector).get(Session) + cast(MetaData, Base.metadata).create_all(session.bind) # pyright: ignore[reportGeneralTypeIssues] + + {# + ideally this would use @inject w/ session: Session, + but something is preventing it from running or + sending in the dependencies to remove_db. + For now, just resolve it directly. + #} + @app.teardown_request + def remove_db(exception: Any): + session = app.injector.injector.get(Session) + session.rollback() + {% endif %} + + return app \ No newline at end of file diff --git a/src/web/BL_Python/web/scaffolding/templates/base/{{application_name}}/endpoints/application.py.j2 b/src/web/BL_Python/web/scaffolding/templates/base/{{application_name}}/endpoints/application.py.j2 index 5fcba9ac..25087467 100644 --- a/src/web/BL_Python/web/scaffolding/templates/base/{{application_name}}/endpoints/application.py.j2 +++ b/src/web/BL_Python/web/scaffolding/templates/base/{{application_name}}/endpoints/application.py.j2 @@ -25,6 +25,12 @@ def root(flask: Flask, config: Config, log: Logger): if config['ENV'] != 'debug': return "", 405 + {# + TODO this does not group by like-URLs that are used for different methods. + for example, get_foo() and post_foo() might both use the URL /foo, but the + HTTP verbs GET (for get_) and POST (for post_). This makes the table look + awkward, so it may be prudent to think about how to represent that. + #} # in debug environments, print a table of all routes and their allowed methods output = "" for rule in flask.url_map.iter_rules(): diff --git a/src/web/BL_Python/web/scaffolding/templates/openapi/{{application_name}}/openapi.yaml.j2 b/src/web/BL_Python/web/scaffolding/templates/openapi/{{application_name}}/openapi.yaml.j2 index 64b5e36c..a39a6976 100644 --- a/src/web/BL_Python/web/scaffolding/templates/openapi/{{application_name}}/openapi.yaml.j2 +++ b/src/web/BL_Python/web/scaffolding/templates/openapi/{{application_name}}/openapi.yaml.j2 @@ -22,7 +22,11 @@ paths: {% for endpoint in endpoints %} /{{endpoint.endpoint_name}}: get: + {% if module.database %} + description: "Get all entries in the {{endpoint.endpoint_name}} table." + {% else %} description: "Hello, {{endpoint.endpoint_name}}!" + {% endif %} operationId: "endpoints.{{endpoint.endpoint_name}}.get_{{endpoint.endpoint_name}}" responses: "200": @@ -32,4 +36,26 @@ paths: type: string description: "Endpoint is working correctly." summary: "A simple method that returns 200 as long as the endpoint is working correctly." + {% if module.database %} + post: + description: Add a new {{endpoint.endpoint_name.capitalize()}} to the {{endpoint.endpoint_name}} table. + operationId: "endpoints.{{endpoint.endpoint_name}}.add_{{endpoint.endpoint_name}}" + requestBody: + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + responses: + 201: + description: "The new {{endpoint.endpoint_name.capitalize()}} was successfully added to the {{endpoint.endpoint_name}} table." + content: + application/json: + schema: + type: string + {% endif %} {% endfor %} \ No newline at end of file diff --git a/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/endpoints/{{endpoint_name}}.py.j2 b/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/endpoints/{{endpoint_name}}.py.j2 index 22769737..cae72ff5 100644 --- a/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/endpoints/{{endpoint_name}}.py.j2 +++ b/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/endpoints/{{endpoint_name}}.py.j2 @@ -2,6 +2,14 @@ from logging import Logger from flask import Blueprint from injector import inject +{% if module.database %} +from flask import request +from sqlalchemy.orm import Session +{% endif %} + +{% if module.database %} +from {{application_name}}.modules.database import {{endpoint.endpoint_name.capitalize()}} +{% endif %} {% if template_type != "openapi" %} {{endpoint.endpoint_name}}_blueprint = Blueprint("{{endpoint.endpoint_name}}", __name__, url_prefix="/{{endpoint.endpoint_name}}") @@ -12,5 +20,36 @@ from injector import inject {% if template_type != "openapi" %} @{{endpoint.endpoint_name}}_blueprint.route("/") {% endif %} +{% if module.database %} +def get_{{endpoint.endpoint_name}}(session: Session, log: Logger): + entries = session.query({{endpoint.endpoint_name.capitalize()}}).all() + return {"names": [x.name for x in entries]} +{% else %} def get_{{endpoint.endpoint_name}}(log: Logger): return "Hello, {{endpoint.endpoint_name}}!" +{% endif %} + +{% if module.database %} +@inject +{% if template_type != "openapi" %} +@{{endpoint.endpoint_name}}_blueprint.route("/", methods=["POST"]) +{% endif %} +def add_{{endpoint.endpoint_name}}(session: Session, log: Logger): + name: str|None = None + try: + data = request.get_json(force=True) + if data is None or not (name := data["name"]): + return "Request JSON must contain a 'name' field with a string value.", 400 + + session.add({{endpoint.endpoint_name.capitalize()}}(name = name)) + except Exception as e: + log.critical(str(e), exc_info=True) + return "Request JSON must contain a 'name' field with a string value.", 400 + + try: + session.commit() + return f"Added {name} to the {{endpoint.endpoint_name.capitalize()}} table.", 201 + except Exception as e: + log.critical(str(e), exc_info=True) + return "An error occurred! Check the application logs.", 500 +{% endif %} \ No newline at end of file diff --git a/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/modules/database/__hook__.py b/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/modules/database/__hook__.py index 67d686b1..48b34073 100644 --- a/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/modules/database/__hook__.py +++ b/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/modules/database/__hook__.py @@ -8,11 +8,13 @@ def on_create(config: dict[str, Any], log: Logger): config["module"]["database"] = {} connection_string = input( - "\nEnter a database connection string.\nBy default this is `sqlite:///:memory:`.\nRetain this default by pressing enter, or type something else.\n> " + "\nEnter a database connection string.\nBy default this is `sqlite:///:memory:?check_same_thread=False`.\nRetain this default by pressing enter, or type something else.\n> " ) config["module"]["database"]["connection_string"] = ( - connection_string if connection_string else "sqlite:///:memory:" + connection_string + if connection_string + else "sqlite:///:memory:?check_same_thread=False" ) log.info( f"Using database connection string `{config['module']['database']['connection_string']}`" diff --git a/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/modules/database/__init__.py.j2 b/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/modules/database/__init__.py.j2 index c547943a..46c5d5b8 100644 --- a/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/modules/database/__init__.py.j2 +++ b/src/web/BL_Python/web/scaffolding/templates/optional/{{application_name}}/modules/database/__init__.py.j2 @@ -3,11 +3,12 @@ from sqlalchemy.ext.declarative import declarative_base Base = declarative_base() - -class Person(Base): - __tablename__ = "person" +{% for endpoint in endpoints %} +class {{endpoint.endpoint_name.capitalize()}}(Base): + __tablename__ = "{{endpoint.endpoint_name}}" id = Column(Integer, primary_key=True) name = Column(String(50)) - def to_dict(self): - return {"id": self.id, "name": self.name} + def __repr__(self): + return f"<{{endpoint.endpoint_name.capitalize()}} {self.name}>" +{% endfor %} \ No newline at end of file diff --git a/src/web/pyproject.toml b/src/web/pyproject.toml index c04ca01d..cbd8a3f2 100644 --- a/src/web/pyproject.toml +++ b/src/web/pyproject.toml @@ -29,8 +29,7 @@ dependencies = [ "flask-login == 0.6.2", "types-flask == 1.1.6", "connexion == 2.14.2", - # TODO is this one necessary, or should it perhaps belong in the app? - "connexion[swagger-ui-bundle] == 2.14.2", + "swagger_ui_bundle==0.0.9", # specific version because Jinja (Flask dependency) # does not specify an upper bound for MarkupSafe, # and it pulls in a breaking change that prevents
urlmethods