From 65ccce5aff27c3dd1591af38dfc682a9fafd5df6 Mon Sep 17 00:00:00 2001 From: checktheroads Date: Mon, 20 Jan 2020 10:17:52 -0700 Subject: [PATCH] add fastapi docs config model --- hyperglass/configuration/models/_utils.py | 30 +++++++++- hyperglass/configuration/models/general.py | 3 + hyperglass/hyperglass.py | 68 +++++++++++++++++----- hyperglass/web.py | 4 +- manage.py | 13 +++-- 5 files changed, 92 insertions(+), 26 deletions(-) diff --git a/hyperglass/configuration/models/_utils.py b/hyperglass/configuration/models/_utils.py index 98006d4..444350c 100644 --- a/hyperglass/configuration/models/_utils.py +++ b/hyperglass/configuration/models/_utils.py @@ -4,7 +4,7 @@ import re # Third Party Imports -from pydantic import BaseSettings +from pydantic import BaseModel def clean_name(_name): @@ -25,7 +25,7 @@ def clean_name(_name): return _scrubbed.lower() -class HyperglassModel(BaseSettings): +class HyperglassModel(BaseModel): """Base model for all hyperglass configuration models.""" pass @@ -51,3 +51,29 @@ class HyperglassModelExtra(HyperglassModel): """Default pydantic configuration.""" extra = "allow" + + +class AnyUri(str): + """Custom field type for HTTP URI, e.g. /example.""" + + @classmethod + def __get_validators__(cls): + """Pydantic custim field method.""" + yield cls.validate + + @classmethod + def validate(cls, value): + """Ensure URI string contains a leading forward-slash.""" + uri_regex = re.compile(r"^(\/.*)$") + if not isinstance(value, str): + raise TypeError("AnyUri type must be a string") + match = uri_regex.fullmatch(value) + if not match: + raise ValueError( + "Invalid format. A URI must begin with a forward slash, e.g. '/example'" + ) + return cls(match.group()) + + def __repr__(self): + """Stringify custom field representation.""" + return f"AnyUri({super().__repr__()})" diff --git a/hyperglass/configuration/models/general.py b/hyperglass/configuration/models/general.py index 8dc223c..8527d39 100644 --- a/hyperglass/configuration/models/general.py +++ b/hyperglass/configuration/models/general.py @@ -18,6 +18,7 @@ from pydantic import validator # Project Imports from hyperglass.configuration.models._utils import HyperglassModel +from hyperglass.configuration.models.docs import Docs from hyperglass.configuration.models.opengraph import OpenGraph @@ -46,6 +47,7 @@ class General(HyperglassModel): "isp", ] opengraph: OpenGraph = OpenGraph() + docs: Docs = Docs() google_analytics: StrictStr = "" redis_host: StrictStr = "localhost" redis_port: StrictInt = 6379 @@ -54,6 +56,7 @@ class General(HyperglassModel): listen_address: Optional[Union[IPvAnyAddress, StrictStr]] listen_port: StrictInt = 8001 log_file: Optional[FilePath] + cors_origins: List[StrictStr] = [] @validator("listen_address", pre=True, always=True) def validate_listen_address(cls, value, values): diff --git a/hyperglass/hyperglass.py b/hyperglass/hyperglass.py index 3b4894d..45af472 100644 --- a/hyperglass/hyperglass.py +++ b/hyperglass/hyperglass.py @@ -1,7 +1,6 @@ """Hyperglass Front End.""" # Standard Library Imports -import asyncio import os import tempfile import time @@ -17,12 +16,14 @@ from prometheus_client import Counter from prometheus_client import generate_latest from prometheus_client import multiprocess from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request from starlette.responses import PlainTextResponse from starlette.responses import UJSONResponse from starlette.staticfiles import StaticFiles # Project Imports +from hyperglass import __version__ from hyperglass.configuration import frontend_params from hyperglass.configuration import params from hyperglass.exceptions import AuthError @@ -37,6 +38,7 @@ from hyperglass.execution.execute import Execute from hyperglass.models.query import Query from hyperglass.util import check_python from hyperglass.util import log +from hyperglass.util import write_env # Verify Python version meets minimum requirement try: @@ -57,14 +59,41 @@ IMAGES_DIR = STATIC_DIR / "images" NEXT_DIR = UI_DIR / "_next" log.debug(f"Static Files: {STATIC_DIR}") +docs_mode_map = {"swagger": "docs_url", "redoc": "redoc_url"} + +docs_config = {"docs_url": None, "redoc_url": None} + +if params.general.docs.enable: + if params.general.docs.mode == "swagger": + docs_config["docs_url"] = params.general.docs.uri + docs_config["redoc_url"] = None + elif params.general.docs.mode == "redoc": + docs_config["docs_url"] = None + docs_config["redoc_url"] = params.general.docs.uri + + # Main App Definition -app = FastAPI() +app = FastAPI( + debug=params.general.debug, + title=params.general.site_title, + description=params.general.site_description, + version=__version__, + **docs_config, +) app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui") app.mount("/_next", StaticFiles(directory=NEXT_DIR), name="_next") app.mount("/images", StaticFiles(directory=IMAGES_DIR), name="images") app.mount("/ui/images", StaticFiles(directory=IMAGES_DIR), name="ui/images") -APP_PARAMS = { +# CORS Configuration +app.add_middleware( + CORSMiddleware, + allow_origins=params.general.cors_origins, + allow_methods=["*"], + allow_headers=["*"], +) + +ASGI_PARAMS = { "host": str(params.general.listen_address), "port": params.general.listen_port, "debug": params.general.debug, @@ -72,7 +101,7 @@ APP_PARAMS = { # Redis Config redis_config = { - "host": params.general.redis_host, + "host": str(params.general.redis_host), "port": params.general.redis_port, "decode_responses": True, } @@ -80,6 +109,7 @@ redis_config = { r_cache = aredis.StrictRedis(db=params.features.cache.redis_id, **redis_config) +@app.on_event("startup") async def check_redis(): """Ensure Redis is running before starting server. @@ -89,19 +119,29 @@ async def check_redis(): Returns: {bool} -- True if Redis is running. """ + redis_host = redis_config["host"] + redis_port = redis_config["port"] try: await r_cache.echo("hyperglass test") - # await r_limiter.echo("hyperglass test") except Exception: raise HyperglassError( - f"Redis isn't running at: {redis_config['host']}:{redis_config['port']}", - alert="danger", + f"Redis isn't running at: {redis_host}:{redis_port}", alert="danger" ) from None + log.debug(f"Redis is running at: {redis_host}:{redis_port}") return True -# Verify Redis is running -asyncio.run(check_redis()) +@app.on_event("startup") +async def write_env_variables(): + """Write environment varibles for Next.js/Node. + + Returns: + {bool} -- True if successful + """ + result = await write_env({"NODE_ENV": "production", "_HYPERGLASS_URL_": "/"}) + if result: + log.debug(result) + return True # Prometheus Config @@ -141,20 +181,16 @@ async def metrics(request): @app.exception_handler(StarletteHTTPException) async def http_exception_handler(request, exc): - """Handle application errors.""" + """Handle web server errors.""" return UJSONResponse( - { - "output": exc.detail.get("message", params.messages.general), - "alert": exc.detail.get("alert", "error"), - "keywords": exc.detail.get("keywords", []), - }, + {"output": exc.detail, "alert": "danger", "keywords": []}, status_code=exc.status_code, ) @app.exception_handler(HyperglassError) async def http_exception_handler(request, exc): - """Handle request validation errors.""" + """Handle application errors.""" return UJSONResponse( {"output": exc.message, "alert": exc.alert, "keywords": exc.keywords}, status_code=400, diff --git a/hyperglass/web.py b/hyperglass/web.py index 275c2f9..2888e57 100644 --- a/hyperglass/web.py +++ b/hyperglass/web.py @@ -4,9 +4,9 @@ def start(): """Start the web server with Uvicorn ASGI.""" import uvicorn - from hyperglass.hyperglass import app, APP_PARAMS + from hyperglass.hyperglass import app, ASGI_PARAMS - uvicorn.run(app, **APP_PARAMS) + uvicorn.run(app, **ASGI_PARAMS) app = start() diff --git a/manage.py b/manage.py index dac3750..0c219de 100755 --- a/manage.py +++ b/manage.py @@ -611,21 +611,22 @@ def build_ui(): def dev_server(host, port, build): """Renders theme and web build, then starts dev web server""" try: - from hyperglass.hyperglass import app, APP_PARAMS + from hyperglass.hyperglass import app, ASGI_PARAMS except ImportError as import_error: raise click.ClickException( click.style("✗ Error importing hyperglass: ", fg="red", bold=True) + click.style(import_error, fg="blue") ) + asgi_params = ASGI_PARAMS.copy() if host is not None: - APP_PARAMS["host"] = host + asgi_params["host"] = host if port is not None: - APP_PARAMS["port"] = port + asgi_params["port"] = port write_env_variables( { "NODE_ENV": "development", - "_HYPERGLASS_URL_": f'http://{APP_PARAMS["host"]}:{APP_PARAMS["port"]}/', + "_HYPERGLASS_URL_": f'http://{asgi_params["host"]}:{asgi_params["port"]}/', } ) if build: @@ -637,9 +638,9 @@ def dev_server(host, port, build): + click.style(e, fg="white") ) from None if build_complete: - start_dev_server(app, APP_PARAMS) + start_dev_server(app, asgi_params) if not build: - start_dev_server(app, APP_PARAMS) + start_dev_server(app, asgi_params) @hg.command("migrate-configs", help="Copy YAML examples to usable config files")