diff --git a/aiprompts/openrpc/convert.py b/aiprompts/openrpc/convert.py new file mode 100644 index 00000000..c6d737c9 --- /dev/null +++ b/aiprompts/openrpc/convert.py @@ -0,0 +1,16 @@ + +import json +from jinja2 import Template + +# load OpenRPC spec +with open("openrpc.json") as f: + spec = json.load(f) + +# load template +with open("openrpc_to_md.j2") as f: + tmpl = Template(f.read()) + +# render markdown +output = tmpl.render(spec=spec) + +print(output) diff --git a/aiprompts/openrpc/dense_md_to_openrpc.py b/aiprompts/openrpc/dense_md_to_openrpc.py new file mode 100644 index 00000000..bf871f6e --- /dev/null +++ b/aiprompts/openrpc/dense_md_to_openrpc.py @@ -0,0 +1,163 @@ +import re +import yaml +import json +import requests +from collections import defaultdict +from jsonschema import validate, Draft7Validator + +# --- Load OpenRPC meta-schema --- +def load_openrpc_schema(): + url = "https://open-rpc.github.io/meta-schema.json" + resp = requests.get(url) + resp.raise_for_status() + return resp.json() + +OPENRPC_SCHEMA = load_openrpc_schema() + +def parse_dense_markdown(md_text: str): + methods = [] + schemas = {} + + # Split into sections + sections = re.split(r"^# ", md_text, flags=re.M) + secmap = {} + for sec in sections: + if not sec.strip(): + continue + title, _, body = sec.partition("\n") + secmap[title.strip()] = body.strip() + + # --- Methods --- + if "Methods" in secmap: + method_blocks = re.split(r"^## ", secmap["Methods"], flags=re.M) + for block in method_blocks: + if not block.strip(): + continue + name, _, body = block.partition("\n") + method = {"name": name.strip(), "params": [], "result": {}} + + lines = [l.rstrip() for l in body.splitlines() if l.strip()] + + # description before **Params** + desc_lines = [] + i = 0 + while i < len(lines) and not lines[i].startswith("**Params**"): + desc_lines.append(lines[i]) + i += 1 + method["description"] = " ".join(desc_lines).strip() + + # Params + if "**Params**" in lines: + pi = lines.index("**Params**") + 1 + while pi < len(lines) and not lines[pi].startswith("**Result**"): + line = lines[pi].lstrip("-").strip() + if line and not line.lower().startswith("none"): + pname, _, pdesc = line.partition("(") + pname = pname.strip() + ptype = pdesc.split(",")[0].replace(")", "").strip() if pdesc else "object" + required = "required" in line + param = { + "name": pname, + "required": required, + "schema": {"type": map_type(ptype)} + } + method["params"].append(param) + pi += 1 + + # Result + if "**Result**" in lines: + ri = lines.index("**Result**") + 1 + if ri < len(lines): + rline = lines[ri].lstrip("-").strip() + rname, _, rtype = rline.partition(":") + rname = rname.strip() + rtype = rtype.strip() + schema = parse_result_type(rtype) + method["result"] = {"name": rname, "schema": schema} + + methods.append(method) + + # --- Schemas --- + if "Schemas" in secmap: + schema_blocks = re.split(r"^## ", secmap["Schemas"], flags=re.M) + for block in schema_blocks: + if not block.strip(): + continue + name, _, body = block.partition("\n") + lines = body.splitlines() + code_block = [] + req_fields = [] + in_yaml = False + for l in lines: + if l.strip().startswith("```yaml"): + in_yaml = True + continue + elif l.strip().startswith("```"): + in_yaml = False + continue + if in_yaml: + code_block.append(l) + if l.strip().startswith("*Required:"): + req_fields = [x.strip() for x in l.split(":")[1].split(",")] + if code_block: + schema_def = yaml.safe_load("\n".join(code_block)) + props = {} + for fname, ftype in schema_def.items(): + if isinstance(ftype, str): + base = ftype.split("#")[0].strip() + desc = ftype.split("#")[1].strip() if "#" in ftype else None + props[fname] = {"type": map_type(base)} + if desc: + props[fname]["description"] = desc + schemas[name.strip()] = { + "type": "object", + "properties": props, + "required": req_fields + } + + spec = {"openrpc": "1.0.0", "methods": methods, "components": {"schemas": schemas}} + + # --- Validate against OpenRPC schema --- + validator = Draft7Validator(OPENRPC_SCHEMA) + errors = sorted(validator.iter_errors(spec), key=lambda e: e.path) + if errors: + for err in errors: + print(f"❌ Validation error at {list(err.path)}: {err.message}") + raise ValueError("Generated spec is not valid OpenRPC") + else: + print("✅ Spec is valid OpenRPC") + + return spec + +# --- Helpers --- +def map_type(t): + mapping = {"int": "integer", "str": "string", "bool": "boolean", "object": "object"} + return mapping.get(t, t) + +def parse_result_type(rtype): + if "|" in rtype: + oneofs = [] + for variant in rtype.split("|"): + v = variant.strip() + if v.startswith("[") and v.endswith("]"): + oneofs.append({"type": "array", "items": {"$ref": f"#/components/schemas/{v[1:-1]}"}}) + else: + oneofs.append({"$ref": f"#/components/schemas/{v}"}) + return {"oneOf": oneofs} + elif rtype.startswith("[") and rtype.endswith("]"): + return {"type": "array", "items": {"type": map_type(rtype[1:-1])}} + elif rtype in ["int", "str", "bool", "object"]: + return {"type": map_type(rtype)} + else: + return {"$ref": f"#/components/schemas/{rtype}"} + + +# --- Example usage --- +if __name__ == "__main__": + with open("dense.md") as f: + md = f.read() + + spec = parse_dense_markdown(md) + + with open("openrpc.json", "w") as f: + json.dump(spec, f, indent=2) diff --git a/aiprompts/openrpc/install.sh b/aiprompts/openrpc/install.sh new file mode 100644 index 00000000..21e5f029 --- /dev/null +++ b/aiprompts/openrpc/install.sh @@ -0,0 +1 @@ +pip install jsonschema requests \ No newline at end of file diff --git a/aiprompts/openrpc/openrpc2md.md b/aiprompts/openrpc/openrpc2md.md new file mode 100644 index 00000000..98129f4d --- /dev/null +++ b/aiprompts/openrpc/openrpc2md.md @@ -0,0 +1,155 @@ + + +# Instructions: Converting OpenRPC → Markdown Shorthand + +## Purpose + +Transform an OpenRPC specification (JSON or YAML) into a **dense Markdown format** that is: + +* Human-readable +* AI-parseable +* Compact (minimal boilerplate) +* Faithful to the original semantics + +--- + +## General Rules + +1. **Drop metadata** + Ignore `info`, `servers`, and other metadata not relevant to API usage. + +2. **Two main sections** + + * `# Methods` + * `# Schemas` + +3. **Method representation** + Each method gets its own `## {method_name}` block: + + * First line: *description* (if present). + * **Params** section: + + * List each parameter as `- name: TYPE (required?)` + * Inline object properties using nested bullet lists or YAML block if complex. + * **Result** section: + + * Same style as params. + * Use shorthand for references: + + * `$ref: "#/components/schemas/Comment"` → `Comment` + * Array of refs → `[Comment]` + +4. **Schema representation** + Each schema gets its own `## {SchemaName}` block: + + * Use fenced YAML block for properties: + + ```yaml + field: TYPE # description + ``` + * List required fields below the block: + `*Required: field1, field2*` + +--- + +## Type Conventions + +* `type: integer` → `int` +* `type: string` → `str` +* `type: boolean` → `bool` +* `type: object` → `object` +* `type: array` → `[TYPE]` (if items defined) +* `oneOf: [SchemaA, SchemaB]` → `SchemaA | SchemaB` + +--- + +## Handling Complex Types + +1. **Objects inside parameters or results** + + * If small (≤3 fields), inline as `{ field1: TYPE, field2: TYPE }`. + * If large, expand as YAML block. + + Example: + + ```markdown + - args (object, required) + - id: int + - parent: int + ``` + +2. **Nested schemas (structs in structs)** + + * Inline only the top level, reference nested schemas by name. + * If the nested schema is not declared in `components/schemas`, define it under `# Schemas`. + + Example: + + ```markdown + ## user_create + **Params** + - user: UserProfile (struct defined below) + ``` + +3. **Arrays** + + * `[TYPE]` for primitives (e.g., `[int]`). + * `[SchemaName]` for objects. + +4. **Results with multiple options** + + * Use `SchemaA | [SchemaA]` for "oneOf". + +--- + +## Example Conversion + +OpenRPC (fragment): + +```json +{ + "name": "comment_get", + "description": "Retrieve comments", + "params": [ + { + "name": "args", + "required": true, + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "author": { "type": "integer" } + } + } + } + ], + "result": { + "name": "comments", + "schema": { + "oneOf": [ + { "$ref": "#/components/schemas/Comment" }, + { "type": "array", "items": { "$ref": "#/components/schemas/Comment" } } + ] + } + } +} +``` + +Markdown: + +```markdown +## comment_get +Retrieve comments + +**Params** +- args (object, required) + - id: int + - author: int + +**Result** +- comments: Comment | [Comment] +``` + +--- + +This way, any AI (or human) can deterministically map **OpenRPC → Markdown shorthand**. diff --git a/aiprompts/openrpc/roundtrip_test.py b/aiprompts/openrpc/roundtrip_test.py new file mode 100644 index 00000000..87ce2da3 --- /dev/null +++ b/aiprompts/openrpc/roundtrip_test.py @@ -0,0 +1,53 @@ +import json +import sys +from dense_md_to_openrpc import parse_dense_markdown +from jinja2 import Template + +# Load the Jinja2 template for JSON → Markdown +with open("openrpc_to_md.j2") as f: + md_template = Template(f.read()) + +def roundtrip(md_path: str): + print(f"🔄 Round-trip test for {md_path}") + + # 1. Markdown → JSON + with open(md_path) as f: + md_text = f.read() + spec1 = parse_dense_markdown(md_text) + print("✅ Step 1: Markdown → JSON") + + # 2. Validate already happens inside parse_dense_markdown + + # 3. JSON → Markdown + md2 = md_template.render(spec=spec1) + print("✅ Step 2: JSON → Markdown") + + # 4. Markdown → JSON again + spec2 = parse_dense_markdown(md2) + print("✅ Step 3: Markdown → JSON (again)") + + # 5. Compare results + spec1_str = json.dumps(spec1, sort_keys=True, indent=2) + spec2_str = json.dumps(spec2, sort_keys=True, indent=2) + + if spec1_str == spec2_str: + print("🎉 Round-trip is stable! (JSON1 == JSON2)") + else: + print("⚠️ Round-trip mismatch!") + with open("spec1.json", "w") as f: + f.write(spec1_str) + with open("spec2.json", "w") as f: + f.write(spec2_str) + with open("roundtrip_diff.md", "w") as f: + f.write("## Original JSON (Markdown → JSON)\n```json\n") + f.write(spec1_str + "\n```\n") + f.write("\n## After Round-trip (JSON → Markdown → JSON)\n```json\n") + f.write(spec2_str + "\n```\n") + print("👉 Differences written to spec1.json, spec2.json, and roundtrip_diff.md") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python roundtrip_test.py dense.md") + sys.exit(1) + + roundtrip(sys.argv[1]) diff --git a/aiprompts/openrpc/schema.j2 b/aiprompts/openrpc/schema.j2 new file mode 100644 index 00000000..b595f5fc --- /dev/null +++ b/aiprompts/openrpc/schema.j2 @@ -0,0 +1,56 @@ +# Methods + +{% for method in spec.methods %} +## {{ method.name }} +{{ method.description or "" }} + +**Params** +{% if method.params|length == 0 %} +- none +{% else %} +{% for p in method.params %} +- {{ p.name }} ({{ p.schema.type or "object" }}{% if p.required %}, required{% endif %}) +{% if p.schema.properties %} +{% for field, fdef in p.schema.properties.items() %} + - {{ field }}: {{ fdef.type or "object" }}{% if fdef.description %} — {{ fdef.description }}{% endif %} +{% endfor %} +{% endif %} +{% if p.schema["$ref"] %} + - {{ p.schema["$ref"].split("/")[-1] }} +{% endif %} +{% endfor %} +{% endif %} + +**Result** +- {{ method.result.name }}: +{% if method.result.schema.oneOf %} + {% for variant in method.result.schema.oneOf %} + {% if variant["$ref"] %} + - {{ variant["$ref"].split("/")[-1] }} + {% elif variant.type == "array" %} + - [{{ variant.items["$ref"].split("/")[-1] }}] + {% else %} + - {{ variant.type }} + {% endif %} + {% endfor %} +{% elif method.result.schema["$ref"] %} + {{ method.result.schema["$ref"].split("/")[-1] }} +{% elif method.result.schema.type == "array" %} + [{{ method.result.schema.items.type or method.result.schema.items["$ref"].split("/")[-1] }}] +{% elif method.result.schema.type %} + {{ method.result.schema.type }} +{% else %} + object +{% endif %} + +--- +{% endfor %} + +# Schemas + +{% for name, schema in spec.components.schemas.items() %} +## {{ name }} +```yaml +{% for field, fdef in schema.properties.items() %} +{{ field }}: {{ fdef.type or "object" }}{% if fdef.description %} # {{ fdef.description }}{% endif %} +{% endfor %} diff --git a/lib/hero/heromodels/examples/client_example.vsh b/lib/hero/heromodels/examples/client_example.vsh new file mode 100755 index 00000000..ec05e0af --- /dev/null +++ b/lib/hero/heromodels/examples/client_example.vsh @@ -0,0 +1,88 @@ +#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run + +import net.unix +import x.json2 +import freeflowuniverse.herolib.ui.console +import freeflowuniverse.herolib.hero.heromodels.openrpc + + +fn send_request(mut conn unix.StreamConn, request openrpc.JsonRpcRequest) ! { + request_json := json2.encode(request) + conn.write_string(request_json)! +} + +fn read_response(mut conn unix.StreamConn) !string { + mut buffer := []u8{len: 8192} + bytes_read := conn.read(mut buffer)! + return buffer[..bytes_read].bytestr() +} + +console.print_header('HeroModels OpenRPC Client Example') + +// Connect to the server +mut conn := unix.connect_stream('/tmp/heromodels')! +defer { + conn.close() or {} +} + +console.print_item('Connected to server') + +// Test 1: Get OpenRPC specification +console.print_header('Test 1: Discover OpenRPC Specification') +discover_request := openrpc.JsonRpcRequest{ + jsonrpc: '2.0' + method: 'discover' + params: json2.null + id: json2.Any(1) +} + +send_request(mut conn, discover_request)! +response := read_response(mut conn)! +console.print_item('OpenRPC Spec received: ${response.len} characters') + +// Test 2: Create a comment +console.print_header('Test 2: Create Comment') +comment_json := '{"comment": "This is a test comment from OpenRPC client", "parent": 0, "author": 1}' + +create_request := openrpc.JsonRpcRequest{ + jsonrpc: '2.0' + method: 'comment_set' + params: json2.raw_decode(comment_json)! + id: json2.Any(2) +} + +send_request(mut conn, create_request)! +create_response := read_response(mut conn)! +console.print_item('Comment created: ${create_response}') + +// Test 3: List all comments +console.print_header('Test 3: List All Comments') +list_request := openrpc.JsonRpcRequest{ + jsonrpc: '2.0' + method: 'comment_list' + params: json2.null + id: json2.Any(3) +} + +send_request(mut conn, list_request)! +list_response := read_response(mut conn)! +console.print_item('Comment list: ${list_response}') + +// Test 4: Get comment by author +console.print_header('Test 4: Get Comments by Author') +get_args_json := '{"author": 1}' + +get_request := openrpc.JsonRpcRequest{ + jsonrpc: '2.0' + method: 'comment_get' + params: json2.raw_decode(get_args_json)! + id: json2.Any(4) +} + +send_request(mut conn, get_request)! +get_response := read_response(mut conn)! +console.print_item('Comments by author: ${get_response}') + +console.print_header('All tests completed successfully!') + + diff --git a/lib/hero/heromodels/examples/example.vsh b/lib/hero/heromodels/examples/example.vsh new file mode 100644 index 00000000..6fa64a9b --- /dev/null +++ b/lib/hero/heromodels/examples/example.vsh @@ -0,0 +1,23 @@ +#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run + +import freeflowuniverse.herolib.hero.heromodels.openrpc +import freeflowuniverse.herolib.hero.heromodels +import freeflowuniverse.herolib.ui.console +import time +import os + +console.print_header('HeroModels OpenRPC Server Example') + +// Create and start the server +mut server := openrpc.new_rpc_server(socket_path: '/tmp/heromodels')! + +// Start server in a separate thread +spawn server.start() + +console.print_item('Server started on /tmp/heromodels') +console.print_item('Press Ctrl+C to stop the server') + +// Keep the main thread alive +for { + time.sleep(1 * time.second) +} \ No newline at end of file diff --git a/lib/hero/heromodels/examples/example1.vsh b/lib/hero/heromodels/examples/example1.vsh index 9750c14d..c98e7f87 100644 --- a/lib/hero/heromodels/examples/example1.vsh +++ b/lib/hero/heromodels/examples/example1.vsh @@ -1,4 +1,4 @@ - +#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run // Create a user diff --git a/lib/hero/heromodels/openrpc/client_example.vsh b/lib/hero/heromodels/openrpc/client_example.vsh deleted file mode 100644 index 1e9d796f..00000000 --- a/lib/hero/heromodels/openrpc/client_example.vsh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run - -import net.unix -import x.json2 -import freeflowuniverse.herolib.ui.console -import freeflowuniverse.herolib.hero.heromodels.openrpc - -// Example client to test the HeroModels OpenRPC server -fn main() { - console.print_header('HeroModels OpenRPC Client Example') - - // Connect to the server - mut conn := unix.connect_stream('/tmp/heromodels')! - defer { - conn.close() or {} - } - - console.print_item('Connected to server') - - // Test 1: Get OpenRPC specification - console.print_header('Test 1: Discover OpenRPC Specification') - discover_request := openrpc.JsonRpcRequest{ - jsonrpc: '2.0' - method: 'discover' - params: json2.null - id: json2.Any(1) - } - - send_request(mut conn, discover_request)! - response := read_response(mut conn)! - console.print_item('OpenRPC Spec received: ${response.len} characters') - - // Test 2: Create a comment - console.print_header('Test 2: Create Comment') - comment_json := '{"comment": "This is a test comment from OpenRPC client", "parent": 0, "author": 1}' - - create_request := openrpc.JsonRpcRequest{ - jsonrpc: '2.0' - method: 'comment_set' - params: json2.raw_decode(comment_json)! - id: json2.Any(2) - } - - send_request(mut conn, create_request)! - create_response := read_response(mut conn)! - console.print_item('Comment created: ${create_response}') - - // Test 3: List all comments - console.print_header('Test 3: List All Comments') - list_request := openrpc.JsonRpcRequest{ - jsonrpc: '2.0' - method: 'comment_list' - params: json2.null - id: json2.Any(3) - } - - send_request(mut conn, list_request)! - list_response := read_response(mut conn)! - console.print_item('Comment list: ${list_response}') - - // Test 4: Get comment by author - console.print_header('Test 4: Get Comments by Author') - get_args_json := '{"author": 1}' - - get_request := openrpc.JsonRpcRequest{ - jsonrpc: '2.0' - method: 'comment_get' - params: json2.raw_decode(get_args_json)! - id: json2.Any(4) - } - - send_request(mut conn, get_request)! - get_response := read_response(mut conn)! - console.print_item('Comments by author: ${get_response}') - - console.print_header('All tests completed successfully!') -} - -fn send_request(mut conn unix.StreamConn, request openrpc.JsonRpcRequest) ! { - request_json := json2.encode(request) - conn.write_string(request_json)! -} - -fn read_response(mut conn unix.StreamConn) !string { - mut buffer := []u8{len: 8192} - bytes_read := conn.read(mut buffer)! - return buffer[..bytes_read].bytestr() -} \ No newline at end of file diff --git a/lib/hero/heromodels/openrpc/discover.v b/lib/hero/heromodels/openrpc/discover.v index 36ee8ed7..2092501a 100644 --- a/lib/hero/heromodels/openrpc/discover.v +++ b/lib/hero/heromodels/openrpc/discover.v @@ -1,9 +1,9 @@ module openrpc - +import freeflowuniverse.herolib.schemas.openrpc import x.json2 // discover returns the OpenRPC specification for the HeroModels service -fn (mut server RPCServer) discover() !string { +fn (mut server RPCServer) discover() !json2.Any { spec_json := $tmpl("openrpc.json") - return spec_json + return openrpc.decode_json_any(spec_json)! } \ No newline at end of file diff --git a/lib/hero/heromodels/openrpc/example.vsh b/lib/hero/heromodels/openrpc/example.vsh deleted file mode 100644 index 12bbf04f..00000000 --- a/lib/hero/heromodels/openrpc/example.vsh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run - -import freeflowuniverse.herolib.hero.heromodels.openrpc -import freeflowuniverse.herolib.hero.heromodels -import freeflowuniverse.herolib.ui.console -import time -import os - -// Example usage of the HeroModels OpenRPC server -fn main() { - console.print_header('HeroModels OpenRPC Server Example') - - // Create and start the server - mut server := openrpc.new_rpc_server(socket_path: '/tmp/heromodels')! - - // Start server in a separate thread - spawn server.start() - - console.print_item('Server started on /tmp/heromodels') - console.print_item('Press Ctrl+C to stop the server') - - // Keep the main thread alive - for { - time.sleep(1 * time.second) - } -} \ No newline at end of file diff --git a/lib/hero/heromodels/openrpc/openrpc.json b/lib/hero/heromodels/openrpc/openrpc.json index d03b493e..011dbe16 100644 --- a/lib/hero/heromodels/openrpc/openrpc.json +++ b/lib/hero/heromodels/openrpc/openrpc.json @@ -50,12 +50,12 @@ "schema": { "oneOf": [ { - "\\$ref": "#/components/schemas/Comment" + "$$ref": "#/components/schemas/Comment" }, { "type": "array", "items": { - "\\$ref": "#/components/schemas/Comment" + "$$ref": "#/components/schemas/Comment" } } ] @@ -71,7 +71,7 @@ "description": "Comment data to create", "required": true, "schema": { - "\\$ref": "#/components/schemas/CommentArg" + "$$ref": "#/components/schemas/CommentArg" } } ], @@ -102,10 +102,10 @@ "properties": { "id": { "type": "integer", - "description": "ID of comment to delete", - "required": true + "description": "ID of comment to delete" } - } + }, + "required": ["id"] } } ], diff --git a/lib/hero/heromodels/openrpc/server.v b/lib/hero/heromodels/openrpc/server.v index 26dddf49..bef6f62c 100644 --- a/lib/hero/heromodels/openrpc/server.v +++ b/lib/hero/heromodels/openrpc/server.v @@ -1,12 +1,12 @@ module openrpc -import x.json2 +import json import net.unix import os import freeflowuniverse.herolib.ui.console pub struct RPCServer { -mut: +pub mut: listener &unix.StreamListener socket_path string } diff --git a/lib/schemas/openrpc/decode.v b/lib/schemas/openrpc/decode.v index 06a89edb..4ceff8ae 100644 --- a/lib/schemas/openrpc/decode.v +++ b/lib/schemas/openrpc/decode.v @@ -4,6 +4,12 @@ import json import x.json2 { Any } import freeflowuniverse.herolib.schemas.jsonschema { Reference, decode_schemaref } +pub fn decode_json_any(data string) !Any { + // mut o:=decode(data)! + return json2.decode[json2.Any](data)! +} + + pub fn decode(data string) !OpenRPC { // mut object := json.decode[OpenRPC](data) or { return error('Failed to decode json\n=======\n${data}\n===========\n${err}') } mut object := json.decode(OpenRPC, data) or {