diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..0063590d8ffd8fa42d17405e296ef9b3e090ef45 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore + +**/.ruff_cache +**/__pycache__ + +README.md +api-design + +Dockerfile + +venv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..1800114dc1282dc036336932073875ba4508dfff --- /dev/null +++ b/.gitignore @@ -0,0 +1,174 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..46128efff9808947b9e043ef7e8dddda24902dca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.13.2-alpine3.20 + +WORKDIR /code + +# Install dependencies +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt + +# Copy the rest of the code +COPY . . +RUN chmod +x prod.sh + +ENTRYPOINT ["./prod.sh"] diff --git a/README.md b/README.md index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..5a30abd28a67e74782fd25a0cfe656b3cb1feb74 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,5 @@ +# REST API for MiARS project + +## Running the server +1. Install the dependencies by running `pip install -r requirements.txt` in the root directory of the project. +2. Run `dev.sh` script to start the dev server. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000000000000000000000000000000000000..f38898ff860c43884a1921558ed1219c4b5dcfa4 --- /dev/null +++ b/app/app.py @@ -0,0 +1,14 @@ +from flask import Flask +from flask_cors import CORS + +from app.configurations.router import router as configurations_router + + +def create_app() -> Flask: + app = Flask(__name__) + CORS(app) + app.register_blueprint(configurations_router) + return app + + +app = create_app() diff --git a/app/configurations/__init__.py b/app/configurations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/configurations/functions.py b/app/configurations/functions.py new file mode 100644 index 0000000000000000000000000000000000000000..f30b8faeda580d785dcb6560afc63bbb516752ff --- /dev/null +++ b/app/configurations/functions.py @@ -0,0 +1,23 @@ +from app.configurations.model import Configuration +from app.configurations.schemas import ConfigurationShort, ConfigurationOut + + +def configuration_to_configuration_short(configuration: Configuration) -> ConfigurationShort: + return ConfigurationShort( + id=configuration.id, + name=configuration.name, + is_applied=configuration.is_applied + ) + + + +def configuration_to_configuration_out(configuration: Configuration) -> ConfigurationOut: + return ConfigurationOut( + id=configuration.id, + name=configuration.name, + source_mac=configuration.source_mac, + destination_mac=configuration.destination_mac, + protocols=configuration.protocols, + frame_ranges=configuration.frame_ranges, + is_applied=configuration.is_applied + ) diff --git a/app/configurations/model.py b/app/configurations/model.py new file mode 100644 index 0000000000000000000000000000000000000000..640b45b909a078ed46649903c73bc4954aae292f --- /dev/null +++ b/app/configurations/model.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from typing import List, Tuple +from uuid import UUID + + +# TODO: This class to be changed to some orm model probably +@dataclass +class Configuration: + id: UUID + name: str + source_mac: List[str] + destination_mac: List[str] + protocols: List[str] + frame_ranges: List[Tuple[int, int]] + is_applied: bool diff --git a/app/configurations/repos/__init__.py b/app/configurations/repos/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4f0a8cfe6727ed191e84d490656331f097e3a10a --- /dev/null +++ b/app/configurations/repos/__init__.py @@ -0,0 +1,6 @@ +from .in_memory_repo import ConfigurationInMemoryRepository + +# you can change the ConfigurationDefaultRepository to another repository implementing the ConfigurationAbstractRepository +ConfigurationDefaultRepository = ConfigurationInMemoryRepository + +__all__ = ["ConfigurationDefaultRepository"] diff --git a/app/configurations/repos/abstract_repo.py b/app/configurations/repos/abstract_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..a735e8ad6b6b4c81d4aedc583cb42d8c449185d7 --- /dev/null +++ b/app/configurations/repos/abstract_repo.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod +from typing import List, Optional +from uuid import UUID + +from app.configurations.model import Configuration + + +class ConfigurationAbstractRepository(ABC): + @abstractmethod + def get_by_id(self, configuration_id: UUID) -> Optional[Configuration]: + raise NotImplementedError() + + @abstractmethod + def get_all(self) -> List[Configuration]: + raise NotImplementedError() + + @abstractmethod + def save(self, configuration: Configuration) -> None: + raise NotImplementedError() + + @abstractmethod + def delete(self, configuration_id: UUID) -> None: + raise NotImplementedError() diff --git a/app/configurations/repos/in_memory_repo.py b/app/configurations/repos/in_memory_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..7a9576f33d2a245a500727e69a2520718120c78b --- /dev/null +++ b/app/configurations/repos/in_memory_repo.py @@ -0,0 +1,22 @@ +from typing import Dict, List, Optional +from uuid import UUID + +from app.configurations.model import Configuration +from app.configurations.repos.abstract_repo import ConfigurationAbstractRepository + + +class ConfigurationInMemoryRepository(ConfigurationAbstractRepository): + def __init__(self): + self.configurations: Dict[UUID, Configuration] = {} + + def get_by_id(self, configuration_id: UUID) -> Optional[Configuration]: + return self.configurations.get(configuration_id) + + def get_all(self) -> List[Configuration]: + return list(self.configurations.values()) + + def save(self, configuration: Configuration) -> None: + self.configurations[configuration.id] = configuration + + def delete(self, configuration_id: UUID) -> None: + self.configurations.pop(configuration_id) diff --git a/app/configurations/router.py b/app/configurations/router.py new file mode 100644 index 0000000000000000000000000000000000000000..c8120e5ceacc3bdf7b69893a89673e6a6cd6df74 --- /dev/null +++ b/app/configurations/router.py @@ -0,0 +1,85 @@ +from uuid import UUID + +from flask import Blueprint, jsonify, request +from pydantic import ValidationError + +from app.configurations.functions import configuration_to_configuration_out, configuration_to_configuration_short +from app.configurations.schemas import ConfigurationIn +from app.configurations.services import ConfigurationDefaultService +from app.configurations.services.abstract_service import ConfigurationAbstractService + +router = Blueprint("configurations", __name__, url_prefix="/configurations") + +configuration_service: ConfigurationAbstractService = ConfigurationDefaultService() + + +@router.route("/") +def get_configurations(): + response_data = [ + configuration_to_configuration_short(configuration).model_dump() + for configuration in configuration_service.get_configurations() + ] + return jsonify({"data": response_data}) + + +@router.route("/", methods=["POST"]) +def create_configuration(): + try: + configuration_in = ConfigurationIn.model_validate(request.get_json()) + configuration = configuration_service.create_configuration(configuration_in) + + if len(configuration_service.get_configurations()) == 1: + configuration_service.apply_configuration(configuration.id) + + configuration_out = configuration_to_configuration_out(configuration) + return jsonify({"data": configuration_out.model_dump()}), 201 + except ValidationError as e: + return jsonify({"data": e.errors()}), 400 + + +@router.route("/<configuration_id>") +def get_configuration(configuration_id): + configuration_id = UUID(configuration_id) + configuration = configuration_service.get_configuration(configuration_id) + + if configuration is None: + return jsonify({"data": {"details": "Not Found"}}), 404 + + return jsonify({"data": configuration_to_configuration_out(configuration).model_dump()}) + + +@router.route("/<configuration_id>", methods=["DELETE"]) +def delete_configuration(configuration_id): + configuration_id = UUID(configuration_id) + configuration_service.delete_configuration(configuration_id) + + return jsonify(None), 204 + + +@router.route("/<configuration_id>", methods=["PUT"]) +def update_configuration(configuration_id): + configuration_id = UUID(configuration_id) + configuration = configuration_service.get_configuration(configuration_id) + + if configuration is None: + return jsonify({"data": {"details": "Not Found"}}), 404 + + try: + configuration_in = ConfigurationIn.model_validate(request.get_json()) + configuration_service.update_configuration(configuration_id, configuration_in) + except ValidationError as e: + return jsonify({"data": e.errors()}), 400 + + return jsonify({"data": configuration_to_configuration_out(configuration).model_dump()}), 201 + + +@router.route("/<configuration_id>/apply", methods=["POST"]) +def apply_configuration(configuration_id): + configuration_id = UUID(configuration_id) + configuration = configuration_service.get_configuration(configuration_id) + + if configuration is None: + return jsonify({"data": {"details": "Not Found"}}), 404 + + configuration_service.apply_configuration(configuration_id) + return jsonify({"data": configuration_to_configuration_out(configuration).model_dump()}), 201 diff --git a/app/configurations/schemas.py b/app/configurations/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..720bbb376e8ca464fdbbebdf41431334cf259943 --- /dev/null +++ b/app/configurations/schemas.py @@ -0,0 +1,25 @@ +from typing import List, Tuple +from uuid import UUID + +from pydantic import BaseModel + +FrameSizeRange = Tuple[int, int] + + +class ConfigurationIn(BaseModel): + name: str + source_mac: List[str] + destination_mac: List[str] + protocols: List[str] + frame_ranges: List[FrameSizeRange] + + +class ConfigurationOut(ConfigurationIn): + id: UUID + is_applied: bool + + +class ConfigurationShort(BaseModel): + id: UUID + name: str + is_applied: bool diff --git a/app/configurations/services/__init__.py b/app/configurations/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..155a1e0cade55260998076a4fb4409761294a36a --- /dev/null +++ b/app/configurations/services/__init__.py @@ -0,0 +1,6 @@ +from .service import ConfigurationService + +# you can change default configuration service to another service implementing the ConfigurationAbstractService +ConfigurationDefaultService = ConfigurationService + +__all__ = ["ConfigurationDefaultService"] diff --git a/app/configurations/services/abstract_service.py b/app/configurations/services/abstract_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3802dae5c852f3b775ac6eead23a9442d9e935f4 --- /dev/null +++ b/app/configurations/services/abstract_service.py @@ -0,0 +1,34 @@ +from abc import ABC, abstractmethod +from typing import List, Optional +from uuid import UUID + +from app.configurations.model import Configuration +from app.configurations.schemas import ConfigurationIn + + +class ConfigurationAbstractService(ABC): + @abstractmethod + def get_configurations(self) -> List[Configuration]: + raise NotImplementedError() + + @abstractmethod + def get_configuration(self, configuration_id) -> Optional[Configuration]: + raise NotImplementedError() + + @abstractmethod + def create_configuration(self, data: ConfigurationIn) -> Configuration: + raise NotImplementedError() + + @abstractmethod + def update_configuration( + self, configuration_id: UUID, new_data: ConfigurationIn + ) -> None: + raise NotImplementedError() + + @abstractmethod + def apply_configuration(self, configuration_id: UUID) -> None: + raise NotImplementedError() + + @abstractmethod + def delete_configuration(self, configuration_id: UUID) -> None: + raise NotImplementedError() diff --git a/app/configurations/services/service.py b/app/configurations/services/service.py new file mode 100644 index 0000000000000000000000000000000000000000..7ace469890dccecb81b21d89d98727b98d53fb93 --- /dev/null +++ b/app/configurations/services/service.py @@ -0,0 +1,62 @@ +from typing import List, Optional +from uuid import UUID, uuid4 + +from app.configurations.model import Configuration +from app.configurations.repos import ConfigurationDefaultRepository +from app.configurations.repos.abstract_repo import ConfigurationAbstractRepository +from app.configurations.schemas import ConfigurationIn +from app.configurations.services.abstract_service import ConfigurationAbstractService + + +class ConfigurationService(ConfigurationAbstractService): + def __init__(self): + self._repo: ConfigurationAbstractRepository = ConfigurationDefaultRepository() + + def get_configurations(self) -> List[Configuration]: + return self._repo.get_all() + + def get_configuration(self, configuration_id: UUID) -> Optional[Configuration]: + return self._repo.get_by_id(configuration_id) + + def create_configuration(self, data: ConfigurationIn) -> Configuration: + configuration = Configuration( + id=uuid4(), + name=data.name, + source_mac=data.source_mac, + destination_mac=data.destination_mac, + protocols=data.protocols, + frame_ranges=data.frame_ranges, + is_applied=False, + ) + self._repo.save(configuration) + return configuration + + def update_configuration( + self, configuration_id: UUID, new_data: ConfigurationIn + ) -> None: + configuration = self._repo.get_by_id(configuration_id) + if configuration is None: + return + configuration.name = new_data.name + configuration.source_mac = new_data.source_mac + configuration.destination_mac = new_data.destination_mac + configuration.protocols = new_data.protocols + configuration.frame_ranges = new_data.frame_ranges + self._repo.save(configuration) + + def apply_configuration(self, configuration_id: UUID) -> None: + configurations = self._repo.get_all() + + for configuration in configurations: + if configuration.is_applied: + configuration.is_applied = False + self._repo.save(configuration) + + configuration = self._repo.get_by_id(configuration_id) + if configuration is None: + return + configuration.is_applied = True + self._repo.save(configuration) + + def delete_configuration(self, configuration_id: UUID) -> None: + self._repo.delete(configuration_id) diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000000000000000000000000000000000000..01c63e52fa771baead66f43e77132a15a836f9a1 --- /dev/null +++ b/dev.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +flask --app=app/app.py run diff --git a/prod.sh b/prod.sh new file mode 100755 index 0000000000000000000000000000000000000000..7e1a3d352f35ce009c597cdf5aaf5a60a8ff4bfc --- /dev/null +++ b/prod.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +gunicorn -w 4 -b 0.0.0.0 "app.app:create_app()" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..599d086a956edced853ced3492e8a82290a5df2b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +annotated-types==0.7.0 +blinker==1.9.0 +click==8.1.8 +Flask==3.1.0 +flask-cors==5.0.1 +gunicorn==23.0.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +packaging==24.2 +pydantic==2.10.6 +pydantic_core==2.27.2 +ruff==0.11.2 +typing_extensions==4.12.2 +Werkzeug==3.1.3