diff --git a/README.md b/README.md index 2a0d150..74db5f3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 -`.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 +`.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); +``` + diff --git a/setup.py b/setup.py index 8c55e35..758a7cf 100644 --- a/setup.py +++ b/setup.py @@ -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="j.birkner@klebert-engineering.de", - 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", diff --git a/src/zswag/app.py b/src/zswag/app.py index bea226d..1158f76 100644 --- a/src/zswag/app.py +++ b/src/zswag/app.py @@ -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 @@ -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) @@ -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 @@ -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" }, @@ -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": { @@ -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 diff --git a/src/zswag/doc.py b/src/zswag/doc.py new file mode 100644 index 0000000..1269cfd --- /dev/null +++ b/src/zswag/doc.py @@ -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() diff --git a/test/calc/calculator.zs b/test/calc/calculator.zs index d192025..c03e519 100644 --- a/test/calc/calculator.zs +++ b/test/calc/calculator.zs @@ -10,13 +10,29 @@ struct U64 uint64 value; }; +/*! + +### This type has documentation + +!*/ + struct Double { float64 value; }; +/*! + +### Calculator Service + +Check out these sweet docs. + +!*/ + service Calculator { U64 powerOfTwo(I32); + + /** This method has a docstring on top of it. */ Double squareRoot(Double); };