├── .github └── workflows │ └── test.yml ├── README.md ├── go.mod ├── go.sum ├── patch_codec.go ├── request.put.go ├── request.put_test.go └── utils.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | go_tests: 7 | name: Go tests 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | platform: [ubuntu-latest, macos-latest, windows-latest] 12 | runs-on: ${{ matrix.platform }} 13 | steps: 14 | - name: Checkout the repo 15 | uses: actions/checkout@v2 16 | - name: Download Go vendor packages 17 | run: go mod download 18 | - name: Run tests 19 | run: go test -v ./... 20 | 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-braid-http 2 | Go utilities for creating HTTP requests following the Braid spec 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/braid-org/go-braid-http 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/pkg/errors v0.9.1 7 | github.com/stretchr/testify v1.7.0 8 | golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 4 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 8 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 9 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 10 | golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d h1:1aflnvSoWWLI2k/dMUAl5lvU1YO4Mb4hz0gh+1rjcxU= 11 | golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 12 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 13 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 14 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 15 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 18 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /patch_codec.go: -------------------------------------------------------------------------------- 1 | package braid 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type Patch struct { 14 | Name string 15 | ContentRange string 16 | ContentLength uint64 17 | ExtraHeaders map[string]string 18 | Body []byte 19 | } 20 | 21 | func (p Patch) MarshalRequest() ([]byte, error) { 22 | if p.ContentLength == 0 { 23 | p.ContentLength = uint64(len(p.Body)) 24 | } 25 | 26 | var buf bytes.Buffer 27 | 28 | if p.Name != "" { 29 | buf.WriteString(fmt.Sprintf("Patch-Name: \"%v\"\n", p.Name)) 30 | } 31 | if p.ContentRange != "" { 32 | buf.WriteString(fmt.Sprintf("Content-Range: %v\n", p.ContentRange)) 33 | } 34 | buf.WriteString(fmt.Sprintf("Content-Length: %v\n", p.ContentLength)) 35 | 36 | for header, value := range p.ExtraHeaders { 37 | buf.WriteString(fmt.Sprintf("%v: %v\n", header, value)) 38 | } 39 | 40 | buf.WriteString("\n") 41 | 42 | _, err := io.CopyN(&buf, bytes.NewReader(p.Body), int64(p.ContentLength)) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return buf.Bytes(), nil 47 | } 48 | 49 | type patchReadState int 50 | 51 | const ( 52 | patchReadStateHeaders patchReadState = iota 53 | patchReadStateBody 54 | patchReadStateDone 55 | ) 56 | 57 | func (p *Patch) UnmarshalRequest(r io.Reader) error { 58 | state := patchReadStateHeaders 59 | for { 60 | line, err := readUntil(r, '\n') 61 | if err == io.EOF { 62 | state = patchReadStateDone 63 | } else if err != nil { 64 | return err 65 | } 66 | 67 | switch state { 68 | case patchReadStateHeaders: 69 | if len(bytes.TrimSpace(line)) == 0 { 70 | state = patchReadStateBody 71 | continue 72 | } 73 | err := p.handleHeader(line) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | case patchReadStateBody, patchReadStateDone: 79 | if len(bytes.TrimSpace(line)) == 0 { 80 | state = patchReadStateDone 81 | break 82 | } 83 | err := p.handleBody(line) 84 | if err != nil { 85 | return err 86 | } 87 | } 88 | 89 | if state == patchReadStateDone { 90 | break 91 | } 92 | } 93 | if uint64(len(p.Body)) < p.ContentLength { 94 | return errors.Errorf("bad content length (expected %v, got %v)", p.ContentLength, len(p.Body)) 95 | } 96 | p.Body = p.Body[:p.ContentLength] 97 | return nil 98 | } 99 | 100 | func (p *Patch) handleHeader(line []byte) error { 101 | if len(bytes.TrimSpace(line)) == 0 { 102 | return nil 103 | } 104 | 105 | parts := bytes.SplitN(line, []byte(":"), 2) 106 | if len(parts) < 2 { 107 | return errors.Errorf("bad patch header: %v", string(line)) 108 | } 109 | header, value := bytes.TrimSpace(parts[0]), bytes.TrimSpace(parts[1]) 110 | switch strings.ToLower(string(header)) { 111 | case "patch-name": 112 | p.Name = string(bytes.Trim(value, `"`)) 113 | 114 | case "content-range": 115 | p.ContentRange = string(value) 116 | 117 | case "content-length": 118 | contentLength, err := strconv.ParseUint(string(value), 10, 64) 119 | if err != nil { 120 | return err 121 | } 122 | p.ContentLength = contentLength 123 | 124 | default: 125 | if p.ExtraHeaders == nil { 126 | p.ExtraHeaders = make(map[string]string) 127 | } 128 | p.ExtraHeaders[string(header)] = string(value) 129 | } 130 | return nil 131 | } 132 | 133 | func (p *Patch) handleBody(line []byte) error { 134 | if len(bytes.TrimSpace(line)) == 0 { 135 | return nil 136 | } 137 | p.Body = append(p.Body, line...) 138 | return nil 139 | } 140 | -------------------------------------------------------------------------------- /request.put.go: -------------------------------------------------------------------------------- 1 | package braid 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "fmt" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | type PutRequest struct { 16 | ContentType string 17 | Accept string 18 | Version string 19 | Parents []string 20 | Patches []Patch 21 | } 22 | 23 | // Creates an *http.Request representing the given Tx that follows the Braid-HTTP 24 | // specification for sending transactions/patches to peers. 25 | func MakePutRequest(ctx context.Context, dialAddr string, opts PutRequest) (*http.Request, error) { 26 | var patchBytes [][]byte 27 | for _, patch := range opts.Patches { 28 | bs, err := patch.MarshalRequest() 29 | if err != nil { 30 | return nil, err 31 | } 32 | patchBytes = append(patchBytes, bs) 33 | } 34 | body := bytes.NewBuffer(bytes.Join(patchBytes, []byte("\n\n"))) 35 | 36 | req, err := http.NewRequestWithContext(ctx, "PUT", dialAddr, body) 37 | if err != nil { 38 | return nil, errors.WithStack(err) 39 | } 40 | 41 | req.Header.Set("Version", opts.Version) 42 | req.Header.Set("Content-Type", opts.ContentType) 43 | req.Header.Set("Content-Length", fmt.Sprintf("%v", body.Len())) 44 | if opts.Accept != "" { 45 | req.Header.Set("Accept", opts.Accept) 46 | } 47 | req.Header.Set("Parents", strings.Join(opts.Parents, ",")) 48 | req.Header.Set("Patches", fmt.Sprintf("%v", len(opts.Patches))) 49 | return req, nil 50 | } 51 | 52 | func ReadPutRequest(r *http.Request) (*PutRequest, error) { 53 | contentType := r.Header.Get("Content-Type") 54 | accept := r.Header.Get("Accept") 55 | version := r.Header.Get("Version") 56 | 57 | parents := strings.Split(r.Header.Get("Parents"), ",") 58 | for i := range parents { 59 | parents[i] = strings.TrimSpace(parents[i]) 60 | } 61 | 62 | numPatchesStr := r.Header.Get("Patches") 63 | if strings.TrimSpace(numPatchesStr) == "" { 64 | return nil, errors.New("missing Patches header") 65 | } 66 | numPatches, err := strconv.ParseUint(numPatchesStr, 10, 64) 67 | if err != nil { 68 | return nil, errors.Wrap(err, "bad Patches header") 69 | } 70 | 71 | reader := bufio.NewReader(r.Body) 72 | 73 | var patches []Patch 74 | for i := uint64(0); i < numPatches; i++ { 75 | var patch Patch 76 | err := patch.UnmarshalRequest(reader) 77 | if err != nil { 78 | return nil, err 79 | } 80 | patches = append(patches, patch) 81 | } 82 | 83 | return &PutRequest{ 84 | ContentType: contentType, 85 | Accept: accept, 86 | Version: version, 87 | Parents: parents, 88 | Patches: patches, 89 | }, nil 90 | } 91 | -------------------------------------------------------------------------------- /request.put_test.go: -------------------------------------------------------------------------------- 1 | package braid 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestPutRequestRoundTrip(t *testing.T) { 11 | patch1 := []byte(`{"asdf": "jkl;"}`) 12 | patch2 := []byte(`{"braid": "http", "oof": ["rab", "zab"]}`) 13 | 14 | expected := &PutRequest{ 15 | ContentType: "application/json", 16 | Accept: "application/json", 17 | Version: "12345", 18 | Parents: []string{"foo", "bar"}, 19 | Patches: []Patch{ 20 | { 21 | Name: "patch-type-1", 22 | ContentRange: "json [-0:-0]", 23 | ContentLength: uint64(len(patch1)), 24 | ExtraHeaders: map[string]string{ 25 | "Quux": "xyzzy", 26 | "Quack": "duck", 27 | }, 28 | Body: patch1, 29 | }, 30 | { 31 | Name: "patch-type-2", 32 | ContentRange: "json .foo.bar", 33 | ContentLength: uint64(len(patch2)), 34 | ExtraHeaders: map[string]string{ 35 | "Encoding": "flarf", 36 | "Cache": "zork", 37 | }, 38 | Body: patch2, 39 | }, 40 | }, 41 | } 42 | 43 | req, err := MakePutRequest(context.Background(), "http://braid.org", *expected) 44 | require.NoError(t, err) 45 | 46 | got, err := ReadPutRequest(req) 47 | require.NoError(t, err) 48 | 49 | require.Equal(t, expected, got) 50 | } 51 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package braid 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | func readUntil(r io.Reader, delim byte) ([]byte, error) { 8 | var line []byte 9 | b := make([]byte, 1) 10 | for { 11 | _, err := r.Read(b) 12 | if err != nil { 13 | return line, err 14 | } 15 | line = append(line, b[0]) 16 | if b[0] == delim { 17 | return line, nil 18 | } 19 | } 20 | } 21 | --------------------------------------------------------------------------------