Skip to content

Commit

Permalink
Functions overhaul
Browse files Browse the repository at this point in the history
* BREAKING CHANGE: Functions now have parameters
* Added gender parameter to random_first_name
* Added random_int function
* Added tests for functions
  • Loading branch information
Umut Seven committed Feb 10, 2021
1 parent 7c9d144 commit 326a6b2
Show file tree
Hide file tree
Showing 12 changed files with 270 additions and 35 deletions.
2 changes: 1 addition & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ ignore=CVS

# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths.
ignore-patterns=
ignore-patterns= tests/**

# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
Expand Down
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,16 @@ it.
{ "first_name": "Egon", "last_name": "Spengler" },
]
# A GET method that returns an employee.
# Take note of the two %functions%- the employee's first and last names will be random at every response.
# Take note of the two %functions%- the employee's first name, last name and age will be random at every response.
- method: GET
path: test/employee/2
status_code: 200
content: '{ "first_name": "%random_first_name%", "last_name": "%random_last_name%" }'
content: >
{
"first_name": "%random_first_name(female)%",
"last_name": "%random_last_name()%",
"age": %random_int(20, 50)%
}
# A POST method that returns a 500. Great for testing error pages.
- method: POST
path: test/employee
Expand Down Expand Up @@ -100,7 +105,7 @@ An example of making a `curl` request to our second endpoint defined above:
< content-length: 52
< content-type: application/json
<
{ "first_name": "Geoffrey", "last_name": "Greeley" }
{ "first_name": "Geoffrey", "last_name": "Greeley", "age": 28 }
```

No need to restart **apyr** after editing `endpoints.yaml`- it's all taken care of!
Expand All @@ -111,8 +116,9 @@ No need to restart **apyr** after editing `endpoints.yaml`- it's all taken care

Currently supported functions are:

| Name | Description |
| :--- | :--- |
| `%random_first_name%` | Will be replaced by a random first name |
| `%random_last_name%` | Will be replaced by a random last name |
| Name | Parameters | Description | Examples |
| :--- | :--- | :--- | :--- |
| `%random_first_name(gender)%` | `gender`: Optional string. Can be `male` or `female`. If left empty, will default to both | Will be replaced by a random first name | `%random_first_name(male)%`, `%random_first_name(female)%`, `%random_first_name()%`
| `%random_last_name()%` | | Will be replaced by a random last name | `%random_last_name()%` |
| `%random_int(start, end)%` | `start`: Required int, `end`: Required int | Will be replaced by a random integer between `start` and `end` | `%random_int(0, 20)%`, `%random_int(20, 50)%` |

36 changes: 16 additions & 20 deletions apyr/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
from pathlib import Path
from typing import List

import names
import yaml
from fastapi import HTTPException
from starlette.responses import Response

from apyr.exceptions import TooManyEndpointsException, NoEndpointsException
from apyr.models import ContentFunction, Endpoint
from apyr.exceptions import (
TooManyEndpointsException,
NoEndpointsException,
FunctionException,
)
from apyr.function_handler import FunctionHandler
from apyr.functions import FUNCTIONS
from apyr.models import Endpoint
from apyr.utils import get_project_root, get_digest

FUNCTIONS = [
ContentFunction(name="random_first_name", returns=names.get_first_name),
ContentFunction(name="random_last_name", returns=names.get_last_name),
]


class EndpointsRepo:

def __init__(self):
self.last_hash: str = ""
self.endpoints: List[Endpoint] = []
Expand All @@ -28,16 +28,6 @@ def _load_endpoints(self):

self.endpoints = [Endpoint(**endpoint) for endpoint in endpoints]

@staticmethod
def _check_for_functions(content: str) -> str:
for function in FUNCTIONS:
full_fun_name = f"%{function.name}%"
if full_fun_name in content:
return_val = function.returns()
content = content.replace(full_fun_name, return_val)

return content

def _check_if_file_changed(self, path: Path) -> bool:
"""Check to see if the file changed.
Expand Down Expand Up @@ -67,7 +57,13 @@ def _filter_endpoints(endpoint: Endpoint):

filtered_endpoint = filtered[0]

content = self._check_for_functions(filtered_endpoint.content)
try:
content = FunctionHandler.run(filtered_endpoint.content, FUNCTIONS)
except FunctionException as ex:
raise HTTPException(
status_code=500, detail={"error": ex.error, "reason": ex.detail}
) from ex

return Response(
status_code=filtered_endpoint.status_code,
media_type=filtered_endpoint.media_type,
Expand Down
7 changes: 7 additions & 0 deletions apyr/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ class NoEndpointsException(EndpointException):
def __init__(self):
self.message = "There are no endpoints matching the conditions."
super().__init__(self.message)


class FunctionException(Exception):
def __init__(self, fun_name: str, detail: str):
self.error = f"Error in function {fun_name}"
self.detail = detail
super().__init__(detail)
53 changes: 53 additions & 0 deletions apyr/function_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import re
from typing import List, Tuple, Dict

from apyr.exceptions import FunctionException
from apyr.models import ContentFunction


class FunctionHandler:
@staticmethod
def parse_content(content: str) -> List[ContentFunction]:
regex = re.compile(
r"(?P<full>%(?P<name>[^%]+?)\((?P<params>.*?)\)%)", flags=re.M
)
match: List[Tuple[str, str, str]] = regex.findall(content)

functions: List[ContentFunction] = []

for capture in match:
full, name, params = capture
param_mapped = list(
map(lambda x: x.strip(), params.split(","))
) # Remove whitespace from params
param_list = list(
filter(lambda x: x != "", param_mapped)
) # Filter out empty params
functions.append(ContentFunction(full=full, name=name, params=param_list))

return functions

@staticmethod
def execute_functions(
content_functions: List[ContentFunction], functions: Dict, content: str
) -> str:
for content_function in content_functions:
fun = functions.get(content_function.name)

if fun is None:
print(f"Function {content_function.name} not found. Skipping..")
continue

try:
result = fun(*content_function.params)
except Exception as ex:
raise FunctionException(content_function.full, str(ex)) from ex

content = content.replace(content_function.full, str(result))

return content

@staticmethod
def run(content: str, functions: Dict) -> str:
content_functions = FunctionHandler.parse_content(content)
return FunctionHandler.execute_functions(content_functions, functions, content)
23 changes: 23 additions & 0 deletions apyr/functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import random
from typing import Optional

import names


def random_first_name(gender: Optional[str]) -> str:
return names.get_first_name(gender)


def random_last_name() -> str:
return names.get_last_name()


def random_int(start: str, end: str) -> int:
return random.randint(int(start), int(end))


FUNCTIONS = {
"random_first_name": random_first_name,
"random_last_name": random_last_name,
"random_int": random_int,
}
5 changes: 3 additions & 2 deletions apyr/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# pylint: disable=no-name-in-module
from enum import Enum
from typing import Optional, Callable
from typing import Optional, List

from pydantic import BaseModel

Expand All @@ -25,5 +25,6 @@ class Endpoint(BaseModel):


class ContentFunction(BaseModel):
full: str
name: str
returns: Callable[[], str]
params: List[str]
2 changes: 1 addition & 1 deletion apyr/routers/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def endpoints_dependency() -> EndpointsRepo:
@endpoint_router.trace("/{path:path}")
@endpoint_router.patch("/{path:path}")
async def all_endpoints(
path: str, request: Request, repo: EndpointsRepo = Depends(endpoints_dependency)
path: str, request: Request, repo: EndpointsRepo = Depends(endpoints_dependency)
):
try:
response = repo.get_response(path, request.method)
Expand Down
9 changes: 7 additions & 2 deletions endpoints.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@
{ "first_name": "Egon", "last_name": "Spengler" },
]
# A GET method that returns an employee.
# Take note of the two %functions%- the employee's first and last names will be random at every response.
# Take note of the two %functions%- the employee's first name, last name and age will be random at every response.
- method: GET
path: test/employee/2
status_code: 200
content: '{ "first_name": "%random_first_name%", "last_name": "%random_last_name%" }'
content: >
{
"first_name": "%random_first_name(female)%",
"last_name": "%random_last_name()%",
"age": %random_int(20, 50)%
}
# A POST method that returns a 500. Great for testing error pages.
- method: POST
path: test/employee
Expand Down
20 changes: 19 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "apyr"
version = "0.2.0"
version = "1.0.0"
description = "Mock that API"
license = "MIT"
readme = "README.md"
Expand All @@ -23,6 +23,7 @@ pylint = "^2.6.0"
pydocstyle = "^5.1.1"
pytest = "^6.2.2"
pytest-dependency = "^0.5.1"
pytest-mock = "^3.5.1"

[build-system]
requires = ["poetry-core>=1.0.0"]
Expand Down
Loading

0 comments on commit 326a6b2

Please sign in to comment.