From 244461b6fa4f7d0e12919337620931bccd2d2a1a Mon Sep 17 00:00:00 2001 From: picard-remi Date: Sun, 21 Jan 2024 13:43:54 +0100 Subject: [PATCH] Add API POST /robotframework/run --- README.md | 58 ++++++ RobotFrameworkService/main.py | 3 +- .../requests/RobotOptions.py | 26 +++ RobotFrameworkService/requests/__init__.py | 0 .../routers/robotframework_run.py | 173 ++++++++++++++++++ examples/tests/demotest.robot | 14 ++ requirements-dev.txt | 3 +- tests/test_app.py | 12 ++ 8 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 RobotFrameworkService/requests/RobotOptions.py create mode 100644 RobotFrameworkService/requests/__init__.py create mode 100644 RobotFrameworkService/routers/robotframework_run.py diff --git a/README.md b/README.md index 9e58a89..80d0bdc 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,66 @@ http://localhost:5003/robotframework/run/mytask http://localhost:5003/robotframework/run/mytask/async ``` +There is also the all-in-one endpoint `POST http://localhost:5003/robotframework/run` which trigger execution of a robot files, test, task or suite. + +It can be customized with options in JSON payload. +All available options are documented in Swagger schema and examples. + +Response contains a header field `x-request-id` that can be used to retrieve logs and reports. + +By default, execution is asynchronous, but it can be changed with **sync** option. + **There is no limitation on executed Robot processes! It is easy to push the webservice in DOS with too many requests at once** +### Call robot test + +``` +curl -X 'POST' \ + 'http://localhost:5003/robotframework/run' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "paths": [ + "examples" + ], + "test": "Demonstration Test" +}' +``` + +### Call robot task + +``` +curl -X 'POST' \ + 'http://localhost:5003/robotframework/run' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "paths": [ + "examples" + ], + "task": "Demonstration Task" +}' +``` + +### Call robot task with variables + +``` +curl -X 'POST' \ + 'http://localhost:5003/robotframework/run' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "paths": [ + "examples" + ], + "task": "Task with more variables", + "variables": { + "firstname": "Max", + "lastname": "Mustermann" + } +}' +``` + ## Reporting Endpoints that provide `log.html` and `report.html` for a specific task execution. You require the `x-request-id` from a previous response that triggered the execution. diff --git a/RobotFrameworkService/main.py b/RobotFrameworkService/main.py index b1901d2..7a13885 100644 --- a/RobotFrameworkService/main.py +++ b/RobotFrameworkService/main.py @@ -11,7 +11,7 @@ from uvicorn.config import Config from RobotFrameworkService.Config import Config as RFS_Config -from RobotFrameworkService.routers import robotframework +from RobotFrameworkService.routers import robotframework, robotframework_run from RobotFrameworkService.version import get_version from .constants import APP_NAME, LOGS @@ -26,6 +26,7 @@ async def lifespan(app: FastAPI): pathlib.Path(LOGS).mkdir(exist_ok=True) app = FastAPI(title=APP_NAME, version=get_version(), lifespan=lifespan) app.include_router(robotframework.router) +app.include_router(robotframework_run.router) app.mount(f"/{LOGS}", StaticFiles(directory=LOGS), name="robotlog") diff --git a/RobotFrameworkService/requests/RobotOptions.py b/RobotFrameworkService/requests/RobotOptions.py new file mode 100644 index 0000000..468930a --- /dev/null +++ b/RobotFrameworkService/requests/RobotOptions.py @@ -0,0 +1,26 @@ +from enum import Enum +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + + +class LogLevel(str, Enum): + TRACE = "TRACE" + DEBUG = "DEBUG" + INFO = "INFO" + WARN = "WARN" + ERROR = "ERROR" + + +class RobotOptions(BaseModel): + paths: Optional[List[str]] = Field(default=None, description="Paths where are located tests") + test: Optional[str] = Field(default=None, description="Test Name To Run") + task: Optional[str] = Field(default=None, description="Task Name To Run") + suite: Optional[str] = Field(default=None, description="Suite Name To Run") + loglevel: LogLevel = Field(default=LogLevel.INFO, description="Log level") + sync: bool = Field(default=False, description="Synchronous execution") + dry_run: bool = Field(default=False, description="Dry run execution") + rpa: bool = Field(default=False, description="RPA execution mode") + include_tags: Optional[List[str]] = Field(default=None, description="Tags to include") + exclude_tags: Optional[List[str]] = Field(default=None, description="Tags to exclude") + variables: Optional[Dict[str, str]] = Field(default=None, description="Variables") diff --git a/RobotFrameworkService/requests/__init__.py b/RobotFrameworkService/requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/RobotFrameworkService/routers/robotframework_run.py b/RobotFrameworkService/routers/robotframework_run.py new file mode 100644 index 0000000..62d3a79 --- /dev/null +++ b/RobotFrameworkService/routers/robotframework_run.py @@ -0,0 +1,173 @@ +import asyncio +import multiprocessing as mp +from concurrent.futures import Executor +from typing import Optional + +import robot +from fastapi import Body, APIRouter, Request, status +from fastapi.responses import Response +from typing_extensions import Annotated + +from RobotFrameworkService.Config import Config as RFS_Config +from RobotFrameworkService.requests.RobotOptions import RobotOptions + +router = APIRouter( + prefix="/robotframework", + responses={ + 404: { + "description": "Not found: Webservice is either busy or requested endpoint is not supported." + } + }, +) + +run_examples = { + "Suite run": { + "value": { + "paths": ["examples"], + "suite": "tasks" + }, + }, + "Test run": { + "value": { + "paths": ["examples"], + "test": "Demonstration Test" + }, + }, + "Task run": { + "value": { + "paths": ["examples"], + "task": "Demonstration Task" + }, + }, + "Task sync run": { + "value": { + "paths": ["examples"], + "task": "Demonstration Task", + "sync": True + }, + }, + "Tests run with included tags": { + "value": { + "paths": ["examples"], + "include_tags": ["tag"] + }, + }, + "Tests run with TRACE log level": { + "value": { + "paths": ["examples"], + "test": "Log With Levels", + "loglevel": "TRACE" + }, + }, + "Task run with variables": { + "value": { + "paths": ["examples"], + "task": "Task with more variables", + "variables": {"firstname": "Max", "lastname": "Mustermann"} + }, + } +} + + +def validate_robot_options(body: RobotOptions) -> Optional[str]: + if body.test and body.suite: + return "Options test and suite cannot be both specified" + if body.task and body.suite: + return "Options task and suite cannot be both specified" + if body.test and body.task: + return "Options test and task cannot be both specified" + + +def build_robot_options(id: str, body: RobotOptions) -> (list, dict): + config = RFS_Config().cmd_args + + options = { + "outputdir": f"logs/{id}", + "rpa": body.rpa, + "consolewidth": 120, + "loglevel": body.loglevel + } + + if body.test: + options["test"] = body.test + + if body.task: + options["task"] = body.task + + if body.suite: + options["suite"] = body.suite + + if body.variables: + variables = [f"{k}:{v}" for k, v in body.variables.items()] + options["variable"] = variables + + if body.include_tags: + options["include"] = body.include_tags + if body.exclude_tags: + options["exclude"] = body.exclude_tags + + if config.variablefiles: + options["variablefile"] = config.variablefiles + + if config.debugfile: + options["debugfile"] = config.debugfile + + return body.paths or [config.taskfolder], options + + +@router.post("/run", tags=["execution"]) +async def run(robot_options: Annotated[RobotOptions, Body(openapi_examples=run_examples)], request: Request): + errors = validate_robot_options(robot_options) + if errors: + return Response( + content=errors, media_type="text/html", status_code=status.HTTP_400_BAD_REQUEST + ) + id = request.headers["request-id"] + tests, options = build_robot_options(id, robot_options) + if robot_options.sync: + response = await run_robot_and_wait( + request.app.state.executor, + id, + func=_run_robot, + args=[tests, options], + ) + return response + else: + await run_robot_in_background( + func=_run_robot, + args=[tests, options], + ) + return id + + +async def run_robot_in_background(func, args: list): + p = mp.Process(target=func, args=args) + p.start() + return p + + +async def run_robot_and_wait(executor: Executor, id, func, args: list): + loop = asyncio.get_event_loop() + result: int = await loop.run_in_executor(executor, func, *args) + if result == 0: + result_page = "PASS" + result_page += f'

Go to log

' + status_code = status.HTTP_200_OK + elif 250 >= result >= 1: + result_page = f"FAIL: {result} tasks failed" + result_page += f'

Go to log

' + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + else: + result_page = f"FAIL: Errorcode {result}" + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + + return Response( + content=result_page, media_type="text/html", status_code=status_code + ) + + +def _run_robot(tests: list, options: dict) -> int: + return robot.run( + *tests, + **options + ) diff --git a/examples/tests/demotest.robot b/examples/tests/demotest.robot index 1577c02..d302d37 100644 --- a/examples/tests/demotest.robot +++ b/examples/tests/demotest.robot @@ -5,3 +5,17 @@ ${who} John Doe *** Test Cases *** Demonstration Test Log ${hello} ${who} + +Demonstration Tag Test + [Tags] tag tag1 + Log Hello Tag1! + +Demonstration Tag 2 Test + [Tags] tag tag2 + Log Hello Tag2! + +Log With Levels + Log TRACE level=TRACE + Log DEBUG level=DEBUG + Log INFO + Log WARN level=WARN diff --git a/requirements-dev.txt b/requirements-dev.txt index 7a074a5..b2a33d5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,2 @@ -robotframework-tidy \ No newline at end of file +robotframework-tidy +httpx \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py index 863a417..051aa67 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -67,6 +67,18 @@ def test_is_robotreport_available(self): report_response = client.get(f"/robotframework/show_report/{execution_id}") self.assertEqual(200, report_response.status_code) + def test_is_robot_run(self): + with TestClient(app) as client: + response = client.post("/robotframework/run", json={"task": "Another task", "test": "Demonstration Test"}) + self.assertEqual(400, response.status_code) + self.assertEqual("Options test and task cannot be both specified", response.text) + + response = client.post("/robotframework/run", json={"task": "Another task", "sync": True}) + self.assertEqual(200, response.status_code) + + response = client.post("/robotframework/run", json={"paths": ["examples"], "test": "Demonstration Test", "sync": True}) + self.assertEqual(200, response.status_code) + def __get_robot_webservice(self, endpoint, expected_response_code=200): with TestClient(app) as client: response = client.get(endpoint)