Skip to content

Commit

Permalink
Add API POST /robotframework/run
Browse files Browse the repository at this point in the history
  • Loading branch information
picard-remi committed Feb 18, 2024
1 parent 3f4d703 commit e36f7f9
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 2 deletions.
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion RobotFrameworkService/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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


Expand Down
26 changes: 26 additions & 0 deletions RobotFrameworkService/requests/RobotOptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from enum import Enum
from typing import Dict

from pydantic import BaseModel, Field


class LogLevel(str, Enum):
TRACE = "TRACE"
DEBUG = "DEBUG"
INFO = "INFO"
WARN = "WARN"
ERROR = "ERROR"


class RobotOptions(BaseModel):
paths: list[str] | None = Field(default=None, description="Paths where are located tests")
test: str | None = Field(default=None, description="Test Name To Run")
task: str | None = Field(default=None, description="Task Name To Run")
suite: str | None = 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: list | None = Field(default=None, description="Tags to include")
exclude_tags: list | None = Field(default=None, description="Tags to exclude")
variables: Dict[str, str] | None = Field(default=None, description="Variables")
Empty file.
172 changes: 172 additions & 0 deletions RobotFrameworkService/routers/robotframework_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import asyncio
import multiprocessing as mp
from concurrent.futures import Executor
from typing_extensions import Annotated

import robot
from fastapi import Body, APIRouter, Request, status
from fastapi.responses import Response

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) -> str | None:
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'<p><a href="/logs/{id}/log.html">Go to log</a></p>'
status_code = status.HTTP_200_OK
elif 250 >= result >= 1:
result_page = f"FAIL: {result} tasks failed"
result_page += f'<p><a href="/logs/{id}/log.html">Go to log</a></p>'
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
)
14 changes: 14 additions & 0 deletions examples/tests/demotest.robot
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
robotframework-tidy
robotframework-tidy
httpx
12 changes: 12 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit e36f7f9

Please sign in to comment.