Commit fcdfd3cf authored by vertighel's avatar vertighel
Browse files

Refining API layer

parent 1cb1f8b2
Loading
Loading
Loading
Loading
Loading
+37 −21
Original line number Diff line number Diff line
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

'''Automatic REST APIs with Internal Resource Registry'''

# System modules
import configparser
import importlib
from pathlib import Path

# Third-party modules
from quart import Blueprint
from quart import Blueprint, request, abort

# Custom modules
from noctua import devices
from noctua.api.sequencer_instance import seq
from noctua.api.basecontext import ends, resource_registry, config_path
from .blocks import blocks_api
from .defaults import defaults_api
from .sequencer import sequencer_api
@@ -22,35 +26,47 @@ api_blueprint.register_blueprint(blocks_api, url_prefix='/blocks')
api_blueprint.register_blueprint(defaults_api, url_prefix='/templates')
api_blueprint.register_blueprint(sequencer_api, url_prefix='/sequencer')

# Load api endpoint config file
ends = configparser.ConfigParser()
config_path = Path(__file__).parent.parent / "config" / "api.ini"
ends.read(config_path)
# This will store { "/telescope/power": <Instance of State>, ... }
resource_registry = {}

api_blueprint = Blueprint('api', __name__)

def dynamic_import(url_path):
    """Dynamically import into this module api.ini files."""
    """
    Import and register resources from api.ini with debug logging of supported methods.
    """
    
    try:
        # Skip manual routes
        if url_path.startswith(("/blocks", "/sequencer", "/templates")):
        if url_path.startswith(("/blocks", "/sequencer")):
            return

        res_class_name = ends.get(url_path, "resource") # CoolerWarmup
        dev_name = ends.get(url_path, "device") # cam        
        dev_instance = getattr(devices, dev_name) # noctua.devices.stx.Camera object
        mod_name = dev_instance.__class__.__name__.lower() # camera
        res_class_name = ends.get(url_path, "resource")
        dev_name = ends.get(url_path, "device")
        
        dev_instance = getattr(devices, dev_name)
        mod_name = dev_instance.__class__.__name__.lower()

        module = importlib.import_module(f"noctua.api.{mod_name}") # noctua.api.camera
        resource_class = getattr(module, res_class_name) # noctua.api.camera.CoolerWarmup
        module = importlib.import_module(f"noctua.api.{mod_name}")
        resource_class = getattr(module, res_class_name)

        # Register dynamic route as MethodView
        # This automatically maps GET/POST/PUT/DELETE to class methods
        # Identify supported HTTP methods by checking for lowercase method names in the class
        methods = [m for m in ["GET", "POST", "PUT", "DELETE"] if hasattr(resource_class, m.lower())]
        
        # Populate internal registry
        resource_registry[url_path] = resource_class(dev=dev_instance)

        # Register route
        view = resource_class.as_view(url_path, dev=dev_instance)
        api_blueprint.add_url_rule(url_path, view_func=view)
        
        print(f"Route registered: /api{url_path:<40} {module.__name__:>5}.{resource_class.__name__}")            
        # Improved debug print with methods list
        full_resource_path = f"{module.__name__}.{resource_class.__name__}"
        print(f"Route registered: /api{url_path:<40} {full_resource_path:<48} {str(methods):<25}")
        
    except Exception as e:
        print(f"Route skipped:    /api{url_path:<40} -- {str(e):>5}")
        print(f"Error loading {url_path}: {e}")
        
# Load all routes from ini
for section in ends.sections():
    if not section.startswith(("/blocks", "/sequencer")):
        dynamic_import(section)
+8 −0
Original line number Diff line number Diff line
import configparser
from pathlib import Path

ends = configparser.ConfigParser()
config_path = Path(__file__).parent.parent / "config" / "api.ini"
ends.read(config_path)

resource_registry = {}
+164 −30
Original line number Diff line number Diff line
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

'''Base class for REST API'''
# System modules

import asyncio
from datetime import datetime

# Third-party modules
from quart import request, jsonify
from quart import request, jsonify, current_app
from quart.views import MethodView
from .basecontext import ends, resource_registry

class BaseResource(MethodView):
    """
    Equivalent to flask_restx Resource.
    Methods get(), post(), put(), delete() will be called automatically.
    Base class for all API resources, providing dependency management and 
    standardized responses.
    """
    
    def __init__(self, dev):
        """
        Initialize the resource with a specific device instance.

        Parameters
        ----------
        dev : object
            The hardware device instance (Layer 1) or Sequencer (Layer 2).
        """
        super().__init__()        
        self.dev = dev

    @property
    def timestamp(self):
        """
        Generate a ISO-formatted UTC timestamp.

        Returns
        -------
        str
            The current timestamp string.
        """
        
        return datetime.utcnow().isoformat()

    async def get_payload(self):
        """
        Parse JSON payload. silent=True prevents Quart from
        returning a 400 error before execution.
        Implicitly parse JSON payload even if Content-Type header is missing.

        Returns
        -------
        dict or list or None
            The parsed JSON data.
        """
        
        return await request.get_json(force=True, silent=True)

    async def run_blocking(self, func, *args, **kwargs):
        """
        Execute blocking L1/L2 hardware calls in a separate thread to keep
        the event loop responsive.

        Parameters
        ----------
        func : callable
            The synchronous function to execute.
        *args : any
            Positional arguments for the function.
        **kwargs : any
            Keyword arguments for the function.

        Returns
        -------
        any
            The result of the function call.
        """
        
        return await asyncio.to_thread(func, *args, **kwargs)

    def make_response(self, response_data, raw_data=None, errors=None, status_code=200):
        """
        Standardized JSON response format.
        If errors is provided as a list, it uses that. 
        Otherwise, it pulls from self.dev.error.

        Parameters
        ----------
        response_data : any
            The main payload to return.
        raw_data : any, optional
            The raw hardware value, if different from response_data.
        errors : list, optional
            A list of strings representing errors.
        status_code : int, optional
            The HTTP status code. Defaults to 200.

        Returns
        -------
        tuple
            A tuple containing (JSON dictionary, status_code).
        """
        
        # Logic: use manual errors if provided, otherwise check the device for errors
        dev_errors = getattr(self.dev, 'error', [])
        final_errors = errors if errors is not None else dev_errors

        # Force a non-200 status code if there are errors
        if final_errors and status_code == 200:
            status_code = 400
            status_code = 500

        res = {
            "raw": raw_data if raw_data is not None else response_data,
@@ -57,24 +106,109 @@ class BaseResource(MethodView):
            "timestamp": self.timestamp,
        }
        
        return jsonify(res), status_code
        return res, status_code

    
    async def _check_dependencies(self, endpoint_path):
        """
        Recursively check if the dependency chain is active using the 
        internal resource registry.

        Parameters
        ----------
        endpoint_path : str
            The URL path of the current endpoint to check.

        Returns
        -------
        tuple
            (bool: is_available, str: error_message)
        """
        if request.args.get('force') == 'true':
            return True, None

        # from noctua.api import ends, resource_registry
        
        dependency = ends.get(endpoint_path, "depends-on", fallback=None)
        
        if dependency:
            # Look up the parent resource instance directly
            parent_resource = resource_registry.get(dependency)
            
            if not parent_resource:
                return False, f"Dependency configuration error: {dependency} not found in registry"

            # Call the parent's get() method directly (internal Python call)
            # This returns (dict, status_code)
            data, status_code = await parent_resource.get()
            
            if status_code != 200 or not data.get("raw") or data.get("error"):
                return False, f"Dependency failed: parent {dependency} is OFF or in error state"
            
            # Recurse
            return await self._check_dependencies(dependency)

        return True, None

    async def dispatch_request(self, *args, **kwargs):
        """
        Override the default dispatch to enforce dependency checks 
        and validate method existence before execution.

        Returns
        -------
        Response
            The Quart response object or a JSON error.
        """
        
        # Determine the logical path for dependency checking
        full_path = request.path
        api_path = full_path.replace("/api", "")
        
        # 1. Validate if the class actually implements the requested HTTP method
        # This prevents the "TypeError: first argument must be callable"
        handler = getattr(self, request.method.lower(), None)
        if handler is None:
            # This will trigger our global 405 JSON error handler
            from quart import abort
            abort(405)

        # 2. Validate dependencies (Topology check)
        is_available, err_msg = await self._check_dependencies(api_path)
        
        if not is_available:
            return jsonify({
                "raw": None,
                "response": None,
                "error": [err_msg],
                "timestamp": self.timestamp,
#                "hint": "Use ?force=true to bypass this check"
            }), 424

        # 3. Proceed to standard MethodView dispatching
        return await super().dispatch_request(*args, **kwargs)
    
def register_error_handlers(app):
    now = datetime.utcnow().isoformat()
    """
    Binds global HTTP errors to a standard JSON format.

    Parameters
    ----------
    app : Quart
        The Quart application instance.
    """
    @app.errorhandler(400)
    async def bad_request(e):
        return jsonify({"error": ["Bad Request"], "timestamp": now}), 400
        return jsonify({"error": ["Bad Request"], "timestamp": datetime.utcnow().isoformat()}), 400

    @app.errorhandler(404)
    async def not_found(e):
        return jsonify({"error": ["URL not found"], "timestamp": now}), 404
        return jsonify({"error": ["URL not found"], "timestamp": datetime.utcnow().isoformat()}), 404

    @app.errorhandler(405)
    async def method_not_allowed(e):
        return jsonify({"error": ["Method not allowed"], "timestamp": now}), 405
    
    @app.errorhandler(500)
    async def internal_error(e):
        return jsonify({"error": [str(e)], "timestamp": now}), 500
    async def not_found(e):
        return jsonify({"error": ["Method not allowed"], "timestamp": datetime.utcnow().isoformat()}), 404

    @app.errorhandler(424)
    async def failed_dependency(e):
        return jsonify({"error": ["Failed Dependency"], "timestamp": datetime.utcnow().isoformat()}), 424
+13 −12
Original line number Diff line number Diff line
@@ -55,6 +55,19 @@ class CoolerTemperatureSetpoint(BaseResource):
        res = await self.run_blocking(action)
        return self.make_response(res)
    
class CoolerWarmup(BaseResource):
    """Manage the warmup of the CCD."""

    async def post(self):
        """Start the warm up the CCD."""
        res = await self.run_blocking(lambda: self.dev.put('cooler', False))
        return self.make_response(res)

    async def delete(self):
        """Stop the warm up of the CCD."""
        
        return self.make_response("Warmup sequence aborted")

class Filters(BaseResource):
    """Camera filters names."""

@@ -256,18 +269,6 @@ class SnapshotDomeslewing(BaseResource):
            seq.tpl.domeslewing = False
        return self.make_response(False)

class CoolerWarmup(BaseResource):
    """Manage the warmup of the CCD."""

    async def post(self):
        """Start the warm up the CCD."""
        res = await self.run_blocking(lambda: self.dev.put('cooler', False))
        return self.make_response(res)

    async def delete(self):
        """Stop the warm up of the CCD."""
        
        return self.make_response("Warmup sequence aborted")

class Connection(BaseResource):
    '''Manage the connection to ASCOM.'''
+46 −0
Original line number Diff line number Diff line
# In noctua/api/common.py (o direttamente nell'init delle API)

from noctua.api import api_blueprint
from noctua.api.__init__ import ends # Il configparser caricato
from quart import request, jsonify
import asyncio

@api_blueprint.route('/all/<namespace>')
async def get_all(namespace):
    """Aggregatore intelligente con gestione della cascata"""
    
    endpoints = {}
    # 1. Filtriamo le sezioni dell'ini per il namespace e ordiniamo per get-priority
    sections = []
    for section in ends.sections():
        if section.startswith("/" + namespace):
            priority = ends[section].getint('get-priority', 999)
            sections.append((priority, section))
    
    sections.sort() # Ordina per priorità (1, 2, 3...)

    # 2. Scansione sequenziale
    for priority, section in sections:
        # Troviamo l'endpoint corrispondente nel server Quart
        # In Quart, possiamo simulare una chiamata interna per massima coerenza
        from quart import current_app
        client = current_app.test_client()
        
        response = await client.get(f"/api{section}")
        data = await response.get_json()
        
        name = section.split("/")[-1] # es. "power" o "light"
        endpoints[name] = data
        
        # 3. LOGICA A CASCATA:
        # Se un elemento con priorità ha un 'raw' False (o errore), 
        # interrompiamo la scansione per questo namespace
        if priority < 999: # Solo per elementi critici (power, connection)
            if data.get("raw") is False or data.get("error") is not None:
                # Marchiamo i successivi come "unavailable"
                break
                
    return jsonify(endpoints)



import asyncio
from datetime import datetime
from dataclasses import dataclass
Loading