1
0
Fork 1
mirror of https://github.com/thatmattlove/hyperglass.git synced 2026-01-17 00:38:06 +00:00

initial migration to litestar

This commit is contained in:
thatmattlove 2024-03-26 23:59:42 -04:00
parent 1ef376fb38
commit d2e1486b5a
19 changed files with 356 additions and 439 deletions

View file

@ -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,
)
# 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",)
STATE = use_state()
WORKING_DIR = Path(__file__).parent
EXAMPLES_DIR = WORKING_DIR / "examples"
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,
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(
OPEN_API = OpenAPIConfig(
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)}
path=STATE.params.docs.path,
root_schema_site="elements",
)
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"},
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,
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,
)
app.add_api_route(
path="/ui/props/",
endpoint=ui_props,
methods=["GET", "OPTIONS"],
response_class=JSONResponse,
response_model=UIParameters,
response_model_by_alias=True,
)
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")

View file

@ -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,
)

View file

@ -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 = ()

View file

@ -1,6 +0,0 @@
# Third Party
import httpx
request = httpx.get("%s/api/devices")
print(request.json())

View file

@ -1 +0,0 @@
curl %s/api/devices

View file

@ -1,6 +0,0 @@
# Third Party
import httpx
request = httpx.get("%s/api/queries")
print(request.json())

View file

@ -1 +0,0 @@
curl %s/api/queries

View file

@ -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"))

View file

@ -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"
}'

View file

@ -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=["*"],
)

View file

@ -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
def get_state(attr: t.Optional[str] = None):
"""Get hyperglass state as a FastAPI dependency."""
return use_state(attr)
__all__ = (
"device",
"devices",
"queries",
"info",
"query",
)
def get_params():
"""Get hyperglass params as FastAPI dependency."""
return use_state("params")
@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_devices():
"""Get hyperglass devices as FastAPI dependency."""
return use_state("devices")
@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_ui_params():
"""Get hyperglass ui_params as FastAPI dependency."""
return use_state("ui_params")
@get("/api/queries", dependencies={"devices": Provide(get_devices)})
async def queries(devices: Devices) -> t.List[str]:
"""Retrieve all directive names."""
return devices.directive_names()
async def query(
query_data: Query,
request: Request,
background_tasks: BackgroundTasks,
state: "HyperglassState" = Depends(get_state),
):
@get("/api/info", dependencies={"params": Provide(get_params)})
async def info(params: Params) -> APIParams:
"""Retrieve looking glass parameters."""
return params.export_api()
@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"
return Response(
response,
background=BackgroundTask(
send_webhook,
params=_state.params,
data=data,
request=request,
timestamp=timestamp,
),
)
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]

27
hyperglass/api/state.py Normal file
View file

@ -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")

View file

@ -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)

View file

@ -35,6 +35,14 @@ from .http_client import HttpConfiguration
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 @@ class Device(HyperglassModelWithId, extra="allow"):
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 @@ class Device(HyperglassModelWithId, extra="allow"):
"""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 @@ class Devices(MultiModel, model=Device, unique_by="id"):
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 @@ class Devices(MultiModel, model=Device, unique_by="id"):
# 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}

View file

@ -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.",
)

View file

@ -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 @@ from .structured import Structured
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 @@ class Params(ParamsPublic, HyperglassModel):
"""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."""

View file

@ -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

View file

@ -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

View file

@ -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