Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
279 changes: 279 additions & 0 deletions besser/generators/backend/api_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
"""Renders the modular FastAPI layer (``main_api.py`` + ``database.py`` +
``bal_stdlib.py`` + one ``routers/<class>.py`` per resource) for the
:class:`~besser.generators.backend.backend_generator.BackendGenerator`.

This used to be a single ~1,600-line ``main_api.py`` produced by
``RESTAPIGenerator`` (backend=True mode). That monolith is now split into a
slim ``main_api.py`` (app setup + router includes, still importable as
``main_api:app`` for uvicorn/Docker/deployment tooling that expects that
filename) plus one router module per B-UML class.

The split is structural, but a few deliberate behavior fixes ride along
(each also noted where it is implemented):

- ``/search/`` endpoints now actually expose their filter parameters (the
old type check never matched converter-produced attributes) and cover
inherited attributes; a class hierarchy with no searchable attribute gets
no ``/search/`` route at all.
- Path parameters and FK payload fields use the declared type of the
target's primary key instead of a hardcoded ``int``.
- A modeled method without an implementation returns ``501`` instead of a
fake ``{"result": null}`` success, and an ``HTTPException`` raised inside
a method body keeps its status instead of being swallowed into a 500.
- A surrogate ``id`` (named ``id`` but not declared ``is_id``) is
server-owned: excluded from create/update payload reads. The primary key
— whatever its name — is immutable through PUT.
- ``/health`` actually executes ``SELECT 1`` instead of hardcoding
``"connected"``.

Everything else — the association-class link contract, OCL enforcement,
real-PK routing, method-code normalization and the error-response shape
(``detail`` carries the endpoint's actual message) — is unchanged from the
monolith, just relocated.
"""

import os
import re
from typing import Dict, List, Tuple

from jinja2 import Environment, FileSystemLoader

from besser.BUML.metamodel.structural import AssociationClass, DomainModel
from besser.BUML.notations.action_language.ActionLanguageASTBuilder import parse_bal
from besser.generators.action_language.RESTGenerator import bal_to_rest
from besser.generators.structural_utils import get_foreign_keys, get_pk_py_types, normalize_method_code
from besser.utilities.utils import sort_by_timestamp

_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")

# Endpoint-name suffixes/prefixes that identify a function as belonging to a
# given class's router. Used by ``cross_router_calls`` to detect when a
# BAL/CODE method body (rendered to Python source before we ever see it)
# calls another class's endpoint function directly, so we can emit a local
# import for it instead of relying on a module-level import (which could
# deadlock on circular router imports when two classes reference each
# other).
_SIMPLE_FUNC_TEMPLATES = (
"create_{c}",
"bulk_create_{c}",
"update_{c}",
"delete_{c}",
"bulk_delete_{c}",
"get_{c}",
"get_all_{c}",
"get_count_{c}",
"get_paginated_{c}",
"search_{c}",
"{c}_", # class-level method endpoints are named "<class>_<method>"
)


def clean_method_name(name):
"""Extract just the method name without parameters."""
if '(' in str(name):
return str(name).split('(')[0].strip()
return str(name).strip()


def get_pk_names(model: DomainModel) -> Dict[str, str]:
"""Map every class name to the name of its primary-key attribute.

Mirrors the SQLAlchemy generator's selection: the ``is_id`` attribute,
else one literally named ``id``, else the surrogate ``id`` column. Used
by the ``pk`` Jinja filter so routers address each resource by its real
primary key (e.g. ``Seat.code``), not a hardcoded ``.id``.
"""
pk_names: Dict[str, str] = {}
for cls in model.get_classes():
attributes = sort_by_timestamp(cls.attributes)
id_attr = next((attr.name for attr in attributes if attr.is_id), None)
if not id_attr:
id_attr = next((attr.name for attr in attributes if attr.name == "id"), None)
pk_names[cls.name] = id_attr or "id"
return pk_names


def get_association_classes(model: DomainModel) -> Dict[str, dict]:
"""Describe each association class of the model.

An association carrying an association class is materialized by the
SQLAlchemy generator as a mapped class (one ``<end>_id`` FK per end plus
the association-class attributes) instead of a plain secondary table, so
the REST layer reads and writes the links through that class. Returns
``{assoc_class_name: {association, ends, attributes}}`` — the same shape
the monolithic ``RESTAPIGenerator`` consumes, so both generators share
one association-class contract.
"""
assoc_classes: Dict[str, dict] = {}
for cls in model.get_classes():
if not isinstance(cls, AssociationClass):
continue
assoc_classes[cls.name] = {
"association": cls.association.name,
"ends": [
{"name": end.name, "type_name": end.type.name}
for end in sorted(cls.association.ends, key=lambda end: end.name)
],
"attributes": [
{
"name": attribute.name,
"is_enum": attribute.type.__class__.__name__ == "Enumeration",
}
for attribute in sort_by_timestamp(cls.attributes)
],
}
return assoc_classes


def cross_router_calls(method_code: str, current_class_name: str, class_names: List[str]) -> List[Tuple[str, str]]:
"""Find function calls in a rendered method body that target another
class's router module.

BAL-derived method bodies (see ``besser.generators.action_language.RESTGenerator``)
can call another class's CRUD/relationship/method endpoint functions
directly, e.g. ``await update_manager(...)`` or ``await create_book(...)``.
When routes lived in a single ``main_api.py`` file those names were all in
the same module namespace. Now that each class has its own router module,
a call that targets a *different* class needs an explicit import. We
detect it here (instead of importing every router into every other
router at module scope) to avoid circular imports between routers that
reference each other.

Returns a sorted, de-duplicated list of ``(target_module, function_name)``
tuples. Calls to functions on ``current_class_name`` itself are excluded
since those are already defined in the same router module.
"""
if not method_code:
return []

current_lower = current_class_name.lower()
candidate_names = set(re.findall(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", method_code))

found = set()
for target in class_names:
target_lower = target.lower()
if target_lower == current_lower:
continue
simple_names = {tmpl.format(c=target_lower) for tmpl in _SIMPLE_FUNC_TEMPLATES}
for name in candidate_names:
if (
name in simple_names
or name.startswith(f"execute_{target_lower}_")
or name.endswith(f"_of_{target_lower}")
or (name.startswith("add_") and name.endswith(f"_to_{target_lower}"))
or (name.startswith("remove_") and name.endswith(f"_from_{target_lower}"))
):
found.add((target_lower, name))

return sorted(found)


def _make_env() -> Environment:
env = Environment(
loader=FileSystemLoader(_TEMPLATES_DIR),
trim_blocks=True,
lstrip_blocks=True,
extensions=['jinja2.ext.do'],
)
env.filters['clean_method_name'] = clean_method_name
env.globals.update(parse_bal=parse_bal, bal_to_rest=bal_to_rest,
normalize_code=normalize_method_code)
return env


def generate_modular_api(
model: DomainModel,
http_methods: List[str],
nested_creations: bool,
port: int,
output_dir: str,
) -> None:
"""Render ``main_api.py``, ``database.py``, ``bal_stdlib.py`` and one
``routers/<class>.py`` per class into ``output_dir``.

``main_api.py`` keeps its historical filename (and still exposes the
module-level ``app`` object) so that existing tooling which shells out to
``uvicorn main_api:app`` (Docker images, the GitHub deployment service)
keeps working unmodified; only its *contents* shrink to app setup plus
``include_router`` calls.
"""
classes = model.classes_sorted_by_inheritance()
class_names = [cls.name for cls in classes]
fkeys: Dict[str, List[str]] = get_foreign_keys(model)
# Class name -> python type of its primary key (default 'int'). Path
# params and FK payload fields must use the model's declared id type —
# a `guest_id: int` param for a String PK 404s on every real id. Shared
# with the SQLAlchemy generator so FK columns and path params agree.
pk_types: Dict[str, str] = get_pk_py_types(model)

# Association-class support: which classes ARE association classes, and
# which plain associations are materialized by one. Shared shape with the
# monolithic RESTAPIGenerator so both produce the identical link contract.
assoc_classes = get_association_classes(model)
assoc_by_association = {
info["association"]: assoc_class_name
for assoc_class_name, info in assoc_classes.items()
}
pk_names = get_pk_names(model)

env = _make_env()
env.globals['cross_router_calls'] = (
lambda method_code, current_class_name: cross_router_calls(method_code, current_class_name, class_names)
)
# `pk` returns the primary-key attribute name of a class, so routers query
# and join by the real PK (e.g. Seat.code) instead of a hardcoded `.id`.
env.filters['pk'] = lambda class_name: pk_names.get(str(class_name), "id")

routers_dir = os.path.join(output_dir, "routers")
os.makedirs(routers_dir, exist_ok=True)
with open(os.path.join(routers_dir, "__init__.py"), mode="w", encoding="utf-8") as f:
f.write("")

# database.py: engine/session setup shared by main_api.py and every router.
database_template = env.get_template("database.py.j2")
with open(os.path.join(output_dir, "database.py"), mode="w", encoding="utf-8") as f:
f.write(database_template.render(name=model.name))

# bal_stdlib.py: BESSER Action Language standard-library helpers, shared
# by any router whose class methods use them.
bal_stdlib_template = env.get_template("bal_stdlib.py.j2")
with open(os.path.join(output_dir, "bal_stdlib.py"), mode="w", encoding="utf-8") as f:
f.write(bal_stdlib_template.render())

# One router module per class.
router_template = env.get_template("router.py.j2")
for cls in classes:
router_code = router_template.render(
**{
"class": cls,
"classes": classes,
"http_methods": http_methods,
"nested_creations": nested_creations,
"fkeys": fkeys,
"model": model,
"pk_types": pk_types,
"assoc_classes": assoc_classes,
"assoc_by_association": assoc_by_association,
}
)
router_path = os.path.join(routers_dir, f"{cls.name.lower()}.py")
with open(router_path, mode="w", encoding="utf-8") as f:
f.write(router_code)

# main_api.py: slim app setup + router includes (keeps its historical
# filename so `uvicorn main_api:app` / Docker / deployment tooling that
# expects it keeps working).
main_api_template = env.get_template("main_api.py.j2")
main_api_code = main_api_template.render(
name=model.name,
model=model,
classes=classes,
http_methods=http_methods,
nested_creations=nested_creations,
port=port,
fkeys=fkeys,
)
with open(os.path.join(output_dir, "main_api.py"), mode="w", encoding="utf-8") as f:
f.write(main_api_code)

print("Code generated in the location: " + os.path.join(output_dir, "main_api.py"))
29 changes: 24 additions & 5 deletions besser/generators/backend/backend_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from besser.generators.rest_api import RESTAPIGenerator
from besser.generators.sql_alchemy import SQLAlchemyGenerator
from besser.generators.pydantic_classes import PydanticGenerator
from besser.generators.backend.api_generator import generate_modular_api
from besser.generators.backend.docker_files import generate_docker_files

class BackendGenerator(GeneratorInterface):
Expand All @@ -22,9 +23,11 @@ class BackendGenerator(GeneratorInterface):
output_dir (str, optional): The output directory where the generated code will be saved. Defaults to None.
docker_image (bool, optional): Flag to indicate if Docker image generation is required. Defaults to False.
docker_config_path (str, optional): The path to the docker configuration file to auto upload the image. Defaults to None.
port (int, optional): Port embedded in the generated ``uvicorn.run`` call. Takes precedence over the
docker configuration's ``docker_port``. Defaults to None (docker config, else 8000).
"""

def __init__(self, model: DomainModel, http_methods: list = None, nested_creations: bool = False, output_dir: str = None, docker_image: bool = False, docker_config_path: str = None):
def __init__(self, model: DomainModel, http_methods: list = None, nested_creations: bool = False, output_dir: str = None, docker_image: bool = False, docker_config_path: str = None, port: int = None):
super().__init__(model, output_dir)
allowed_methods = ["GET", "POST", "PUT", "DELETE"]
if not http_methods:
Expand All @@ -35,6 +38,7 @@ def __init__(self, model: DomainModel, http_methods: list = None, nested_creatio
self.nested_creations = nested_creations
self.docker_image = docker_image
self.docker_config_path = docker_config_path
self.port = port
self.config = self.load_config()

def load_config(self):
Expand Down Expand Up @@ -79,10 +83,22 @@ def generate(self):
os.makedirs(backend_folder_path, exist_ok=True)
print(f"Backend folder created at {backend_folder_path}")

docker_port = self.config["docker_port"] if self.config else 8000 # Use default port if config not provided

rest_api = RESTAPIGenerator(model=self.model, http_methods=self.http_methods, nested_creations=self.nested_creations, output_dir=backend_folder_path, backend=True, port=docker_port)
rest_api.generate()
# An explicitly requested port wins over the docker configuration; 8000 is the fallback.
docker_port = self.port or (self.config["docker_port"] if self.config else 8000)

# requirements.txt is shared boilerplate with the standalone REST API
# generator; reuse it instead of duplicating the dependency list here.
RESTAPIGenerator(model=self.model, output_dir=backend_folder_path).generate_requirements()

# main_api.py (slim app + router includes) + database.py + bal_stdlib.py
# + routers/<class>.py, one router per resource.
generate_modular_api(
model=self.model,
http_methods=self.http_methods,
nested_creations=self.nested_creations,
port=docker_port,
output_dir=backend_folder_path,
)

sql_alchemy = SQLAlchemyGenerator(model=self.model, output_dir=backend_folder_path)
sql_alchemy.generate()
Expand Down Expand Up @@ -113,8 +129,11 @@ def build_and_push_docker_image(self, backend_folder_path):
WORKDIR /app

COPY main_api.py /app
COPY database.py /app
COPY bal_stdlib.py /app
COPY pydantic_classes.py /app
COPY sql_alchemy.py /app
COPY routers/ /app/routers/

RUN pip install requests==2.31.0
RUN pip install fastapi==0.110.0
Expand Down
3 changes: 2 additions & 1 deletion besser/generators/backend/docker_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ def generate_dockerfile(path: str):
RUN pip install --no-cache-dir -r requirements.txt

# Copy application files
COPY main_api.py pydantic_classes.py sql_alchemy.py ./
COPY main_api.py database.py bal_stdlib.py pydantic_classes.py sql_alchemy.py ./
COPY routers/ ./routers/

# Switch to non-root user
USER appuser
Expand Down
Empty file.
Loading
Loading