mirror of https://github.com/oxen-io/oxen-core.git
377 lines
13 KiB
Python
Executable File
377 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import sys
|
|
import os
|
|
import shutil
|
|
import re
|
|
import fileinput
|
|
from enum import Enum, auto
|
|
import json
|
|
import requests
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"-L",
|
|
"--markdown-level",
|
|
type=int,
|
|
choices=[1, 2, 3, 4],
|
|
default=2,
|
|
help="Specify a heading level for the top-level endpoints; the default is 2, which means "
|
|
"endpoints start in a `## name` section. For example, 3 would start endpoints with `### name` "
|
|
"instead.",
|
|
)
|
|
parser.add_argument("--out", "-o", metavar='DIR', default="api", help="Output directory for generated endpoints")
|
|
parser.add_argument("--disable-public", action='store_true', help="disable PUBLIC endpoint detection (and disable marking endpoints as requiring admin)")
|
|
parser.add_argument("--disable-no-args", action='store_true', help="disable NO_ARGS enforcement of `Inputs: none`")
|
|
parser.add_argument("--dev", action='store_true', help="generate dev mode docs, which include endpoints marked 'Dev-RPC'")
|
|
parser.add_argument("--no-sort", "-S", action='store_true', help="disable sorting endpoints by name (use file order)")
|
|
parser.add_argument("--no-group", "-G", action='store_true', help="disable grouping endpoints by category")
|
|
parser.add_argument("--no-emdash", "-M", action='store_true', help="disable converting ' -- ' to ' — ' (em-dashes)")
|
|
parser.add_argument("--rpc", metavar='URL', default="http://public-na.optf.ngo:22023", help="URL to a running oxend RPC node for live example fetching")
|
|
parser.add_argument("filename", nargs="+")
|
|
args = parser.parse_args()
|
|
|
|
for f in args.filename:
|
|
if not os.path.exists(f):
|
|
parser.error(f"{f} does not exist!")
|
|
|
|
|
|
# We parse the file looking for `///` comment blocks beginning with "RPC: <cat>/<name>".
|
|
#
|
|
# <name> is the RPC endpoint name to use in the documentation (alternative names can be specified
|
|
# using "Old names:"; see below).
|
|
#
|
|
# <cat> is the category for grouping endpoints together.
|
|
#
|
|
# Following comment lines are then a Markdown long description, until we find one or more of:
|
|
#
|
|
# "Inputs: none."
|
|
# "Outputs: none."
|
|
# "Inputs:" followed by markdown (typically an unordered list) until the next match from this list.
|
|
# "Outputs:" followed by markdown
|
|
# "Example input:" followed by a code block (i.e. containing json)
|
|
# "Example output:" followed by a code block (i.e. json output)
|
|
# "Example-JSON-Fetch" goes and fetches the endpoint (live) with the previous example input as the
|
|
# "params" value (or no params if "Inputs: none").
|
|
# "Old names: a, b, c"
|
|
#
|
|
# subject to the following rules:
|
|
# - each section must have exactly one Input; if the type inherits NO_ARGS then it *must* be an
|
|
# "Inputs: none".
|
|
# - each section must have exactly one Output
|
|
# - "Example input:" section must be immediately followed by an "Example output"
|
|
# - "Example output:" sections are permitted without a preceding example input only if the endpoint
|
|
# takes no inputs.
|
|
# - 0 or more example pairs are permitted.
|
|
# - Old names is permitted only once, if it occurs at all; the given names will be indicated as
|
|
# deprecated, old names for the endpoint.
|
|
#
|
|
# Immediately following the command we expect to find a not-only-comment line (e.g. `struct
|
|
# <whatever>`) and apply some checks to this:
|
|
# - if the line does *not* contain the word `PUBLIC` then we mark the endpoint as requiring admin
|
|
# access in its description.
|
|
# - if the line contains the word `NO_ARGS` then we double-check that "Inputs: none" was also given
|
|
# and error if a more complex Inputs: section was written.
|
|
|
|
|
|
hdr = '#' * args.markdown_level
|
|
MD_INPUT_HEADER = f"{hdr}# Parameters"
|
|
MD_OUTPUT_HEADER = f"{hdr}# Returns"
|
|
|
|
MD_EXAMPLES_HEADER = f"{hdr}# Examples"
|
|
MD_EXAMPLE_IN_HDR = f"{hdr}## Input"
|
|
MD_EXAMPLE_OUT_HDR = f"{hdr}## Output"
|
|
|
|
MD_EX_SINGLE_IN_HDR = f"{hdr}# Example Input"
|
|
MD_EX_SINGLE_OUT_HDR = f"{hdr}# Example Output"
|
|
|
|
MD_NO_INPUT = "This endpoint takes no inputs. _(An optional empty dict/object may be provided, but is not required.)_"
|
|
MD_ADMIN = "\n\n> _This endpoint requires admin RPC access; it is not available on public RPC servers._"
|
|
|
|
RPC_COMMENT = re.compile(r"^\s*/// ?")
|
|
RPC_START = re.compile(r"^RPC:\s*([\w/]+)(.*)$")
|
|
DEV_RPC_START = re.compile(r"^Dev-RPC:\s*([\w/]+)(.*)$")
|
|
IN_NONE = re.compile(r"^Inputs?: *[nN]one\.?$")
|
|
IN_SOME = re.compile(r"^Inputs?:\s*$")
|
|
OUT_SOME = re.compile(r"^Outputs?:\s*$")
|
|
EXAMPLE_IN = re.compile(r"^Example [iI]nputs?:\s*$")
|
|
EXAMPLE_OUT = re.compile(r"^Example [oO]utputs?:\s*$")
|
|
EXAMPLE_JSON_FETCH = re.compile(r"^Example-JSON-Fetch\s*$")
|
|
OLD_NAMES = re.compile(r"[Oo]ld [nN]ames?:")
|
|
PLAIN_NAME = re.compile(r"\w+")
|
|
PUBLIC = re.compile(r"\bPUBLIC\b")
|
|
NO_ARGS = re.compile(r"\bNO_ARGS\b")
|
|
|
|
input = fileinput.input(args.filename)
|
|
rpc_name = None
|
|
|
|
|
|
def error(msg):
|
|
print(
|
|
f"\x1b[31;1mERROR\x1b[0m[{input.filename()}:{input.filelineno()}] "
|
|
f"while parsing endpoint {rpc_name}:",
|
|
file=sys.stderr,
|
|
)
|
|
if msg and isinstance(msg, list):
|
|
for m in msg:
|
|
print(f" - {m}", file=sys.stderr)
|
|
else:
|
|
print(f" {msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
class Parsing(Enum):
|
|
DESC = auto()
|
|
INPUTS = auto()
|
|
OUTPUTS = auto()
|
|
EX_IN = auto()
|
|
EX_OUT = auto()
|
|
NONE = auto()
|
|
|
|
|
|
cur_file = None
|
|
found_some = True
|
|
|
|
endpoints = {}
|
|
|
|
while True:
|
|
line = input.readline()
|
|
if not line:
|
|
break
|
|
|
|
if cur_file is None or cur_file != input.filename():
|
|
if not found_some:
|
|
error(f"Found no parseable endpoint descriptions in {cur_file}")
|
|
cur_file = input.filename()
|
|
found_some = False
|
|
|
|
line, removed_comment = re.subn(RPC_COMMENT, "", line, count=1)
|
|
if not removed_comment:
|
|
continue
|
|
|
|
m = re.search(RPC_START, line)
|
|
if not m and args.dev:
|
|
m = re.search(DEV_RPC_START, line)
|
|
if not m:
|
|
continue
|
|
if m and m[2]:
|
|
error(f"found trailing garbage after 'RPC: m[1]': {m[2]}")
|
|
if m[1].count('/') != 1:
|
|
error(f"Found invalid RPC name: expected 'cat/name', not '{m[1]}'")
|
|
|
|
cat, rpc_name = m[1].split('/')
|
|
if args.no_group:
|
|
cat = ''
|
|
description, inputs, outputs = "", "", ""
|
|
done_desc = False
|
|
no_inputs = False
|
|
examples = []
|
|
cur_ex_in = None
|
|
old_names = []
|
|
|
|
mode = Parsing.DESC
|
|
|
|
while True:
|
|
line = input.readline()
|
|
line, removed_comment = re.subn(RPC_COMMENT, "", line, count=1)
|
|
if not removed_comment:
|
|
break
|
|
|
|
if re.search(IN_NONE, line):
|
|
if inputs:
|
|
error("found multiple Inputs:")
|
|
inputs, no_inputs, mode = MD_NO_INPUT, True, Parsing.NONE
|
|
|
|
elif re.search(IN_SOME, line):
|
|
if inputs:
|
|
error("found multiple Inputs:")
|
|
mode = Parsing.INPUTS
|
|
|
|
elif re.search(OUT_SOME, line):
|
|
if outputs:
|
|
error("found multiple Outputs:")
|
|
mode = Parsing.OUTPUTS
|
|
|
|
elif re.search(EXAMPLE_IN, line):
|
|
if cur_ex_in is not None:
|
|
error("found multiple input examples without paired output examples")
|
|
cur_ex_in = ""
|
|
mode = Parsing.EX_IN
|
|
|
|
elif re.search(EXAMPLE_OUT, line):
|
|
if not cur_ex_in and not no_inputs:
|
|
error(
|
|
"found output example without preceding input example (or 'Inputs: none.')"
|
|
)
|
|
examples.append([cur_ex_in, ""])
|
|
cur_ex_in = None
|
|
mode = Parsing.EX_OUT
|
|
|
|
elif re.search(EXAMPLE_JSON_FETCH, line):
|
|
if not cur_ex_in and not no_inputs:
|
|
error(
|
|
"found output example fetch instruction without preceding input (or 'Inputs: none.')"
|
|
)
|
|
params = None
|
|
if cur_ex_in:
|
|
params = cur_ex_in.strip()
|
|
if not params.startswith("```json\n"):
|
|
error("current example input is not tagged as json for Example-JSON-Fetch")
|
|
params = params[8:]
|
|
if not params.endswith("\n```"):
|
|
error("current example input doesn't look right (expected trailing ```)")
|
|
params = params[:-4]
|
|
try:
|
|
params = json.loads(params)
|
|
except Exception as e:
|
|
error("failed to parse json example input as json")
|
|
|
|
result = requests.post(args.rpc + "/json_rpc", json={"jsonrpc": "2.0", "id": "0", "method": rpc_name, "params": params}).json()
|
|
if 'error' in result:
|
|
error(f"JSON fetched example returned an error: {result['error']}")
|
|
elif 'result' not in result:
|
|
error(f"JSON fetched example doesn't contain a \"result\" key: {result}")
|
|
ex_out = json.dumps(result["result"], indent=2, sort_keys=True)
|
|
|
|
examples.append([cur_ex_in, f"\n```json\n{ex_out}\n```\n"])
|
|
cur_ex_in = None
|
|
mode = Parsing.NONE
|
|
|
|
elif re.search(OLD_NAMES, line):
|
|
old_names = [x.strip() for x in line.split(':', 1)[1].split(',')]
|
|
if not old_names or not all(re.fullmatch(PLAIN_NAME, n) for n in old_names):
|
|
error(f"found unparseable old names line: {line}")
|
|
|
|
elif mode == Parsing.NONE:
|
|
if line and not line.isspace():
|
|
error(f"Found unexpected content while looking for a tag: '{line}'")
|
|
|
|
elif mode == Parsing.DESC:
|
|
description += line
|
|
|
|
elif mode == Parsing.INPUTS:
|
|
inputs += line
|
|
|
|
elif mode == Parsing.OUTPUTS:
|
|
outputs += line
|
|
|
|
elif mode == Parsing.EX_IN:
|
|
cur_ex_in += line
|
|
|
|
elif mode == Parsing.EX_OUT:
|
|
examples[-1][1] += line
|
|
|
|
problems = []
|
|
# We hit the end of the commented section
|
|
if not description or inputs.isspace():
|
|
problems.append("endpoint has no description")
|
|
if not inputs or inputs.isspace():
|
|
problems.append(
|
|
"endpoint has no inputs description; perhaps you need to add 'Inputs: none.'?"
|
|
)
|
|
if not outputs or outputs.isspace():
|
|
problems.append("endpoint has no outputs description")
|
|
if cur_ex_in is not None:
|
|
problems.append(
|
|
"endpoint has a trailing example input without a following example output"
|
|
)
|
|
if not no_inputs and any(not x[0] or x[0].isspace() for x in examples):
|
|
problems.append("found one or more blank input examples")
|
|
if any(not x[1] or x[1].isspace() for x in examples):
|
|
problems.append("found one or more blank output examples")
|
|
|
|
public = args.disable_public or re.search(PUBLIC, line)
|
|
if not public:
|
|
description += MD_ADMIN
|
|
|
|
if old_names:
|
|
s = 's' if len(old_names) > 1 else ''
|
|
description += f"\n\n> _For backwards compatibility this endpoint is also accessible via the following deprecated endpoint name{s}:_"
|
|
for n in old_names:
|
|
description += f"\n> - _`{n}`_"
|
|
|
|
if not args.disable_no_args:
|
|
if re.search(NO_ARGS, line) and not no_inputs:
|
|
problems.append("found NO_ARGS, but 'Inputs: none' was specified in description")
|
|
|
|
if problems:
|
|
error(problems)
|
|
|
|
md = f"""
|
|
{hdr} `{rpc_name}`
|
|
|
|
{description}
|
|
|
|
{MD_INPUT_HEADER}
|
|
|
|
{inputs}
|
|
|
|
{MD_OUTPUT_HEADER}
|
|
|
|
{outputs}
|
|
"""
|
|
|
|
if examples:
|
|
if len(examples) > 1:
|
|
md += f"\n\n{MD_EXAMPLES_HEADER}\n\n"
|
|
for ex in examples:
|
|
if ex[0] is not None:
|
|
md += f"""
|
|
{MD_EXAMPLE_IN_HDR}
|
|
|
|
{ex[0]}
|
|
"""
|
|
md += f"""
|
|
{MD_EXAMPLE_OUT_HDR}
|
|
|
|
{ex[1]}
|
|
"""
|
|
|
|
else:
|
|
if examples[0][0] is not None:
|
|
md += f"\n\n{MD_EX_SINGLE_IN_HDR}\n\n{examples[0][0]}"
|
|
md += f"\n\n{MD_EX_SINGLE_OUT_HDR}\n\n{examples[0][1]}"
|
|
|
|
if not args.no_emdash:
|
|
md = md.replace(" -- ", " — ")
|
|
|
|
if cat in endpoints:
|
|
endpoints[cat].append((rpc_name, md))
|
|
else:
|
|
endpoints[cat] = [(rpc_name, md)]
|
|
|
|
if not endpoints:
|
|
error(f"Found no parseable endpoint descriptions in {cur_file}")
|
|
|
|
if not args.no_sort:
|
|
for v in endpoints.values():
|
|
v.sort(key=lambda x: x[0])
|
|
|
|
os.makedirs(args.out, exist_ok=True)
|
|
|
|
static_path = os.path.dirname(os.path.realpath(__file__)) + '/static'
|
|
|
|
for f in ('index.md', 'sidebar.md'):
|
|
shutil.copy(f"{static_path}/{f}", f"{args.out}/{f}")
|
|
print(f"Copied static/{f} => {args.out}/{f}")
|
|
|
|
preamble_prefix = static_path + '/preamble-'
|
|
|
|
for cat, eps in endpoints.items():
|
|
out = f"{args.out}/{cat}.md"
|
|
with open(out, "w") as f:
|
|
preamble = f"{preamble_prefix}{cat}.md"
|
|
if os.path.isfile(preamble):
|
|
with open(preamble, "r") as fp:
|
|
for line in fp:
|
|
f.write(line)
|
|
f.write("\n\n")
|
|
else:
|
|
print(f"Warning: {preamble} doesn't exist, writing generic preamble for {cat}", file=sys.stderr)
|
|
f.write(f"# {cat} endpoints\n\n")
|
|
|
|
for _, md in eps:
|
|
f.write(md)
|
|
print(f"Wrote {out}")
|