sourceafJsonRpc::JsonRpc.fan

using util::JsonInStream

** An implementation of [JSON-RPC v2]`https://www.jsonrpc.org/`.
mixin JsonRpc {
    
    ** Creates an instance of 'JsonRpc'.
    ** 
    ** 'sink' may either a single instance, or a 'Str:Obj' map of sink instances where 'Str' is a
    ** prefix that must match the RPC method name.
    ** 
    ** pre>
    ** syntax: fantom
    ** jsonRpc := JsonRpc([
    **     "text/"  : TextSink()
    **     "image/" : ImageSink()
    ** ])
    ** <pre
    ** 
    ** Options defaults are:
    ** 
    **   pathDelimiter : '/'
    **   errFn         : |JsonRpcErr rpcErr                | { }
    **   fromJsonFn    : |Obj? jsonVal, Type argType ->Obj?| { jsonVal }
    **   toJsonFn      : |Obj? returnVal             ->Obj?| { returnVal }
    **   rpcHookFn     : |Obj sink, Method method, Obj?[]? args->Obj?| { method.callOn(sink, args) }
    ** 
    ** 'fromJsonFn' is used to convert method arguments and 'toJsonFn' is used to convert the 
    ** method's return value.
    ** 
    ** 'rpcHookFn' allows you to intercept the final method invocation, make changes, and 
    ** optionally continue with the invocation.
    ** 
    static new make(Obj sink, [Str:Obj]? opts := null) {
        JsonRpcMutantImpl(sink, opts)
    }
    
    ** Invokes the (batch of) RPCs for the given JSON request, and returns the JSON response.
    ** 
    ** 'null' is returned, should the request be a notification.
    abstract Str? call(InStream jsonIn)

    ** Converts this class into an *immutable* function.
    ** 
    ** Note that the handler and all options must be immutable too.
    abstract |InStream->Str?| toImmutableFn()
}

internal mixin JsonRpcMixin : JsonRpc {
    abstract Str:Obj    sinks()
    abstract Str:Obj    opts()

    override Str? call(InStream jsonIn) {
        reqObj := null
        
        try reqObj = JsonInStream(jsonIn).readJson
        catch (ParseErr perr)
            return JsonRpcRes {
                it.error = JsonRpcErr(JsonRpcErr.parseError, "Parse error - ${perr.msg}")
            }.toJson(errFn)

        if (reqObj is Map) {
            resRpc := callRpc(reqObj)
            return resRpc?.toJson(errFn)
        }
        
        if (reqObj is List) {
            reqBat := (Obj[]) reqObj
            
            if (reqBat.isEmpty)
                return JsonRpcRes {
                    it.error = JsonRpcErr(JsonRpcErr.invalidRequest, "Invalid request")
                }.toJson(errFn)

            resBat := StrBuf()
            reqBat.each |reqRpc| {
                resRpc := callRpc(reqRpc)
                if (resRpc != null)
                    resBat.join(resRpc.toJson(errFn), ",\n")
            }

            if (resBat.isEmpty)
                return null
            resBat.insert(0, "[\n").add("\n]")
            return resBat.toStr
        }
        
        return JsonRpcRes {
            it.error = JsonRpcErr(JsonRpcErr.invalidRequest, "Invalid request")
        }.toJson(errFn)
    }

    private JsonRpcRes? callRpc(Obj reqObj) {
        reqRpc  := null as JsonRpcReq
        error   := null as JsonRpcErr
        resObj  := null
        
        try reqRpc = JsonRpcReq.fromObj(reqObj)
        catch (JsonRpcErr rpcErr)
            return JsonRpcRes {
                it.error = rpcErr
            }
        catch (Err err)
            return JsonRpcRes {
                it.error = JsonRpcErr(JsonRpcErr.invalidRequest, "Invalid request")
            }

        try resObj = callSink(reqRpc)
        catch (JsonRpcErr rpcErr)
            error  = rpcErr
        catch (Err err)
            error  = JsonRpcErr(JsonRpcErr.applicationError, err.msg, err)

        
        if (reqRpc.isNotification) {
            // errors elsewhere are taken care of when returning a JsonRpcRes
            if (error != null)
                errFn()?.call(error)
            return null
        }
    
        resRpc := JsonRpcRes {
            it.id       = reqRpc.id
            it.result   = resObj
            it.error    = error
        }
        return resRpc
    }
    
    private Obj? callSink(JsonRpcReq rpcReq) {
        idx     := rpcReq.method.indexr(optPathDelimiter.toChar)
        prefix  := idx != null ? rpcReq.method[0..idx]    : ""
        methodN := idx != null ? rpcReq.method[idx+1..-1] : rpcReq.method
        sink    := sinks[prefix]
        
        if (sink == null)
            throw JsonRpcErr(JsonRpcErr.methodNotFound, "Method not found: ${rpcReq.method}")
        
        // fall back to looking for a method without the "on" prefix
        method  := sink.typeof.method("on" + methodN.capitalize, false) ?: sink.typeof.method(methodN, false)
        
        if (method == null)
            throw JsonRpcErr(JsonRpcErr.methodNotFound, "Method not found: ${rpcReq.method}")

        params  := rpcReq.params
        args    := null as Obj?[]
        
        try {
            if (params is List)
                args = ((Obj?[]) params).map |arg, i| {
                    fromJsonFn(arg, method.params[i].type)
                }
            
            else
            if (params is Map) {
                obj := (Str:Obj?) params
                args = method.params.map |param, i| {
                    if (obj.containsKey(param.name)) {
                        val := obj[param.name]
                        arg := fromJsonFn(val, param.type)
                        return arg
                    }

                    // a fudge the weird interpretation of the JSON-RPC spec by LSP
                    // put this *before* default 'cos we expect ALL the params to be supplied
                    if (param.name == "params" && method.params.size == 1)
                        return fromJsonFn(obj, param.type)
                    
                    if (param.hasDefault)
                        return method.paramDef(param, sink)

                    str := method.qname + "("
                    i.times { str += "..., " }
                    str += param.name + ")  <-- " + obj.keys
                    throw JsonRpcErr(JsonRpcErr.invalidParams, "Unknown param: ${str}")
                }
            }
        }
        catch (JsonRpcErr err)  throw err
        catch (Err        err)  throw JsonRpcErr(JsonRpcErr.internalError, "Could not invoke method: ${err.msg}", err)
        
        ret := rpcHookFn(sink, method, args)
        
        try ret = toJsonFn(ret) 
        catch (Err        err)  throw JsonRpcErr(JsonRpcErr.internalError, "Could not convert response: ${err.msg}", err)
        return ret
    }
    
    Int optPathDelimiter() {
        opts["pathDelimiter"] ?: '/'
    }
    
    private |JsonRpcErr|? errFn() {
        opts["errFn"]
    }

    private Obj? fromJsonFn(Obj? jsonVal, Type argType) {
        ((|Obj?, Type->Obj?|?) opts["fromJsonFn"])?.call(jsonVal, argType) ?: jsonVal
    }
    
    private Obj? toJsonFn(Obj? val) {
        ((|Obj?->Obj?|?) opts["toJsonFn"])?.call(val) ?: val
    }
    
    private Obj? rpcHookFn(Obj sink, Method method, Obj?[]? args) {
        fn := (|Obj, Method, Obj?[]?->Obj?|?) opts["rpcHookFn"]
        return fn != null
            ? fn.call(sink, method, args)
            : method.callOn(sink, args)
    }
} 

internal class JsonRpcMutantImpl : JsonRpcMixin {
    override Str:Obj    sinks
    override Str:Obj    opts
    
    new make(Obj sink, [Str:Obj]? opts := null) {
        this.opts   = opts ?: Str:Obj[:]

        if (sink isnot Map)
            sink = Str:Obj[:].set("", sink)
        
        // order sink prefixes so more-specific prefixes are matched first
        sinks := (Str:Obj) sink
        sunks :=  Str:Obj[:] { it.ordered = true }
        sinks.keys
            .sortr |p1, p2| { p1.split(optPathDelimiter).size <=> p2.split(optPathDelimiter).size }
            .each  |key| {
                sunks[key] = sinks[key]
            }
        this.sinks = sunks
    }
    
    override |InStream->Str?| toImmutableFn() {
        JsonRpcConstImpl(sinks, opts).toImmutableFn
    }
}

internal const class JsonRpcConstImpl : JsonRpcMixin {
    override const Str:Obj          sinks
    override const Str:Obj          opts
    override const |InStream->Str?| toImmutableFn

    new make(Str:Obj sinks, Str:Obj opts) {
        this.sinks          = sinks
        this.opts           = opts
        this.toImmutableFn  = #call.func.bind([this])
    }
}