diff --git a/contrib/msggen/msggen/__main__.py b/contrib/msggen/msggen/__main__.py index e423ac5683f6..4080c4481f1c 100644 --- a/contrib/msggen/msggen/__main__.py +++ b/contrib/msggen/msggen/__main__.py @@ -1,6 +1,7 @@ from msggen.model import Method, CompositeField, Service from msggen.grpc import GrpcGenerator, GrpcConverterGenerator, GrpcUnconverterGenerator, GrpcServerGenerator from msggen.rust import RustGenerator +from msggen.fsharp import FSharpGenerator from pathlib import Path import subprocess import json @@ -150,6 +151,12 @@ def genrustjsonrpc(service): RustGenerator(dest).generate(service) +def genfsharpjsonrpc(service): + request_file_name = repo_root() / "contrib" / "Model.fs" + dest1 = open(request_file_name, "w") + FSharpGenerator(dest1).generate(service) + + def load_msggen_meta(): meta = json.load(open('.msggen.json', 'r')) return meta @@ -165,6 +172,7 @@ def run(): meta = load_msggen_meta() gengrpc(service, meta) genrustjsonrpc(service) + genfsharpjsonrpc(service) write_msggen_meta(meta) diff --git a/contrib/msggen/msggen/fsharp.py b/contrib/msggen/msggen/fsharp.py new file mode 100644 index 000000000000..afd361f79449 --- /dev/null +++ b/contrib/msggen/msggen/fsharp.py @@ -0,0 +1,564 @@ +import re +import sys +from typing import TextIO, Tuple +from textwrap import dedent, indent +import logging +from .model import (ArrayField, CompositeField, EnumField, + PrimitiveField, Service) + +logger = logging.getLogger(__name__) + +typemap = { + 'boolean': 'bool', + 'hex': 'string', + 'msat': 'int64', + 'msat_or_all': 'AmountOrAll', + 'msat_or_any': 'AmountOrAny', + 'number': 'int64', + 'pubkey': 'PubKey', + 'short_channel_id': 'ShortChannelId', + 'signature': 'string', + 'string': 'string', + 'txid': 'string', + 'float': 'float', + 'feerate': 'Feerate', + 'outpoint': 'OutPoint', + 'outputdesc': 'OutputDescriptor', + 'hash': 'Hash', + 'secret': 'PrivKey', + + 'u8': 'byte', + 'u16': 'uint16', + 'u32': 'uint32', + 'u64': 'uint64', + 'i8': 'byte', + 'i16': 'int16', + 'i32': 'int32', + 'i64': 'int64', +} + +converter_map = { + 'int64': 'MSatJsonConverter', + 'PubKey': 'PubKeyJsonConverter', + 'ShortChannelId': 'ShortChannelIdJsonConverter', + 'PrivKey': 'PrivKeyJsonConverter', + 'Hash': 'HashJsonConverter', + 'AmountOrAny': 'AmountOrAnyJsonConverter', + 'AmountOrAll': 'AmountOrAllJsonConverter', + 'OutPoint': 'OutPointJsonConverter', + 'Feerate': 'FeerateJsonConverter', + 'OutputDescriptor': 'OutputDescriptorJsonConverter', +} + +# Manual overrides for some of the auto-generated types for paths +overrides = { + 'ListPeers.peers[].channels[].state_changes[].old_state': "ChannelState", + 'ListPeers.peers[].channels[].state_changes[].new_state': "ChannelState", + 'ListPeers.peers[].channels[].state_changes[].cause': "ChannelStateChangeCause", + 'ListPeers.peers[].channels[].htlcs[].state': None, + 'ListPeers.peers[].channels[].opener': "ChannelSide", + 'ListPeers.peers[].channels[].closer': "ChannelSide", + 'ListPeers.peers[].channels[].features[]': "string", + 'ListFunds.channels[].state': 'ChannelState', + 'ListTransactions.transactions[].type[]': None, + 'Invoice.exposeprivatechannels': None, +} + +header = f""" +/// This file was automatically generated using following command: +/// ```bash +/// {' '.join(sys.argv)} +/// ``` +/// +/// Do not edit this file, it'll be overwritten. Rather edit the schema that +/// This file was generated from +namespace ClnSharp + +""" + + +def to_PascalCase(s): + return s.replace("_", " ").title().replace(" ", "") + + +def normalize_varname(field): + """Make sure that the variable name of this field is valid + """ + field.path = field.path.replace("-", "_") + field.path = re.sub(r'(?)>]\n' + if p.required: + defi += f' []\n {to_PascalCase(p.name.normalized())}: {typename}\n' + else: + defi += f' []\n []\n {to_PascalCase(p.name.normalized())}: {typename} option\n' + return defi, decl + + +def gen_enum(e): + defi, decl = "", "" + if e.path in overrides and overrides[e.path] is None: + return "", "" + if e.description != "": + decl += f"/// {e.description}\n" + + decl += f'[]\n' + decl += f"type {e.typename} =\n" + + for i, v in enumerate(e.variants): + if v is None: + continue + name = v.normalized() + decl += f' | [] {name} = {i} \n' + + decl += "\n\n" + + typename = e.typename + if e.path in overrides: + decl = "" + typename = overrides[e.path] + + if e.required: + defi = f' // Path `{e.path}`\n' + defi += f' []\n' + defi += f' [)>]\n' + defi += f' {to_PascalCase(e.name.normalized())}: {typename}\n' + else: + defi = f' []\n' + defi += f' []\n' + defi += f' {to_PascalCase(e.name.normalized())}: {typename} option\n' + return defi, decl + + +def gen_array(a: ArrayField): + name = a.name.normalized().replace("[]", "") + logger.debug(f"Generating array field {a.name} -> {name} ({a.path})") + _, decl = gen_field(a.itemtype) + + if a.path in overrides: + decl = "" + itemtype = overrides[a.path] + elif isinstance(a.itemtype, PrimitiveField): + itemtype = a.itemtype.typename + elif isinstance(a.itemtype, CompositeField): + itemtype = a.itemtype.typename + elif isinstance(a.itemtype, EnumField): + itemtype = a.itemtype.typename + + if itemtype is None: + return "", "" + + itemtype = typemap.get(itemtype, itemtype) + alias = a.name.normalized() + if a.required: + defi = f' []\n {to_PascalCase(name)}: {itemtype}{" []" * a.dims}\n' + else: + defi = f' []\n' + defi += f" []\n" + defi += f" {to_PascalCase(name)}: {itemtype}{' []' * a.dims} option\n" + return defi, decl + + +def gen_field(field): + if isinstance(field, CompositeField): + return gen_composite(field) + elif isinstance(field, EnumField): + return gen_enum(field) + elif isinstance(field, ArrayField): + return gen_array(field) + elif isinstance(field, PrimitiveField): + return gen_primitive(field) + else: + raise ValueError(f"Unmanaged type {field}") + + +def gen_composite(c) -> Tuple[str, str]: + logger.debug(f"Generating composite field {c.name} ({c.path})") + fields = [] + for f in c.fields: + fields.append(gen_field(f)) + r = "".join([f[1] for f in fields]) + if len(fields) == 0: + r += f'type {c.typename} = unit\n' + else: + r += f'[]\n' + r += '[]\n' + r += f"type {c.typename} =" + r += " {\n" + r += "".join([f[0] for f in fields]) + r += "}\n" + r += "\n" + return ("", r) + + +class FSharpGenerator: + def __init__(self, dest: TextIO): + self.dest = dest + + def write(self, text: str, numindent: int = 0) -> None: + raw = dedent(text) + if numindent > 0: + raw = indent(text, " " * numindent) + self.dest.write(raw) + + def generate_requests(self, service: Service): + """""" + + self.write(f'[]\n') + self.write("module Requests =\n") + + for method in service.methods: + _, decl = gen_composite(method.request) + self.write(decl, numindent=1) + + self.write("\n\n") + + def generate_responses(self, service: Service): + self.write(f'[]\n') + self.write("module Responses = \n") + for method in service.methods: + _, decl = gen_composite(method.response) + self.write(decl, numindent=1) + + self.write("\n\n") + + def generate_enums(self, service: Service): + """The reqeust and Response enums serve as parsing primitives. + """ + self.write(f""" + [] + type Request = + """) + + for method in service.methods: + self.write(f"| {method.name} of Requests.{method.request.typename}\n", numindent=1) + self.write(f"with\n", numindent=2) + self.write(f"member this.MethodName =\n", numindent=2) + self.write(f"match this with\n", numindent=3) + for method in service.methods: + self.write(f"| {method.name} _ -> \"{method.name.lower()}\"\n", numindent=3) + + self.write(f"member this.Data =\n", numindent=2) + self.write(f"match this with\n", numindent=3) + for method in service.methods: + self.write(f"| {method.name} x -> x |> box\n", numindent=3) + + self.write("\n") + + self.write(f""" + [] + type Response = + """) + + for method in service.methods: + self.write(f"| {method.name} of Responses.{method.response.typename}\n", numindent=1) + self.write("\n") + + def write_header(self): + self.write(header) + opens = """ +open System.Text.Json +open System.Text.Json.Serialization + +""" + primitives = """ +open System + +type ChannelState = + | OPENINGD = 0 + | CHANNELD_AWAITING_LOCKIN = 1 + | CHANNELD_NORMAL = 2 + | CHANNELD_SHUTTING_DOWN = 3 + | CLOSINGD_SIGEXCHANGE = 4 + | CLOSINGD_COMPLETE = 5 + | AWAITING_UNILATERAL = 6 + | FUNDING_SPEND_SEEN = 7 + | ONCHAIN = 8 + | DUALOPEND_OPEN_INIT = 9 + | DUALOPEND_AWAITING_LOCKIN = 10 + +type ChannelStateChangeCause = + | [] UNKNOWN = 0 + | [] LOCAL = 1 + | [] USER = 2 + | [] REMOTE = 3 + | [] PROTOCOL = 4 + | [] ONCHAIN = 5 + +type [] msat + +type AmountOrAny = + | Amount of int64 + | Any + +type AmountOrAll = + | Amount of int64 + | All + +type Feerate = + | Slow + | Normal + | Urgent + | PerKb of uint32 + | PerKw of uint32 + + with + override this.ToString() = + match this with + | Slow -> "slow" + | Normal -> "normal" + | Urgent -> "urgent" + | PerKb v -> $"{v} perkb" + | PerKw v -> $"{v} perkw" + +type ChannelSide = + | [] LOCAL = 0 + | [] REMOTE = 1 + +[] +type ShortChannelId = ShortChannelId of string + with + member this.Value = let (ShortChannelId v) = this in v + override this.ToString() = this.Value + +[] +type PrivKey = PrivKey of byte[] + with + member this.Value = let (PrivKey v) = this in v + override this.ToString() = this.Value |> Convert.ToHexString + +[] +type PubKey = PubKey of byte[] + with + member this.Value = let (PubKey v) = this in v + override this.ToString() = this.Value |> Convert.ToHexString + +[] +type OutputDescriptor = OutputDescriptor of string + with + member this.Value = let (OutputDescriptor v) = this in v + override this.ToString() = this.Value + +[] +type Hash = Hash of byte[] + with + member this.Value = let (Hash v) = this in v + override this.ToString() = this.Value |> Convert.ToHexString + +[] +type OutPoint = { + VOut: uint32 + PrevHash: byte[] +} + with + override this.ToString() = $"{this.PrevHash |> Convert.ToHexString}:{this.VOut}" +""" + self.write(opens) + self.write(primitives) + + json_converters = """ +open System.Runtime.CompilerServices + +[] +module private PrimitiveExtensions = + let parseClnAmount(s: string): int64 = + if s |> String.IsNullOrWhiteSpace then + raise <| FormatException($"Invalid string for money. null") + else if s.EndsWith("msat") then + s.Substring(0, s.Length - 4) |> int64 |> unbox + else if s.EndsWith("sat") then + s.Substring(0, s.Length - 3) |> int64 |> unbox + else if s.EndsWith("btc") then + s.Substring(0, s.Length - 3) |> int64 |> unbox + else + raise <| FormatException $"Invalid string for money {s}" + + +type MSatJsonConverter() = + inherit JsonConverter>() + override this.Write(writer, value, options) = + writer.WriteStringValue(value.ToString() + "msat") + override this.Read(reader, _typeToConvert, _options) = + reader.GetString() + |> parseClnAmount + +type PubKeyJsonConverter() = + inherit JsonConverter() + + override this.Write(writer, value, _options) = + value.ToString() + |> writer.WriteStringValue + override this.Read(reader, _typeToConvert, _options) = + let b = reader.GetString() |> Convert.FromHexString + if b.Length <> 33 then + raise <| JsonException($"Invalid length for pubkey: {b.Length}, it must be 33") + else + b |> PubKey + +type ShortChannelIdJsonConverter() = + inherit JsonConverter() + override this.Write(writer, value, _options) = + value.ToString() + |> writer.WriteStringValue + override this.Read(reader, _typeToConvert, _options) = + reader.GetString() |> ShortChannelId + +type PrivKeyJsonConverter() = + inherit JsonConverter() + + override this.Write(writer, value, _options) = + value.ToString() + |> writer.WriteStringValue + + override this.Read(reader, _typeToConvert, _options) = + let b = reader.GetString() |> Convert.FromHexString + PrivKey b + +type HashJsonConverter() = + inherit JsonConverter() + override this.Write(writer, value, _options) = + value.ToString() + |> writer.WriteStringValue + + override this.Read(reader, _typeToConvert, _options) = + reader.GetString() + |> Convert.FromHexString + |> Hash + +type AmountOrAnyJsonConverter() = + inherit JsonConverter() + + override this.Write(writer, value, options) = + match value with + | AmountOrAny.Any -> + writer.WriteStringValue "any" + | AmountOrAny.Amount a -> + writer.WriteStringValue(a.ToString() + "msat") + override this.Read(reader, _typeToConvert, _options) = + match reader.GetString() with + | "any" -> + AmountOrAny.Any + | x -> + parseClnAmount x + |> AmountOrAny.Amount + +type AmountOrAllJsonConverter() = + inherit JsonConverter() + + override this.Write(writer, value, options) = + match value with + | AmountOrAll.All -> + writer.WriteStringValue "all" + | AmountOrAll.Amount a -> + writer.WriteStringValue(a.ToString() + "msat") + override this.Read(reader, _typeToConvert, _options) = + match reader.GetString() with + | "all" -> + AmountOrAll.All + | x -> + parseClnAmount x + |> AmountOrAll.Amount + +type OutPointJsonConverter() = + inherit JsonConverter() + + override this.Write(writer, value, options) = + writer.WriteStringValue(value.ToString()) + + override this.Read(reader, _typeToConvert, _options) = + let splits = reader.GetString().Split ":" + if splits.Length <> 2 then + raise <| JsonException("not a valid txid:output tuple") + else + { + OutPoint.PrevHash = splits.[0] |> Convert.FromHexString + VOut = splits.[1] |> uint32 + } + +type FeerateJsonConverter() = + inherit JsonConverter() + + override this.Write(writer, value, options) = + value.ToString() |> writer.WriteStringValue + + override this.Read(reader, _typeToConvert, _options) = + let s = reader.GetString() + let number = + s + |> Seq.choose(fun c -> match UInt32.TryParse($"{c}") with | true, v -> Some v | _ -> None) + |> Seq.fold(fun acc d -> acc * 10u + d) 0u + + let s = s.ToLowerInvariant() + if s.EndsWith "perkw" then + Feerate.PerKw(number) + else if s.EndsWith "perkb" then + Feerate.PerKb number + else if s = "slow" then + Feerate.Slow + else if s = "normal" then + Feerate.Normal + else if s = "urgent" then + Feerate.Urgent + else + raise <| JsonException $"Unable to parse feerate from string {s}" + +type OutputDescriptorJsonConverter() = + inherit JsonConverter() + + override this.Write(writer, value, options) = + value.ToString() |> writer.WriteStringValue + + override this.Read(reader, _typeToConvert, _options) = + reader.GetString() + |> OutputDescriptor + +[] +type RouteHop = { + [] + [)>] + Id: PubKey + + [] + [)>] + Scid: ShortChannelId + + [] + [)>] + Feebase: int64 + + [] + Feeprop: uint32 + + [] + Expirydelta: uint16 +} + +[] +type Routehint = { + [] + Hops: RouteHop[] +} + +[] +type RoutehintList = { + [] + Hints: Routehint [] +} + +""" + self.write(json_converters) + + def generate(self, service: Service): + self.write_header() + self.generate_requests(service) + self.generate_responses(service) + self.generate_enums(service)