Skip to content

Commit ef222b4

Browse files
authored
Refactor of API versus JBI, and leverage of pytest fixtures for smaller tests (#76)
* Group all API endpoints into ``src.app`` * Do not import module on each request * Use Actions model instead of Mapping * Move configuration to src.app * Test webhook in api tests * Rewrite router tests without HTTP * Rename src.jbi.router to src.jbi.runner * Show action module in logs * Refactor tests to leverage pytest features * Rename bad config file * Use explicit folders for templates and static * Add trailing slash in powered_by_jbi URL (for consistency with others) * Allow None in Actions.get() * Add tests for views * Leverage fixtures in monitor tests * Test all endpoints * Test default action commenting * Add tests for configuration * Add missing tests for bugzilla comments * Migrate default_with_status_and_assignee to fixtures * @grahamalama review
1 parent a9f4915 commit ef222b4

23 files changed

+972
-953
lines changed

src/app/api.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,29 @@
44
import logging
55
import time
66
from datetime import datetime
7+
from pathlib import Path
8+
from typing import Dict, List, Optional
79

810
import sentry_sdk
911
import uvicorn # type: ignore
10-
from fastapi import FastAPI, Request
12+
from fastapi import Body, Depends, FastAPI, Request
13+
from fastapi.responses import HTMLResponse, JSONResponse
1114
from fastapi.staticfiles import StaticFiles
15+
from fastapi.templating import Jinja2Templates
1216
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
1317

18+
from src.app import configuration
1419
from src.app.environment import get_settings
1520
from src.app.log import configure_logging
1621
from src.app.monitor import api_router as monitor_router
17-
from src.jbi.router import api_router as jbi_router
22+
from src.jbi.bugzilla import BugzillaWebhookRequest
23+
from src.jbi.models import Actions
24+
from src.jbi.runner import IgnoreInvalidRequestError, execute_action
25+
from src.jbi.services import get_jira
26+
27+
SRC_DIR = Path(__file__).parents[1]
28+
29+
templates = Jinja2Templates(directory=SRC_DIR / "templates")
1830

1931
settings = get_settings()
2032

@@ -27,8 +39,7 @@
2739
)
2840

2941
app.include_router(monitor_router)
30-
app.include_router(jbi_router)
31-
app.mount("/static", StaticFiles(directory="src/static"), name="static")
42+
app.mount("/static", StaticFiles(directory=SRC_DIR / "static"), name="static")
3243

3344
sentry_sdk.init( # pylint: disable=abstract-class-instantiated # noqa: E0110
3445
dsn=settings.sentry_dsn
@@ -78,6 +89,56 @@ async def request_summary(request: Request, call_next):
7889
return response
7990

8091

92+
@app.post("/bugzilla_webhook")
93+
def bugzilla_webhook(
94+
request: BugzillaWebhookRequest = Body(..., embed=False),
95+
actions: Actions = Depends(configuration.get_actions),
96+
):
97+
"""API endpoint that Bugzilla Webhook Events request"""
98+
try:
99+
result = execute_action(request, actions, settings)
100+
return JSONResponse(content=result, status_code=200)
101+
except IgnoreInvalidRequestError as exception:
102+
return JSONResponse(content={"error": str(exception)}, status_code=200)
103+
104+
105+
@app.get("/whiteboard_tags/")
106+
def get_whiteboard_tag(
107+
whiteboard_tag: Optional[str] = None,
108+
actions: Actions = Depends(configuration.get_actions),
109+
):
110+
"""API for viewing whiteboard_tags and associated data"""
111+
if existing := actions.get(whiteboard_tag):
112+
return {whiteboard_tag: existing}
113+
return actions.all()
114+
115+
116+
@app.get("/jira_projects/")
117+
def get_jira_projects():
118+
"""API for viewing projects that are currently accessible by API"""
119+
jira = get_jira()
120+
visible_projects: List[Dict] = jira.projects(included_archived=None)
121+
return [project["key"] for project in visible_projects]
122+
123+
124+
@app.get("/powered_by_jbi/", response_class=HTMLResponse)
125+
def powered_by_jbi(
126+
request: Request,
127+
enabled: Optional[bool] = None,
128+
actions: Actions = Depends(configuration.get_actions),
129+
):
130+
"""API for `Powered By` endpoint"""
131+
entries = actions.all()
132+
context = {
133+
"request": request,
134+
"title": "Powered by JBI",
135+
"num_configs": len(entries),
136+
"data": entries,
137+
"enable_query": enabled,
138+
}
139+
return templates.TemplateResponse("powered_by_template.html", context)
140+
141+
81142
if __name__ == "__main__":
82143
uvicorn.run(
83144
"app:app", host=settings.host, port=settings.port, reload=settings.app_reload

src/jbi/configuration.py renamed to src/app/configuration.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,3 @@ def get_actions(
3131
except ValidationError as exception:
3232
logger.exception(exception)
3333
raise ConfigError("Errors exist.") from exception
34-
35-
36-
def get_actions_dict():
37-
"""Returns dict of `get_actions()`"""
38-
return get_actions().dict()["actions"]

src/app/monitor.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from fastapi.responses import JSONResponse
88

99
from src.app import environment
10-
from src.jbi.services import jbi_service_health_map, jira_visible_projects
10+
from src.jbi.services import jbi_service_health_map
1111

1212
api_router = APIRouter(tags=["Monitor"])
1313

@@ -62,9 +62,3 @@ def head_lbheartbeat(request: Request):
6262
def version():
6363
"""Return version.json, as required by Dockerflow."""
6464
return environment.get_version()
65-
66-
67-
@api_router.get("/jira_projects/")
68-
def get_jira_projects():
69-
"""API for viewing projects that are currently accessible by API"""
70-
return jira_visible_projects()

src/jbi/bugzilla.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
import datetime
77
import json
88
import logging
9-
from typing import Dict, List, Optional
9+
from typing import Dict, List, Optional, Tuple
1010
from urllib.parse import ParseResult, urlparse
1111

1212
from pydantic import BaseModel # pylint: disable=no-name-in-module
1313

14+
from src.jbi.errors import ActionNotFoundError
15+
from src.jbi.models import Action, Actions
16+
1417
logger = logging.getLogger(__name__)
1518

1619
JIRA_HOSTNAMES = ("jira", "atlassian")
@@ -200,6 +203,15 @@ def extract_from_see_also(self):
200203

201204
return None
202205

206+
def lookup_action(self, actions: Actions) -> Tuple[str, Action]:
207+
"""Find first matching action from bug's whiteboard list"""
208+
tags: List[str] = self.get_potential_whiteboard_config_list()
209+
for tag in tags:
210+
tag = tag.lower()
211+
if action := actions.get(tag):
212+
return tag, action
213+
raise ActionNotFoundError(", ".join(tags))
214+
203215

204216
class BugzillaWebhookRequest(BaseModel):
205217
"""Bugzilla Webhook Request Object"""

src/jbi/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
"""Custom exceptions for JBI"""
22

33

4+
class ActionNotFoundError(Exception):
5+
"""No Action could be found for this bug"""
6+
7+
48
class IgnoreInvalidRequestError(Exception):
59
"""Error thrown when requests are invalid and ignored"""
610

src/jbi/models.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
"""
22
Python Module for Pydantic Models and validation
33
"""
4+
import functools
45
import importlib
56
from inspect import signature
67
from types import ModuleType
7-
from typing import Any, Dict, Optional
8+
from typing import Any, Callable, Dict, Mapping, Optional
89

910
from pydantic import Extra, root_validator, validator
1011
from pydantic_yaml import YamlModel
1112

1213

13-
class Action(YamlModel, extra=Extra.allow):
14+
class Action(YamlModel):
1415
"""
1516
Action is the inner model for each action in the configuration file"""
1617

@@ -19,6 +20,13 @@ class Action(YamlModel, extra=Extra.allow):
1920
allow_private: bool = False
2021
parameters: dict = {}
2122

23+
@functools.cached_property
24+
def callable(self) -> Callable:
25+
"""Return the initialized callable for this action."""
26+
action_module: ModuleType = importlib.import_module(self.action)
27+
initialized: Callable = action_module.init(**self.parameters) # type: ignore
28+
return initialized
29+
2230
@root_validator
2331
def validate_action_config(
2432
cls, values
@@ -40,13 +48,27 @@ def validate_action_config(
4048
raise ValueError("action is not properly setup.") from exception
4149
return values
4250

51+
class Config:
52+
"""Pydantic configuration"""
53+
54+
extra = Extra.allow
55+
keep_untouched = (functools.cached_property,)
56+
4357

4458
class Actions(YamlModel):
4559
"""
4660
Actions is the overall model for the list of `actions` in the configuration file
4761
"""
4862

49-
actions: Dict[str, Action]
63+
actions: Mapping[str, Action]
64+
65+
def get(self, tag: Optional[str]) -> Optional[Action]:
66+
"""Lookup actions by whiteboard tag"""
67+
return self.actions.get(tag.lower()) if tag else None
68+
69+
def all(self):
70+
"""Return mapping of all actions"""
71+
return self.actions
5072

5173
@validator("actions")
5274
def validate_action_matches_whiteboard(

src/jbi/router.py

Lines changed: 0 additions & 147 deletions
This file was deleted.

0 commit comments

Comments
 (0)