Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

### Features include:

- **Response formatting**: automatically formats and colors output (json, html, msgpack, xml, etc.)
- **Response formatting**: automatically formats and colors output (json, html, msgpack, protobuf, xml, etc.)
- **Image rendering**: render images directly in your terminal
- **Compression**: automatic gzip and zstd response body decompression
- **Authentication**: support for Basic Auth, Bearer Token, and AWS Signature V4
Expand Down
1 change: 1 addition & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ Supported formats for automatic formatting and syntax highlighting:
- XML (`application/xml`, `text/xml`)
- MessagePack (`application/msgpack`)
- NDJSON/JSONLines (`application/x-ndjson`)
- Protobuf (`application/x-protobuf`, `application/protobuf`)
- Server-Sent Events (`text/event-stream`)

```sh
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
golang.org/x/image v0.35.0
golang.org/x/net v0.49.0
golang.org/x/sys v0.40.0
google.golang.org/protobuf v1.36.11
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,7 @@ golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
10 changes: 10 additions & 0 deletions internal/fetch/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
TypeJSON
TypeMsgPack
TypeNDJSON
TypeProtobuf
TypeSSE
TypeXML
)
Expand Down Expand Up @@ -298,6 +299,10 @@ func formatResponse(ctx context.Context, r *Request, resp *http.Response) (io.Re
if format.FormatMsgPack(buf, p) == nil {
buf = p.Bytes()
}
case TypeProtobuf:
if format.FormatProtobuf(buf, p) == nil {
buf = p.Bytes()
}
case TypeXML:
if format.FormatXML(buf, p) == nil {
buf = p.Bytes()
Expand Down Expand Up @@ -329,12 +334,17 @@ func getContentType(headers http.Header) ContentType {
return TypeMsgPack
case "x-ndjson", "ndjson", "x-jsonl", "jsonl", "x-jsonlines":
return TypeNDJSON
case "protobuf", "x-protobuf", "grpc+proto", "x-google-protobuf", "vnd.google.protobuf":
return TypeProtobuf
case "xml":
return TypeXML
}
if strings.HasSuffix(subtype, "+json") || strings.HasSuffix(subtype, "-json") {
return TypeJSON
}
if strings.HasSuffix(subtype, "+proto") {
return TypeProtobuf
}
if strings.HasSuffix(subtype, "+xml") {
return TypeXML
}
Expand Down
207 changes: 207 additions & 0 deletions internal/format/protobuf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package format

import (
"fmt"
"strconv"
"unicode"
"unicode/utf8"

"github.com/ryanfowler/fetch/internal/core"

"google.golang.org/protobuf/encoding/protowire"
)

// FormatProtobuf formats the provided raw protobuf data to the Printer.
func FormatProtobuf(buf []byte, p *core.Printer) error {
err := formatProtobuf(buf, p, 0)
if err != nil {
p.Reset()
}
return err
}

func formatProtobuf(buf []byte, p *core.Printer, indent int) error {
for len(buf) > 0 {
num, wtype, n := protowire.ConsumeTag(buf)
if n < 0 {
return protowire.ParseError(n)
}
buf = buf[n:]

writeIndent(p, indent)
writeFieldNumber(p, num)

switch wtype {
case protowire.VarintType:
v, n := protowire.ConsumeVarint(buf)
if n < 0 {
return protowire.ParseError(n)
}
buf = buf[n:]
writeWireType(p, "varint")
p.WriteString(" ")
p.WriteString(strconv.FormatUint(v, 10))
p.WriteString("\n")

case protowire.Fixed64Type:
v, n := protowire.ConsumeFixed64(buf)
if n < 0 {
return protowire.ParseError(n)
}
buf = buf[n:]
writeWireType(p, "fixed64")
p.WriteString(" ")
p.WriteString(fmt.Sprintf("0x%016x", v))
p.WriteString("\n")

case protowire.Fixed32Type:
v, n := protowire.ConsumeFixed32(buf)
if n < 0 {
return protowire.ParseError(n)
}
buf = buf[n:]
writeWireType(p, "fixed32")
p.WriteString(" ")
p.WriteString(fmt.Sprintf("0x%08x", v))
p.WriteString("\n")

case protowire.BytesType:
v, n := protowire.ConsumeBytes(buf)
if n < 0 {
return protowire.ParseError(n)
}
buf = buf[n:]

// Try to parse as nested message.
if isValidProtobuf(v) {
writeWireType(p, "message")
p.WriteString(" {\n")
err := formatProtobuf(v, p, indent+1)
if err != nil {
return err
}
writeIndent(p, indent)
p.WriteString("}\n")
} else if isPrintableBytes(v) {
writeWireType(p, "bytes")
p.WriteString(" ")
writeProtobufString(p, string(v))
p.WriteString("\n")
} else {
writeWireType(p, "bytes")
p.WriteString(" ")
writeProtobufBytes(p, v)
p.WriteString("\n")
}

case protowire.StartGroupType, protowire.EndGroupType:
// Groups are deprecated; skip them.
return fmt.Errorf("deprecated group wire type")

default:
return fmt.Errorf("unknown wire type: %d", wtype)
}
}
return nil
}

// isValidProtobuf checks if the bytes can be parsed as a valid protobuf message.
func isValidProtobuf(buf []byte) bool {
if len(buf) == 0 {
return false
}

for len(buf) > 0 {
num, wtype, n := protowire.ConsumeTag(buf)
if n < 0 || num == 0 {
return false
}
buf = buf[n:]

switch wtype {
case protowire.VarintType:
_, n = protowire.ConsumeVarint(buf)
case protowire.Fixed64Type:
_, n = protowire.ConsumeFixed64(buf)
case protowire.Fixed32Type:
_, n = protowire.ConsumeFixed32(buf)
case protowire.BytesType:
_, n = protowire.ConsumeBytes(buf)
case protowire.StartGroupType, protowire.EndGroupType:
return false
default:
return false
}
if n < 0 {
return false
}
buf = buf[n:]
}
return true
}

// isPrintableBytes returns true if the bytes are printable UTF-8 text.
func isPrintableBytes(b []byte) bool {
if !utf8.Valid(b) {
return false
}
for _, r := range string(b) {
if !unicode.IsPrint(r) && !unicode.IsSpace(r) {
return false
}
}
return true
}

func writeFieldNumber(p *core.Printer, num protowire.Number) {
p.Set(core.Blue)
p.Set(core.Bold)
p.WriteString(strconv.FormatInt(int64(num), 10))
p.Reset()
p.WriteString(":")
}

func writeWireType(p *core.Printer, wtype string) {
p.WriteString(" ")
p.Set(core.Dim)
p.WriteString("(")
p.WriteString(wtype)
p.WriteString(")")
p.Reset()
}

func writeProtobufString(p *core.Printer, s string) {
p.WriteString("\"")
p.Set(core.Green)
for _, c := range s {
switch c {
case '\n':
p.WriteString(`\n`)
case '\r':
p.WriteString(`\r`)
case '\t':
p.WriteString(`\t`)
case '"':
p.WriteString(`\"`)
case '\\':
p.WriteString(`\\`)
default:
p.WriteRune(c)
}
}
p.Reset()
p.WriteString("\"")
}

func writeProtobufBytes(p *core.Printer, b []byte) {
p.Set(core.Yellow)
p.WriteString("<")
for i, byt := range b {
if i > 0 {
p.WriteString(" ")
}
p.WriteString(fmt.Sprintf("%02x", byt))
}
p.WriteString(">")
p.Reset()
}
Loading