diff --git a/hyperglass/api/__init__.py b/hyperglass/api/__init__.py index 88c9d407..7dc510e3 100644 --- a/hyperglass/api/__init__.py +++ b/hyperglass/api/__init__.py @@ -1,253 +1,62 @@ -"""hyperglass REST API & Web UI.""" - -# Standard Library -import sys -from typing import List -from pathlib import Path +"""hyperglass API.""" # Third Party -from fastapi import FastAPI -from fastapi.responses import JSONResponse -from fastapi.exceptions import ValidationException, RequestValidationError -from fastapi.staticfiles import StaticFiles -from starlette.exceptions import HTTPException as StarletteHTTPException -from fastapi.openapi.utils import get_openapi -from fastapi.middleware.cors import CORSMiddleware -from fastapi.middleware.gzip import GZipMiddleware +from litestar import Litestar +from litestar.openapi import OpenAPIConfig +from litestar.exceptions import HTTPException, ValidationException +from litestar.static_files import create_static_files_router # Project -from hyperglass.log import log -from hyperglass.util import cpu_count from hyperglass.state import use_state from hyperglass.constants import __version__ -from hyperglass.models.ui import UIParameters -from hyperglass.api.events import on_startup, on_shutdown -from hyperglass.api.routes import docs, info, query, router, queries, routers, ui_props from hyperglass.exceptions import HyperglassError -from hyperglass.api.error_handlers import ( - app_handler, - http_handler, - default_handler, - validation_handler, -) -from hyperglass.models.api.response import ( - QueryError, - InfoResponse, - QueryResponse, - RoutersResponse, - SupportedQueryResponse, -) -STATE = use_state() +# Local +from .events import check_redis +from .routes import info, query, device, devices, queries +from .middleware import COMPRESSION_CONFIG, create_cors_config +from .error_handlers import app_handler, http_handler, default_handler, validation_handler + +__all__ = ("app",) -WORKING_DIR = Path(__file__).parent -EXAMPLES_DIR = WORKING_DIR / "examples" +STATE = use_state() UI_DIR = STATE.settings.static_path / "ui" IMAGES_DIR = STATE.settings.static_path / "images" -EXAMPLE_DEVICES_PY = EXAMPLES_DIR / "devices.py" -EXAMPLE_QUERIES_PY = EXAMPLES_DIR / "queries.py" -EXAMPLE_QUERY_PY = EXAMPLES_DIR / "query.py" -EXAMPLE_DEVICES_CURL = EXAMPLES_DIR / "devices.sh" -EXAMPLE_QUERIES_CURL = EXAMPLES_DIR / "queries.sh" -EXAMPLE_QUERY_CURL = EXAMPLES_DIR / "query.sh" -ASGI_PARAMS = { - "host": str(STATE.settings.host), - "port": STATE.settings.port, - "debug": STATE.settings.debug, - "workers": cpu_count(2), -} -DOCS_PARAMS = {} -if STATE.params.docs.enable: - DOCS_PARAMS.update({"openapi_url": "/openapi.json"}) - if STATE.params.docs.mode == "redoc": - DOCS_PARAMS.update({"docs_url": None, "redoc_url": STATE.params.docs.path}) - elif STATE.params.docs.mode == "swagger": - DOCS_PARAMS.update({"docs_url": STATE.params.docs.path, "redoc_url": None}) - -for directory in (UI_DIR, IMAGES_DIR): - if not directory.exists(): - log.warning("Directory '{d}' does not exist, creating...", d=str(directory)) - directory.mkdir() - -# Main App Definition -app = FastAPI( - debug=STATE.settings.debug, - title=STATE.params.site_title, - description=STATE.params.site_description, +OPEN_API = OpenAPIConfig( + title=STATE.params.docs.title.format(site_title=STATE.params.site_title), version=__version__, - default_response_class=JSONResponse, - **DOCS_PARAMS, -) - -# Add Event Handlers -for startup in on_startup: - app.add_event_handler("startup", startup) - -for shutdown in on_shutdown: - app.add_event_handler("shutdown", shutdown) - -# HTTP Error Handler -app.add_exception_handler(StarletteHTTPException, http_handler) - -# Backend Application Error Handler -app.add_exception_handler(HyperglassError, app_handler) - -# Request Validation Error Handler -app.add_exception_handler(RequestValidationError, validation_handler) - -# App Validation Error Handler -app.add_exception_handler(ValidationException, validation_handler) - -# Uncaught Error Handler -app.add_exception_handler(Exception, default_handler) - - -def _custom_openapi(): - """Generate custom OpenAPI config.""" - openapi_schema = get_openapi( - title=STATE.params.docs.title.format(site_title=STATE.params.site_title), - version=__version__, - description=STATE.params.docs.description, - routes=app.routes, - ) - openapi_schema["info"]["x-logo"] = {"url": "/images/light" + STATE.params.web.logo.light.suffix} - - query_samples = [] - queries_samples = [] - devices_samples = [] - - with EXAMPLE_QUERY_CURL.open("r") as e: - example = e.read() - query_samples.append({"lang": "cURL", "source": example % str(STATE.params.docs.base_url)}) - - with EXAMPLE_QUERY_PY.open("r") as e: - example = e.read() - query_samples.append( - {"lang": "Python", "source": example % str(STATE.params.docs.base_url)} - ) - - with EXAMPLE_DEVICES_CURL.open("r") as e: - example = e.read() - queries_samples.append( - {"lang": "cURL", "source": example % str(STATE.params.docs.base_url)} - ) - with EXAMPLE_DEVICES_PY.open("r") as e: - example = e.read() - queries_samples.append( - {"lang": "Python", "source": example % str(STATE.params.docs.base_url)} - ) - - with EXAMPLE_QUERIES_CURL.open("r") as e: - example = e.read() - devices_samples.append( - {"lang": "cURL", "source": example % str(STATE.params.docs.base_url)} - ) - - with EXAMPLE_QUERIES_PY.open("r") as e: - example = e.read() - devices_samples.append( - {"lang": "Python", "source": example % str(STATE.params.docs.base_url)} - ) - - openapi_schema["paths"]["/api/query/"]["post"]["x-code-samples"] = query_samples - openapi_schema["paths"]["/api/devices"]["get"]["x-code-samples"] = devices_samples - openapi_schema["paths"]["/api/queries"]["get"]["x-code-samples"] = queries_samples - - app.openapi_schema = openapi_schema - return app.openapi_schema - - -CORS_ORIGINS = STATE.params.cors_origins.copy() -if STATE.settings.dev_mode: - CORS_ORIGINS = [*CORS_ORIGINS, STATE.settings.dev_url, "http://localhost:3000"] - -# CORS Configuration -app.add_middleware( - CORSMiddleware, - allow_origins=CORS_ORIGINS, - allow_methods=["GET", "POST", "OPTIONS"], - allow_headers=["*"], -) - -# GZIP Middleware -app.add_middleware(GZipMiddleware) - -app.add_api_route( - path="/api/info", - endpoint=info, - methods=["GET"], - response_model=InfoResponse, - response_class=JSONResponse, - summary=STATE.params.docs.info.summary, - description=STATE.params.docs.info.description, - tags=[STATE.params.docs.info.title], -) - -app.add_api_route( - path="/api/devices", - endpoint=routers, - methods=["GET"], - response_model=List[RoutersResponse], - response_class=JSONResponse, - summary=STATE.params.docs.devices.summary, - description=STATE.params.docs.devices.description, - tags=[STATE.params.docs.devices.title], -) - -app.add_api_route( - path="/api/devices/{id}", - endpoint=router, - methods=["GET"], - response_model=RoutersResponse, - response_class=JSONResponse, - summary=STATE.params.docs.devices.summary, - description=STATE.params.docs.devices.description, - tags=[STATE.params.docs.devices.title], -) - -app.add_api_route( - path="/api/queries", - endpoint=queries, - methods=["GET"], - response_class=JSONResponse, - response_model=List[SupportedQueryResponse], - summary=STATE.params.docs.queries.summary, - description=STATE.params.docs.queries.description, - tags=[STATE.params.docs.queries.title], -) - -app.add_api_route( - path="/api/query", - endpoint=query, - methods=["POST"], - summary=STATE.params.docs.query.summary, - description=STATE.params.docs.query.description, - responses={ - 400: {"model": QueryError, "description": "Request Content Error"}, - 422: {"model": QueryError, "description": "Request Format Error"}, - 500: {"model": QueryError, "description": "Server Error"}, + description=STATE.params.docs.description, + path=STATE.params.docs.path, + root_schema_site="elements", +) + + +app = Litestar( + route_handlers=[ + device, + devices, + queries, + info, + query, + create_static_files_router( + path="/images", directories=[IMAGES_DIR], name="images", include_in_schema=False + ), + create_static_files_router( + path="/", directories=[UI_DIR], name="ui", html_mode=True, include_in_schema=False + ), + ], + exception_handlers={ + HTTPException: http_handler, + HyperglassError: app_handler, + ValidationException: validation_handler, + Exception: default_handler, }, - response_model=QueryResponse, - tags=[STATE.params.docs.query.title], - response_class=JSONResponse, -) - -app.add_api_route( - path="/ui/props/", - endpoint=ui_props, - methods=["GET", "OPTIONS"], - response_class=JSONResponse, - response_model=UIParameters, - response_model_by_alias=True, + on_startup=[check_redis], + debug=STATE.settings.debug, + cors_config=create_cors_config(state=STATE), + compression_config=COMPRESSION_CONFIG, + openapi_config=OPEN_API if STATE.params.docs.enable else None, ) - - -if STATE.params.docs.enable: - app.add_api_route(path=STATE.params.docs.path, endpoint=docs, include_in_schema=False) - app.openapi = _custom_openapi - -app.mount("/images", StaticFiles(directory=IMAGES_DIR), name="images") -app.mount("/", StaticFiles(directory=UI_DIR, html=True), name="ui") diff --git a/hyperglass/api/error_handlers.py b/hyperglass/api/error_handlers.py index f888004d..2c40583c 100644 --- a/hyperglass/api/error_handlers.py +++ b/hyperglass/api/error_handlers.py @@ -1,49 +1,55 @@ """API Error Handlers.""" # Third Party -from fastapi import Request -from starlette.responses import JSONResponse +from litestar import Request, Response # Project from hyperglass.log import log from hyperglass.state import use_state +__all__ = ( + "default_handler", + "http_handler", + "app_handler", + "validation_handler", +) -async def default_handler(request: Request, exc: BaseException) -> JSONResponse: + +def default_handler(request: Request, exc: BaseException) -> Response: """Handle uncaught errors.""" state = use_state() log.critical( "{method} {path} {detail!s}", method=request.method, path=request.url.path, detail=exc ) - return JSONResponse( + return Response( {"output": state.params.messages.general, "level": "danger", "keywords": []}, status_code=500, ) -async def http_handler(request: Request, exc: BaseException) -> JSONResponse: +def http_handler(request: Request, exc: BaseException) -> Response: """Handle web server errors.""" log.critical( "{method} {path} {detail}", method=request.method, path=request.url.path, detail=exc.detail ) - return JSONResponse( + return Response( {"output": exc.detail, "level": "danger", "keywords": []}, status_code=exc.status_code, ) -async def app_handler(request: Request, exc: BaseException) -> JSONResponse: +def app_handler(request: Request, exc: BaseException) -> Response: """Handle application errors.""" log.critical( "{method} {path} {detail}", method=request.method, path=request.url.path, detail=exc.message ) - return JSONResponse( + return Response( {"output": exc.message, "level": exc.level, "keywords": exc.keywords}, status_code=exc.status_code, ) -async def validation_handler(request: Request, exc: BaseException) -> JSONResponse: +def validation_handler(request: Request, exc: BaseException) -> Response: """Handle Pydantic validation errors raised by FastAPI.""" error = exc.errors()[0] log.critical( @@ -52,7 +58,7 @@ async def validation_handler(request: Request, exc: BaseException) -> JSONRespon path=request.url.path, detail=error["msg"], ) - return JSONResponse( + return Response( {"output": error["msg"], "level": "error", "keywords": error["loc"]}, status_code=422, ) diff --git a/hyperglass/api/events.py b/hyperglass/api/events.py index ad257dec..3d20c4ab 100644 --- a/hyperglass/api/events.py +++ b/hyperglass/api/events.py @@ -1,14 +1,18 @@ """API Events.""" +# Standard Library +import typing as t + +# Third Party +from litestar import Litestar + # Project from hyperglass.state import use_state +__all__ = ("check_redis",) + -def check_redis() -> None: +async def check_redis(_: Litestar) -> t.NoReturn: """Ensure Redis is running before starting server.""" cache = use_state("cache") cache.check() - - -on_startup = (check_redis,) -on_shutdown = () diff --git a/hyperglass/api/examples/devices.py b/hyperglass/api/examples/devices.py deleted file mode 100644 index e6ec88da..00000000 --- a/hyperglass/api/examples/devices.py +++ /dev/null @@ -1,6 +0,0 @@ -# Third Party -import httpx - -request = httpx.get("%s/api/devices") - -print(request.json()) diff --git a/hyperglass/api/examples/devices.sh b/hyperglass/api/examples/devices.sh deleted file mode 100644 index cf94cb23..00000000 --- a/hyperglass/api/examples/devices.sh +++ /dev/null @@ -1 +0,0 @@ -curl %s/api/devices \ No newline at end of file diff --git a/hyperglass/api/examples/queries.py b/hyperglass/api/examples/queries.py deleted file mode 100644 index b44b0fab..00000000 --- a/hyperglass/api/examples/queries.py +++ /dev/null @@ -1,6 +0,0 @@ -# Third Party -import httpx - -request = httpx.get("%s/api/queries") - -print(request.json()) diff --git a/hyperglass/api/examples/queries.sh b/hyperglass/api/examples/queries.sh deleted file mode 100644 index b89af31e..00000000 --- a/hyperglass/api/examples/queries.sh +++ /dev/null @@ -1 +0,0 @@ -curl %s/api/queries \ No newline at end of file diff --git a/hyperglass/api/examples/query.py b/hyperglass/api/examples/query.py deleted file mode 100644 index 3b75ddfb..00000000 --- a/hyperglass/api/examples/query.py +++ /dev/null @@ -1,13 +0,0 @@ -# Third Party -import httpx - -query = { - "query_location": "router01", - "query_type": "bgp_route", - "query_vrf": "default", - "query_target": "1.1.1.0/24", -} - -request = httpx.post("%s/api/query/", data=query) - -print(request.json().get("output")) diff --git a/hyperglass/api/examples/query.sh b/hyperglass/api/examples/query.sh deleted file mode 100644 index ec41f6c1..00000000 --- a/hyperglass/api/examples/query.sh +++ /dev/null @@ -1,7 +0,0 @@ -curl -X POST %s/api/query/ -d \ - '{ - "query_location": "router01", - "query_type": "bgp_route", - "query_vrf": "default", - "query_target": "1.1.1.0/24" - }' \ No newline at end of file diff --git a/hyperglass/api/middleware.py b/hyperglass/api/middleware.py new file mode 100644 index 00000000..71a57266 --- /dev/null +++ b/hyperglass/api/middleware.py @@ -0,0 +1,34 @@ +"""hyperglass API middleware.""" + +# Standard Library +import typing as t + +# Third Party +from litestar.config.cors import CORSConfig +from litestar.config.compression import CompressionConfig + +if t.TYPE_CHECKING: + # Project + from hyperglass.state import HyperglassState + +__all__ = ("create_cors_config", "COMPRESSION_CONFIG") + +COMPRESSION_CONFIG = CompressionConfig(backend="brotli", brotli_gzip_fallback=True) + +REQUEST_LOG_MESSAGE = "REQ" +RESPONSE_LOG_MESSAGE = "RES" +REQUEST_LOG_FIELDS = ("method", "path", "path_params", "query") +RESPONSE_LOG_FIELDS = ("status_code",) + + +def create_cors_config(state: "HyperglassState") -> CORSConfig: + """Create CORS configuration from parameters.""" + origins = state.params.cors_origins.copy() + if state.settings.dev_mode: + origins = [*origins, state.settings.dev_url, "http://localhost:3000"] + + return CORSConfig( + allow_origins=origins, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["*"], + ) diff --git a/hyperglass/api/routes.py b/hyperglass/api/routes.py index 89acd581..72984ec6 100644 --- a/hyperglass/api/routes.py +++ b/hyperglass/api/routes.py @@ -3,69 +3,77 @@ # Standard Library import time import typing as t -from datetime import datetime +from datetime import UTC, datetime # Third Party -from fastapi import Depends, Request, HTTPException, BackgroundTasks -from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from litestar import Request, Response, get, post +from litestar.di import Provide +from litestar.background_tasks import BackgroundTask # Project from hyperglass.log import log -from hyperglass.state import HyperglassState, use_state -from hyperglass.constants import __version__ -from hyperglass.models.ui import UIParameters +from hyperglass.state import HyperglassState from hyperglass.exceptions import HyperglassError from hyperglass.models.api import Query from hyperglass.models.data import OutputDataModel from hyperglass.util.typing import is_type from hyperglass.execution.main import execute -from hyperglass.models.config.params import Params -from hyperglass.models.config.devices import Devices +from hyperglass.models.api.response import QueryResponse +from hyperglass.models.config.params import Params, APIParams +from hyperglass.models.config.devices import Devices, APIDevice # Local +from .state import get_state, get_params, get_devices from .tasks import send_webhook from .fake_output import fake_output +__all__ = ( + "device", + "devices", + "queries", + "info", + "query", +) -def get_state(attr: t.Optional[str] = None): - """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) + +@get("/api/devices/{id:str}", dependencies={"devices": Provide(get_devices)}) +async def device(devices: Devices, id: str) -> APIDevice: + """Retrieve a device by ID.""" + return devices[id].export_api() -def get_params(): - """Get hyperglass params as FastAPI dependency.""" - return use_state("params") +@get("/api/devices", dependencies={"devices": Provide(get_devices)}) +async def devices(devices: Devices) -> t.List[APIDevice]: + """Retrieve all devices.""" + return devices.export_api() -def get_devices(): - """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") +@get("/api/queries", dependencies={"devices": Provide(get_devices)}) +async def queries(devices: Devices) -> t.List[str]: + """Retrieve all directive names.""" + return devices.directive_names() -def get_ui_params(): - """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") +@get("/api/info", dependencies={"params": Provide(get_params)}) +async def info(params: Params) -> APIParams: + """Retrieve looking glass parameters.""" + return params.export_api() -async def query( - query_data: Query, - request: Request, - background_tasks: BackgroundTasks, - state: "HyperglassState" = Depends(get_state), -): +@post("/api/query", dependencies={"_state": Provide(get_state)}) +async def query(_state: HyperglassState, request: Request, data: Query) -> QueryResponse: """Ingest request data pass it to the backend application to perform the query.""" - timestamp = datetime.utcnow() - background_tasks.add_task(send_webhook, query_data, request, timestamp) + timestamp = datetime.now(UTC) # Initialize cache - cache = state.redis + cache = _state.redis - # Use hashed query_data string as key for for k/v cache store so + # Use hashed `data` string as key for for k/v cache store so # each command output value is unique. - cache_key = f"hyperglass.query.{query_data.digest()}" + cache_key = f"hyperglass.query.{data.digest()}" - log.info("{!r} starting query execution", query_data) + log.info("{!r} starting query execution", data) cache_response = cache.get_map(cache_key, "output") json_output = False @@ -73,38 +81,38 @@ async def query( runtime = 65535 if cache_response: - log.debug("{!r} cache hit (cache key {!r})", query_data, cache_key) + log.debug("{!r} cache hit (cache key {!r})", data, cache_key) # If a cached response exists, reset the expiration time. - cache.expire(cache_key, expire_in=state.params.cache.timeout) + cache.expire(cache_key, expire_in=_state.params.cache.timeout) cached = True runtime = 0 timestamp = cache.get_map(cache_key, "timestamp") elif not cache_response: - log.debug("{!r} cache miss (cache key {!r})", query_data, cache_key) + log.debug("{!r} cache miss (cache key {!r})", data, cache_key) - timestamp = query_data.timestamp + timestamp = data.timestamp starttime = time.time() - if state.params.fake_output: + if _state.params.fake_output: # Return fake, static data for development purposes, if enabled. output = await fake_output( - query_type=query_data.query_type, - structured=query_data.device.structured_output or False, + query_type=data.query_type, + structured=data.device.structured_output or False, ) else: # Pass request to execution module - output = await execute(query_data) + output = await execute(data) endtime = time.time() elapsedtime = round(endtime - starttime, 4) - log.debug("{!r} runtime: {!s} seconds", query_data, elapsedtime) + log.debug("{!r} runtime: {!s} seconds", data, elapsedtime) if output is None: - raise HyperglassError(message=state.params.messages.general, alert="danger") + raise HyperglassError(message=_state.params.messages.general, alert="danger") json_output = is_type(output, OutputDataModel) @@ -115,9 +123,9 @@ async def query( cache.set_map_item(cache_key, "output", raw_output) cache.set_map_item(cache_key, "timestamp", timestamp) - cache.expire(cache_key, expire_in=state.params.cache.timeout) + cache.expire(cache_key, expire_in=_state.params.cache.timeout) - log.debug("{!r} cached for {!s} seconds", query_data, state.params.cache.timeout) + log.debug("{!r} cached for {!s} seconds", data, _state.params.cache.timeout) runtime = int(round(elapsedtime, 0)) @@ -130,60 +138,27 @@ async def query( if json_output: response_format = "application/json" - log.success("{!r} execution completed", query_data) + log.success("{!r} execution completed", data) - return { + response = { "output": cache_response, "id": cache_key, "cached": cached, "runtime": runtime, "timestamp": timestamp, "format": response_format, - "random": query_data.random(), + "random": data.random(), "level": "success", "keywords": [], } - -async def docs(params: "Params" = Depends(get_params)): - """Serve custom docs.""" - if params.docs.enable: - docs_func_map = {"swagger": get_swagger_ui_html, "redoc": get_redoc_html} - docs_func = docs_func_map[params.docs.mode] - return docs_func( - openapi_url=params.docs.openapi_url, title=params.site_title + " - API Docs" - ) - raise HTTPException(detail="Not found", status_code=404) - - -async def router(id: str, devices: "Devices" = Depends(get_devices)): - """Get a device's API-facing attributes.""" - return devices[id].export_api() - - -async def routers(devices: "Devices" = Depends(get_devices)): - """Serve list of configured routers and attributes.""" - return devices.export_api() - - -async def queries(params: "Params" = Depends(get_params)): - """Serve list of enabled query types.""" - return params.queries.list - - -async def info(params: "Params" = Depends(get_params)): - """Serve general information about this instance of hyperglass.""" - return { - "name": params.site_title, - "organization": params.org_name, - "primary_asn": int(params.primary_asn), - "version": __version__, - } - - -async def ui_props(ui_params: "UIParameters" = Depends(get_ui_params)): - """Serve UI configration.""" - return ui_params - - -endpoints = [query, docs, routers, info, ui_props] + return Response( + response, + background=BackgroundTask( + send_webhook, + params=_state.params, + data=data, + request=request, + timestamp=timestamp, + ), + ) diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py new file mode 100644 index 00000000..f41a96d7 --- /dev/null +++ b/hyperglass/api/state.py @@ -0,0 +1,27 @@ +"""hyperglass state dependencies.""" + +# Standard Library +import typing as t + +# Project +from hyperglass.state import use_state + + +async def get_state(attr: t.Optional[str] = None): + """Get hyperglass state as a FastAPI dependency.""" + return use_state(attr) + + +async def get_params(): + """Get hyperglass params as FastAPI dependency.""" + return use_state("params") + + +async def get_devices(): + """Get hyperglass devices as FastAPI dependency.""" + return use_state("devices") + + +async def get_ui_params(): + """Get hyperglass ui_params as FastAPI dependency.""" + return use_state("ui_params") diff --git a/hyperglass/api/tasks.py b/hyperglass/api/tasks.py index b9d93fd5..443357ef 100644 --- a/hyperglass/api/tasks.py +++ b/hyperglass/api/tasks.py @@ -1,55 +1,26 @@ """Tasks to be executed from web API.""" # Standard Library -from typing import Dict, Union -from pathlib import Path +import typing as t from datetime import datetime # Third Party from httpx import Headers -from starlette.requests import Request +from litestar import Request # Project from hyperglass.log import log -from hyperglass.state import use_state from hyperglass.external import Webhook, bgptools from hyperglass.models.api import Query -__all__ = ( - "import_public_key", - "process_headers", - "send_webhook", -) +if t.TYPE_CHECKING: + # Project + from hyperglass.models.config.params import Params +__all__ = ("send_webhook",) -def import_public_key(app_path: Union[Path, str], device_name: str, keystring: str) -> bool: - """Import a public key for hyperglass-agent.""" - if not isinstance(app_path, Path): - app_path = Path(app_path) - cert_dir = app_path / "certs" - - if not cert_dir.exists(): - cert_dir.mkdir() - - if not cert_dir.exists(): - raise RuntimeError(f"Failed to create certs directory at {str(cert_dir)}") - - filename = f"{device_name}.pem" - cert_file = cert_dir / filename - - with cert_file.open("w+") as file: - file.write(str(keystring)) - - with cert_file.open("r") as file: - read_file = file.read().strip() - if not keystring == read_file: - raise RuntimeError("Wrote key, but written file did not match input key") - - return True - - -async def process_headers(headers: Headers) -> Dict: +async def process_headers(headers: Headers) -> t.Dict[str, t.Any]: """Filter out unwanted headers and return as a dictionary.""" headers = dict(headers) header_keys = ( @@ -64,12 +35,12 @@ async def process_headers(headers: Headers) -> Dict: async def send_webhook( - query_data: Query, + params: "Params", + data: Query, request: Request, timestamp: datetime, -): +) -> t.NoReturn: """If webhooks are enabled, get request info and send a webhook.""" - params = use_state("params") try: if params.logging.http is not None: headers = await process_headers(headers=request.headers) @@ -84,10 +55,9 @@ async def send_webhook( network_info = await bgptools.network_info(host) async with Webhook(params.logging.http) as hook: - await hook.send( query={ - **query_data.dict(), + **data.dict(), "headers": headers, "source": host, "network": network_info.get(host, {}), @@ -95,4 +65,4 @@ async def send_webhook( } ) except Exception as err: - log.error("Error sending webhook to {}: {}", params.logging.http.provider, str(err)) + log.error("Error sending webhook to {}: {!s}", params.logging.http.provider, err) diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py index b683d3e7..33e3b1d0 100644 --- a/hyperglass/models/config/devices.py +++ b/hyperglass/models/config/devices.py @@ -35,6 +35,14 @@ ALL_DEVICE_TYPES = {*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()} +class APIDevice(t.TypedDict): + """API Response Model for a device.""" + + id: str + name: str + group: t.Union[str, None] + + class DirectiveOptions(HyperglassModel, extra="ignore"): """Per-device directive options.""" @@ -99,7 +107,7 @@ def generate_id(name: str) -> str: return {"id": device_id, "name": display_name, "display_name": None, **values} - def export_api(self) -> t.Dict[str, t.Any]: + def export_api(self) -> APIDevice: """Export API-facing device fields.""" return { "id": self.id, @@ -122,6 +130,11 @@ def directive_ids(self) -> t.List[str]: """Get all directive IDs associated with the device.""" return [directive.id for directive in self.directives] + @property + def directive_names(self) -> t.List[str]: + """Get all directive names associated with the device.""" + return list({directive.name for directive in self.directives}) + def has_directives(self, *directive_ids: str) -> bool: """Determine if a directive is used on this device.""" for directive_id in directive_ids: @@ -304,7 +317,7 @@ def __init__(self: "Devices", *items: t.Dict[str, t.Any]) -> None: with_id = (Device._with_id(item) for item in items) super().__init__(*with_id) - def export_api(self: "Devices") -> t.List[t.Dict[str, t.Any]]: + def export_api(self: "Devices") -> t.List[APIDevice]: """Export API-facing device fields.""" return [d.export_api() for d in self] @@ -332,6 +345,10 @@ def directive_plugins(self: "Devices") -> t.Dict[Path, t.Tuple[str]]: # Convert the directive set to a tuple. return {k: tuple(v) for k, v in result.items()} + def directive_names(self) -> t.List[str]: + """Get all directive names for all devices.""" + return list({directive.name for device in self for directive in device.directives}) + def frontend(self: "Devices") -> t.List[t.Dict[str, t.Any]]: """Export grouped devices for UIParameters.""" groups = {device.group for device in self} diff --git a/hyperglass/models/config/docs.py b/hyperglass/models/config/docs.py index a988bd0e..58929fdd 100644 --- a/hyperglass/models/config/docs.py +++ b/hyperglass/models/config/docs.py @@ -48,7 +48,7 @@ class Docs(HyperglassModel): description="Base URL used in request samples.", ) path: AnyUri = Field( - "/api/docs", + "/docs", title="URI", description="HTTP URI/path where API documentation can be accessed.", ) diff --git a/hyperglass/models/config/params.py b/hyperglass/models/config/params.py index 6d67ae85..4348ff64 100644 --- a/hyperglass/models/config/params.py +++ b/hyperglass/models/config/params.py @@ -2,14 +2,15 @@ # Standard Library import typing as t -from pathlib import Path import urllib.parse +from pathlib import Path # Third Party -from pydantic import Field, ConfigDict, ValidationInfo, field_validator, HttpUrl +from pydantic import Field, HttpUrl, ConfigDict, ValidationInfo, field_validator # Project from hyperglass.settings import Settings +from hyperglass.constants import __version__ # Local from .web import Web @@ -23,6 +24,15 @@ Localhost = t.Literal["localhost"] +class APIParams(t.TypedDict): + """/api/info response model.""" + + name: str + organization: str + primary_asn: int + version: str + + class ParamsPublic(HyperglassModel): """Public configuration parameters.""" @@ -124,6 +134,15 @@ def common_plugins(self) -> t.Tuple[Path, ...]: """Get all validated external common plugins as Path objects.""" return tuple(Path(p) for p in self.plugins) + def export_api(self) -> APIParams: + """Export API-specific parameters.""" + return { + "name": self.site_title, + "organization": self.org_name, + "primary_asn": int(self.primary_asn), + "version": __version__, + } + def frontend(self) -> t.Dict[str, t.Any]: """Export UI-specific parameters.""" diff --git a/pyproject.toml b/pyproject.toml index f5d31908..43b810df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "PyYAML>=6.0", "aiofiles>=23.2.1", "distro==1.8.0", - "fastapi>=0.110.0", "favicons==0.2.2", "gunicorn>=21.2.0", "httpx==0.24.0", @@ -30,6 +29,7 @@ dependencies = [ "toml>=0.10.2", "pydantic-settings>=2.2.1", "pydantic-extra-types>=2.6.0", + "litestar[standard,brotli]>=2.7.0", ] readme = "README.md" requires-python = ">= 3.11" @@ -75,7 +75,7 @@ import_heading_stdlib = "Standard Library" import_heading_thirdparty = "Third Party" include_trailing_comma = true indent = ' ' -known_third_party = ["starlette", "fastapi", "inquirer"] +known_third_party = ["litestar", "inquirer"] length_sort = true line_length = 100 multi_line_output = 3 diff --git a/requirements-dev.lock b/requirements-dev.lock index 1b599b38..d4398fc2 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -14,11 +14,16 @@ annotated-types==0.6.0 # via pydantic anyio==4.3.0 # via httpcore - # via starlette + # via litestar + # via watchfiles +async-timeout==4.0.3 + # via redis bandit==1.7.7 bcrypt==4.1.2 # via paramiko black==24.2.0 +brotli==1.1.0 + # via litestar certifi==2024.2.2 # via httpcore # via httpx @@ -31,6 +36,8 @@ chardet==5.2.0 # via reportlab click==8.1.7 # via black + # via litestar + # via rich-click # via typer # via uvicorn colorama==0.4.6 @@ -43,8 +50,12 @@ distlib==0.3.8 # via virtualenv distro==1.8.0 # via hyperglass -fastapi==0.110.0 - # via hyperglass +editorconfig==0.12.4 + # via jsbeautifier +faker==24.4.0 + # via polyfactory +fast-query-parsers==1.0.3 + # via litestar favicons==0.2.2 # via hyperglass filelock==3.13.1 @@ -62,8 +73,11 @@ h11==0.14.0 # via uvicorn httpcore==0.17.3 # via httpx +httptools==0.6.1 + # via uvicorn httpx==0.24.0 # via hyperglass + # via litestar identify==2.5.35 # via pre-commit idna==3.6 @@ -72,16 +86,28 @@ idna==3.6 iniconfig==2.0.0 # via pytest isort==5.13.2 +jinja2==3.1.3 + # via litestar +jsbeautifier==1.15.1 + # via litestar +litestar==2.7.0 + # via hyperglass loguru==0.7.0 # via hyperglass lxml==5.1.0 # via svglib markdown-it-py==3.0.0 # via rich +markupsafe==2.1.5 + # via jinja2 mccabe==0.7.0 # via flake8 mdurl==0.1.2 # via markdown-it-py +msgspec==0.18.6 + # via litestar +multidict==6.0.5 + # via litestar mypy-extensions==1.0.0 # via black netmiko==4.1.2 @@ -112,6 +138,8 @@ platformdirs==4.2.0 # via virtualenv pluggy==1.4.0 # via pytest +polyfactory==2.15.0 + # via litestar pre-commit==3.6.2 psutil==5.9.4 # via hyperglass @@ -125,7 +153,6 @@ pycodestyle==2.11.1 pycparser==2.21 # via cffi pydantic==2.6.3 - # via fastapi # via hyperglass # via pydantic-extra-types # via pydantic-settings @@ -150,13 +177,18 @@ pytest==8.0.1 # via pytest-dependency pytest-asyncio==0.23.5 pytest-dependency==0.6.0 +python-dateutil==2.9.0.post0 + # via faker python-dotenv==1.0.1 # via pydantic-settings + # via uvicorn pyyaml==6.0.1 # via bandit # via hyperglass + # via litestar # via netmiko # via pre-commit + # via uvicorn redis==4.5.4 # via hyperglass reportlab==4.1.0 @@ -166,6 +198,10 @@ rich==13.7.0 # via bandit # via favicons # via hyperglass + # via litestar + # via rich-click +rich-click==1.7.4 + # via litestar rlpycairo==0.3.0 # via favicons ruff==0.2.2 @@ -176,14 +212,14 @@ setuptools==69.1.0 # via nodeenv # via pytest-dependency six==1.16.0 + # via jsbeautifier + # via python-dateutil # via textfsm sniffio==1.3.0 # via anyio # via httpcore # via httpx stackprinter==0.2.11 -starlette==0.36.3 - # via fastapi stevedore==5.1.0 # via bandit svglib==1.5.1 @@ -205,18 +241,27 @@ typer==0.9.0 # via favicons # via hyperglass typing-extensions==4.9.0 - # via fastapi + # via litestar + # via polyfactory # via pydantic # via pydantic-core + # via rich-click # via typer uvicorn==0.21.1 # via hyperglass -uvloop==0.17.0 + # via litestar +uvloop==0.19.0 # via hyperglass + # via litestar + # via uvicorn virtualenv==20.25.0 # via pre-commit +watchfiles==0.21.0 + # via uvicorn webencodings==0.5.1 # via cssselect2 # via tinycss2 +websockets==12.0 + # via uvicorn xmltodict==0.13.0 # via hyperglass diff --git a/requirements.lock b/requirements.lock index eee32218..4e03b072 100644 --- a/requirements.lock +++ b/requirements.lock @@ -14,9 +14,14 @@ annotated-types==0.6.0 # via pydantic anyio==4.3.0 # via httpcore - # via starlette + # via litestar + # via watchfiles +async-timeout==4.0.3 + # via redis bcrypt==4.1.2 # via paramiko +brotli==1.1.0 + # via litestar certifi==2024.2.2 # via httpcore # via httpx @@ -26,6 +31,8 @@ cffi==1.16.0 chardet==5.2.0 # via reportlab click==8.1.7 + # via litestar + # via rich-click # via typer # via uvicorn cryptography==42.0.3 @@ -34,8 +41,12 @@ cssselect2==0.7.0 # via svglib distro==1.8.0 # via hyperglass -fastapi==0.110.0 - # via hyperglass +editorconfig==0.12.4 + # via jsbeautifier +faker==24.4.0 + # via polyfactory +fast-query-parsers==1.0.3 + # via litestar favicons==0.2.2 # via hyperglass freetype-py==2.4.0 @@ -49,19 +60,34 @@ h11==0.14.0 # via uvicorn httpcore==0.17.3 # via httpx +httptools==0.6.1 + # via uvicorn httpx==0.24.0 # via hyperglass + # via litestar idna==3.6 # via anyio # via httpx +jinja2==3.1.3 + # via litestar +jsbeautifier==1.15.1 + # via litestar +litestar==2.7.0 + # via hyperglass loguru==0.7.0 # via hyperglass lxml==5.1.0 # via svglib markdown-it-py==3.0.0 # via rich +markupsafe==2.1.5 + # via jinja2 mdurl==0.1.2 # via markdown-it-py +msgspec==0.18.6 + # via litestar +multidict==6.0.5 + # via litestar netmiko==4.1.2 # via hyperglass ntc-templates==4.3.0 @@ -76,6 +102,8 @@ pillow==10.2.0 # via favicons # via hyperglass # via reportlab +polyfactory==2.15.0 + # via litestar psutil==5.9.4 # via hyperglass py-cpuinfo==9.0.0 @@ -85,7 +113,6 @@ pycairo==1.26.0 pycparser==2.21 # via cffi pydantic==2.6.3 - # via fastapi # via hyperglass # via pydantic-extra-types # via pydantic-settings @@ -103,11 +130,16 @@ pynacl==1.5.0 # via paramiko pyserial==3.5 # via netmiko +python-dateutil==2.9.0.post0 + # via faker python-dotenv==1.0.1 # via pydantic-settings + # via uvicorn pyyaml==6.0.1 # via hyperglass + # via litestar # via netmiko + # via uvicorn redis==4.5.4 # via hyperglass reportlab==4.1.0 @@ -116,6 +148,10 @@ reportlab==4.1.0 rich==13.7.0 # via favicons # via hyperglass + # via litestar + # via rich-click +rich-click==1.7.4 + # via litestar rlpycairo==0.3.0 # via favicons scp==0.14.5 @@ -123,13 +159,13 @@ scp==0.14.5 setuptools==69.1.0 # via netmiko six==1.16.0 + # via jsbeautifier + # via python-dateutil # via textfsm sniffio==1.3.0 # via anyio # via httpcore # via httpx -starlette==0.36.3 - # via fastapi svglib==1.5.1 # via favicons tenacity==8.2.3 @@ -146,16 +182,25 @@ typer==0.9.0 # via favicons # via hyperglass typing-extensions==4.9.0 - # via fastapi + # via litestar + # via polyfactory # via pydantic # via pydantic-core + # via rich-click # via typer uvicorn==0.21.1 # via hyperglass -uvloop==0.17.0 + # via litestar +uvloop==0.19.0 # via hyperglass + # via litestar + # via uvicorn +watchfiles==0.21.0 + # via uvicorn webencodings==0.5.1 # via cssselect2 # via tinycss2 +websockets==12.0 + # via uvicorn xmltodict==0.13.0 # via hyperglass