Skip to content

Commit

Permalink
Merge pull request #4 from Klebert-Engineering/feature/doc-extraction
Browse files Browse the repository at this point in the history
Doc Extraction / v0.3.0
  • Loading branch information
josephbirkner authored Apr 30, 2020
2 parents abf6e6a + 805cec3 commit fa1190b
Show file tree
Hide file tree
Showing 5 changed files with 271 additions and 19 deletions.
68 changes: 63 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import zswag
import zserio
import my.app.controller

zserio.require("myapp/service.zs")
zserio.require("myapp/service.zs", package_prefix="myapp")
from myapp.service import Service

# The OpenApi argument `yaml_path=...` is optional
Expand All @@ -51,11 +51,69 @@ def myApi(request):
# _service._myApiImpl = my.app.controller.myApi
```

**FYI**
**Note:** The server is currently generated such that the
zserio RPC method parameter is expected to be a Base64-encoded
string called `requestData`, which is placed in the URL query part.
It is planned to make this more flexible in a future release.

The OpenAPI spec is auto-generated if you do not specify an existing file.
If you specify an empty YAML path, the yaml file is placed next to the
`<service>.zs` source-file.
## Using the client

If you have a service called `my.package.Service`, then zserio
will automatically generate a client for the service under
`my.package.Service.Client`. This client can be instantiated alas ...

```python
from my.package import Service
import zswag

client = Service.Client(zswag.HttpClient(host=host, port=port))
```

`zswag.HttpClient` provides the service client interface expected
For more options with `HttpClient` apart from `host` and `port`,
check out it's doc-string.

## Swagger UI

If you have installed `pip install connexion[swagger-ui]`, you can view
API docs of your service under `[/prefix]/ui`.

## OpenAPI YAML spec

### YAML file location/auto-generation

* If you specify a non-empty path to a file which does not yet exist, the OpenAPI spec is auto-generated in that location.
* If you specify an empty YAML path, the yaml file is placed next to the
`<service>.zs` source-file.
* If you specify an existing file, `zswag` will simply verify
all the methods specified in your zserio service are also reflected in
the OpenAPI-spec.

### Documentation extraction

When the OpenAPI/Swagger YAML is auto-generated, `ZserioSwaggerApp`
tries to populate the service/method/argument/result descriptions
with doc-strings which are extracted from the zserio sources.

For structs and services, the documentation is expected to be
enclosed by `/*! .... !*/` markers preceding the declaration:

```C
/*!
### My Markdown Struct Doc
I choose to __highlight__ this word.
!*/

struct MyStruct {
...
}
```

For service methods, a single-line doc-string is parsed which
immediately precedes the declaration:

```C
/** This method is documented. */
ReturnType myMethod(ArgumentType);
```
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@

setuptools.setup(
name="zswag",
version="0.2.0",
version="0.3.0",
url="https://github.com/klebert-engineering/zswag",
author="Klebert Engineering",
author_email="[email protected]",

description="Convience functionality to create python modules from zserio services at warp speed.",
description="Convenience functionality to create python modules from zserio services at warp speed.",
long_description=long_description,
long_description_content_type="text/markdown",

Expand Down
75 changes: 63 additions & 12 deletions src/zswag/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,32 @@
from types import ModuleType
from typing import Type

from .doc import get_doc_str, IdentType, md_filter_definition


# Name of variable that is added to controller
CONTROLLER_SERVICE_INSTANCE = "_service"


class MethodInfo:
"""
(Private) Return value of ZserioSwaggerApp._method_info()
"""
def __init__(self, *, name, docstring="", returntype="", argtype="", returndoc="", argdoc=""):
self.name = name
self.docstring = docstring
self.returntype = returntype
self.argtype = argtype
self.returndoc = returndoc
self.argdoc = argdoc


class ZserioSwaggerApp(connexion.App):

def __init__(self, *,
controller: ModuleType,
service_type: Type[zserio.ServiceInterface],
zs_pkg_path: str = None,
yaml_path: str = None):
"""
Brief
Expand All @@ -32,11 +48,13 @@ def __init__(self, *,
If you have installed `pip install connexion[swagger-ui]`, you can view
API docs of your service under [/prefix]/ui.
Documentation for the service is automatically extracted if `zs_pkg_path` is issued.
Code example
In file my.app.__init__:
from gen.zserio import Service
from zserio_gen.my.service import Service
from zswag import ZserioSwaggerApp
app = ZserioSwaggerApp(my.app.controller, Service)
Expand Down Expand Up @@ -71,6 +89,7 @@ def myApi(request):
self.yaml_path = yaml_path
yaml_parent_path = os.path.dirname(yaml_path)
yaml_basename = os.path.basename(yaml_path)
self.zs_pkg_path = zs_pkg_path

# Initialise zserio service
self.service_type = service_type
Expand Down Expand Up @@ -132,11 +151,17 @@ def verify_openapi_schema(self):

def generate_openapi_schema(self):
print(f"NOTE: Writing OpenApi schema to {self.yaml_path}")
service_name_parts = self.service_instance.SERVICE_FULL_NAME.split(".")
schema = {
"openapi": "3.0.0",
"info": {
"title": self.service_instance.SERVICE_FULL_NAME,
"description": f"REST API for {self.service_instance.SERVICE_FULL_NAME}",
"title": ".".join(service_name_parts[1:]),
"description": md_filter_definition(get_doc_str(
ident_type=IdentType.SERVICE,
pkg_path=self.zs_pkg_path,
ident=self.service_instance.SERVICE_FULL_NAME,
fallback=[f"REST API for {self.service_instance.SERVICE_FULL_NAME}"]
)[0]),
"contact": {
"email": "TODO"
},
Expand All @@ -147,24 +172,25 @@ def generate_openapi_schema(self):
},
"servers": [],
"paths": {
f"/{methodName}": {
f"/{method_info.name}": {
"get": {
"summary": "TODO: Brief one-liner.",
"description": "TODO: Describe operation in more detail.",
"operationId": methodName,
"summary": method_info.docstring,
"description": method_info.docstring,
"operationId": method_info.name,
"parameters": [{
"name": "requestData",
"in": "query",
"description": "TODO: Describe parameter",
"description": method_info.argdoc,
"required": True,
"schema": {
"type": "string",
"format": "byte"
"type": "string",
"default": "Base64-encoded bytes",
"format": "byte"
}
}],
"responses": {
"200": {
"description": "TODO: Describe response content",
"description": method_info.returndoc,
"content": {
"application/octet-stream": {
"schema": {
Expand All @@ -177,8 +203,33 @@ def generate_openapi_schema(self):
},
"x-openapi-router-controller": self.service_instance_path
},
} for methodName in self.service_instance._methodMap
} for method_info in (self._method_info(method_name) for method_name in self.service_instance._methodMap)
}
}
with open(self.yaml_path, "w") as yaml_file:
yaml.dump(schema, yaml_file, default_flow_style=False)

def _method_info(self, method_name: str) -> MethodInfo:
result = MethodInfo(name=method_name)
if not self.zs_pkg_path:
return result
doc_strings = get_doc_str(
ident_type=IdentType.RPC,
pkg_path=self.zs_pkg_path,
ident=f"{self.service_instance.SERVICE_FULL_NAME}.{method_name}")
if not doc_strings:
return result
result.docstring = doc_strings[0]
result.returntype = doc_strings[1]
result.argtype = doc_strings[2]
result.returndoc = md_filter_definition(get_doc_str(
ident_type=IdentType.STRUCT,
pkg_path=self.zs_pkg_path,
ident=result.returntype,
fallback=[f"### struct {result.returntype}"])[0])
result.argdoc = md_filter_definition(get_doc_str(
ident_type=IdentType.STRUCT,
pkg_path=self.zs_pkg_path,
ident=result.argtype,
fallback=[f"### struct {result.argtype}"])[0])
return result
127 changes: 127 additions & 0 deletions src/zswag/doc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from enum import Enum
from typing import List, Dict
import re
import glob
import os

RPC_DOC_PATTERN = r"""
service\s+{service_name}\s+\{{ # service MyService {{ (double-braces due to later .format())
(?:\n|.)* # ...
/\*\*?\s* # /**
((?:[^*]|\*[^/])*) # (doc-string) -> captured
\*/ # */
\s*([A-Za-z0-9_]*)\s+ # (return-type) -> captured
{rpc_name} # method-name
\s*\(\s* # (
([A-Za-z0-9_]*) # (argument-type) -> captured
\s*\) # )
"""

STRUCT_PATTERN = r"""
/\*\! # /*!
((?:[^!]|![^*]|!\*[^/])*) # (doc-string) -> captured
!\*/\s+ # !*/
struct\s+{name} # struct NAME
"""

SERVICE_PATTERN = r"""
/\*\! # /*!
((?:[^!]|![^*]|!\*[^/])*) # (doc-string) -> captured
!\*/\s+ # !*/
service\s+{name} # service NAME
"""



class IdentType(Enum):
"""
Use these enum entries with `get_doc_str()`.
"""
STRUCT = 0
SERVICE = 1
RPC = 2


"""
Caches glob.glob() results for *.zs file searches in get_doc_str().
The dictionary points from a package path to amalgamated zserio code
for that package.
"""
zs_pkg_cache: Dict[str, str] = {}


def get_amalgamated_zs(pkg_path):
global zs_pkg_cache
if pkg_path in zs_pkg_cache:
return zs_pkg_cache[pkg_path]
zs_files = glob.glob(os.path.join(pkg_path, "**/*.zs"), recursive="True")
print(f"[INFO] Found {len(zs_files)} zserio files under {pkg_path}")
result = ""
for zs_file_path in zs_files:
with open(zs_file_path) as zs_file:
result += zs_file.read() + "\n"
zs_pkg_cache[pkg_path] = result
return result


def get_doc_str(*, ident_type: IdentType, pkg_path: str, ident: str, fallback: List[str] = None) -> List[str]:
f"""
Get a docstring for a particular zserio identifier. This method searches all .zs-files
under `pkg_path` for a specific pattern given by `ident_type` and `ident`
The following patterns are looked for:
With `ident_type` IdentType.STRUCT:
With ident as "path.to.package.NAME":
{STRUCT_PATTERN}
With `ident_type` IdentType.SERVICE)
Same as STRUCT, except looking for "service NAME".
With `ident_type` IdentType.RPC)
With ident as "path.to.service.SERVICE.NAME":
{RPC_DOC_PATTERN}
The list of all capture group values is returned.
"""
if fallback is None:
fallback = []
if not pkg_path:
return fallback
zs_src = get_amalgamated_zs(pkg_path)
ident_parts = ident.split(".")
pattern_format_replacements = {}
pattern = ""
if ident_type == IdentType.STRUCT:
if not ident_parts:
print("[ERROR] Need at least one identifier part to find struct docs.")
return fallback
pattern = STRUCT_PATTERN
pattern_format_replacements["name"] = ident_parts[-1]
elif ident_type == IdentType.SERVICE:
if not ident_parts:
print("[ERROR] Need at least one identifier part to find service docs.")
return fallback
pattern = SERVICE_PATTERN
pattern_format_replacements["name"] = ident_parts[-1]
elif ident_type == IdentType.RPC:
if not ident_parts or len(ident_parts) < 2:
print("[ERROR] Need at least tow identifiers (service.rpc-name) to find RPC docs.")
return fallback
pattern = RPC_DOC_PATTERN
pattern_format_replacements["service_name"] = ident_parts[-2]
pattern_format_replacements["rpc_name"] = ident_parts[-1]
else:
print("[ERROR] get_doc_str: Unsupported identifier type!")
return fallback
compiled_pattern = re.compile(pattern.format(**pattern_format_replacements), re.X)
match = compiled_pattern.search(zs_src)
if match:
return list(match.groups())
else:
print(f"[WARNING] Could not find doc-string for `{ident}`!")
return fallback


def md_filter_definition(md: str) -> str:
return re.sub(r"\n*\*\*[Dd]efinitions?[:\s]*\*\*\n*", "", md.strip()).strip()
Loading

0 comments on commit fa1190b

Please sign in to comment.