Browse files

initial release

  • Loading branch information...
1 parent 879ee1b commit 3f2a57a3e7c6d3ad2ab4732234476418bd3f9454 Aaron Raddon committed Nov 27, 2011
Showing with 750 additions and 0 deletions.
  1. +18 −0 Makefile
  2. +13 −0 README.rst
  3. +12 −0 examples/Makefile
  4. +19 −0 examples/example.go
  5. +328 −0 m2go.go
  6. +39 −0 m2go_test.go
  7. +49 −0 signals.go
  8. +188 −0 tnet.go
  9. +84 −0 tnet_test.go
View
18 Makefile
@@ -0,0 +1,18 @@
+include $(GOROOT)/src/Make.inc
+
+TARG=m2go
+GOFMT=gofmt -spaces=true -tabindent=false -tabwidth=2
+
+GOFILES=\
+ m2go.go\
+ tnet.go\
+ signals.go\
+
+include $(GOROOT)/src/Make.pkg
+
+format:
+ ${GOFMT} -w m2go_test.go
+ ${GOFMT} -w tnet_test.go
+ ${GOFMT} -w example.go
+ ${GOFMT} -w ${GOFILES}
+
View
13 README.rst
@@ -0,0 +1,13 @@
+m2go, a `Mongrel2 http server<http://mongrel2.org>`_ for go. Includes adapter to run `Web.go <https://github.com/hoisie/web.go>`_
+
+Installation
+========================
+
+Requires mongrel2, and a configured mongrel2 website.
+
+Currently built against go-weekly weekly-2011-11-18
+
+Uses a custom version of Web.go (see github) until patches make it in.
+
+
+Git clone && gomake install or goinstall
View
12 examples/Makefile
@@ -0,0 +1,12 @@
+include $(GOROOT)/src/Make.inc
+
+ALL=example
+
+all: $(ALL)
+
+clean:
+ rm -rf *.[68] $(ALL)
+
+%: %.go
+ $(GC) $*.go
+ $(LD) -o $@ $*.$O
View
19 examples/example.go
@@ -0,0 +1,19 @@
+//#!~/gocode/bin/gorun
+package main
+
+import (
+ "m2go"
+ //"fmt"
+ "web"
+)
+
+func hello(val string) string {
+ return "hello " + val
+}
+
+func main() {
+ web.Get("/(.*)", hello)
+ web.Post("/(.*)", hello)
+
+ m2go.Run( "tcp://127.0.0.1:9555|tcp://127.0.0.1:9556|54c6755b-9628-40a4-9a2d-cc82a816345e")
+}
View
328 m2go.go
@@ -0,0 +1,328 @@
+/*
+
+ Mongrel2 Handler, Parser, and Web.go Adapter
+
+*/
+package m2go
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ //"strconv"
+ "web"
+ zmq "github.com/alecthomas/gozmq"
+ //config "github.com/kless/goconfig/config"
+)
+
+var Logger = log.New(os.Stdout, "", log.Ldate|log.Ltime)
+
+var er error
+
+type Config struct {
+ logfile string
+ configFile string
+ useconsole bool
+}
+
+var M2Config = &Config{"log/dev.log","prod.conf", true}
+
+
+/*
+ M2 Request and server handling
+*/
+
+type M2Request struct {
+ uuid string
+ requestid string
+ path string
+ rest string
+ body string
+ headers string
+ cid uint //customer id
+}
+
+type Response struct {
+ out string // response text
+ status string // OK etc
+ code string // 404, 500, etc
+}
+
+type m2Conn struct {
+ headers map[string][]string
+ wroteHeaders bool
+ out bytes.Buffer
+}
+
+func (conn *m2Conn) Write(data []byte) (n int, err error) {
+ var buf bytes.Buffer
+
+ if !conn.wroteHeaders {
+ conn.wroteHeaders = true
+ for k, v := range conn.headers {
+ for _, i := range v {
+ buf.WriteString(k + ": " + i + "\r\n")
+ }
+ }
+
+ buf.WriteString("\r\n")
+ conn.out.Write(buf.Bytes())
+ }
+ return conn.out.Write(data)
+}
+
+func (conn *m2Conn) StartResponse(status int) {
+ var buf bytes.Buffer
+ text := web.StatusText[status]
+
+ fmt.Fprintf(&buf, "HTTP/1.1 %d %s\r\n", status, text)
+ conn.out.Write(buf.Bytes())
+}
+
+func (conn *m2Conn) SetHeader(hdr string, val string, unique bool) {
+ if _, contains := conn.headers[hdr]; !contains {
+ conn.headers[hdr] = []string{val}
+ return
+ }
+
+ if unique {
+ //just overwrite the first value
+ conn.headers[hdr][0] = val
+ } else {
+ newHeaders := make([]string, len(conn.headers)+1)
+ copy(newHeaders, conn.headers[hdr])
+ newHeaders[len(newHeaders)-1] = val
+ conn.headers[hdr] = newHeaders
+ }
+}
+
+func (conn *m2Conn) Close() {
+
+}
+
+func NewHttpRequestFromM2(headers http.Header, body io.Reader) *web.Request {
+
+ host := headers.Get("host")
+ method := strings.ToUpper(headers.Get("method"))
+ path := headers.Get("uri")
+ proto := headers.Get("version")
+ rawurl := "http://" + host + path
+ url_, _ := url.Parse(rawurl)
+ useragent := headers.Get("user-agent")
+ remoteAddr := headers.Get("x-forwarded-for")
+ //remotePort, _ := strconv.Atoi(headers.Get("REMOTE_PORT"))
+
+ if method == "POST" {
+ if ctype, ok := headers["CONTENT_TYPE"]; ok {
+ headers["Content-Type"] = ctype
+ }
+
+ if clength, ok := headers["CONTENT_LENGTH"]; ok {
+ headers["Content-Length"] = clength
+ }
+ }
+
+ //read the cookies
+ cookies := web.ReadCookies(headers)
+
+ return &web.Request{
+ Method: method,
+ RawURL: rawurl,
+ URL: url_,
+ Proto: proto,
+ Host: host,
+ UserAgent: useragent,
+ Body: body,
+ Headers: headers,
+ RemoteAddr: remoteAddr,
+ //RemotePort: remotePort,
+ Cookie: cookies,
+ }
+
+}
+
+// Parse an incoming request of format/type mongrel2
+// and return an M2Request object
+func M2Parse(reqs string) (r *M2Request, err error){
+
+ r = new(M2Request)
+ //Logger.Printf("raw\n\n %s \n\n", strconv.Quote(string(reqs)))
+
+ parts := strings.SplitN(string(reqs)," ",4)
+ r.uuid = parts[0]
+ r.requestid = parts[1]
+ r.path = parts[2]
+ r.rest = strings.ToLower(parts[3])
+
+ tn, err := NewTnet(r.rest)
+ if err != nil {
+ err = errors.New("Error parsing request " + err.Error())
+ return
+ }
+ r.headers = tn.Value.(string)
+ tnetb, err := tn.Next()
+ if err != nil {
+ err = errors.New("Error parsing request " + err.Error())
+ return
+ }
+ r.body = tnetb.Value.(string)
+
+ return
+}
+
+func MakeWebGoRequest(m2req *M2Request) (req *web.Request, err error) {
+ headers := make(http.Header)
+
+ var f map[string]interface{}
+
+ err = json.Unmarshal([]byte(m2req.headers), &f)
+
+ if err != nil {
+ Logger.Printf("arg, error %s", err)
+ }
+ for k, v := range f {
+ headers.Set(k, v.(string))
+ }
+ body := bytes.NewBuffer([]byte(m2req.body))
+
+ req = NewHttpRequestFromM2(headers, body)
+
+ return req, nil
+}
+
+func HandleM2Request(s *web.Server, datain []byte, response func(response string)()) {
+
+ m2req, tnerr := M2Parse(string(datain))
+ if tnerr != nil {
+ // TODO: tap into web.go 500 mechanism
+ Logger.Println("ERROR", tnerr.Error())
+ }
+
+ wreq, err := MakeWebGoRequest(m2req)
+ if err != nil {
+ // TODO: tap into web.go 500 mechanism
+ Logger.Println("error", err.Error())
+ }
+
+ conn := m2Conn{ make(map[string][]string), false, bytes.Buffer{}}
+ s.RouteHandler(wreq, &conn)
+
+ msg := fmt.Sprintf("%s %d:%s, %s", m2req.uuid, len(m2req.requestid), m2req.requestid, string(conn.out.Bytes()))
+ Logger.Print("--------------------RESPONSE---------------------\n" + msg + "\n")
+ response(msg)
+}
+
+// default package init function
+func init() {
+
+ RegisterSignalHandler(os.SIGINT, func() {
+ fmt.Print("in signal handler SIGINT")
+ Exit(0)
+ })
+ RegisterSignalHandler(os.SIGTERM, func() {
+ fmt.Print("in signal handler SIGTERM")
+ Exit(0)
+ })
+ RegisterSignalHandler(os.SIGUSR1, func() {
+ fmt.Print("in signal handler USR1")
+ })
+
+ go handleSignals()
+
+ loadConfig()
+
+}
+
+
+// server Method to initialize with config
+func loadConfig(){
+
+ flag.StringVar(&M2Config.configFile, "config", "none", "Config File to use")
+ flag.BoolVar(&M2Config.useconsole, "useconsole", true, "log to console?")
+ flag.Parse()
+
+ if M2Config.configFile != "none" {
+ //c, _ := config.ReadDefault(M2Config.configFile)
+ // read log file etc
+
+ } else {
+
+ if M2Config.useconsole == true {
+ fmt.Println("Logging to console")
+ }
+ }
+
+}
+
+//Runs the web application and serves scgi requests
+func Run(addr string) {
+ web.Runner(addr, M2Runner)
+}
+
+// method for server that Runs the web application, sets up m2 connections
+// and serves http requests
+// @addr = string config parameter like this:
+// "tcp://127.0.0.1:9555|tcp://127.0.0.1:9556|54c6755b-9628-40a4-9a2d-cc82a816345e"
+func M2Runner(s *web.Server, addr string) {
+
+ var Context zmq.Context
+ var SocketIn zmq.Socket
+ var SocketOut zmq.Socket
+ m2addr := strings.Split(addr,"|")//
+
+ s.Config.StaticDir = "NONE"
+
+ //mainServer.RunM2(addr)
+ if s.Logger == nil {
+ s.Logger = Logger
+ }
+ s.Logger.Printf("web.go serving m2 %s\n", addr)
+
+ /*
+ Connection to ZMQ setup
+ */
+ var err error
+ if Context, err = zmq.NewContext(); err != nil {
+ panic("No ZMQ Context?")
+ }
+ defer Context.Close()
+
+ // listen for incoming requests
+ if SocketIn, err = Context.NewSocket(zmq.PULL); err != nil {
+ panic("No ZMQ Socket?")
+ }
+ defer SocketIn.Close()
+ SocketIn.Connect(m2addr[0])
+
+
+ if SocketOut, err = Context.NewSocket(zmq.PUB); err != nil {
+ panic("No ZMQ Socket Outbound??")
+ }
+ // outbound response on a different channel
+ SocketOut.SetSockOptString(zmq.IDENTITY, m2addr[2])
+ //socket.SetSockOptString(zmq.SUBSCRIBE, filter)
+ defer SocketOut.Close()
+ SocketOut.Connect(m2addr[1])
+
+ handleResponse := func(response string) {
+ SocketOut.Send([]byte(response), 0)
+ }
+
+ for {
+ // each inbound request
+ datapt, err := SocketIn.Recv(0)
+ if err != nil {
+ Logger.Println("ZMQ Socket Input accept error", err.Error())
+ }
+ go HandleM2Request(s,datapt,handleResponse)
+ }
+
+}
View
39 m2go_test.go
@@ -0,0 +1,39 @@
+package m2go_test
+
+import (
+ //"m2go"
+ "web"
+ "testing"
+)
+type M2Data struct {
+ inbound string
+ uuid string
+ path string
+}
+var testData = [...]M2Data{M2Data{"54c6755b-9628-40a4-9a2d-cc82a816345e 143 /c/18597 652:{\"PATH\":\"/c/18597\",\"x-forwarded-for\":\"127.0.0.1\",\"cache-control\":\"max-age=0\",\"origin\":\"http://localhost:6767\",\"content-type\":\"application/x-www-form-urlencoded\",\"accept-language\":\"en-US,en;q=0.8\",\"accept-encoding\":\"gzip,deflate,sdch\",\"connection\":\"keep-alive\",\"content-length\":\"58\",\"accept-charset\":\"ISO-8859-1,utf-8;q=0.7,*;q=0.3\",\"accept\":\"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\"user-agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_2) AppleWebKit/535.7 (KHTML, like Gecko) Chrome/16.0.912.41 Safari/535.7\",\"host\":\"localhost:6767\",\"cookie\":\"cookies\",\"METHOD\":\"POST\",\"VERSION\":\"HTTP/1.1\",\"URI\":\"/c/18597\",\"PATTERN\":\"/c/\"},58:source=myid%3D1234%26category%3Dbooks%26ts%3D1322416191407,","54c6755b-9628-40a4-9a2d-cc82a816345e","/c/18597" }}
+
+
+
+func hello(ctx *web.Context, val, val2 string) string {
+ for k,v := range ctx.Params {
+ println(k, v)
+ }
+ return "hello " + val + " " + val2
+}
+
+
+func TestMain(t *testing.T) {
+
+ web.Post("/(.*)/(.*)", hello)
+
+ //m2go.Run("tcp://127.0.0.1:9555|tcp://127.0.0.1:9556|54c6755b-9628-40a4-9a2d-cc82a816345e")
+
+}
+
+func TestM2FormatParse(t *testing.T) {
+
+ //54c6755b-9628-40a4-9a2d-cc82a816345e 134 /c/18597 652:{"PATH":"/c/18597","x-forwarded-for":"127.0.0.1","cache-control":"max-age=0","origin":"http://localhost:6767","content-type":"application/x-www-form-urlencoded","accept-language":"en-US,en;q=0.8","accept-encoding":"gzip,deflate,sdch","connection":"keep-alive","content-length":"58","accept-charset":"ISO-8859-1,utf-8;q=0.7,*;q=0.3","accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_2) AppleWebKit/535.7 (KHTML, like Gecko) Chrome/16.0.912.41 Safari/535.7","host":"localhost:6767","cookie":"cookies","METHOD":"POST","VERSION":"HTTP/1.1","URI":"/c/18597","PATTERN":"/c/"},58:source=myid%3D1234%26category%3Dbooks%26ts%3D1322415601141,
+
+}
+
+
View
49 signals.go
@@ -0,0 +1,49 @@
+package m2go
+
+import (
+ "os"
+ "os/signal"
+)
+
+// -----------------------------------------------------------------------------
+// Signal Handling from https://github.com/tav/ampify/blob/master/src/amp/runtime/runtime.go
+// -----------------------------------------------------------------------------
+
+var signalHandlers = make(map[os.UnixSignal]func())
+
+func RegisterSignalHandler(signal os.UnixSignal, handler func()) {
+ signalHandlers[signal] = handler
+}
+
+func ClearSignalHandler(signal os.UnixSignal) {
+ signalHandlers[signal] = func() {}
+}
+
+func handleSignals() {
+ var sig os.Signal
+ for {
+ sig = <-signal.Incoming
+ handler, found := signalHandlers[sig.(os.UnixSignal)]
+ if found {
+ handler()
+ }
+ }
+}
+
+var exitHandlers = []func(){}
+
+func RunExitHandlers() {
+ for _, handler := range exitHandlers {
+ handler()
+ }
+}
+
+func RegisterExitHandler(handler func()) {
+ exitHandlers = append(exitHandlers, handler)
+}
+
+func Exit(code int) {
+ RunExitHandlers()
+ os.Exit(code)
+}
+
View
188 tnet.go
@@ -0,0 +1,188 @@
+/*
+Tnet strings, see http://tnetstrings.org/
+
+example SIZE COLON VALUE TYPE
+ 2:42# = len : value type
+ 5:hello, = len : value type
+
+ types:
+ , = string (byte array)
+ # = integer
+ ^ = float
+ ! = boolean of 'true' or 'false'
+ ~ = null always encoded as 0:~
+ } = Dictionary which you recurse into to fill with key=value pairs inside the payload contents.
+ ] = List which you recurse into to fill with values of any type.
+
+*/
+package m2go
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ //"reflect"
+)
+
+
+type Tnet struct {
+ Raw string
+ Payload string
+ Datatype string
+ Extra string
+ Length int
+ Value interface {}
+}
+
+// grabs the next value from the leftovers: "extra"
+func (t *Tnet) Next() (tnet Tnet, err error){
+ return NewTnet(t.Extra)
+}
+
+// Parse string and return value, remaining value
+func NewTnet(datain string) (tn Tnet, err error){
+ if len(datain) < 1 {
+ return
+ }
+
+ tn = Tnet{Raw:datain}
+
+ err = tn.parse()
+ if tn.Length > 0 && err == nil {
+ switch tn.Datatype {
+ case "#":
+ tn.Value, err = strconv.Atoi64(tn.Payload)
+ case "}":
+ err = parseDict(&tn)
+ case "]":
+ err = parseList(&tn)
+ case ",":
+ tn.Value = tn.Payload
+ case "~":
+ tn.Value = nil
+ case "!":
+ if tn.Payload == "true" {
+ tn.Value = true
+ } else if tn.Payload == "false" {
+ tn.Value = false
+ } else {
+ err = errors.New("Unexpected true/false value " + tn.Payload)
+ }
+ case "^":
+ tn.Value, err = strconv.Atof64(tn.Payload)
+ default:
+ err = errors.New("Tnet unknown datatype = " + tn.Datatype)
+ }
+ }
+
+ //fmt.Printf("tnetstrings 1 = %s\n 2=%s\n3=\n%s", payload, tnet.datatype, value)
+ return
+}
+
+// tnetParse: parses a single value by tnet rules (above)
+func (tn *Tnet) parse() (err error){
+
+ parts := strings.SplitN(tn.Raw,":",2)
+ if len(parts) != 2 || len(parts[0]) < 1 {
+ return errors.New("Invalid, didn't contain len:value+")
+ }
+
+ if tn.Length, er = strconv.Atoi(parts[0]); er != nil {
+ err = errors.New("Error getting length part " + er.Error())
+ }
+ if len(parts[1]) < tn.Length + 1 {
+ return errors.New("Invalid length, ")
+ }
+
+ tn.Payload = parts[1][:tn.Length]
+ tn.Datatype = parts[1][tn.Length:tn.Length + 1]
+
+ // validations
+ if tn.Length != len(tn.Payload) {
+ err = errors.New(fmt.Sprintf("TNet error, invalid length, was %d but expected %d", len(tn.Payload), tn.Length))
+ }
+ if len(tn.Raw) > tn.Length + 1 {
+ tn.Extra = parts[1][tn.Length + 1:]
+ }
+
+ return
+}
+func parseList(tparent *Tnet) (err error){
+ if len(tparent.Payload) == 0 {
+ return
+ }
+
+ var data string
+
+ var tnetlist = make([]Tnet,0)
+ data = tparent.Payload
+
+ // tparent = 11:3:bob,2:55#]
+ // data = tparent.Payload = 3:bob,2:55#
+ // l1 = 3:bob, extra = 2:55#
+ // l2 = 2:55# extra = nil
+ for {
+ tnl, er := NewTnet(data)
+ if er != nil {
+ err = er
+ tparent.Value = tnetlist
+ return
+ }
+
+ tnetlist = append(tnetlist,tnl)
+
+ if len(tnl.Extra) == 0 {
+ tparent.Value = tnetlist
+ return
+ }
+
+ data = tnl.Extra
+ }
+
+ return
+}
+func parseDict(tparent *Tnet) (err error){
+
+ if len(tparent.Payload) == 0 {
+ return
+ }
+
+ var data string
+ var nvmap = make(map[string]Tnet)
+
+ tparent.Value = nvmap
+ data = tparent.Payload
+ // tparent = 26:4:name,3:bob,3:age,2:55#}
+ // data = tparent.Payload = 4:name,3:bob,3:age,2:55#
+ // tn = 4:name, extra = 3:bob,3:age,2:55#
+ // tv = 3:bob, extra = 3:age,2:55#
+ // tn2 = 3:age, extra = 2:55#
+ // tv2 = 2:55 extra = nil
+ for {
+ tn, er := NewTnet(data)
+ if er != nil {
+ err = er
+ return
+ }
+ if len(tn.Extra) < 1 {
+ return errors.New("unbalanced dictionary name/value pair (missing value) ")
+ }
+ tv, er2 := NewTnet(tn.Extra)
+ if er2 != nil {
+ err = er2
+ return
+ }
+
+ nvmap[tn.Payload] = tv
+
+ if len(tv.Extra) == 0 {
+ return
+ }
+
+ data = tv.Extra
+
+ }
+
+ return
+}
View
84 tnet_test.go
@@ -0,0 +1,84 @@
+/*
+ See more about Tnet strings http://tnetstrings.org/
+
+*/
+package m2go_test
+
+import (
+ . "m2go"
+ "testing"
+ "reflect"
+ //"fmt"
+)
+func assert(is bool, msg string) {
+
+}
+
+func TestTnet(t *testing.T) {
+
+
+ tnet, err := NewTnet("5:hello,")
+ if err != nil || tnet.Length != 5 || tnet.Datatype != "," || tnet.Value.(string) != "hello" {
+ t.Errorf("Should be error free and = 'hello' but was: err=%s, len=%d '%s'",err, tnet.Length, tnet.Value)
+ }
+
+ // make the length wrong (too long)
+ tnet, err = NewTnet("7:hello,")
+ if err == nil {
+ t.Errorf("Should have error %s",err)
+ }
+
+ tnet, err = NewTnet("2:42#")
+ if err != nil || tnet.Datatype != "#" || tnet.Value.(int64) != int64(42) {
+ t.Errorf("Should be error free and = 42, but was; err=%s, v=%v", err, tnet.Value)
+ }
+
+ tnet, err = NewTnet("6:42.356^")
+ if err != nil || tnet.Datatype != "^" || tnet.Value.(float64) != float64(42.356) {
+ t.Errorf("Should be error free and = 42, but was; err=%s, v=%v", err, tnet.Value)
+ }
+
+ tnet, err = NewTnet("4:true!")
+ if err != nil || tnet.Datatype != "!" || tnet.Value.(bool) != true {
+ t.Errorf("Should be error free and = true, but was; err=%s, v=%v", err, tnet.Value)
+ }
+
+
+ tnet, err = NewTnet("5:false!")
+ if err != nil || tnet.Datatype != "!" || tnet.Value.(bool) != false {
+ t.Errorf("Should be error free and = false, but was; err=%s, v=%v", err, tnet.Value)
+ }
+
+ tnet, err = NewTnet("0:~")
+ if err != nil || tnet.Datatype != "~" || tnet.Value != nil || tnet.Length != 0{
+ t.Errorf("Should be error free and = nil, but was; err=%s, v=%v", err, tnet.Value)
+ }
+
+ // dictionary
+ tnet, err = NewTnet("24:4:name,3:bob,3:age,2:55#}")
+ rv := reflect.ValueOf(tnet.Value)
+ m := tnet.Value.(map[string]Tnet)
+
+ if err != nil || tnet.Datatype != "}" || rv.Kind() != reflect.Map || len(m) != 2 || tnet.Length != 24 {
+ t.Errorf("Should be error free and = map len=2, but was; err=%s, v=%v", err, tnet.Value)
+ }
+ if m["name"].Value.(string) != "bob" || m["age"].Value.(int64) != int64(55) {
+ t.Errorf("should be bob, age =55 but was %v", m)
+ }
+
+ // list
+ tnet, err = NewTnet("27:6:42.356^3:bob,2:55#4:true!]")
+ rv = reflect.ValueOf(tnet.Value)
+
+ l := tnet.Value.([]Tnet)
+
+ if err != nil || tnet.Datatype != "]" || rv.Kind() != reflect.Slice || len(l) != 4 || tnet.Length != 27 {
+ t.Errorf("Should be error free and = map len=2, but was; err=%s, v=%v", err, tnet.Value)
+ }
+ if l[0].Value.(float64) != float64(42.356) || l[1].Value.(string) != "bob" || l[2].Value.(int64) != int64(55) {
+ t.Errorf("should be 42.356, bob, age =55, true but was %v", l)
+ }
+
+}
+
+

0 comments on commit 3f2a57a

Please sign in to comment.