dev
This commit is contained in:
21
internal/codegen/file.go
Normal file
21
internal/codegen/file.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (f *File) P(v ...interface{}) {
|
||||
for _, x := range v {
|
||||
fmt.Fprint(&f.buf, x)
|
||||
}
|
||||
fmt.Fprintln(&f.buf)
|
||||
}
|
||||
|
||||
func (f *File) Content() []byte {
|
||||
return f.buf.Bytes()
|
||||
}
|
||||
11
internal/httprule/fieldpath.go
Normal file
11
internal/httprule/fieldpath.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package httprule
|
||||
|
||||
import "strings"
|
||||
|
||||
// FieldPath describes the path for a field from a message.
|
||||
// Individual segments are in snake case (same as in protobuf file).
|
||||
type FieldPath []string
|
||||
|
||||
func (f FieldPath) String() string {
|
||||
return strings.Join(f, ".")
|
||||
}
|
||||
94
internal/httprule/rule.go
Normal file
94
internal/httprule/rule.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package httprule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"google.golang.org/genproto/googleapis/api/annotations"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
func Get(m protoreflect.MethodDescriptor) (*annotations.HttpRule, bool) {
|
||||
descriptor, ok := proto.GetExtension(m.Options(), annotations.E_Http).(*annotations.HttpRule)
|
||||
if !ok || descriptor == nil {
|
||||
return nil, false
|
||||
}
|
||||
return descriptor, true
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
// The HTTP method to use.
|
||||
Method string
|
||||
// The template describing the URL to use.
|
||||
Template Template
|
||||
Body string
|
||||
AdditionalRules []Rule
|
||||
}
|
||||
|
||||
func ParseRule(httpRule *annotations.HttpRule) (Rule, error) {
|
||||
method, err := httpRuleMethod(httpRule)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
url, err := httpRuleURL(httpRule)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
template, err := ParseTemplate(url)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
additional := make([]Rule, len(httpRule.GetAdditionalBindings()))
|
||||
for i, r := range httpRule.GetAdditionalBindings() {
|
||||
a, err := ParseRule(r)
|
||||
if err != nil {
|
||||
return Rule{}, fmt.Errorf("parse additional binding %d: %w", i, err)
|
||||
}
|
||||
additional[i] = a
|
||||
}
|
||||
return Rule{
|
||||
Method: method,
|
||||
Template: template,
|
||||
Body: httpRule.GetBody(),
|
||||
AdditionalRules: additional,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func httpRuleURL(rule *annotations.HttpRule) (string, error) {
|
||||
switch v := rule.GetPattern().(type) {
|
||||
case *annotations.HttpRule_Get:
|
||||
return v.Get, nil
|
||||
case *annotations.HttpRule_Post:
|
||||
return v.Post, nil
|
||||
case *annotations.HttpRule_Delete:
|
||||
return v.Delete, nil
|
||||
case *annotations.HttpRule_Patch:
|
||||
return v.Patch, nil
|
||||
case *annotations.HttpRule_Put:
|
||||
return v.Put, nil
|
||||
case *annotations.HttpRule_Custom:
|
||||
return v.Custom.GetPath(), nil
|
||||
default:
|
||||
return "", fmt.Errorf("http rule does not have an URL defined")
|
||||
}
|
||||
}
|
||||
|
||||
func httpRuleMethod(rule *annotations.HttpRule) (string, error) {
|
||||
switch v := rule.GetPattern().(type) {
|
||||
case *annotations.HttpRule_Get:
|
||||
return http.MethodGet, nil
|
||||
case *annotations.HttpRule_Post:
|
||||
return http.MethodPost, nil
|
||||
case *annotations.HttpRule_Delete:
|
||||
return http.MethodDelete, nil
|
||||
case *annotations.HttpRule_Patch:
|
||||
return http.MethodPatch, nil
|
||||
case *annotations.HttpRule_Put:
|
||||
return http.MethodPut, nil
|
||||
case *annotations.HttpRule_Custom:
|
||||
return v.Custom.GetKind(), nil
|
||||
default:
|
||||
return "", fmt.Errorf("http rule does not have an URL defined")
|
||||
}
|
||||
}
|
||||
397
internal/httprule/template.go
Normal file
397
internal/httprule/template.go
Normal file
@@ -0,0 +1,397 @@
|
||||
package httprule
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Template represents a http path template.
|
||||
//
|
||||
// Example: `/v1/{name=books/*}:publish`.
|
||||
type Template struct {
|
||||
Segments []Segment
|
||||
Verb string
|
||||
}
|
||||
|
||||
// Segment represents a single segment of a Template.
|
||||
type Segment struct {
|
||||
Kind SegmentKind
|
||||
Literal string
|
||||
Variable VariableSegment
|
||||
}
|
||||
|
||||
type SegmentKind int
|
||||
|
||||
const (
|
||||
SegmentKindLiteral SegmentKind = iota
|
||||
SegmentKindMatchSingle
|
||||
SegmentKindMatchMultiple
|
||||
SegmentKindVariable
|
||||
)
|
||||
|
||||
// VariableSegment represents a variable segment.
|
||||
type VariableSegment struct {
|
||||
FieldPath FieldPath
|
||||
Segments []Segment
|
||||
}
|
||||
|
||||
func ParseTemplate(s string) (Template, error) {
|
||||
p := &parser{
|
||||
content: s,
|
||||
}
|
||||
template, err := p.parse()
|
||||
if err != nil {
|
||||
return Template{}, err
|
||||
}
|
||||
if err := validate(template); err != nil {
|
||||
return Template{}, err
|
||||
}
|
||||
return template, nil
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
content string
|
||||
|
||||
// The next pos in content to read
|
||||
pos int
|
||||
// The currently read rune in content
|
||||
tok rune
|
||||
}
|
||||
|
||||
func (p *parser) parse() (Template, error) {
|
||||
// Grammar.
|
||||
// Template = "/" Segments [ Verb ] ;
|
||||
// Segments = Segment { "/" Segment } ;
|
||||
// Segment = "*" | "**" | LITERAL | Variable ;
|
||||
// Variable = "{" FieldPath [ "=" Segments ] "}" ;
|
||||
// FieldPath = IDENT { "." IDENT } ;
|
||||
// Verb = ":" LITERAL ;.
|
||||
p.next()
|
||||
if err := p.expect('/'); err != nil {
|
||||
return Template{}, err
|
||||
}
|
||||
segments, err := p.parseSegments()
|
||||
if err != nil {
|
||||
return Template{}, err
|
||||
}
|
||||
var verb string
|
||||
if p.tok == ':' {
|
||||
v, err := p.parseVerb()
|
||||
if err != nil {
|
||||
return Template{}, err
|
||||
}
|
||||
verb = v
|
||||
}
|
||||
if p.tok != -1 {
|
||||
return Template{}, fmt.Errorf("expected EOF, got %q", p.tok)
|
||||
}
|
||||
return Template{
|
||||
Segments: segments,
|
||||
Verb: verb,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseSegments() ([]Segment, error) {
|
||||
seg, err := p.parseSegment()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.tok == '/' {
|
||||
p.next()
|
||||
rest, err := p.parseSegments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]Segment{seg}, rest...), nil
|
||||
}
|
||||
return []Segment{seg}, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseSegment() (Segment, error) {
|
||||
switch {
|
||||
case p.tok == '*' && p.peek() == '*':
|
||||
return p.parseMatchMultipleSegment(), nil
|
||||
case p.tok == '*':
|
||||
return p.parseMatchSingleSegment(), nil
|
||||
case p.tok == '{':
|
||||
return p.parseVariableSegment()
|
||||
default:
|
||||
return p.parseLiteralSegment()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseMatchMultipleSegment() Segment {
|
||||
p.next()
|
||||
p.next()
|
||||
return Segment{
|
||||
Kind: SegmentKindMatchMultiple,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseMatchSingleSegment() Segment {
|
||||
p.next()
|
||||
return Segment{
|
||||
Kind: SegmentKindMatchSingle,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseLiteralSegment() (Segment, error) {
|
||||
lit, err := p.parseLiteral()
|
||||
if err != nil {
|
||||
return Segment{}, err
|
||||
}
|
||||
return Segment{
|
||||
Kind: SegmentKindLiteral,
|
||||
Literal: lit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseVariableSegment() (Segment, error) {
|
||||
if err := p.expect('{'); err != nil {
|
||||
return Segment{}, err
|
||||
}
|
||||
fieldPath, err := p.parseFieldPath()
|
||||
if err != nil {
|
||||
return Segment{}, err
|
||||
}
|
||||
segments := []Segment{
|
||||
{Kind: SegmentKindMatchSingle},
|
||||
}
|
||||
if p.tok == '=' {
|
||||
p.next()
|
||||
s, err := p.parseSegments()
|
||||
if err != nil {
|
||||
return Segment{}, err
|
||||
}
|
||||
segments = s
|
||||
}
|
||||
if err := p.expect('}'); err != nil {
|
||||
return Segment{}, err
|
||||
}
|
||||
return Segment{
|
||||
Kind: SegmentKindVariable,
|
||||
Variable: VariableSegment{
|
||||
FieldPath: fieldPath,
|
||||
Segments: segments,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseVerb() (string, error) {
|
||||
if err := p.expect(':'); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p.parseLiteral()
|
||||
}
|
||||
|
||||
func (p *parser) parseFieldPath() ([]string, error) {
|
||||
fp, err := p.parseIdent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.tok == '.' {
|
||||
p.next()
|
||||
rest, err := p.parseFieldPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]string{fp}, rest...), nil
|
||||
}
|
||||
return []string{fp}, nil
|
||||
}
|
||||
|
||||
// parseLiteral consumes input as long as next token(s) belongs to pchars, as defined in RFC3986.
|
||||
// Returns an error if not literal is found.
|
||||
//
|
||||
// https://www.ietf.org/rfc/rfc3986.txt, P.49
|
||||
//
|
||||
// pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
|
||||
// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
|
||||
// sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
|
||||
// / "*" / "+" / "," / ";" / "="
|
||||
// pct-encoded = "%" HEXDIG HEXDIG
|
||||
func (p *parser) parseLiteral() (string, error) {
|
||||
var literal []rune
|
||||
startPos := p.pos
|
||||
for {
|
||||
if isSingleCharPChar(p.tok) {
|
||||
literal = append(literal, p.tok)
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
if p.tok == '%' && isHexDigit(p.peekN(1)) && isHexDigit(p.peekN(2)) {
|
||||
literal = append(literal, p.tok)
|
||||
p.next()
|
||||
literal = append(literal, p.tok)
|
||||
p.next()
|
||||
literal = append(literal, p.tok)
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if len(literal) == 0 {
|
||||
return "", fmt.Errorf("expected literal at position %d, found %s", startPos-1, p.tokenString())
|
||||
}
|
||||
return string(literal), nil
|
||||
}
|
||||
|
||||
func (p *parser) parseIdent() (string, error) {
|
||||
var ident []rune
|
||||
startPos := p.pos
|
||||
for {
|
||||
if isAlpha(p.tok) || isDigit(p.tok) || p.tok == '_' {
|
||||
ident = append(ident, p.tok)
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if len(ident) == 0 {
|
||||
return "", fmt.Errorf("expected identifier at position %d, found %s", startPos-1, p.tokenString())
|
||||
}
|
||||
return string(ident), nil
|
||||
}
|
||||
|
||||
func (p *parser) next() {
|
||||
if p.pos < len(p.content) {
|
||||
p.tok = rune(p.content[p.pos])
|
||||
p.pos++
|
||||
} else {
|
||||
p.tok = -1
|
||||
p.pos = len(p.content)
|
||||
}
|
||||
}
|
||||
|
||||
func (p parser) tokenString() string {
|
||||
if p.tok == -1 {
|
||||
return "EOF"
|
||||
}
|
||||
return fmt.Sprintf("%q", p.tok)
|
||||
}
|
||||
|
||||
func (p *parser) peek() rune {
|
||||
return p.peekN(1)
|
||||
}
|
||||
|
||||
func (p *parser) peekN(n int) rune {
|
||||
if offset := p.pos + n - 1; offset < len(p.content) {
|
||||
return rune(p.content[offset])
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (p *parser) expect(r rune) error {
|
||||
if p.tok != r {
|
||||
return fmt.Errorf("expected token %q at position %d, found %s", r, p.pos, p.tokenString())
|
||||
}
|
||||
p.next()
|
||||
return nil
|
||||
}
|
||||
|
||||
// https://www.ietf.org/rfc/rfc3986.txt, P.49
|
||||
//
|
||||
// pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
|
||||
// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
|
||||
// sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
|
||||
// / "*" / "+" / "," / ";" / "="
|
||||
// pct-encoded = "%" HEXDIG HEXDIG
|
||||
func isSingleCharPChar(r rune) bool {
|
||||
if isAlpha(r) || isDigit(r) {
|
||||
return true
|
||||
}
|
||||
switch r {
|
||||
case '@', '-', '.', '_', '~', '!',
|
||||
'$', '&', '\'', '(', ')', '*', '+',
|
||||
',', ';', '=': // ':'
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isAlpha(r rune) bool {
|
||||
return ('A' <= r && r <= 'Z') || ('a' <= r && r <= 'z')
|
||||
}
|
||||
|
||||
func isDigit(r rune) bool {
|
||||
return '0' <= r && r <= '9'
|
||||
}
|
||||
|
||||
func isHexDigit(r rune) bool {
|
||||
switch {
|
||||
case '0' <= r && r <= '9':
|
||||
return true
|
||||
case 'A' <= r && r <= 'F':
|
||||
return true
|
||||
case 'a' <= r && r <= 'f':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validate validates parts of the template that are
|
||||
// allowed by the grammar, but disallowed in practice.
|
||||
//
|
||||
// - nested variable segments
|
||||
// - '**' for segments other than the last.
|
||||
func validate(t Template) error {
|
||||
// check for nested variable segments
|
||||
for _, s1 := range t.Segments {
|
||||
if s1.Kind != SegmentKindVariable {
|
||||
continue
|
||||
}
|
||||
for _, s2 := range s1.Variable.Segments {
|
||||
if s2.Kind == SegmentKindVariable {
|
||||
return fmt.Errorf("nested variable segment is not allowed")
|
||||
}
|
||||
}
|
||||
}
|
||||
// check for '**' that are not the last part of the template
|
||||
for i, s := range t.Segments {
|
||||
if i == len(t.Segments)-1 {
|
||||
continue
|
||||
}
|
||||
if s.Kind == SegmentKindMatchMultiple {
|
||||
return fmt.Errorf("'**' only allowed as last part of template")
|
||||
}
|
||||
if s.Kind == SegmentKindVariable {
|
||||
for _, s2 := range s.Variable.Segments {
|
||||
if s2.Kind == SegmentKindMatchMultiple {
|
||||
return fmt.Errorf("'**' only allowed as last part of template")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// check for variable where '**' is not last part
|
||||
for _, s := range t.Segments {
|
||||
if s.Kind != SegmentKindVariable {
|
||||
continue
|
||||
}
|
||||
for i, s2 := range s.Variable.Segments {
|
||||
if i == len(s.Variable.Segments)-1 {
|
||||
continue
|
||||
}
|
||||
if s2.Kind == SegmentKindMatchMultiple {
|
||||
return fmt.Errorf("'**' only allowed as the last part of the template")
|
||||
}
|
||||
}
|
||||
}
|
||||
// check for top level expansions
|
||||
for _, s := range t.Segments {
|
||||
if s.Kind == SegmentKindMatchSingle {
|
||||
return fmt.Errorf("'*' must only be used in variables")
|
||||
}
|
||||
if s.Kind == SegmentKindMatchMultiple {
|
||||
return fmt.Errorf("'**' must only be used in variables")
|
||||
}
|
||||
}
|
||||
// check for duplicate variable bindings
|
||||
seen := make(map[string]struct{})
|
||||
for _, s := range t.Segments {
|
||||
if s.Kind == SegmentKindVariable {
|
||||
field := s.Variable.FieldPath.String()
|
||||
if _, ok := seen[s.Variable.FieldPath.String()]; ok {
|
||||
return fmt.Errorf("variable '%s' bound multiple times", field)
|
||||
}
|
||||
seen[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
178
internal/httprule/template_test.go
Normal file
178
internal/httprule/template_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package httprule
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func Test_ParseTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tt := range []struct {
|
||||
input string
|
||||
path Template
|
||||
}{
|
||||
{
|
||||
input: "/v1/messages",
|
||||
path: Template{
|
||||
Segments: []Segment{
|
||||
{Kind: SegmentKindLiteral, Literal: "v1"},
|
||||
{Kind: SegmentKindLiteral, Literal: "messages"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "/v1/messages:peek",
|
||||
path: Template{
|
||||
Segments: []Segment{
|
||||
{Kind: SegmentKindLiteral, Literal: "v1"},
|
||||
{Kind: SegmentKindLiteral, Literal: "messages"},
|
||||
},
|
||||
Verb: "peek",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "/{id}",
|
||||
path: Template{
|
||||
Segments: []Segment{
|
||||
{
|
||||
Kind: SegmentKindVariable,
|
||||
Variable: VariableSegment{
|
||||
FieldPath: []string{"id"},
|
||||
Segments: []Segment{
|
||||
{Kind: SegmentKindMatchSingle},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "/{message.id}",
|
||||
path: Template{
|
||||
Segments: []Segment{
|
||||
{
|
||||
Kind: SegmentKindVariable,
|
||||
Variable: VariableSegment{
|
||||
FieldPath: []string{"message", "id"},
|
||||
Segments: []Segment{
|
||||
{Kind: SegmentKindMatchSingle},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "/{id=messages/*}",
|
||||
path: Template{
|
||||
Segments: []Segment{
|
||||
{
|
||||
Kind: SegmentKindVariable,
|
||||
Variable: VariableSegment{
|
||||
FieldPath: []string{"id"},
|
||||
Segments: []Segment{
|
||||
{Kind: SegmentKindLiteral, Literal: "messages"},
|
||||
{Kind: SegmentKindMatchSingle},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "/{id=messages/*/threads/*}",
|
||||
path: Template{
|
||||
Segments: []Segment{
|
||||
{
|
||||
Kind: SegmentKindVariable,
|
||||
Variable: VariableSegment{
|
||||
FieldPath: []string{"id"},
|
||||
Segments: []Segment{
|
||||
{Kind: SegmentKindLiteral, Literal: "messages"},
|
||||
{Kind: SegmentKindMatchSingle},
|
||||
{Kind: SegmentKindLiteral, Literal: "threads"},
|
||||
{Kind: SegmentKindMatchSingle},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "/{id=**}",
|
||||
path: Template{
|
||||
Segments: []Segment{
|
||||
{
|
||||
Kind: SegmentKindVariable,
|
||||
Variable: VariableSegment{
|
||||
FieldPath: []string{"id"},
|
||||
Segments: []Segment{
|
||||
{Kind: SegmentKindMatchMultiple},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "/v1/messages/{message}/threads/{thread}",
|
||||
path: Template{
|
||||
Segments: []Segment{
|
||||
{Kind: SegmentKindLiteral, Literal: "v1"},
|
||||
{Kind: SegmentKindLiteral, Literal: "messages"},
|
||||
{
|
||||
Kind: SegmentKindVariable,
|
||||
Variable: VariableSegment{
|
||||
FieldPath: []string{"message"},
|
||||
Segments: []Segment{
|
||||
{Kind: SegmentKindMatchSingle},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Kind: SegmentKindLiteral, Literal: "threads"},
|
||||
{
|
||||
Kind: SegmentKindVariable,
|
||||
Variable: VariableSegment{
|
||||
FieldPath: []string{"thread"},
|
||||
Segments: []Segment{
|
||||
{Kind: SegmentKindMatchSingle},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := ParseTemplate(tt.input)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, tt.path, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ParseTemplate_Invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tt := range []struct {
|
||||
template string
|
||||
expected string
|
||||
}{
|
||||
{template: "", expected: "expected token '/' at position 0, found EOF"},
|
||||
{template: "//", expected: "expected literal at position 1, found '/'"},
|
||||
{template: "/v1:", expected: "expected literal at position 3, found EOF"},
|
||||
{template: "/v1/:", expected: "expected literal at position 4, found ':'"},
|
||||
{template: "/{name=messages/{id}}", expected: "nested variable segment is not allowed"},
|
||||
{template: "/**/*", expected: "'**' only allowed as last part of template"},
|
||||
{template: "/v1/messages/*", expected: "'*' must only be used in variables"},
|
||||
{template: "/v1/{id}/{id}", expected: "variable 'id' bound multiple times"},
|
||||
} {
|
||||
t.Run(tt.template, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParseTemplate(tt.template)
|
||||
assert.Check(t, err != nil)
|
||||
assert.ErrorContains(t, err, tt.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
59
internal/plugin/commentgen.go
Normal file
59
internal/plugin/commentgen.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/bsm-tools/protoc-gen-ts/internal/codegen"
|
||||
"google.golang.org/genproto/googleapis/api/annotations"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
type commentGenerator struct {
|
||||
descriptor protoreflect.Descriptor
|
||||
}
|
||||
|
||||
func (c commentGenerator) generateLeading(f *codegen.File, indent int) {
|
||||
loc := c.descriptor.ParentFile().SourceLocations().ByDescriptor(c.descriptor)
|
||||
var comments string
|
||||
if loc.TrailingComments != "" {
|
||||
comments = comments + loc.TrailingComments
|
||||
}
|
||||
if loc.LeadingComments != "" {
|
||||
comments = comments + loc.LeadingComments
|
||||
}
|
||||
lines := strings.Split(comments, "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
f.P(t(indent), "/** "+strings.TrimSpace(line)+" */ ")
|
||||
}
|
||||
if field, ok := c.descriptor.(protoreflect.FieldDescriptor); ok {
|
||||
if behaviorComment := fieldBehaviorComment(field); len(behaviorComment) > 0 {
|
||||
f.P(t(indent), "/** "+behaviorComment+" */")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fieldBehaviorComment(field protoreflect.FieldDescriptor) string {
|
||||
behaviors := getFieldBehaviors(field)
|
||||
if len(behaviors) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
behaviorStrings := make([]string, 0, len(behaviors))
|
||||
for _, b := range behaviors {
|
||||
behaviorStrings = append(behaviorStrings, b.String())
|
||||
}
|
||||
return "Behaviors: " + strings.Join(behaviorStrings, ", ")
|
||||
}
|
||||
|
||||
func getFieldBehaviors(field protoreflect.FieldDescriptor) []annotations.FieldBehavior {
|
||||
if behaviors, ok := proto.GetExtension(
|
||||
field.Options(), annotations.E_FieldBehavior,
|
||||
).([]annotations.FieldBehavior); ok {
|
||||
return behaviors
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
internal/plugin/enumgen.go
Normal file
31
internal/plugin/enumgen.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.apinb.com/bsm-tools/protoc-gen-ts/internal/codegen"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
type enumGenerator struct {
|
||||
pkg protoreflect.FullName
|
||||
enum protoreflect.EnumDescriptor
|
||||
}
|
||||
|
||||
func (e enumGenerator) Generate(f *codegen.File) {
|
||||
commentGenerator{descriptor: e.enum}.generateLeading(f, 0)
|
||||
f.P("export type ", scopedDescriptorTypeName(e.pkg, e.enum), " =")
|
||||
if e.enum.Values().Len() == 1 {
|
||||
commentGenerator{descriptor: e.enum.Values().Get(0)}.generateLeading(f, 1)
|
||||
f.P(t(1), strconv.Quote(string(e.enum.Values().Get(0).Name())), ";")
|
||||
return
|
||||
}
|
||||
rangeEnumValues(e.enum, func(value protoreflect.EnumValueDescriptor, last bool) {
|
||||
commentGenerator{descriptor: value}.generateLeading(f, 1)
|
||||
if last {
|
||||
f.P(t(1), "| ", strconv.Quote(string(value.Name())), ";")
|
||||
} else {
|
||||
f.P(t(1), "| ", strconv.Quote(string(value.Name())))
|
||||
}
|
||||
})
|
||||
}
|
||||
52
internal/plugin/generate.go
Normal file
52
internal/plugin/generate.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/bsm-tools/protoc-gen-ts/internal/codegen"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protodesc"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/descriptorpb"
|
||||
"google.golang.org/protobuf/types/pluginpb"
|
||||
)
|
||||
|
||||
func Generate(request *pluginpb.CodeGeneratorRequest) (*pluginpb.CodeGeneratorResponse, error) {
|
||||
generate := make(map[string]struct{})
|
||||
registry, err := protodesc.NewFiles(&descriptorpb.FileDescriptorSet{
|
||||
File: request.GetProtoFile(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create proto registry: %w", err)
|
||||
}
|
||||
for _, f := range request.GetFileToGenerate() {
|
||||
generate[f] = struct{}{}
|
||||
}
|
||||
packaged := make(map[protoreflect.FullName][]protoreflect.FileDescriptor)
|
||||
for _, f := range request.GetFileToGenerate() {
|
||||
file, err := registry.FindFileByPath(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find file %s: %w", f, err)
|
||||
}
|
||||
packaged[file.Package()] = append(packaged[file.Package()], file)
|
||||
}
|
||||
|
||||
var res pluginpb.CodeGeneratorResponse
|
||||
for pkg, files := range packaged {
|
||||
var index codegen.File
|
||||
indexPathElems := append(strings.Split(string(pkg), "."), "index.ts")
|
||||
if err := (packageGenerator{pkg: pkg, files: files}).Generate(&index); err != nil {
|
||||
return nil, fmt.Errorf("generate package '%s': %w", pkg, err)
|
||||
}
|
||||
index.P()
|
||||
index.P("// @@protoc_insertion_point(typescript-http-eof)")
|
||||
res.File = append(res.File, &pluginpb.CodeGeneratorResponse_File{
|
||||
Name: proto.String(path.Join(indexPathElems...)),
|
||||
Content: proto.String(string(index.Content())),
|
||||
})
|
||||
}
|
||||
res.SupportedFeatures = proto.Uint64(uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL))
|
||||
return &res, nil
|
||||
}
|
||||
58
internal/plugin/helpers.go
Normal file
58
internal/plugin/helpers.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
func scopedDescriptorTypeName(pkg protoreflect.FullName, desc protoreflect.Descriptor) string {
|
||||
name := string(desc.Name())
|
||||
var prefix string
|
||||
if desc.Parent() != desc.ParentFile() {
|
||||
prefix = descriptorTypeName(desc.Parent()) + "_"
|
||||
}
|
||||
if desc.ParentFile().Package() != pkg {
|
||||
prefix = packagePrefix(desc.ParentFile().Package()) + prefix
|
||||
}
|
||||
return prefix + name
|
||||
}
|
||||
|
||||
func descriptorTypeName(desc protoreflect.Descriptor) string {
|
||||
name := string(desc.Name())
|
||||
var prefix string
|
||||
if desc.Parent() != desc.ParentFile() {
|
||||
prefix = descriptorTypeName(desc.Parent()) + "_"
|
||||
}
|
||||
return prefix + name
|
||||
}
|
||||
|
||||
func packagePrefix(pkg protoreflect.FullName) string {
|
||||
return strings.Join(strings.Split(string(pkg), "."), "") + "_"
|
||||
}
|
||||
|
||||
func rangeFields(message protoreflect.MessageDescriptor, f func(field protoreflect.FieldDescriptor)) {
|
||||
for i := 0; i < message.Fields().Len(); i++ {
|
||||
f(message.Fields().Get(i))
|
||||
}
|
||||
}
|
||||
|
||||
func rangeMethods(methods protoreflect.MethodDescriptors, f func(method protoreflect.MethodDescriptor)) {
|
||||
for i := 0; i < methods.Len(); i++ {
|
||||
f(methods.Get(i))
|
||||
}
|
||||
}
|
||||
|
||||
func rangeEnumValues(enum protoreflect.EnumDescriptor, f func(value protoreflect.EnumValueDescriptor, last bool)) {
|
||||
for i := 0; i < enum.Values().Len(); i++ {
|
||||
if i == enum.Values().Len()-1 {
|
||||
f(enum.Values().Get(i), true)
|
||||
} else {
|
||||
f(enum.Values().Get(i), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func t(n int) string {
|
||||
return strings.Repeat(" ", n)
|
||||
}
|
||||
48
internal/plugin/jsonleafwalk.go
Normal file
48
internal/plugin/jsonleafwalk.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"git.apinb.com/bsm-tools/protoc-gen-ts/internal/httprule"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
type jsonLeafWalkFunc func(path httprule.FieldPath, field protoreflect.FieldDescriptor)
|
||||
|
||||
func walkJSONLeafFields(message protoreflect.MessageDescriptor, f jsonLeafWalkFunc) {
|
||||
var w jsonWalker
|
||||
w.walkMessage(nil, message, f)
|
||||
}
|
||||
|
||||
type jsonWalker struct {
|
||||
seen map[protoreflect.FullName]struct{}
|
||||
}
|
||||
|
||||
func (w *jsonWalker) enter(name protoreflect.FullName) bool {
|
||||
if _, ok := w.seen[name]; ok {
|
||||
return false
|
||||
}
|
||||
if w.seen == nil {
|
||||
w.seen = make(map[protoreflect.FullName]struct{})
|
||||
}
|
||||
w.seen[name] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *jsonWalker) walkMessage(path httprule.FieldPath, message protoreflect.MessageDescriptor, f jsonLeafWalkFunc) {
|
||||
if w.enter(message.FullName()) {
|
||||
for i := 0; i < message.Fields().Len(); i++ {
|
||||
field := message.Fields().Get(i)
|
||||
p := append(httprule.FieldPath{}, path...)
|
||||
p = append(p, string(field.Name()))
|
||||
switch {
|
||||
case !field.IsMap() && field.Kind() == protoreflect.MessageKind:
|
||||
if IsWellKnownType(field.Message()) {
|
||||
f(p, field)
|
||||
} else {
|
||||
w.walkMessage(p, field.Message(), f)
|
||||
}
|
||||
default:
|
||||
f(p, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
internal/plugin/messagegen.go
Normal file
24
internal/plugin/messagegen.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"git.apinb.com/bsm-tools/protoc-gen-ts/internal/codegen"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
type messageGenerator struct {
|
||||
pkg protoreflect.FullName
|
||||
message protoreflect.MessageDescriptor
|
||||
}
|
||||
|
||||
func (m messageGenerator) Generate(f *codegen.File) {
|
||||
commentGenerator{descriptor: m.message}.generateLeading(f, 0)
|
||||
f.P("export type ", scopedDescriptorTypeName(m.pkg, m.message), " = {")
|
||||
rangeFields(m.message, func(field protoreflect.FieldDescriptor) {
|
||||
commentGenerator{descriptor: field}.generateLeading(f, 1)
|
||||
fieldType := typeFromField(m.pkg, field)
|
||||
f.P(t(1), field.JSONName(), "?: ", fieldType.Reference(), ";")
|
||||
})
|
||||
|
||||
f.P("};")
|
||||
f.P()
|
||||
}
|
||||
51
internal/plugin/packagegen.go
Normal file
51
internal/plugin/packagegen.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"git.apinb.com/bsm-tools/protoc-gen-ts/internal/codegen"
|
||||
"git.apinb.com/bsm-tools/protoc-gen-ts/internal/protowalk"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
type packageGenerator struct {
|
||||
pkg protoreflect.FullName
|
||||
files []protoreflect.FileDescriptor
|
||||
}
|
||||
|
||||
func (p packageGenerator) Generate(f *codegen.File) error {
|
||||
p.generateHeader(f)
|
||||
var seenService bool
|
||||
var walkErr error
|
||||
protowalk.WalkFiles(p.files, func(desc protoreflect.Descriptor) bool {
|
||||
if wkt, ok := WellKnownType(desc); ok {
|
||||
f.P(wkt.TypeDeclaration())
|
||||
return false
|
||||
}
|
||||
switch v := desc.(type) {
|
||||
case protoreflect.MessageDescriptor:
|
||||
if v.IsMapEntry() {
|
||||
return false
|
||||
}
|
||||
messageGenerator{pkg: p.pkg, message: v}.Generate(f)
|
||||
case protoreflect.EnumDescriptor:
|
||||
enumGenerator{pkg: p.pkg, enum: v}.Generate(f)
|
||||
case protoreflect.ServiceDescriptor:
|
||||
if err := (serviceGenerator{pkg: p.pkg, service: v, genHandler: !seenService}).Generate(f); err != nil {
|
||||
walkErr = err
|
||||
return false
|
||||
}
|
||||
seenService = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p packageGenerator) generateHeader(f *codegen.File) {
|
||||
f.P("// Code generated by protoc-gen-typescript-http. DO NOT EDIT.")
|
||||
f.P("/* eslint-disable camelcase */")
|
||||
f.P("// @ts-nocheck")
|
||||
f.P()
|
||||
}
|
||||
237
internal/plugin/servicegen.go
Normal file
237
internal/plugin/servicegen.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/bsm-tools/protoc-gen-ts/internal/codegen"
|
||||
"git.apinb.com/bsm-tools/protoc-gen-ts/internal/httprule"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
type serviceGenerator struct {
|
||||
pkg protoreflect.FullName
|
||||
genHandler bool
|
||||
service protoreflect.ServiceDescriptor
|
||||
}
|
||||
|
||||
func (s serviceGenerator) Generate(f *codegen.File) error {
|
||||
s.generateInterface(f)
|
||||
if s.genHandler {
|
||||
s.generateHandler(f)
|
||||
}
|
||||
return s.generateClient(f)
|
||||
}
|
||||
|
||||
func (s serviceGenerator) generateInterface(f *codegen.File) {
|
||||
commentGenerator{descriptor: s.service}.generateLeading(f, 0)
|
||||
f.P("export interface ", descriptorTypeName(s.service), " {")
|
||||
rangeMethods(s.service.Methods(), func(method protoreflect.MethodDescriptor) {
|
||||
if !supportedMethod(method) {
|
||||
return
|
||||
}
|
||||
commentGenerator{descriptor: method}.generateLeading(f, 1)
|
||||
input := typeFromMessage(s.pkg, method.Input())
|
||||
output := typeFromMessage(s.pkg, method.Output())
|
||||
f.P(t(1), method.Name(), "(request: ", input.Reference(), "): Promise<", output.Reference(), ">;")
|
||||
})
|
||||
f.P("}")
|
||||
f.P()
|
||||
}
|
||||
|
||||
func (s serviceGenerator) generateHandler(f *codegen.File) {
|
||||
f.P("type RequestType = {")
|
||||
f.P(t(1), "path: string;")
|
||||
f.P(t(1), "method: string;")
|
||||
f.P(t(1), "body: string | null;")
|
||||
f.P("};")
|
||||
f.P()
|
||||
f.P("type RequestHandler = (request: RequestType, meta: { service: string, method: string }) => Promise<unknown>;")
|
||||
f.P()
|
||||
}
|
||||
|
||||
func (s serviceGenerator) generateClient(f *codegen.File) error {
|
||||
f.P(
|
||||
"export function create",
|
||||
descriptorTypeName(s.service),
|
||||
"Client(",
|
||||
"\n",
|
||||
t(1),
|
||||
"handler: RequestHandler",
|
||||
"\n",
|
||||
"): ",
|
||||
descriptorTypeName(s.service),
|
||||
" {",
|
||||
)
|
||||
f.P(t(1), "return {")
|
||||
var methodErr error
|
||||
rangeMethods(s.service.Methods(), func(method protoreflect.MethodDescriptor) {
|
||||
if err := s.generateMethod(f, method); err != nil {
|
||||
methodErr = fmt.Errorf("generate method %s: %w", method.Name(), err)
|
||||
}
|
||||
})
|
||||
if methodErr != nil {
|
||||
return methodErr
|
||||
}
|
||||
f.P(t(1), "};")
|
||||
f.P("}")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s serviceGenerator) generateMethod(f *codegen.File, method protoreflect.MethodDescriptor) error {
|
||||
outputType := typeFromMessage(s.pkg, method.Output())
|
||||
r, ok := httprule.Get(method)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rule, err := httprule.ParseRule(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse http rule: %w", err)
|
||||
}
|
||||
f.P(t(2), method.Name(), "(request) { // eslint-disable-line @typescript-eslint/no-unused-vars")
|
||||
s.generateMethodPathValidation(f, method.Input(), rule)
|
||||
s.generateMethodPath(f, method.Input(), rule)
|
||||
s.generateMethodBody(f, method.Input(), rule)
|
||||
s.generateMethodQuery(f, method.Input(), rule)
|
||||
f.P(t(3), "let uri = path;")
|
||||
f.P(t(3), "if (queryParams.length > 0) {")
|
||||
f.P(t(4), "uri += `?${queryParams.join(\"&\")}`")
|
||||
f.P(t(3), "}")
|
||||
f.P(t(3), "return handler({")
|
||||
f.P(t(4), "path: uri,")
|
||||
f.P(t(4), "method: ", strconv.Quote(rule.Method), ",")
|
||||
f.P(t(4), "body,")
|
||||
f.P(t(3), "}, {")
|
||||
f.P(t(4), "service: \"", method.Parent().Name(), "\",")
|
||||
f.P(t(4), "method: \"", method.Name(), "\",")
|
||||
f.P(t(3), "}) as Promise<", outputType.Reference(), ">;")
|
||||
f.P(t(2), "},")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s serviceGenerator) generateMethodPathValidation(
|
||||
f *codegen.File,
|
||||
input protoreflect.MessageDescriptor,
|
||||
rule httprule.Rule,
|
||||
) {
|
||||
for _, seg := range rule.Template.Segments {
|
||||
if seg.Kind != httprule.SegmentKindVariable {
|
||||
continue
|
||||
}
|
||||
fp := seg.Variable.FieldPath
|
||||
nullPath := nullPropagationPath(fp, input)
|
||||
protoPath := strings.Join(fp, ".")
|
||||
errMsg := "missing required field request." + protoPath
|
||||
f.P(t(3), "if (!request.", nullPath, ") {")
|
||||
f.P(t(4), "throw new Error(", strconv.Quote(errMsg), ");")
|
||||
f.P(t(3), "}")
|
||||
}
|
||||
}
|
||||
|
||||
func (s serviceGenerator) generateMethodPath(
|
||||
f *codegen.File,
|
||||
input protoreflect.MessageDescriptor,
|
||||
rule httprule.Rule,
|
||||
) {
|
||||
pathParts := make([]string, 0, len(rule.Template.Segments))
|
||||
for _, seg := range rule.Template.Segments {
|
||||
switch seg.Kind {
|
||||
case httprule.SegmentKindVariable:
|
||||
fieldPath := jsonPath(seg.Variable.FieldPath, input)
|
||||
pathParts = append(pathParts, "${request."+fieldPath+"}")
|
||||
case httprule.SegmentKindLiteral:
|
||||
pathParts = append(pathParts, seg.Literal)
|
||||
case httprule.SegmentKindMatchSingle: // TODO: Double check this and following case
|
||||
pathParts = append(pathParts, "*")
|
||||
case httprule.SegmentKindMatchMultiple:
|
||||
pathParts = append(pathParts, "**")
|
||||
}
|
||||
}
|
||||
path := strings.Join(pathParts, "/")
|
||||
if rule.Template.Verb != "" {
|
||||
path += ":" + rule.Template.Verb
|
||||
}
|
||||
f.P(t(3), "const path = `", path, "`; // eslint-disable-line quotes")
|
||||
}
|
||||
|
||||
func (s serviceGenerator) generateMethodBody(
|
||||
f *codegen.File,
|
||||
input protoreflect.MessageDescriptor,
|
||||
rule httprule.Rule,
|
||||
) {
|
||||
switch {
|
||||
case rule.Body == "":
|
||||
f.P(t(3), "const body = null;")
|
||||
case rule.Body == "*":
|
||||
f.P(t(3), "const body = JSON.stringify(request);")
|
||||
default:
|
||||
nullPath := nullPropagationPath(httprule.FieldPath{rule.Body}, input)
|
||||
f.P(t(3), "const body = JSON.stringify(request?.", nullPath, " ?? {});")
|
||||
}
|
||||
}
|
||||
|
||||
func (s serviceGenerator) generateMethodQuery(
|
||||
f *codegen.File,
|
||||
input protoreflect.MessageDescriptor,
|
||||
rule httprule.Rule,
|
||||
) {
|
||||
f.P(t(3), "const queryParams: string[] = [];")
|
||||
// nothing in query
|
||||
if rule.Body == "*" {
|
||||
return
|
||||
}
|
||||
// fields covered by path
|
||||
pathCovered := make(map[string]struct{})
|
||||
for _, segment := range rule.Template.Segments {
|
||||
if segment.Kind != httprule.SegmentKindVariable {
|
||||
continue
|
||||
}
|
||||
pathCovered[segment.Variable.FieldPath.String()] = struct{}{}
|
||||
}
|
||||
walkJSONLeafFields(input, func(path httprule.FieldPath, field protoreflect.FieldDescriptor) {
|
||||
if _, ok := pathCovered[path.String()]; ok {
|
||||
return
|
||||
}
|
||||
if rule.Body != "" && path[0] == rule.Body {
|
||||
return
|
||||
}
|
||||
nullPath := nullPropagationPath(path, input)
|
||||
jp := jsonPath(path, input)
|
||||
f.P(t(3), "if (request.", nullPath, ") {")
|
||||
switch {
|
||||
case field.IsList():
|
||||
f.P(t(4), "request.", jp, ".forEach((x) => {")
|
||||
f.P(t(5), "queryParams.push(`", jp, "=${encodeURIComponent(x.toString())}`)")
|
||||
f.P(t(4), "})")
|
||||
default:
|
||||
f.P(t(4), "queryParams.push(`", jp, "=${encodeURIComponent(request.", jp, ".toString())}`)")
|
||||
}
|
||||
f.P(t(3), "}")
|
||||
})
|
||||
}
|
||||
|
||||
func supportedMethod(method protoreflect.MethodDescriptor) bool {
|
||||
_, ok := httprule.Get(method)
|
||||
return ok && !method.IsStreamingClient() && !method.IsStreamingServer()
|
||||
}
|
||||
|
||||
func jsonPath(path httprule.FieldPath, message protoreflect.MessageDescriptor) string {
|
||||
return strings.Join(jsonPathSegments(path, message), ".")
|
||||
}
|
||||
|
||||
func nullPropagationPath(path httprule.FieldPath, message protoreflect.MessageDescriptor) string {
|
||||
return strings.Join(jsonPathSegments(path, message), "?.")
|
||||
}
|
||||
|
||||
func jsonPathSegments(path httprule.FieldPath, message protoreflect.MessageDescriptor) []string {
|
||||
segs := make([]string, len(path))
|
||||
for i, p := range path {
|
||||
field := message.Fields().ByName(protoreflect.Name(p))
|
||||
segs[i] = field.JSONName()
|
||||
if i < len(path) {
|
||||
message = field.Message()
|
||||
}
|
||||
}
|
||||
return segs
|
||||
}
|
||||
82
internal/plugin/type.go
Normal file
82
internal/plugin/type.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package plugin
|
||||
|
||||
import "google.golang.org/protobuf/reflect/protoreflect"
|
||||
|
||||
type Type struct {
|
||||
IsNamed bool
|
||||
Name string
|
||||
|
||||
IsList bool
|
||||
IsMap bool
|
||||
Underlying *Type
|
||||
}
|
||||
|
||||
func (t Type) Reference() string {
|
||||
switch {
|
||||
case t.IsMap:
|
||||
return "{ [key: string]: " + t.Underlying.Reference() + " }"
|
||||
case t.IsList:
|
||||
return t.Underlying.Reference() + "[]"
|
||||
default:
|
||||
return t.Name
|
||||
}
|
||||
}
|
||||
|
||||
func typeFromField(pkg protoreflect.FullName, field protoreflect.FieldDescriptor) Type {
|
||||
switch {
|
||||
case field.IsMap():
|
||||
underlying := namedTypeFromField(pkg, field.MapValue())
|
||||
return Type{
|
||||
IsMap: true,
|
||||
Underlying: &underlying,
|
||||
}
|
||||
case field.IsList():
|
||||
underlying := namedTypeFromField(pkg, field)
|
||||
return Type{
|
||||
IsList: true,
|
||||
Underlying: &underlying,
|
||||
}
|
||||
default:
|
||||
return namedTypeFromField(pkg, field)
|
||||
}
|
||||
}
|
||||
|
||||
func namedTypeFromField(pkg protoreflect.FullName, field protoreflect.FieldDescriptor) Type {
|
||||
switch field.Kind() {
|
||||
case protoreflect.StringKind, protoreflect.BytesKind:
|
||||
return Type{IsNamed: true, Name: "string"}
|
||||
case protoreflect.BoolKind:
|
||||
return Type{IsNamed: true, Name: "boolean"}
|
||||
case
|
||||
protoreflect.Int32Kind,
|
||||
protoreflect.Int64Kind,
|
||||
protoreflect.Uint32Kind,
|
||||
protoreflect.Uint64Kind,
|
||||
protoreflect.DoubleKind,
|
||||
protoreflect.Fixed32Kind,
|
||||
protoreflect.Fixed64Kind,
|
||||
protoreflect.Sfixed32Kind,
|
||||
protoreflect.Sfixed64Kind,
|
||||
protoreflect.Sint32Kind,
|
||||
protoreflect.Sint64Kind,
|
||||
protoreflect.FloatKind:
|
||||
return Type{IsNamed: true, Name: "number"}
|
||||
case protoreflect.MessageKind:
|
||||
return typeFromMessage(pkg, field.Message())
|
||||
case protoreflect.EnumKind:
|
||||
desc := field.Enum()
|
||||
if wkt, ok := WellKnownType(field.Enum()); ok {
|
||||
return Type{IsNamed: true, Name: wkt.Name()}
|
||||
}
|
||||
return Type{IsNamed: true, Name: scopedDescriptorTypeName(pkg, desc)}
|
||||
default:
|
||||
return Type{IsNamed: true, Name: "unknown"}
|
||||
}
|
||||
}
|
||||
|
||||
func typeFromMessage(pkg protoreflect.FullName, message protoreflect.MessageDescriptor) Type {
|
||||
if wkt, ok := WellKnownType(message); ok {
|
||||
return Type{IsNamed: true, Name: wkt.Name()}
|
||||
}
|
||||
return Type{IsNamed: true, Name: scopedDescriptorTypeName(pkg, message)}
|
||||
}
|
||||
157
internal/plugin/wellknown.go
Normal file
157
internal/plugin/wellknown.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
const (
|
||||
wellKnownPrefix = "google.protobuf."
|
||||
)
|
||||
|
||||
type WellKnown string
|
||||
|
||||
// https://developers.google.com/protocol-buffers/docs/reference/google.protobuf
|
||||
const (
|
||||
WellKnownAny WellKnown = "google.protobuf.Any"
|
||||
WellKnownDuration WellKnown = "google.protobuf.Duration"
|
||||
WellKnownEmpty WellKnown = "google.protobuf.Empty"
|
||||
WellKnownFieldMask WellKnown = "google.protobuf.FieldMask"
|
||||
WellKnownStruct WellKnown = "google.protobuf.Struct"
|
||||
WellKnownTimestamp WellKnown = "google.protobuf.Timestamp"
|
||||
|
||||
// Wrapper types.
|
||||
WellKnownFloatValue WellKnown = "google.protobuf.FloatValue"
|
||||
WellKnownInt64Value WellKnown = "google.protobuf.Int64Value"
|
||||
WellKnownInt32Value WellKnown = "google.protobuf.Int32Value"
|
||||
WellKnownUInt64Value WellKnown = "google.protobuf.UInt64Value"
|
||||
WellKnownUInt32Value WellKnown = "google.protobuf.UInt32Value"
|
||||
WellKnownBytesValue WellKnown = "google.protobuf.BytesValue"
|
||||
WellKnownDoubleValue WellKnown = "google.protobuf.DoubleValue"
|
||||
WellKnownBoolValue WellKnown = "google.protobuf.BoolValue"
|
||||
WellKnownStringValue WellKnown = "google.protobuf.StringValue"
|
||||
|
||||
// Descriptor types.
|
||||
WellKnownValue WellKnown = "google.protobuf.Value"
|
||||
WellKnownNullValue WellKnown = "google.protobuf.NullValue"
|
||||
WellKnownListValue WellKnown = "google.protobuf.ListValue"
|
||||
)
|
||||
|
||||
func IsWellKnownType(desc protoreflect.Descriptor) bool {
|
||||
switch desc.(type) {
|
||||
case protoreflect.MessageDescriptor, protoreflect.EnumDescriptor:
|
||||
return strings.HasPrefix(string(desc.FullName()), wellKnownPrefix)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func WellKnownType(desc protoreflect.Descriptor) (WellKnown, bool) {
|
||||
if !IsWellKnownType(desc) {
|
||||
return "", false
|
||||
}
|
||||
return WellKnown(desc.FullName()), true
|
||||
}
|
||||
|
||||
func (wkt WellKnown) Name() string {
|
||||
return "wellKnown" + strings.TrimPrefix(string(wkt), wellKnownPrefix)
|
||||
}
|
||||
|
||||
func (wkt WellKnown) TypeDeclaration() string {
|
||||
var w writer
|
||||
switch wkt {
|
||||
case WellKnownAny:
|
||||
w.P("// If the Any contains a value that has a special JSON mapping,")
|
||||
w.P("// it will be converted as follows:")
|
||||
w.P("// {\"@type\": xxx, \"value\": yyy}.")
|
||||
w.P("// Otherwise, the value will be converted into a JSON object,")
|
||||
w.P("// and the \"@type\" field will be inserted to indicate the actual data type.")
|
||||
w.P("interface ", wkt.Name(), " {")
|
||||
w.P(" ", "\"@type\": string;")
|
||||
w.P(" [key: string]: unknown;")
|
||||
w.P("}")
|
||||
case WellKnownDuration:
|
||||
w.P("// Generated output always contains 0, 3, 6, or 9 fractional digits,")
|
||||
w.P("// depending on required precision, followed by the suffix \"s\".")
|
||||
w.P("// Accepted are any fractional digits (also none) as long as they fit")
|
||||
w.P("// into nano-seconds precision and the suffix \"s\" is required.")
|
||||
w.P("type ", wkt.Name(), " = string;")
|
||||
case WellKnownEmpty:
|
||||
w.P("// An empty JSON object")
|
||||
w.P("type ", wkt.Name(), " = Record<never, never>;")
|
||||
case WellKnownTimestamp:
|
||||
w.P("// Encoded using RFC 3339, where generated output will always be Z-normalized")
|
||||
w.P("// and uses 0, 3, 6 or 9 fractional digits.")
|
||||
w.P("// Offsets other than \"Z\" are also accepted.")
|
||||
w.P("type ", wkt.Name(), " = string;")
|
||||
case WellKnownFieldMask:
|
||||
w.P("// In JSON, a field mask is encoded as a single string where paths are")
|
||||
w.P("// separated by a comma. Fields name in each path are converted")
|
||||
w.P("// to/from lower-camel naming conventions.")
|
||||
w.P("// As an example, consider the following message declarations:")
|
||||
w.P("//")
|
||||
w.P("// message Profile {")
|
||||
w.P("// User user = 1;")
|
||||
w.P("// Photo photo = 2;")
|
||||
w.P("// }")
|
||||
w.P("// message User {")
|
||||
w.P("// string display_name = 1;")
|
||||
w.P("// string address = 2;")
|
||||
w.P("// }")
|
||||
w.P("//")
|
||||
w.P("// In proto a field mask for `Profile` may look as such:")
|
||||
w.P("//")
|
||||
w.P("// mask {")
|
||||
w.P("// paths: \"user.display_name\"")
|
||||
w.P("// paths: \"photo\"")
|
||||
w.P("// }")
|
||||
w.P("//")
|
||||
w.P("// In JSON, the same mask is represented as below:")
|
||||
w.P("//")
|
||||
w.P("// {")
|
||||
w.P("// mask: \"user.displayName,photo\"")
|
||||
w.P("// }")
|
||||
w.P("type ", wkt.Name(), " = string;")
|
||||
case WellKnownFloatValue,
|
||||
WellKnownDoubleValue,
|
||||
WellKnownInt64Value,
|
||||
WellKnownInt32Value,
|
||||
WellKnownUInt64Value,
|
||||
WellKnownUInt32Value:
|
||||
w.P("type ", wkt.Name(), " = number | null;")
|
||||
case WellKnownBytesValue, WellKnownStringValue:
|
||||
w.P("type ", wkt.Name(), " = string | null;")
|
||||
case WellKnownBoolValue:
|
||||
w.P("type ", wkt.Name(), " = boolean | null;")
|
||||
case WellKnownStruct:
|
||||
w.P("// Any JSON value.")
|
||||
w.P("type ", wkt.Name(), " = Record<string, unknown>;")
|
||||
case WellKnownValue:
|
||||
w.P("type ", wkt.Name(), " = unknown;")
|
||||
case WellKnownNullValue:
|
||||
w.P("type ", wkt.Name(), " = null;")
|
||||
case WellKnownListValue:
|
||||
w.P("type ", wkt.Name(), " = ", WellKnownValue.Name(), "[];")
|
||||
default:
|
||||
w.P("// No mapping for this well known type is generated, yet.")
|
||||
w.P("type ", wkt.Name(), " = unknown;")
|
||||
}
|
||||
return w.String()
|
||||
}
|
||||
|
||||
type writer struct {
|
||||
b strings.Builder
|
||||
}
|
||||
|
||||
func (w *writer) P(ss ...string) {
|
||||
for _, s := range ss {
|
||||
// strings.Builder never returns an error, so safe to ignore
|
||||
_, _ = w.b.WriteString(s)
|
||||
}
|
||||
_, _ = w.b.WriteString("\n")
|
||||
}
|
||||
|
||||
func (w *writer) String() string {
|
||||
return w.b.String()
|
||||
}
|
||||
128
internal/protowalk/walk.go
Normal file
128
internal/protowalk/walk.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package protowalk
|
||||
|
||||
import (
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
type WalkFunc func(desc protoreflect.Descriptor) bool
|
||||
|
||||
func WalkFiles(files []protoreflect.FileDescriptor, f WalkFunc) {
|
||||
var w walker
|
||||
w.walkFiles(files, f)
|
||||
}
|
||||
|
||||
type walker struct {
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
func (w *walker) enter(name string) bool {
|
||||
if _, ok := w.seen[name]; ok {
|
||||
return false
|
||||
}
|
||||
if w.seen == nil {
|
||||
w.seen = make(map[string]struct{})
|
||||
}
|
||||
w.seen[name] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *walker) walkFiles(files []protoreflect.FileDescriptor, f WalkFunc) {
|
||||
for _, file := range files {
|
||||
w.walkFile(file, f)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkFile(file protoreflect.FileDescriptor, f WalkFunc) {
|
||||
if w.enter(file.Path()) {
|
||||
if !f(file) {
|
||||
return
|
||||
}
|
||||
w.walkEnums(file.Enums(), f)
|
||||
w.walkMessages(file.Messages(), f)
|
||||
w.walkServices(file.Services(), f)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkEnums(enums protoreflect.EnumDescriptors, f WalkFunc) {
|
||||
for i := 0; i < enums.Len(); i++ {
|
||||
w.walkEnum(enums.Get(i), f)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkEnum(enum protoreflect.EnumDescriptor, f WalkFunc) {
|
||||
if w.enter(string(enum.FullName())) {
|
||||
f(enum)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkMessages(messages protoreflect.MessageDescriptors, f WalkFunc) {
|
||||
for i := 0; i < messages.Len(); i++ {
|
||||
w.walkMessage(messages.Get(i), f)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkMessage(message protoreflect.MessageDescriptor, f WalkFunc) {
|
||||
if w.enter(string(message.FullName())) {
|
||||
if !f(message) {
|
||||
return
|
||||
}
|
||||
w.walkFields(message.Fields(), f)
|
||||
w.walkMessages(message.Messages(), f)
|
||||
w.walkEnums(message.Enums(), f)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkFields(fields protoreflect.FieldDescriptors, f WalkFunc) {
|
||||
for i := 0; i < fields.Len(); i++ {
|
||||
w.walkField(fields.Get(i), f)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkField(field protoreflect.FieldDescriptor, f WalkFunc) {
|
||||
if w.enter(string(field.FullName())) {
|
||||
if !f(field) {
|
||||
return
|
||||
}
|
||||
if field.IsMap() {
|
||||
w.walkField(field.MapKey(), f)
|
||||
w.walkField(field.MapValue(), f)
|
||||
}
|
||||
if field.Message() != nil {
|
||||
w.walkMessage(field.Message(), f)
|
||||
}
|
||||
if field.Enum() != nil {
|
||||
w.walkEnum(field.Enum(), f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkServices(services protoreflect.ServiceDescriptors, f WalkFunc) {
|
||||
for i := 0; i < services.Len(); i++ {
|
||||
w.walkService(services.Get(i), f)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkService(service protoreflect.ServiceDescriptor, f WalkFunc) {
|
||||
if w.enter(string(service.FullName())) {
|
||||
if !f(service) {
|
||||
return
|
||||
}
|
||||
w.walkMethods(service.Methods(), f)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkMethods(methods protoreflect.MethodDescriptors, f WalkFunc) {
|
||||
for i := 0; i < methods.Len(); i++ {
|
||||
w.walkMethod(methods.Get(i), f)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *walker) walkMethod(method protoreflect.MethodDescriptor, f WalkFunc) {
|
||||
if w.enter(string(method.FullName())) {
|
||||
if !f(method) {
|
||||
return
|
||||
}
|
||||
w.walkMessage(method.Input(), f)
|
||||
w.walkMessage(method.Output(), f)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user