2021-04-04 14:55:27 +02:00
# standard imports
import socket
import os
import logging
import enum
import re
import json
2021-08-21 09:31:59 +02:00
import base64
2021-04-04 14:55:27 +02:00
from urllib . request import (
Request ,
urlopen ,
urlparse ,
urljoin ,
build_opener ,
install_opener ,
)
2021-08-21 09:31:59 +02:00
from urllib . error import URLError
2021-04-04 14:55:27 +02:00
# local imports
from . jsonrpc import (
2021-08-21 09:31:59 +02:00
JSONRPCRequest ,
2021-04-04 14:55:27 +02:00
jsonrpc_result ,
2021-08-21 09:31:59 +02:00
ErrorParser ,
2021-04-04 14:55:27 +02:00
)
from . http import PreemptiveBasicAuthHandler
2021-08-21 09:31:59 +02:00
from . error import JSONRPCException
from . auth import Auth
2021-04-04 14:55:27 +02:00
2021-08-21 09:31:59 +02:00
logg = logging . getLogger ( __name__ )
2021-04-04 14:55:27 +02:00
2021-08-21 09:31:59 +02:00
error_parser = ErrorParser ( )
2021-04-04 14:55:27 +02:00
class ConnType ( enum . Enum ) :
2021-08-21 09:31:59 +02:00
""" Describe the underlying RPC connection type.
"""
2021-04-04 14:55:27 +02:00
CUSTOM = 0x00
HTTP = 0x100
HTTP_SSL = 0x101
WEBSOCKET = 0x200
WEBSOCKET_SSL = 0x201
UNIX = 0x1000
re_http = ' ^http(s)?:// '
re_ws = ' ^ws(s)?:// '
re_unix = ' ^ipc:// '
2021-08-21 09:31:59 +02:00
2021-04-04 14:55:27 +02:00
def str_to_connspec ( s ) :
2021-08-21 09:31:59 +02:00
""" Determine the connection type from a connection string.
: param s : Connection string
: type d : str
: rtype : chainlib . connection . ConnType
: returns : Connection type value
"""
2021-04-04 14:55:27 +02:00
if s == ' custom ' :
return ConnType . CUSTOM
m = re . match ( re_http , s )
if m != None :
if m . group ( 1 ) != None :
return ConnType . HTTP_SSL
return ConnType . HTTP
m = re . match ( re_ws , s )
if m != None :
if m . group ( 1 ) != None :
return ConnType . WEBSOCKET_SSL
return ConnType . WEBSOCKET
m = re . match ( re_unix , s )
if m != None :
return ConnType . UNIX
raise ValueError ( ' unknown connection type {} ' . format ( s ) )
2021-08-21 09:31:59 +02:00
class RPCConnection :
""" Base class for defining an RPC connection to a chain node.
This class may be instantiated directly , or used as an object factory to provide a thread - safe RPC connection mechanism to a single RPC node .
: param url : A valid URL connection string for the RPC connection
: type url : str
: param chain_spec : The chain spec of
: type chain_spec : chainlib . chain . ChainSpec
: param auth : Authentication settings to use when connecting
: type auth : chainlib . auth . Auth
: todo : basic auth is currently parsed from the connection string , should be auth object instead . auth object effectively not in use .
"""
2021-04-04 14:55:27 +02:00
__locations = { }
2021-04-24 07:36:45 +02:00
__constructors = {
' default ' : {
} ,
}
__constructors_for_chains = { }
2021-04-04 14:55:27 +02:00
2021-08-21 09:31:59 +02:00
def __init__ ( self , url = None , chain_spec = None , auth = None ) :
2021-04-04 14:55:27 +02:00
self . chain_spec = chain_spec
self . location = None
self . basic = None
if url == None :
return
2021-08-21 09:31:59 +02:00
self . auth = auth
if self . auth != None and not isinstance ( self . auth , Auth ) :
raise TypeError ( ' auth parameter needs to be subclass of chainlib.auth.Auth ' )
2021-04-04 14:55:27 +02:00
url_parsed = urlparse ( url )
logg . debug ( ' creating connection {} -> {} ' . format ( url , url_parsed ) )
2021-08-21 09:31:59 +02:00
# TODO: temporary basic auth parse
2021-04-04 14:55:27 +02:00
basic = url_parsed . netloc . split ( ' @ ' )
location = None
if len ( basic ) == 1 :
location = url_parsed . netloc
else :
location = basic [ 1 ]
self . basic = basic [ 0 ] . split ( ' : ' )
#if url_parsed.port != None:
# location += ':' + str(url_parsed.port)
2021-08-21 09:31:59 +02:00
#
2021-04-04 14:55:27 +02:00
self . location = os . path . join ( ' {} :// ' . format ( url_parsed . scheme ) , location )
self . location = urljoin ( self . location , url_parsed . path )
logg . debug ( ' parsed url {} to location {} ' . format ( url , self . location ) )
2021-04-24 07:36:45 +02:00
@staticmethod
def from_conntype ( t , tag = ' default ' ) :
2021-08-21 09:31:59 +02:00
""" Retrieve a connection constructor from the given tag and connection type.
: param t : Connection type
: type t : chainlib . connection . ConnType
: param tag : The connection selector tag
: type tag :
"""
2021-04-24 07:36:45 +02:00
return RPCConnection . __constructors [ tag ] [ t ]
@staticmethod
2021-08-21 09:31:59 +02:00
def register_constructor ( conntype , c , tag = ' default ' ) :
""" Associate a connection constructor for a given tag and connection type.
The constructor must be a chainlib . connection . RPCConnection object or an object of a subclass thereof .
: param conntype : Connection type of constructor
: type conntype : chainlib . connection . ConnType
: param c : Constructor
: type c : chainlib . connection . RPCConnection
: param tag : Tag to store the connection constructor under
: type tag : str
"""
2021-04-24 07:36:45 +02:00
if RPCConnection . __constructors . get ( tag ) == None :
RPCConnection . __constructors [ tag ] = { }
2021-08-21 09:31:59 +02:00
RPCConnection . __constructors [ tag ] [ conntype ] = c
logg . info ( ' registered RPC connection constructor {} for type {} tag {} ' . format ( c , conntype , tag ) )
2021-04-24 07:36:45 +02:00
2021-04-04 14:55:27 +02:00
# TODO: constructor needs to be constructor-factory, that itself can select on url type
@staticmethod
2021-04-24 07:36:45 +02:00
def register_location ( location , chain_spec , tag = ' default ' , exist_ok = False ) :
2021-08-21 09:31:59 +02:00
""" Associate a URL for a given tag and chain spec.
: param location : URL of RPC connection
: type location : str
: param chain_spec : Chain spec describing the chain behind the RPC connection
: type chain_spec : chainlib . chain . ChainSpec
: param tag : Tag to store the connection location under
: type tag : str
: param exist_ok : Overwrite existing record
: type exist_ok : bool
: raises ValueError : Record already exists , and exist_ok is not set
"""
2021-04-04 14:55:27 +02:00
chain_str = str ( chain_spec )
if RPCConnection . __locations . get ( chain_str ) == None :
RPCConnection . __locations [ chain_str ] = { }
elif not exist_ok :
v = RPCConnection . __locations [ chain_str ] . get ( tag )
if v != None :
raise ValueError ( ' duplicate registration of tag {} : {} , requested {} already had {} ' . format ( chain_str , tag , location , v ) )
conntype = str_to_connspec ( location )
RPCConnection . __locations [ chain_str ] [ tag ] = ( conntype , location )
2021-04-24 07:36:45 +02:00
logg . info ( ' registered rpc connection {} ( {} / {} ) as {} ' . format ( location , chain_str , tag , conntype ) )
2021-04-04 14:55:27 +02:00
@staticmethod
def connect ( chain_spec , tag = ' default ' ) :
2021-08-21 09:31:59 +02:00
""" Connect to the location defined by the given tag and chain spec, using the associated constructor.
Location must first be registered using the RPCConnection . register_location method .
Constructor must first be registered using the RPCConnection . register_constructor method .
: param chain_spec : Chain spec part of the location record
: type chain_spec : chainlib . chain . ChainSpec
: param tag : Tag part of the location record
: type tag : str
: rtype : chainlib . connection . RPCConnection
: returns : Instantiation of the matching registered constructor
"""
2021-04-04 14:55:27 +02:00
chain_str = str ( chain_spec )
c = RPCConnection . __locations [ chain_str ] [ tag ]
2021-04-24 07:36:45 +02:00
constructor = RPCConnection . from_conntype ( c [ 0 ] , tag = tag )
logg . debug ( ' rpc connect {} {} {} ' . format ( constructor , c , tag ) )
2021-04-04 14:55:27 +02:00
return constructor ( url = c [ 1 ] , chain_spec = chain_spec )
def disconnect ( self ) :
2021-08-21 09:31:59 +02:00
""" Should be overridden to clean up any resources bound by the connect method.
"""
2021-04-04 14:55:27 +02:00
pass
def __del__ ( self ) :
self . disconnect ( )
2021-08-21 09:31:59 +02:00
class HTTPConnection ( RPCConnection ) :
""" Generic HTTP connection subclass of RPCConnection
"""
pass
2021-04-04 14:55:27 +02:00
2021-08-21 09:31:59 +02:00
class UnixConnection ( RPCConnection ) :
""" Generic Unix socket connection subclass of RPCConnection
"""
pass
2021-04-04 14:55:27 +02:00
class JSONRPCHTTPConnection ( HTTPConnection ) :
2021-08-21 09:31:59 +02:00
""" Generic JSON-RPC specific HTTP connection wrapper.
"""
def check_rpc ( self ) :
""" Check if RPC connection is a valid JSON-RPC endpoint.
: raises Exception : Invalid connection .
"""
j = JSONRPCRequest ( )
req = j . template ( )
req [ ' method ' ] = ' ping '
try :
self . do ( req )
except JSONRPCException :
pass
def check ( self ) :
""" Check if endpoint is reachable.
: rtype : bool
: returns : True if reachable
"""
try :
self . check_rpc ( )
except URLError as e :
logg . error ( ' cannot connect to node {} ; {} ' . format ( self . location , e ) )
return False
return True
2021-04-04 14:55:27 +02:00
def do ( self , o , error_parser = error_parser ) :
2021-08-21 09:31:59 +02:00
""" Execute a JSON-RPC query, from dict as generated by chainlib.jsonrpc.JSONRPCRequest:finalize.
If connection was created with an auth object , the auth object will be used to authenticate the query .
If connection was created with a basic url string , the corresponding basic auth credentials will be used to authenticate the query .
: param o : JSON - RPC query object
: type o : dict
: param error_parser : Error parser object to process JSON - RPC error response with .
: type error_parser : chainlib . jsonrpc . ErrorParser
: raises ValueError : Invalid response from JSON - RPC endpoint
: raises URLError : Endpoint could not be reached
: rtype : any
: returns : Result value part of JSON RPC response
: todo : Invalid response exception from invalid json response
"""
2021-04-04 14:55:27 +02:00
req = Request (
self . location ,
method = ' POST ' ,
)
req . add_header ( ' Content-Type ' , ' application/json ' )
2021-08-21 09:31:59 +02:00
# use specific auth if present
if self . auth != None :
p = self . auth . urllib_header ( )
req . add_header ( p [ 0 ] , p [ 1 ] )
2021-04-04 14:55:27 +02:00
data = json . dumps ( o )
logg . debug ( ' (HTTP) send {} ' . format ( data ) )
2021-08-21 09:31:59 +02:00
# use basic auth if present
2021-04-04 14:55:27 +02:00
if self . basic != None :
handler = PreemptiveBasicAuthHandler ( )
handler . add_password (
realm = None ,
uri = self . location ,
user = self . basic [ 0 ] ,
passwd = self . basic [ 1 ] ,
)
ho = build_opener ( handler )
install_opener ( ho )
2021-08-21 09:31:59 +02:00
2021-04-04 14:55:27 +02:00
r = urlopen ( req , data = data . encode ( ' utf-8 ' ) )
2021-08-21 09:31:59 +02:00
2021-04-04 14:55:27 +02:00
result = json . load ( r )
logg . debug ( ' (HTTP) recv {} ' . format ( result ) )
if o [ ' id ' ] != result [ ' id ' ] :
raise ValueError ( ' RPC id mismatch; sent {} received {} ' . format ( o [ ' id ' ] , result [ ' id ' ] ) )
return jsonrpc_result ( result , error_parser )
class JSONRPCUnixConnection ( UnixConnection ) :
2021-08-21 09:31:59 +02:00
""" Execute a JSON-RPC query, from dict as generated by chainlib.jsonrpc.JSONRPCRequest:finalize.
: param o : JSON - RPC query object
: type o : dict
: param error_parser : Error parser object to process JSON - RPC error response with .
: type error_parser : chainlib . jsonrpc . ErrorParser
: raises ValueError : Invalid response from JSON - RPC endpoint
: raises IOError : Endpoint could not be reached
: rtype : any
: returns : Result value part of JSON RPC response
: todo : Invalid response exception from invalid json response
"""
2021-04-04 14:55:27 +02:00
def do ( self , o , error_parser = error_parser ) :
conn = socket . socket ( family = socket . AF_UNIX , type = socket . SOCK_STREAM , proto = 0 )
conn . connect ( self . location )
data = json . dumps ( o )
logg . debug ( ' unix socket send {} ' . format ( data ) )
l = len ( data )
n = 0
while n < l :
c = conn . send ( data . encode ( ' utf-8 ' ) )
if c == 0 :
s . close ( )
raise IOError ( ' unix socket ( {} / {} ) {} ' . format ( n , l , data ) )
n + = c
r = b ' '
while True :
b = conn . recv ( 4096 )
if len ( b ) == 0 :
break
r + = b
conn . close ( )
logg . debug ( ' unix socket recv {} ' . format ( r . decode ( ' utf-8 ' ) ) )
result = json . loads ( r )
if result [ ' id ' ] != o [ ' id ' ] :
raise ValueError ( ' RPC id mismatch; sent {} received {} ' . format ( o [ ' id ' ] , result [ ' id ' ] ) )
return jsonrpc_result ( result , error_parser )
2021-04-24 07:36:45 +02:00
2021-08-21 09:31:59 +02:00
# TODO: Automatic creation should be hidden behind symbol, in the spirit of no unsolicited side-effects. (perhaps connection should be module dir, and jsonrpc a submodule)
2021-04-24 07:36:45 +02:00
RPCConnection . register_constructor ( ConnType . HTTP , JSONRPCHTTPConnection , tag = ' default ' )
RPCConnection . register_constructor ( ConnType . HTTP_SSL , JSONRPCHTTPConnection , tag = ' default ' )
RPCConnection . register_constructor ( ConnType . UNIX , JSONRPCUnixConnection , tag = ' default ' )