client/rest, cmd/baseserver: started a basecoin REST client
```shell $ go get -u -v github.com/tendermint/basecoin/cmd/baseserver $ baseserver init $ baseserver serve ``` A server that can be ran by default on port 8998 otherwise one can specify the port using flag `--port` like this: ```shell $ baseserver serve --port 9999 ``` to serve it on port 9999, accessible at http://localhost:9999 Implemented: - [X] /keys POST -- generate a new key - [X] /keys GET -- list all keys - [X] /keys/{name} DELETE-- delete a named key - [X] /keys/{name} GET -- get a named key - [X] /keys/{name} POST, PUT -- update a named key - [X] /sign POST -- sign a transaction - [X] /build/send POST -- send money from one actor to another. However, still needs testing and verification of output - [X] /tx POST -- post a transaction to the blockchain. However, still needs testing and verification of output This base code to get the handlers starters was adapted from: * https://github.com/tendermint/go-crypto/blob/master/keys/server * https://github.com/tendermint/basecoin/blob/unstable/client/commands/proxy/root.go Updates #186
This commit is contained in:
parent
eae1883f3d
commit
d4ab79ece0
|
@ -0,0 +1,25 @@
|
|||
## basecoin-server
|
||||
|
||||
### Proxy server
|
||||
This package exposes access to key management i.e
|
||||
- creating
|
||||
- listing
|
||||
- updating
|
||||
- deleting
|
||||
|
||||
The HTTP handlers can be embedded in a larger server that
|
||||
does things like signing transactions and posting them to a
|
||||
Tendermint chain (which requires domain-knowledge of the transaction
|
||||
types and is out of scope of this generic app).
|
||||
|
||||
### Key Management
|
||||
We expose a couple of methods for safely managing your keychain.
|
||||
If you are embedding this in a larger server, you will typically
|
||||
want to mount all these paths /keys.
|
||||
|
||||
HTTP Method | Route | Description
|
||||
---|---|---
|
||||
POST|/|Requires a name and passphrase to create a brand new key
|
||||
GET|/|Retrieves the list of all available key names, along with their public key and address
|
||||
GET|/{name} | Updates the passphrase for the given key. It requires you to correctly provide the current passphrase, as well as a new one.
|
||||
DELETE|/{name} | Permanently delete this private key. It requires you to correctly provide the current passphrase.
|
|
@ -0,0 +1,234 @@
|
|||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/tendermint/basecoin"
|
||||
"github.com/tendermint/basecoin/client/commands"
|
||||
"github.com/tendermint/basecoin/modules/auth"
|
||||
"github.com/tendermint/basecoin/modules/base"
|
||||
"github.com/tendermint/basecoin/modules/coin"
|
||||
"github.com/tendermint/basecoin/modules/fee"
|
||||
"github.com/tendermint/basecoin/modules/nonce"
|
||||
keysutils "github.com/tendermint/go-crypto/cmd"
|
||||
keys "github.com/tendermint/go-crypto/keys"
|
||||
)
|
||||
|
||||
type Keys struct {
|
||||
algo string
|
||||
manager keys.Manager
|
||||
}
|
||||
|
||||
func DefaultKeysManager() keys.Manager {
|
||||
return keysutils.GetKeyManager()
|
||||
}
|
||||
|
||||
func New(manager keys.Manager, algo string) *Keys {
|
||||
return &Keys{
|
||||
algo: algo,
|
||||
manager: manager,
|
||||
}
|
||||
}
|
||||
|
||||
func (k *Keys) GenerateKey(w http.ResponseWriter, r *http.Request) {
|
||||
ckReq := &CreateKeyRequest{
|
||||
Algo: k.algo,
|
||||
}
|
||||
if err := parseRequestJSON(r, ckReq); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
key, seed, err := k.manager.Create(ckReq.Name, ckReq.Passphrase, ckReq.Algo)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
res := &CreateKeyResponse{Key: key, Seed: seed}
|
||||
writeSuccess(w, res)
|
||||
}
|
||||
|
||||
func (k *Keys) GetKey(w http.ResponseWriter, r *http.Request) {
|
||||
query := mux.Vars(r)
|
||||
name := query["name"]
|
||||
key, err := k.manager.Get(name)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, &key)
|
||||
}
|
||||
|
||||
func (k *Keys) ListKeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := k.manager.List()
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, keys)
|
||||
}
|
||||
|
||||
var (
|
||||
errNonMatchingPathAndJSONKeyNames = errors.New("path and json key names don't match")
|
||||
)
|
||||
|
||||
func (k *Keys) UpdateKey(w http.ResponseWriter, r *http.Request) {
|
||||
uReq := new(UpdateKeyRequest)
|
||||
if err := parseRequestJSON(r, uReq); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
query := mux.Vars(r)
|
||||
name := query["name"]
|
||||
if name != uReq.Name {
|
||||
writeError(w, errNonMatchingPathAndJSONKeyNames)
|
||||
return
|
||||
}
|
||||
|
||||
if err := k.manager.Update(uReq.Name, uReq.OldPass, uReq.NewPass); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
key, err := k.manager.Get(uReq.Name)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, &key)
|
||||
}
|
||||
|
||||
func (k *Keys) DeleteKey(w http.ResponseWriter, r *http.Request) {
|
||||
dReq := new(DeleteKeyRequest)
|
||||
if err := parseRequestJSON(r, dReq); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
query := mux.Vars(r)
|
||||
name := query["name"]
|
||||
if name != dReq.Name {
|
||||
writeError(w, errNonMatchingPathAndJSONKeyNames)
|
||||
return
|
||||
}
|
||||
|
||||
if err := k.manager.Delete(dReq.Name, dReq.Passphrase); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := &ErrorResponse{Success: true}
|
||||
writeSuccess(w, resp)
|
||||
}
|
||||
|
||||
func (k *Keys) Register(r *mux.Router) {
|
||||
r.HandleFunc("/keys", k.GenerateKey).Methods("POST")
|
||||
r.HandleFunc("/keys", k.ListKeys).Methods("GET")
|
||||
r.HandleFunc("/keys/{name}", k.GetKey).Methods("GET")
|
||||
r.HandleFunc("/keys/{name}", k.UpdateKey).Methods("POST", "PUT")
|
||||
r.HandleFunc("/keys/{name}", k.DeleteKey).Methods("DELETE")
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
Keys *Keys
|
||||
}
|
||||
|
||||
func (ctx *Context) RegisterHandlers(r *mux.Router) error {
|
||||
ctx.Keys.Register(r)
|
||||
r.HandleFunc("/build/send", doSend).Methods("POST")
|
||||
r.HandleFunc("/sign", doSign).Methods("POST")
|
||||
r.HandleFunc("/tx", doPostTx).Methods("POST")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func doPostTx(w http.ResponseWriter, r *http.Request) {
|
||||
tx := new(basecoin.Tx)
|
||||
if err := parseRequestJSON(r, tx); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
commit, err := PostTx(*tx)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeSuccess(w, commit)
|
||||
}
|
||||
|
||||
func doSign(w http.ResponseWriter, r *http.Request) {
|
||||
sr := new(SignRequest)
|
||||
if err := parseRequestJSON(r, sr); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
tx := sr.Tx
|
||||
if err := SignTx(sr.Name, sr.Password, tx); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, tx)
|
||||
}
|
||||
|
||||
func doSend(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
si := new(SendInput)
|
||||
if err := parseRequestJSON(r, si); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var errsList []string
|
||||
if si.From == nil {
|
||||
errsList = append(errsList, `"from" cannot be nil`)
|
||||
}
|
||||
if si.Sequence <= 0 {
|
||||
errsList = append(errsList, `"sequence" must be > 0`)
|
||||
}
|
||||
if si.To == nil {
|
||||
errsList = append(errsList, `"to" cannot be nil`)
|
||||
}
|
||||
if si.Fees == nil {
|
||||
errsList = append(errsList, `"fees" cannot be nil`)
|
||||
}
|
||||
if len(errsList) > 0 {
|
||||
err := &ErrorResponse{
|
||||
Error: strings.Join(errsList, ", "),
|
||||
Code: 406,
|
||||
}
|
||||
writeCode(w, err, 406)
|
||||
return
|
||||
}
|
||||
|
||||
coins := []coin.Coin{*si.Fees}
|
||||
in := []coin.TxInput{
|
||||
coin.NewTxInput(*si.From, coins),
|
||||
}
|
||||
out := []coin.TxOutput{
|
||||
coin.NewTxOutput(*si.To, coins),
|
||||
}
|
||||
|
||||
tx := coin.NewSendTx(in, out)
|
||||
tx = fee.NewFee(tx, *si.Fees, *si.From)
|
||||
|
||||
signers := []basecoin.Actor{
|
||||
*si.From,
|
||||
*si.To,
|
||||
}
|
||||
tx = nonce.NewTx(si.Sequence, signers, tx)
|
||||
tx = base.NewChainTx(commands.GetChainID(), 0, tx)
|
||||
|
||||
if si.Multi {
|
||||
tx = auth.NewMulti(tx).Wrap()
|
||||
} else {
|
||||
tx = auth.NewSig(tx).Wrap()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package rest
|
||||
|
||||
import (
|
||||
"github.com/tendermint/tendermint/rpc/client"
|
||||
"github.com/tendermint/tendermint/rpc/core"
|
||||
rpc "github.com/tendermint/tendermint/rpc/lib/server"
|
||||
)
|
||||
|
||||
func Routes(c client.Client) map[string]*rpc.RPCFunc {
|
||||
return map[string]*rpc.RPCFunc{
|
||||
// subscribe/unsubscribe are reserved for websocket events.
|
||||
// We can just the core Tendermint implementation, which uses
|
||||
// the EventSwitch that we registered in NewWebsocketManager above.
|
||||
"subscribe": rpc.NewWSRPCFunc(core.Subscribe, "event"),
|
||||
"unsubscribe": rpc.NewWSRPCFunc(core.Unsubscribe, "event"),
|
||||
|
||||
// info API
|
||||
"status": rpc.NewRPCFunc(c.Status, ""),
|
||||
"blockchain": rpc.NewRPCFunc(c.BlockchainInfo, "minHeight,maxHeight"),
|
||||
"genesis": rpc.NewRPCFunc(c.Genesis, ""),
|
||||
"block": rpc.NewRPCFunc(c.Block, "height"),
|
||||
"commit": rpc.NewRPCFunc(c.Commit, "height"),
|
||||
"tx": rpc.NewRPCFunc(c.Tx, "hash.prove"),
|
||||
"validators": rpc.NewRPCFunc(c.Validators, ""),
|
||||
|
||||
// broadcast API
|
||||
"broadcast_tx_commit": rpc.NewRPCFunc(c.BroadcastTxCommit, "tx"),
|
||||
"broadcast_tx_sync": rpc.NewRPCFunc(c.BroadcastTxSync, "tx"),
|
||||
"broadcast_tx_async": rpc.NewRPCFunc(c.BroadcastTxAsync, "tx"),
|
||||
|
||||
// abci API
|
||||
"abci_query": rpc.NewRPCFunc(c.ABCIQuery, "path,data,prove"),
|
||||
"abci_info": rpc.NewRPCFunc(c.ABCIInfo, ""),
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/go-playground/validator.v9"
|
||||
|
||||
"github.com/tendermint/basecoin"
|
||||
"github.com/tendermint/basecoin/modules/coin"
|
||||
"github.com/tendermint/go-crypto/keys"
|
||||
data "github.com/tendermint/go-wire/data"
|
||||
)
|
||||
|
||||
type CreateKeyRequest struct {
|
||||
Name string `json:"name,omitempty" validate:"required,min=4,printascii"`
|
||||
Passphrase string `json:"passphrase,omitempty" validate:"required,min=10"`
|
||||
|
||||
// Algo is the requested algorithm to create the key
|
||||
Algo string `json:"algo,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteKeyRequest struct {
|
||||
Name string `json:"name,omitempty" validate:"required,min=4,printascii"`
|
||||
Passphrase string `json:"passphrase,omitempty" validate:"required,min=10"`
|
||||
}
|
||||
|
||||
type UpdateKeyRequest struct {
|
||||
Name string `json:"name,omitempty" validate:"required,min=4,printascii"`
|
||||
OldPass string `json:"passphrase,omitempty" validate:"required,min=10"`
|
||||
NewPass string `json:"new_passphrase,omitempty" validate:"required,min=10"`
|
||||
}
|
||||
|
||||
type SignRequest struct {
|
||||
Name string `json:"name,omitempty" validate:"required,min=4,printascii"`
|
||||
Password string `json:"password,omitempty" validate:"required,min=10"`
|
||||
|
||||
Tx basecoin.Tx `json:"tx" validate:"required"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Success bool `json:"success,omitempty"`
|
||||
|
||||
// Error is the error message if Success is false
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// Code is set if Success is false
|
||||
Code int `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
type CreateKeyResponse struct {
|
||||
Key keys.Info `json:"key,omitempty"`
|
||||
Seed string `json:"seed_phrase,omitempty"`
|
||||
}
|
||||
|
||||
// SendInput is the request to send an amount from one actor to another.
|
||||
// Note: Not using the `validator:""` tags here because SendInput has
|
||||
// many fields so it would be nice to figure out all the invalid
|
||||
// inputs and report them back to the caller, in one shot.
|
||||
type SendInput struct {
|
||||
Fees *coin.Coin `json:"amount"`
|
||||
Multi bool `json:"multi,omitempty"`
|
||||
Sequence uint32 `json:"sequence"`
|
||||
|
||||
To *basecoin.Actor `json:"to"`
|
||||
From *basecoin.Actor `json:"from"`
|
||||
}
|
||||
|
||||
// Validators
|
||||
|
||||
var theValidator = validator.New()
|
||||
|
||||
func validate(req interface{}) error {
|
||||
return errors.Wrap(theValidator.Struct(req), "Validate")
|
||||
}
|
||||
|
||||
// Helpers
|
||||
func parseRequestJSON(r *http.Request, save interface{}) error {
|
||||
defer r.Body.Close()
|
||||
|
||||
slurp, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Read Request")
|
||||
}
|
||||
if err := json.Unmarshal(slurp, save); err != nil {
|
||||
return errors.Wrap(err, "Parse")
|
||||
}
|
||||
return validate(save)
|
||||
}
|
||||
|
||||
func writeSuccess(w http.ResponseWriter, data interface{}) {
|
||||
writeCode(w, data, 200)
|
||||
}
|
||||
|
||||
func writeCode(w http.ResponseWriter, out interface{}, code int) {
|
||||
blob, err := data.ToJSON(out)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
w.Write(blob)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
resp := &ErrorResponse{
|
||||
Code: 406,
|
||||
Error: err.Error(),
|
||||
}
|
||||
writeCode(w, resp, 406)
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
# baseserver
|
||||
|
||||
baseserver is the REST counterpart to basecli
|
||||
|
||||
## Compiling and running it
|
||||
```shell
|
||||
$ go get -u -v github.com/tendermint/basecoin/cmd/baseserver
|
||||
$ baseserver init
|
||||
$ baseserver serve --port 8888
|
||||
```
|
||||
|
||||
to run the server at localhost:8888, otherwise if you don't specify --port,
|
||||
by default the server will be run on port 8998.
|
||||
|
||||
## Supported routes
|
||||
Route | Method | Completed | Description
|
||||
---|---|---|---
|
||||
/keys|GET|✔️|Lists all keys
|
||||
/keys|POST|✔️|Generate a new key. It expects fields: "name", "algo", "passphrase"
|
||||
/keys/{name}|GET|✔️|Retrieves the specific key
|
||||
/keys/{name}|POST/PUT|✔️|Updates the named key
|
||||
/keys/{name}|DELETE|✔️|Deletes the named key
|
||||
/build/send|POST|✔️|Send a transaction
|
||||
/sign|POST|✔️|Sign a transaction
|
||||
/tx|POST|✖️|Post a transaction to the blockchain
|
||||
/seeds/status|GET|✖️|Returns the information on the last seed
|
||||
|
||||
## Sample usage
|
||||
- Generate a key
|
||||
```shell
|
||||
$ curl -X POST http://localhost8998/keys --data '{"algo": "ed25519", "name": "SampleX", "passphrase": "Say no more"}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"key": {
|
||||
"name": "SampleX",
|
||||
"address": "603EE63C41E322FC7A247864A9CD0181282EB458",
|
||||
"pubkey": {
|
||||
"type": "ed25519",
|
||||
"data": "C050948CFC087F5E1068C7E244DDC30E03702621CC9442A28E6C9EDA7771AA0C"
|
||||
}
|
||||
},
|
||||
"seed_phrase": "border almost future parade speak soccer bulk orange real brisk caution body river chapter"
|
||||
}
|
||||
```
|
||||
|
||||
- Sign a key
|
||||
```shell
|
||||
$ curl -X POST http://localhost:8998/sign --data '{
|
||||
"name": "matt",
|
||||
"password": "Say no more",
|
||||
"tx": {
|
||||
"type": "sigs/multi",
|
||||
"data": {
|
||||
"tx": {"type":"coin/send","data":{"inputs":[{"address":{"chain":"","app":"role","addr":"62616E6B32"},"coins":[{"denom":"mycoin","amount":900000}]}],"outputs":[{"address":{"chain":"","app":"sigs","addr":"BDADF167E6CF2CDF2D621E590FF1FED2787A40E0"},"coins":[{"denom":"mycoin","amount":900000}]}]}},
|
||||
"signatures": null
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "sigs/multi",
|
||||
"data": {
|
||||
"tx": {
|
||||
"type": "coin/send",
|
||||
"data": {
|
||||
"inputs": [
|
||||
{
|
||||
"address": {
|
||||
"chain": "",
|
||||
"app": "role",
|
||||
"addr": "62616E6B32"
|
||||
},
|
||||
"coins": [
|
||||
{
|
||||
"denom": "mycoin",
|
||||
"amount": 900000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"address": {
|
||||
"chain": "",
|
||||
"app": "sigs",
|
||||
"addr": "BDADF167E6CF2CDF2D621E590FF1FED2787A40E0"
|
||||
},
|
||||
"coins": [
|
||||
{
|
||||
"denom": "mycoin",
|
||||
"amount": 900000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"signatures": [
|
||||
{
|
||||
"Sig": {
|
||||
"type": "ed25519",
|
||||
"data": "F6FE3053F1E6C236F886A0D525C1AF840F7831B6E50F7E1108C345AA524303920F09945DA110AD5184B3F45717D7114E368B12AFE027FECECC2FC193D4906A0C"
|
||||
},
|
||||
"Pubkey": {
|
||||
"type": "ed25519",
|
||||
"data": "0D8D19E527BAE9D1256A3D03009E2708171CDCB71CCDEDA2DC52DD9AD23AEE25"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
|
@ -0,0 +1,66 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/tendermint/basecoin/client/commands"
|
||||
rest "github.com/tendermint/basecoin/client/rest"
|
||||
"github.com/tendermint/tmlibs/cli"
|
||||
)
|
||||
|
||||
var srvCli = &cobra.Command{
|
||||
Use: "baseserver",
|
||||
Short: "Light REST client for tendermint",
|
||||
Long: `Baseserver presents a nice (not raw hex) interface to the basecoin blockchain structure.`,
|
||||
}
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Serve the light REST client for tendermint",
|
||||
Long: "Access basecoin via REST",
|
||||
RunE: serve,
|
||||
}
|
||||
|
||||
var port int
|
||||
|
||||
func main() {
|
||||
commands.AddBasicFlags(srvCli)
|
||||
|
||||
flagset := serveCmd.Flags()
|
||||
flagset.IntVar(&port, "port", 8998, "the port to run the server on")
|
||||
|
||||
srvCli.AddCommand(
|
||||
commands.InitCmd,
|
||||
serveCmd,
|
||||
)
|
||||
|
||||
// TODO: Decide whether to use $HOME/.basecli for compatibility
|
||||
// or just use $HOME/.baseserver?
|
||||
cmd := cli.PrepareMainCmd(srvCli, "BC", os.ExpandEnv("$HOME/.basecli"))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultAlgo = "ed25519"
|
||||
|
||||
func serve(cmd *cobra.Command, args []string) error {
|
||||
keysManager := rest.DefaultKeysManager()
|
||||
router := mux.NewRouter()
|
||||
ctx := rest.Context{
|
||||
Keys: rest.New(keysManager, defaultAlgo),
|
||||
}
|
||||
if err := ctx.RegisterHandlers(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
log.Printf("Serving on %q", addr)
|
||||
return http.ListenAndServe(addr, router)
|
||||
}
|
Loading…
Reference in New Issue