├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── examples └── main.go ├── go.mod ├── go.sum ├── net.go ├── radio.go ├── reader.go ├── reader_test.go ├── value.go ├── value_test.go ├── writer.go └── writer_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | vendor/ 3 | 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | 18 | .DS_Store 19 | 20 | 21 | # custom ignores 22 | expt/ 23 | test.db 24 | cortex.db 25 | .vscode/ 26 | dev/ 27 | *.db -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Shivaprasad Bhat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: clean fmt test 2 | 3 | fmt: 4 | @echo "Formatting..." 5 | @goimports -l -w ./ 6 | 7 | clean: 8 | @echo "Cleaning up..." 9 | @rm -rf ./bin 10 | @go mod tidy -v 11 | 12 | test: 13 | @echo "Running tests..." 14 | @go test -cover ./... 15 | 16 | test-verbose: 17 | @echo "Running tests..." 18 | @go test -v -cover ./... 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Radio 2 | 3 | [![GoDoc](https://godoc.org/github.com/spy16/radio?status.svg)](https://godoc.org/github.com/spy16/radio) [![Go Report Card](https://goreportcard.com/badge/github.com/spy16/radio)](https://goreportcard.com/report/github.com/spy16/radio) 4 | 5 | Radio is a `Go` (or `Golang`) library for creating [RESP](https://redis.io/topics/protocol) (**RE**dis **S**erialization **P**rotocol) 6 | compatible services/servers. 7 | 8 | ## Features 9 | 10 | - [Fast](#benchmarks) Redis compatible server library 11 | - Single RESP parser (`radio.Reader`) that can be used for both client-side and server-side parsing 12 | - Parser supports Inline Commands to use with raw tcp clients (example: `telnet`) 13 | - RESP value types to simplify wrapping values and serializing 14 | - RESP Parser that can be used with any `io.Reader` implementation (e.g., AOF files etc.) 15 | 16 | ## Benchmarks 17 | 18 | Benchmarks were run using [redis-benchmark](https://redis.io/topics/benchmarks) tool. 19 | 20 | - Go Version: `go version go1.12.1 darwin/amd64` 21 | - Host: `MacBook Pro 15" Intel Core i7, 2.8 GHz, 4 Cores + 16GB Memory` 22 | 23 | **Redis**: 24 | 25 | - Run Server: `redis-server --port 9736 --appendonly no` 26 | - Run Benchmark: `redis-benchmark -h 127.0.0.1 -p 9736 -q -t PING -c 100 -n 1000000` 27 | 28 | ```plaintext 29 | PING_INLINE: 80515.30 requests per second 30 | PING_BULK: 78678.20 requests per second 31 | ``` 32 | 33 | **Redcon**: 34 | 35 | - Run Server: See [tidwall/redcon](https://github.com/tidwall/redcon#example) 36 | - Except `ping` command, everything else was removed from the example above 37 | - Run Benchmark: `redis-benchmark -h 127.0.0.1 -p 6380 -q -t PING -c 100 -n 1000000` 38 | 39 | ```plaintext 40 | PING_INLINE: 71669.17 requests per second 41 | PING_BULK: 71828.76 requests per second 42 | ``` 43 | 44 | **Radio**: 45 | 46 | - Run Server: `go run examples/main.go -addr :8080` 47 | - Run Benchmark: `redis-benchmark -h 127.0.0.1 -p 8080 -q -t PING -c 100 -n 1000000` 48 | 49 | ```plaintext 50 | PING_INLINE: 71199.71 requests per second 51 | PING_BULK: 71301.25 requests per second 52 | ``` 53 | 54 | ### TODO 55 | 56 | - [ ] Add pieplining support 57 | - [ ] Pub sub support 58 | - [ ] Client functions 59 | -------------------------------------------------------------------------------- /examples/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "strings" 10 | 11 | "github.com/spy16/radio" 12 | ) 13 | 14 | func main() { 15 | var addr string 16 | flag.StringVar(&addr, "addr", ":9736", "TCP address to listen for connections") 17 | flag.Parse() 18 | 19 | l, err := net.Listen("tcp", addr) 20 | if err != nil { 21 | log.Fatal(err.Error()) 22 | } 23 | 24 | respHandler := radio.HandlerFunc(serveRESP) 25 | 26 | log.Printf("listening for clients on '%s'...", addr) 27 | log.Fatalf("server exited: %v", radio.ListenAndServe(context.Background(), l, respHandler)) 28 | } 29 | 30 | func serveRESP(wr radio.ResponseWriter, req *radio.Request) { 31 | switch req.Command { 32 | case "ping": 33 | wr.Write(radio.SimpleStr("PONG")) 34 | 35 | case "COMMAND": 36 | wr.Write(&radio.Array{}) 37 | 38 | default: 39 | wr.Write(radio.ErrorStr(fmt.Sprintf("ERR unknown command '%s'", strings.Replace(req.Command, "\r\n", " ", -1)))) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/spy16/radio 2 | 3 | go 1.12 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spy16/radio/c1ff1469dbc5c49e6f3258d12265c11e96dbde0b/go.sum -------------------------------------------------------------------------------- /net.go: -------------------------------------------------------------------------------- 1 | package radio 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net" 8 | "reflect" 9 | "time" 10 | ) 11 | 12 | // ListenAndServe starts a RESP server on the given listener. Parsed requests will 13 | // be passed to the given handler. 14 | func ListenAndServe(ctx context.Context, l net.Listener, handler Handler) error { 15 | for { 16 | select { 17 | case <-ctx.Done(): 18 | return ctx.Err() 19 | 20 | default: 21 | } 22 | 23 | con, err := l.Accept() 24 | if err != nil { 25 | return err 26 | } 27 | 28 | if tc, ok := con.(*net.TCPConn); ok { 29 | tc.SetKeepAlive(true) 30 | tc.SetKeepAlivePeriod(10 * time.Minute) 31 | } 32 | go clientLoop(ctx, con, handler) 33 | } 34 | } 35 | 36 | func clientLoop(ctx context.Context, rwc io.ReadWriteCloser, handler Handler) { 37 | rdr := NewReader(rwc, true) 38 | rw := NewWriter(rwc) 39 | defer rwc.Close() 40 | 41 | for { 42 | select { 43 | case <-ctx.Done(): 44 | return 45 | default: 46 | } 47 | 48 | val, err := rdr.Read() 49 | if err != nil { 50 | if err == io.EOF { 51 | break 52 | } 53 | 54 | rw.Write(ErrorStr("ERR " + err.Error())) 55 | return 56 | } 57 | 58 | req, err := newRequest(val) 59 | if err != nil { 60 | rw.Write(ErrorStr("ERR " + err.Error())) 61 | return 62 | } 63 | 64 | if req == nil { 65 | continue 66 | } 67 | 68 | handler.ServeRESP(rw, req) 69 | } 70 | } 71 | 72 | func newRequest(val Value) (*Request, error) { 73 | arr, ok := val.(*Array) 74 | if !ok { 75 | return nil, nil 76 | } 77 | 78 | if arr.IsNil() || len(arr.Items) == 0 { 79 | return nil, nil 80 | } 81 | 82 | req := &Request{} 83 | for i, itm := range arr.Items { 84 | v, ok := itm.(*BulkStr) 85 | if !ok { 86 | return nil, fmt.Errorf("unexpected type '%s'", reflect.TypeOf(itm)) 87 | } 88 | 89 | if i == 0 { 90 | req.Command = v.String() 91 | } else { 92 | req.Args = append(req.Args, v.String()) 93 | } 94 | } 95 | 96 | return req, nil 97 | } 98 | -------------------------------------------------------------------------------- /radio.go: -------------------------------------------------------------------------------- 1 | package radio 2 | 3 | // Handler represents a RESP command handler. 4 | type Handler interface { 5 | ServeRESP(wr ResponseWriter, req *Request) 6 | } 7 | 8 | // ResponseWriter represents a RESP writer object. 9 | type ResponseWriter interface { 10 | Write(v Value) (int, error) 11 | } 12 | 13 | // Request represents a RESP request. 14 | type Request struct { 15 | Command string 16 | Args []string 17 | } 18 | 19 | // HandlerFunc implements Handler interface using a function type. 20 | type HandlerFunc func(wr ResponseWriter, req *Request) 21 | 22 | // ServeRESP dispatches the request to the wrapped function. 23 | func (handlerFunc HandlerFunc) ServeRESP(wr ResponseWriter, req *Request) { 24 | handlerFunc(wr, req) 25 | } 26 | -------------------------------------------------------------------------------- /reader.go: -------------------------------------------------------------------------------- 1 | package radio 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "math" 9 | ) 10 | 11 | const defaultBufSize = 4096 12 | 13 | // ErrBufferFull is returned when there is no space left on the buffer to read 14 | // more data. 15 | var ErrBufferFull = errors.New("buffer is full") 16 | 17 | // NewReader initializes the RESP reader with given reader. In server mode, 18 | // input data will be read line-by-line except in case of array of bulkstrings. 19 | // 20 | // Read https://redis.io/topics/protocol#sending-commands-to-a-redis-server for 21 | // more information on how clients interact with server. 22 | func NewReader(r io.Reader, isServer bool) *Reader { 23 | return NewReaderSize(r, isServer, defaultBufSize) 24 | } 25 | 26 | // NewReaderSize initializes the RESP reader with given buffer size. 27 | // See NewReader for more information. 28 | func NewReaderSize(r io.Reader, isServer bool, size int) *Reader { 29 | return &Reader{ 30 | ir: r, 31 | IsServer: isServer, 32 | buf: make([]byte, size), 33 | sz: size, 34 | } 35 | } 36 | 37 | // Reader implements server and client RESP protocol parser. IsServer flag 38 | // controls the RESP parsing mode. When IsServer set to true, only Multi Bulk 39 | // (Array of Bulk strings) and inline commands are supported. When IsServer set 40 | // to false, all RESP values are enabled. FixedBuffer fields allows controlling 41 | // the growth of buffer. 42 | // Read https://redis.io/topics/protocol for RESP protocol specification. 43 | type Reader struct { 44 | // IsServer controls the RESP parsing mode. If set, only inline string 45 | // and multi-bulk (array of bulk strings) will be enabled. 46 | IsServer bool 47 | 48 | // FixedBuffer if set does not allow the buffer to grow in case of 49 | // large incoming data and instead returns ErrBufferFull. If this is 50 | // false, buffer grows by doubling the buffer size as needed. 51 | FixedBuffer bool 52 | 53 | ir io.Reader 54 | start int 55 | end int 56 | buf []byte 57 | sz int 58 | inArray bool 59 | } 60 | 61 | // Read reads next RESP value from the stream. 62 | func (rd *Reader) Read() (Value, error) { 63 | if _, err := rd.buffer(false); err != nil { 64 | return nil, err 65 | } 66 | prefix := rd.buf[rd.start] 67 | 68 | if rd.IsServer { 69 | if rd.inArray && prefix != '$' { 70 | return nil, fmt.Errorf("Protocol error: expecting '$', got '%c'", prefix) 71 | } 72 | 73 | if prefix != '*' && prefix != '$' { 74 | v, err := rd.readInline() 75 | if err != nil { 76 | return nil, err 77 | } 78 | return v, nil 79 | } 80 | } 81 | 82 | switch prefix { 83 | case '+': 84 | v, err := rd.readSimpleStr() 85 | if err != nil { 86 | return nil, err 87 | } 88 | return v, nil 89 | 90 | case '-': 91 | v, err := rd.readErrorStr() 92 | if err != nil { 93 | return nil, err 94 | } 95 | return v, nil 96 | 97 | case ':': 98 | v, err := rd.readInteger() 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | return v, nil 104 | 105 | case '$': 106 | v, err := rd.readBulkStr() 107 | if err != nil { 108 | return nil, err 109 | } 110 | return v, nil 111 | 112 | case '*': 113 | v, err := rd.readArray() 114 | if err != nil { 115 | return nil, err 116 | } 117 | return v, nil 118 | } 119 | 120 | return nil, fmt.Errorf("bad prefix '%c'", prefix) 121 | } 122 | 123 | // Size returns the current buffer size and the minimum buffer size 124 | // reader is configured with. 125 | func (rd *Reader) Size() (minSize int, currentSize int) { 126 | return rd.sz, len(rd.buf) 127 | } 128 | 129 | // Discard discards the contents of the buffer. 130 | func (rd *Reader) Discard() { 131 | rd.start = rd.end 132 | } 133 | 134 | func (rd *Reader) readSimpleStr() (SimpleStr, error) { 135 | rd.start++ // skip over '+' 136 | 137 | data, err := rd.readTillCRLF() 138 | if err != nil { 139 | return "", err 140 | } 141 | 142 | return SimpleStr(data), nil 143 | } 144 | 145 | func (rd *Reader) readErrorStr() (ErrorStr, error) { 146 | rd.start++ // skip over '-' 147 | 148 | data, err := rd.readTillCRLF() 149 | if err != nil { 150 | return "", err 151 | } 152 | 153 | return ErrorStr(data), nil 154 | } 155 | 156 | func (rd *Reader) readInteger() (Integer, error) { 157 | rd.start++ // skip over ':' 158 | 159 | n, err := rd.readNumber() 160 | return Integer(n), err 161 | } 162 | 163 | func (rd *Reader) readInline() (*Array, error) { 164 | data, err := rd.readTillCRLF() 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | return &Array{ 170 | Items: []Value{ 171 | &BulkStr{ 172 | Value: data, 173 | }, 174 | }, 175 | }, nil 176 | } 177 | 178 | func (rd *Reader) readBulkStr() (*BulkStr, error) { 179 | rd.start++ // skip over '$' 180 | 181 | size, err := rd.readNumber() 182 | if err != nil { 183 | if rd.IsServer && (err == errInvalidNumber || err == errNoNumber) { 184 | return nil, errors.New("Protocol error: invalid bulk length") 185 | } 186 | return nil, err 187 | } 188 | 189 | if size < 0 { 190 | if rd.IsServer { 191 | return nil, errors.New("Protocol error: invalid bulk length") 192 | } 193 | 194 | // -1 (negative size) means a null bulk string 195 | // Refer https://redis.io/topics/protocol#resp-bulk-strings 196 | return &BulkStr{}, nil 197 | } 198 | 199 | data, err := rd.readExactly(size) 200 | if err != nil { 201 | return nil, err 202 | } 203 | rd.start += 2 // skip over CRLF 204 | 205 | return &BulkStr{ 206 | Value: data, 207 | }, nil 208 | } 209 | 210 | func (rd *Reader) readArray() (*Array, error) { 211 | rd.inArray = true 212 | defer func() { 213 | rd.inArray = false 214 | }() 215 | rd.start++ // skip over '+' 216 | 217 | size, err := rd.readNumber() 218 | if err != nil { 219 | if rd.IsServer && (err == errInvalidNumber || err == errNoNumber) { 220 | return nil, errors.New("Protocol error: invalid multibulk length") 221 | } 222 | 223 | return nil, err 224 | } 225 | 226 | if size < 0 { 227 | // -1 (negative size) means a null array 228 | return &Array{}, nil 229 | } 230 | 231 | arr := &Array{} 232 | arr.Items = []Value{} 233 | 234 | for i := 0; i < size; i++ { 235 | item, err := rd.Read() 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | arr.Items = append(arr.Items, item) 241 | } 242 | 243 | return arr, nil 244 | } 245 | 246 | func (rd *Reader) readExactly(n int) ([]byte, error) { 247 | for rd.end-rd.start < n { 248 | if _, err := rd.buffer(true); err != nil { 249 | return nil, err 250 | } 251 | } 252 | 253 | data := rd.buf[rd.start : rd.start+n] 254 | rd.start += n + 1 255 | return data, nil 256 | } 257 | 258 | func (rd *Reader) readTillCRLF() ([]byte, error) { 259 | var crlf int 260 | 261 | for { 262 | crlf = bytes.Index(rd.buf[rd.start:rd.end], []byte("\r\n")) 263 | if crlf >= 0 { 264 | break 265 | } 266 | 267 | if _, err := rd.buffer(true); err != nil { 268 | return nil, err 269 | } 270 | } 271 | 272 | if crlf == 0 { 273 | return []byte(""), nil 274 | } 275 | 276 | data := make([]byte, crlf) 277 | copy(data, rd.buf[rd.start:rd.start+crlf]) 278 | rd.start += crlf + 2 279 | return data, nil 280 | } 281 | 282 | func (rd *Reader) readNumber() (int, error) { 283 | data, err := rd.readTillCRLF() 284 | if err != nil { 285 | return 0, err 286 | } 287 | 288 | if len(data) == 0 { 289 | return 0, errNoNumber 290 | } 291 | 292 | return toInt(data) 293 | } 294 | 295 | func (rd *Reader) buffer(force bool) (int, error) { 296 | if !force && rd.end > rd.start { 297 | return 0, nil // buffer already has some data. 298 | } 299 | 300 | if rd.end > 0 && rd.start >= rd.end { 301 | rd.start = 0 302 | rd.end = 0 303 | } else if rd.end == len(rd.buf) { 304 | if rd.FixedBuffer { 305 | return 0, ErrBufferFull 306 | } 307 | 308 | rd.buf = append(rd.buf, make([]byte, len(rd.buf))...) 309 | } 310 | 311 | n, err := rd.ir.Read(rd.buf[rd.end:]) 312 | if err != nil { 313 | return 0, err 314 | } 315 | rd.end += n 316 | 317 | return n, nil 318 | } 319 | 320 | func toInt(data []byte) (int, error) { 321 | var d, sign int 322 | L := len(data) 323 | for i, b := range data { 324 | if i == 0 { 325 | if b == '-' { 326 | sign = -1 327 | continue 328 | } 329 | 330 | sign = 1 331 | } 332 | 333 | if b < '0' || b > '9' { 334 | return 0, errInvalidNumber 335 | } 336 | 337 | if b == '0' { 338 | continue 339 | } 340 | 341 | pos := int(math.Pow(10, float64(L-i-1))) 342 | d += int(b-'0') * pos 343 | } 344 | 345 | return sign * d, nil 346 | } 347 | 348 | var ( 349 | errInvalidNumber = errors.New("invalid number format") 350 | errNoNumber = errors.New("no number") 351 | ) 352 | -------------------------------------------------------------------------------- /reader_test.go: -------------------------------------------------------------------------------- 1 | package radio_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/spy16/radio" 12 | ) 13 | 14 | func TestNewReader(suite *testing.T) { 15 | suite.Parallel() 16 | 17 | suite.Run("WithServerMode", func(t *testing.T) { 18 | rd := radio.NewReader(bytes.NewBufferString("hello"), true) 19 | if rd == nil { 20 | t.Errorf("return value must not be nil") 21 | } 22 | if !rd.IsServer { 23 | t.Errorf("reader expected to be in server mode, but in client mode") 24 | } 25 | }) 26 | 27 | suite.Run("WithClientMode", func(t *testing.T) { 28 | rd := radio.NewReader(bytes.NewBufferString("hello"), false) 29 | if rd == nil { 30 | t.Errorf("return value must not be nil") 31 | } 32 | if rd.IsServer { 33 | t.Errorf("reader expected to be in client mode, but in server mode") 34 | } 35 | }) 36 | 37 | suite.Run("WithSize", func(t *testing.T) { 38 | rd := radio.NewReaderSize(bytes.NewBufferString("+hello\r\n"), false, 2) 39 | if rd == nil { 40 | t.Errorf("return value must not be nil") 41 | } 42 | if rd.IsServer { 43 | t.Errorf("reader expected to be in client mode, but in server mode") 44 | } 45 | 46 | v, err := rd.Read() 47 | if err != nil { 48 | t.Errorf("not expecting error, got '%v'", err) 49 | } 50 | 51 | if v == nil { 52 | t.Errorf("expecting non-nil value from reader, got nil") 53 | } 54 | 55 | minSz, curSz := rd.Size() 56 | if minSz != 2 { 57 | t.Errorf("expected minimum buffer size to be 2, got %d", minSz) 58 | } 59 | 60 | if curSz != 8 { 61 | t.Errorf("expected current buffer size to be 8, got %d", curSz) 62 | } 63 | }) 64 | 65 | suite.Run("WithFixedSize", func(t *testing.T) { 66 | rd := radio.NewReaderSize(bytes.NewBufferString("*hello"), false, 2) 67 | if rd == nil { 68 | t.Errorf("return value must not be nil") 69 | } 70 | rd.FixedBuffer = true 71 | 72 | v, err := rd.Read() 73 | if err != radio.ErrBufferFull { 74 | t.Errorf("expecting error '%v', got '%v'", radio.ErrBufferFull, err) 75 | } 76 | 77 | if v != nil { 78 | t.Errorf("expecting nil value from reader, got '%v'", v) 79 | } 80 | }) 81 | } 82 | 83 | func TestReader_Read_ClientMode(suite *testing.T) { 84 | suite.Parallel() 85 | 86 | cases := []readTestCase{ 87 | { 88 | title: "NoInput", 89 | input: "", 90 | val: nil, 91 | err: io.EOF, 92 | }, 93 | { 94 | title: "BadPrefix", 95 | input: "?helo", 96 | val: nil, 97 | err: errors.New("bad prefix '?'"), 98 | }, 99 | { 100 | title: "SimpleStr", 101 | input: "+hello\r\n", 102 | val: radio.SimpleStr("hello"), 103 | err: nil, 104 | }, 105 | { 106 | title: "SimpleStr-Empty", 107 | input: "+\r\n", 108 | val: radio.SimpleStr(""), 109 | err: nil, 110 | }, 111 | { 112 | title: "SimpleStr-NoCRLF", 113 | input: "+hello", 114 | val: nil, 115 | err: io.EOF, 116 | }, 117 | { 118 | title: "SimpleStr-MultiValue", 119 | input: "+hello\r\n+world\r\n", 120 | val: radio.SimpleStr("hello"), 121 | err: nil, 122 | }, 123 | { 124 | title: "ErrorStr", 125 | input: "-ERR failed\r\n", 126 | val: radio.ErrorStr("ERR failed"), 127 | err: nil, 128 | }, 129 | { 130 | title: "ErrorStr-NoValue", 131 | input: "-\r\n", 132 | val: radio.ErrorStr(""), 133 | err: nil, 134 | }, 135 | { 136 | title: "ErrorStr-NoCRLF", 137 | input: "-ERR failed", 138 | val: nil, 139 | err: io.EOF, 140 | }, 141 | { 142 | title: "Integer", 143 | input: ":100\r\n", 144 | val: radio.Integer(100), 145 | err: nil, 146 | }, 147 | { 148 | title: "Integer-NoValue", 149 | input: ":\r\n", 150 | val: nil, 151 | err: errors.New("no number"), 152 | }, 153 | { 154 | title: "Integer-NoCRLF", 155 | input: ":100", 156 | val: nil, 157 | err: io.EOF, 158 | }, 159 | { 160 | title: "Integer-BadFormat", 161 | input: ":10.5\r\n", 162 | val: nil, 163 | err: errors.New("invalid number format"), 164 | }, 165 | { 166 | title: "Integer-EOF", 167 | input: ":", 168 | val: nil, 169 | err: io.EOF, 170 | }, 171 | { 172 | title: "BulkStr", 173 | input: "$5\r\nhello\r\n", 174 | val: &radio.BulkStr{ 175 | Value: []byte("hello"), 176 | }, 177 | err: nil, 178 | }, 179 | { 180 | title: "BulkStr-NoSize", 181 | input: "$\r\n", 182 | val: nil, 183 | err: errors.New("no number"), 184 | }, 185 | { 186 | title: "BulkStr-NegativeSize", 187 | input: "$-1\r\n", 188 | val: &radio.BulkStr{}, 189 | err: nil, 190 | }, 191 | { 192 | title: "BulkStr-NoData", 193 | input: "$10\r\nhel\r\n", 194 | val: nil, 195 | err: io.EOF, 196 | }, 197 | { 198 | title: "Array", 199 | input: "*1\r\n+hello\r\n", 200 | val: &radio.Array{ 201 | Items: []radio.Value{ 202 | radio.SimpleStr("hello"), 203 | }, 204 | }, 205 | err: nil, 206 | }, 207 | { 208 | title: "Array-NoSize", 209 | input: "*\r\n", 210 | val: nil, 211 | err: errors.New("no number"), 212 | }, 213 | { 214 | title: "Array-NegativeSize", 215 | input: "*-1\r\n", 216 | val: &radio.Array{}, 217 | err: nil, 218 | }, 219 | { 220 | title: "Array-InsufficientData", 221 | input: "*2\r\n+hello\r\n", 222 | val: nil, 223 | err: io.EOF, 224 | }, 225 | { 226 | title: "Array-InvalidSize", 227 | input: "*2.5\r\n", 228 | val: nil, 229 | err: errors.New("invalid number format"), 230 | }, 231 | } 232 | 233 | runAllCases(suite, cases, false) 234 | } 235 | 236 | func TestReader_Read_ServerMode(suite *testing.T) { 237 | suite.Parallel() 238 | 239 | cases := []readTestCase{ 240 | { 241 | title: "InlineStr", 242 | input: "hello\r\n", 243 | val: &radio.Array{ 244 | Items: []radio.Value{ 245 | &radio.BulkStr{ 246 | Value: []byte("hello"), 247 | }, 248 | }, 249 | }, 250 | err: nil, 251 | }, 252 | { 253 | title: "InlineStr-EOF", 254 | input: "hello", 255 | val: nil, 256 | err: io.EOF, 257 | }, 258 | { 259 | title: "MultiBulk-NullValue", 260 | input: "*-1\r\n", 261 | val: &radio.Array{}, 262 | err: nil, 263 | }, 264 | { 265 | title: "MultiBulk-EOF", 266 | input: "*1\r\n", 267 | val: nil, 268 | err: io.EOF, 269 | }, 270 | { 271 | title: "SimpleStrInArray", 272 | input: "*1\r\n+hello\r\n", 273 | val: nil, 274 | err: errors.New("Protocol error: expecting '$', got '+'"), 275 | }, 276 | { 277 | title: "MultiBulk-InvalidSize", 278 | input: "*1.4\r\n", 279 | val: nil, 280 | err: errors.New("Protocol error: invalid multibulk length"), 281 | }, 282 | { 283 | title: "MultiBulk-InvalidBulkSize", 284 | input: "*1\r\n$1.5\r\n", 285 | val: nil, 286 | err: errors.New("Protocol error: invalid bulk length"), 287 | }, 288 | { 289 | title: "MultiBulk-NegativeBulkSize", 290 | input: "*1\r\n$-1\r\n", 291 | val: nil, 292 | err: errors.New("Protocol error: invalid bulk length"), 293 | }, 294 | } 295 | 296 | runAllCases(suite, cases, true) 297 | } 298 | 299 | func runAllCases(suite *testing.T, cases []readTestCase, serverMode bool) { 300 | for _, cs := range cases { 301 | if cs.title == "" { 302 | cs.title = cs.input 303 | } 304 | 305 | suite.Run(cs.title, func(t *testing.T) { 306 | par := radio.NewReader(strings.NewReader(cs.input), serverMode) 307 | val, err := par.Read() 308 | 309 | if !reflect.DeepEqual(cs.err, err) { 310 | t.Errorf("expecting error '%v', got '%v'", cs.err, err) 311 | } 312 | 313 | if !reflect.DeepEqual(cs.val, val) { 314 | t.Errorf("expecting RESP value '%s{%v}', got '%s{%v}'", 315 | reflect.TypeOf(cs.val), cs.val, reflect.TypeOf(val), val) 316 | } 317 | }) 318 | } 319 | 320 | } 321 | 322 | type readTestCase struct { 323 | title string 324 | input string 325 | val radio.Value 326 | err error 327 | } 328 | -------------------------------------------------------------------------------- /value.go: -------------------------------------------------------------------------------- 1 | package radio 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Value represents the RESP protocol values. 10 | type Value interface { 11 | // Serialize should return the RESP serialized representation of 12 | // the value. 13 | Serialize() string 14 | } 15 | 16 | // SimpleStr represents a simple string in RESP. 17 | // Refer https://redis.io/topics/protocol#resp-simple-strings 18 | type SimpleStr string 19 | 20 | // Serialize returns RESP representation of simple string. 21 | func (ss SimpleStr) Serialize() string { 22 | return fmt.Sprintf("+%s\r\n", string(ss)) 23 | } 24 | 25 | // ErrorStr represents a error string in RESP. 26 | // Refer https://redis.io/topics/protocol#resp-errors 27 | type ErrorStr string 28 | 29 | // Serialize returns RESP representation of ErrorStr. 30 | func (es ErrorStr) Serialize() string { 31 | return fmt.Sprintf("-%s\r\n", string(es)) 32 | } 33 | 34 | // Integer represents RESP integer value. 35 | // Refer https://redis.io/topics/protocol#resp-integers 36 | type Integer int 37 | 38 | // Serialize returns RESP representation of Integer. 39 | func (in Integer) Serialize() string { 40 | return fmt.Sprintf(":%d\r\n", in) 41 | } 42 | 43 | func (in Integer) String() string { 44 | return strconv.Itoa(int(in)) 45 | } 46 | 47 | // BulkStr represents a binary safe string in RESP. 48 | // Refer https://redis.io/topics/protocol#resp-bulk-strings 49 | type BulkStr struct { 50 | Value []byte 51 | } 52 | 53 | // Serialize returns RESP representation of the Bulk String. 54 | func (bs *BulkStr) Serialize() string { 55 | if bs.Value == nil { 56 | return "$-1\r\n" 57 | } 58 | 59 | return fmt.Sprintf("$%d\r\n%s\r\n", len(bs.Value), bs.Value) 60 | } 61 | 62 | // IsNil returns true if the value is a Null bulk string as per 63 | // RESP protocol specification. 64 | func (bs *BulkStr) IsNil() bool { 65 | return bs.Value == nil 66 | } 67 | 68 | func (bs *BulkStr) String() string { 69 | return string(bs.Value) 70 | } 71 | 72 | // Array represents Array RESP type. 73 | // Refer https://redis.io/topics/protocol#resp-arrays 74 | type Array struct { 75 | Items []Value 76 | } 77 | 78 | // Serialize returns RESP representation of the Array. 79 | func (arr *Array) Serialize() string { 80 | if arr.Items == nil { 81 | return fmt.Sprintf("*-1\r\n") 82 | } 83 | 84 | s := fmt.Sprintf("*%d\r\n", len(arr.Items)) 85 | for _, val := range arr.Items { 86 | s += val.Serialize() 87 | } 88 | 89 | return s 90 | } 91 | 92 | // IsNil returns true if the underlying items slice is nil. 93 | func (arr *Array) IsNil() bool { 94 | return arr.Items == nil 95 | } 96 | 97 | func (arr *Array) String() string { 98 | strs := []string{} 99 | for _, itm := range arr.Items { 100 | strs = append(strs, fmt.Sprintf("%s", itm)) 101 | } 102 | 103 | return strings.Join(strs, "\n") 104 | } 105 | -------------------------------------------------------------------------------- /value_test.go: -------------------------------------------------------------------------------- 1 | package radio_test 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/spy16/radio" 9 | ) 10 | 11 | func TestSerialize(suite *testing.T) { 12 | suite.Parallel() 13 | 14 | cases := []struct { 15 | val radio.Value 16 | resp string 17 | str string 18 | }{ 19 | { 20 | val: radio.SimpleStr("hello"), 21 | resp: "+hello\r\n", 22 | str: "hello", 23 | }, 24 | { 25 | val: radio.Integer(10), 26 | resp: ":10\r\n", 27 | str: "10", 28 | }, 29 | { 30 | val: radio.ErrorStr("failed"), 31 | resp: "-failed\r\n", 32 | str: "failed", 33 | }, 34 | { 35 | val: &radio.BulkStr{ 36 | Value: nil, 37 | }, 38 | resp: "$-1\r\n", 39 | str: "", 40 | }, 41 | { 42 | val: &radio.BulkStr{ 43 | Value: []byte(""), 44 | }, 45 | resp: "$0\r\n\r\n", 46 | str: "", 47 | }, 48 | { 49 | val: &radio.BulkStr{ 50 | Value: []byte("hello"), 51 | }, 52 | resp: "$5\r\nhello\r\n", 53 | str: "hello", 54 | }, 55 | { 56 | val: &radio.Array{ 57 | Items: nil, 58 | }, 59 | resp: "*-1\r\n", 60 | str: "", 61 | }, 62 | { 63 | val: &radio.Array{ 64 | Items: []radio.Value{ 65 | radio.SimpleStr("hello"), 66 | radio.Integer(10), 67 | }, 68 | }, 69 | resp: "*2\r\n+hello\r\n:10\r\n", 70 | str: "hello\n10", 71 | }, 72 | { 73 | val: &radio.Array{ 74 | Items: []radio.Value{}, 75 | }, 76 | resp: "*0\r\n", 77 | str: "", 78 | }, 79 | } 80 | 81 | for _, cs := range cases { 82 | suite.Run(reflect.TypeOf(cs.val).String(), func(t *testing.T) { 83 | actualResp := cs.val.Serialize() 84 | if cs.resp != actualResp { 85 | t.Errorf("expecting serialization '%s', got '%s'", cs.resp, actualResp) 86 | } 87 | 88 | var actualStr string 89 | if stringer, ok := cs.val.(fmt.Stringer); ok { 90 | actualStr = stringer.String() 91 | } else { 92 | actualStr = fmt.Sprintf("%s", cs.val) 93 | } 94 | 95 | if cs.str != actualStr { 96 | t.Errorf("expecting string '%s', got '%s'", cs.str, actualStr) 97 | } 98 | }) 99 | } 100 | } 101 | 102 | func TestValue_IsNil(suite *testing.T) { 103 | suite.Parallel() 104 | 105 | cases := []struct { 106 | title string 107 | val radio.Value 108 | shouldBeNil bool 109 | }{ 110 | { 111 | title: "BulkStr-WhenEmpty", 112 | val: &radio.BulkStr{ 113 | Value: []byte(""), 114 | }, 115 | shouldBeNil: false, 116 | }, 117 | { 118 | title: "BulkStr-WithValue", 119 | val: &radio.BulkStr{ 120 | Value: []byte("hello"), 121 | }, 122 | shouldBeNil: false, 123 | }, 124 | { 125 | title: "BulkStr-WhenNil", 126 | val: &radio.BulkStr{ 127 | Value: nil, 128 | }, 129 | shouldBeNil: true, 130 | }, 131 | { 132 | title: "Array-WhenEmpty", 133 | val: &radio.Array{ 134 | Items: []radio.Value{}, 135 | }, 136 | shouldBeNil: false, 137 | }, 138 | { 139 | title: "Array-WithValue", 140 | val: &radio.Array{ 141 | Items: []radio.Value{ 142 | radio.SimpleStr("hello"), 143 | }, 144 | }, 145 | shouldBeNil: false, 146 | }, 147 | { 148 | title: "Array-WhenNil", 149 | val: &radio.Array{ 150 | Items: nil, 151 | }, 152 | shouldBeNil: true, 153 | }, 154 | } 155 | 156 | for _, cs := range cases { 157 | suite.Run(cs.title, func(t *testing.T) { 158 | nillable, ok := cs.val.(interface{ IsNil() bool }) 159 | if ok { 160 | if nillable.IsNil() != cs.shouldBeNil { 161 | t.Errorf("expecting '%t', got '%t'", cs.shouldBeNil, !cs.shouldBeNil) 162 | } 163 | } else { 164 | t.Logf("type %s is not nillable", reflect.TypeOf(cs.val)) 165 | } 166 | }) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /writer.go: -------------------------------------------------------------------------------- 1 | package radio 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // NewWriter initializes a RESP writer to write to given io.Writer. 8 | func NewWriter(wr io.Writer) *Writer { 9 | return &Writer{ 10 | w: wr, 11 | } 12 | } 13 | 14 | // Writer provides functions for writing RESP protocol values. 15 | type Writer struct { 16 | w io.Writer 17 | } 18 | 19 | func (rw *Writer) Write(v Value) (int, error) { 20 | return rw.w.Write([]byte(v.Serialize())) 21 | } 22 | 23 | // WriteErr writes given error as RESP error to the writer. 24 | func (rw *Writer) WriteErr(err error) (int, error) { 25 | return rw.w.Write([]byte(ErrorStr(err.Error()).Serialize())) 26 | } 27 | -------------------------------------------------------------------------------- /writer_test.go: -------------------------------------------------------------------------------- 1 | package radio_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/spy16/radio" 8 | ) 9 | 10 | func TestWriter_Write(t *testing.T) { 11 | b := &bytes.Buffer{} 12 | wr := radio.NewWriter(b) 13 | wr.Write(radio.SimpleStr("hello")) 14 | 15 | expected := "+hello\r\n" 16 | 17 | if actual := b.String(); actual != expected { 18 | t.Errorf("expecting '%s', got '%s'", expected, actual) 19 | } 20 | } 21 | --------------------------------------------------------------------------------