Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Swagger: Fix duplicate path parameters #658

Merged
merged 5 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 38 additions & 46 deletions asab/api/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def __init__(self, api_service, app, web_container, config_section_name="asab:do

self.Manifest = api_service.Manifest

self.DefaultRouteTag: str = asab.Config["asab:doc"].get("default_route_tag") # default: 'module_name'
self.DefaultRouteTag: str = asab.Config.get("asab:doc", "default_route_tag")
if self.DefaultRouteTag not in ["module_name", "class_name"]:
raise ValueError(
"Unknown default_route_tag: {}. Choose between options "
Expand All @@ -52,13 +52,11 @@ def build_swagger_documentation(self, host) -> dict:
"""
Take a docstring of the class and a docstring of methods and merge them into Swagger data.
"""
app_doc_string: str = self.App.__doc__
app_description: str = get_docstring_description(app_doc_string)
specification: dict = {
"openapi": "3.0.1",
"info": {
"title": "{}".format(self.App.__class__.__name__),
"description": app_description,
"description": get_docstring_description(self.App.__doc__),
"contact": {
"name": "ASAB-based microservice",
"url": "https://www.github.com/teskalabs/asab",
Expand All @@ -78,12 +76,11 @@ def build_swagger_documentation(self, host) -> dict:
}

# Application specification
app_info: dict = get_docstring_yaml_dict(self.App)
specification.update(app_info)
specification.update(get_docstring_yaml(self.App))

# Find asab and microservice routes, sort them alphabetically by the first tag
asab_routes = []
microservice_routes = []
routes = []

for route in self.WebContainer.WebApp.router.routes():
if route.method == "HEAD":
Expand All @@ -92,15 +89,14 @@ def build_swagger_documentation(self, host) -> dict:
continue

# Determine which routes are asab-based
path: str = self.get_route_path(route)
if re.search("asab", path) or re.search("/doc", path) or re.search("/oauth2-redirect.html", path):
if re.search("(asab|doc|oauth2-redirect.html|bspump)", self.get_route_path(route)):
asab_routes.append(self.parse_route_data(route))
else:
microservice_routes.append(self.parse_route_data(route))
routes.append(self.parse_route_data(route))

microservice_routes.sort(key=get_first_tag)
routes.sort(key=get_first_tag)

for endpoint in microservice_routes:
for endpoint in routes:
endpoint_name = list(endpoint.keys())[0]
# if endpoint already exists, then update, else create a new one
spec_endpoint = specification["paths"].get(endpoint_name)
Expand All @@ -123,51 +119,49 @@ def parse_route_data(self, route) -> dict:
"""
Take a route (a single method of an endpoint) and return its description data.
"""
path_parameters: list = extract_path_parameters(route)
handler_name: str = get_handler_name(route)

# Parse docstring description and yaml data
docstring: str = route.handler.__doc__
docstring_description: str = get_docstring_description(docstring)
docstring_description += "\n\n**Handler:** `{}`".format(handler_name)
docstring_yaml_dict: dict = get_docstring_yaml_dict(route.handler)
docstring_description = "{}\n\n**Handler:** `{}`".format(
get_docstring_description(route.handler.__doc__),
get_handler_name(route)
)

# Create route info dictionary
route_info_data: dict = {
"summary": docstring_description.split("\n")[0],
"description": docstring_description,
"responses": {"200": {"description": "Success"}},
route_data: dict = {
"summary": docstring_description.split("\n", 1)[0],
"description": docstring_description.split("\n", 1)[1],
"responses": {"200": {"description": "Success."}},
"parameters": [],
"tags": []
}

# Update it with parsed YAML and add query parameters
if docstring_yaml_dict is not None:
route_info_data.update(docstring_yaml_dict)
for query_parameter in docstring_yaml_dict.get("parameters", []):
if query_parameter.get("parameters"):
route_info_data["parameters"].append(query_parameter["parameters"])
docstring_yaml: dict = get_docstring_yaml(route.handler)
if docstring_yaml is not None:
route_data.update(docstring_yaml)

for path_parameter in path_parameters:
route_info_data["parameters"].append(path_parameter)
# Find all path parameters, add them if not specified in docstring.
for path_parameter in get_path_parameters(route):
if path_parameter.get("name") is not None:
if path_parameter["name"] not in [r["name"] for r in route_data["parameters"]]:
route_data["parameters"].append(path_parameter)

# Add default tag if not specified in docstring yaml
if len(route_info_data["tags"]) == 0:
if len(route_data["tags"]) == 0:
# Try to get the tags from class docstring
class_tags = get_class_tags(route)
if class_tags:
route_info_data["tags"] = class_tags[:1]
route_data["tags"] = class_tags[:1]
# Or generate tag from component name
elif self.DefaultRouteTag == "class_name":
route_info_data["tags"] = [get_class_name(route)]
route_data["tags"] = [get_class_name(route)]
elif self.DefaultRouteTag == "module_name":
route_info_data["tags"] = [get_module_name(route)]
route_data["tags"] = [get_module_name(route)]

# Create the route dictionary
route_path: str = self.get_route_path(route)
method_name: str = route.method.lower()
method_dict: dict = get_json_schema(route)
method_dict.update(route_info_data)
method_dict.update(route_data)

return {route_path: {method_name: method_dict}}

Expand Down Expand Up @@ -202,7 +196,7 @@ def create_security_schemes(self) -> dict:
)
return security_schemes_dict

def get_version_from_manifest(self) -> dict:
def get_version_from_manifest(self) -> str:
"""
Get version from MANIFEST.json if exists.
"""
Expand Down Expand Up @@ -304,20 +298,20 @@ def get_docstring_description(docstring: typing.Optional[str]) -> str:
return description


def extract_path_parameters(route) -> list:
def get_path_parameters(route) -> list:
"""
Take a single route and return its parameters.
Return path parameters of a route.
"""
parameters: list = []
route_info = route.get_info()
if "formatter" in route_info:
path = route_info["formatter"]
for params in re.findall(r'\{[^\}]+\}', path):
parameters.append({
'in': 'path',
'name': params[1:-1],
'required': True,
'schema': {'type': 'string'},
"in": "path",
"name": params[1:-1],
"required": True,
"schema": {"type": "string"},
})
return parameters

Expand All @@ -341,9 +335,7 @@ def get_class_name(route) -> str:
def get_class_tags(route) -> typing.Optional[list]:
if not inspect.ismethod(route.handler):
return None
handler_class = route.handler.__self__.__class__
yaml_dict = get_docstring_yaml_dict(handler_class)
return yaml_dict.get("tags")
return get_docstring_yaml(route.handler.__self__.__class__).get("tags")


def get_module_name(route) -> str:
Expand Down Expand Up @@ -372,7 +364,7 @@ def get_first_tag(route_data: dict) -> str:
return method.get("tags")[0].lower()


def get_docstring_yaml_dict(component) -> dict:
def get_docstring_yaml(component) -> dict:
"""
Inspect the docstring of a component for YAML data and parse it if there is any.
"""
Expand Down
43 changes: 41 additions & 2 deletions asab/api/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,50 @@ def emit(self, record):
@noauth
@allow_no_tenant
async def get_logs(self, request):
'''
"""
Get logs.
---
tags: ['asab.log']
'''

responses:
"200":
description: Logs.
content:
application/json:
schema:
type: array
items:
type: object
properties:
t:
type: string
description: Time when the log was emitted.
example: 2024-12-10T14:28:53.421079Z
C:
type: string
description: Class that produced the log.
example: myapp.myservice.service
M:
type: string
description: Log message.
example: Periodic check finished.
l:
type: int
example: 6
oneOf:
- title: Alert
const: 1
- title: Critical
const: 2
- title: Error
const: 3
- title: Warning
const: 4
- title: Notice
const: 5
- title: Info
const: 6
"""

return json_response(request, self.Buffer)

Expand Down
76 changes: 51 additions & 25 deletions asab/api/web_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def __init__(self, api_svc, webapp, log_handler):
@allow_no_tenant
async def changelog(self, request):
"""
It returns a change log file.
Get changelog file.
---
tags: ['asab.api']
"""
Expand All @@ -47,23 +47,29 @@ async def changelog(self, request):
@allow_no_tenant
async def manifest(self, request):
"""
It returns the manifest of the ASAB service.
Get manifest of the ASAB service.

THe manifest is a JSON object loaded from `MANIFEST.json` file.
The manifest is a JSON object loaded from `MANIFEST.json` file.
The manifest contains the creation (build) time and the version of the ASAB service.
The `MANIFEST.json` is produced during the creation of docker image by `asab-manifest.py` script.

Example of `MANIFEST.json`:

```
{
'created_at': 2022-03-21T15:49:37.14000,
'version' :v22.9-4
}
```

---
tags: ['asab.api']

responses:
"200":
description: Manifest of the application.
content:
application/json:
schema:
type: object
properties:
created_at:
type: str
example: 2024-12-10T15:49:37.14000
version:
type: str
example: v24.50.01
"""

if self.ApiService.Manifest is None:
Expand All @@ -76,21 +82,30 @@ async def manifest(self, request):
@allow_no_tenant
async def environ(self, request):
"""
It returns a JSON response containing the contents of the environment variables.
Get environment variables.

Example:

```
{
"LANG": "en_GB.UTF-8",
"SHELL": "/bin/zsh",
"HOME": "/home/foobar",
}

```
Get JSON response containing the contents of the environment variables.

---
tags: ['asab.api']

responses:
"200":
description: Environment variables.
content:
application/json:
schema:
type: object
properties:
LANG:
type: str
example: "en_GB.UTF-8"
SHELL:
type: str
example: "/bin/zsh"
HOME:
type: str
example: "/home/foobar"
"""
return json_response(request, dict(os.environ))

Expand All @@ -99,9 +114,11 @@ async def environ(self, request):
@allow_no_tenant
async def config(self, request):
"""
It returns the JSON with the config of the ASAB service.
Get configuration of the service.

IMPORTANT: All passwords are erased.
Return configuration of the ASAB service in JSON format.

**IMPORTANT: All passwords are erased.**

Example:

Expand All @@ -122,6 +139,15 @@ async def config(self, request):

---
tags: ['asab.api']

responses:
"200":
description: Configuration of the service.
content:
application/json:
schema:
type: object
example: {"general": {"config_file": "", "tick_period": "1", "uid": "", "gid": ""}, "asab:metrics": {"native_metrics": "true", "expiration": "60"}}
"""

# Copy the config and erase all passwords
Expand Down
Loading