integrate request builder into HTTP client for googleapis support (#157)
This commit is contained in:
313
builder/path_template.go
Normal file
313
builder/path_template.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
// -------------------------- Path template representation -------------------------
|
||||
|
||||
// pathSegment is a helper interface for elements of a path.
|
||||
type pathSegment interface {
|
||||
isSegment() bool
|
||||
}
|
||||
|
||||
// pathTemplate represents a parsed URL path template.
|
||||
type pathTemplate struct {
|
||||
// literalPrefix is the fixed part of the path before the first {var},
|
||||
// e.g. "/v1/users/" for "/v1/users/{user_id}/orders:get".
|
||||
// It is removed from segments, so segments contain only the remaining path literals and variables.
|
||||
literalPrefix string
|
||||
|
||||
// segments is a sequence of pathLiteral or pathVar representing the rest of the path after literalPrefix.
|
||||
segments []pathSegment
|
||||
|
||||
// customVerb is an optional ":verb" suffix, e.g. ":get".
|
||||
customVerb string
|
||||
}
|
||||
|
||||
// pathLiteral represents a fixed literal segment in a path template, e.g., "/v1/users/".
|
||||
type pathLiteral struct {
|
||||
text string
|
||||
}
|
||||
|
||||
func (p pathLiteral) isSegment() bool { return true }
|
||||
|
||||
// pathVar represents a variable segment in a path template, e.g., "{user.id}".
|
||||
type pathVar struct {
|
||||
// fieldPath is the dotted path to the field in the struct, e.g., "user.id".
|
||||
fieldPath string
|
||||
|
||||
// pattern is the optional pattern after '=', e.g., "*" or "**/orders".
|
||||
// It specifies how the variable can match parts of the URL path.
|
||||
pattern string
|
||||
|
||||
// multiSegment is true if the pattern can match multiple path segments
|
||||
// (contains '/' or "**").
|
||||
multiSegment bool
|
||||
}
|
||||
|
||||
func (p pathVar) isSegment() bool { return true }
|
||||
|
||||
// ----------------------------- Path template parsing -----------------------------
|
||||
|
||||
// parsePathTemplate parses a URL path template into a pathTemplate.
|
||||
// It extracts:
|
||||
// 1. literalPrefix — fixed part before the first variable,
|
||||
// 2. segments — sequence of pathLiteral and pathVar,
|
||||
// 3. customVerb — optional ":verb" suffix.
|
||||
//
|
||||
// Complexity: time O(n), memory O(n).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// input: "/v1/users/{user_id}/orders:get"
|
||||
// output: pathTemplate{
|
||||
// literalPrefix: "/v1/users/",
|
||||
// segments: [{user_id}, "/orders"],
|
||||
// customVerb: ":get",
|
||||
// }
|
||||
func parsePathTemplate(input string) (*pathTemplate, error) {
|
||||
// Step 1: extract custom verb after the last colon, e.g. ":get"
|
||||
var customVerb string
|
||||
if i := strings.LastIndex(input, ":"); i >= 0 && i > strings.LastIndex(input, "/") {
|
||||
customVerb = input[i:]
|
||||
input = input[:i]
|
||||
}
|
||||
|
||||
var (
|
||||
segments []pathSegment
|
||||
buf strings.Builder
|
||||
)
|
||||
|
||||
// Step 2: iterate over the input and split into segments
|
||||
for i := 0; i < len(input); {
|
||||
if input[i] != '{' {
|
||||
buf.WriteByte(input[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Add literal before '{' if any
|
||||
if buf.Len() > 0 {
|
||||
segments = append(segments, pathLiteral{text: buf.String()})
|
||||
buf.Reset()
|
||||
}
|
||||
|
||||
// Find closing '}'
|
||||
start := i + 1
|
||||
offset := strings.IndexByte(input[start:], '}') // relative offset from start
|
||||
if offset < 0 {
|
||||
return nil, fmt.Errorf("unclosed '{' in path: %s", input)
|
||||
}
|
||||
end := start + offset
|
||||
|
||||
token := input[start:end]
|
||||
i = end + 1 // jump past '}'
|
||||
|
||||
// Split field path and optional pattern
|
||||
var fieldPath, pattern string
|
||||
if k := strings.IndexByte(token, '='); k >= 0 {
|
||||
fieldPath = strings.TrimSpace(token[:k])
|
||||
pattern = strings.TrimSpace(token[k+1:])
|
||||
} else {
|
||||
fieldPath = strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
if fieldPath == "" {
|
||||
return nil, fmt.Errorf("empty variable in path: %s", input)
|
||||
}
|
||||
|
||||
pv := pathVar{
|
||||
fieldPath: fieldPath,
|
||||
pattern: pattern,
|
||||
multiSegment: isMultiSegmentPattern(pattern),
|
||||
}
|
||||
segments = append(segments, pv)
|
||||
}
|
||||
|
||||
// Step 3: add any trailing literal after last '}'
|
||||
if buf.Len() > 0 {
|
||||
segments = append(segments, pathLiteral{text: buf.String()})
|
||||
}
|
||||
|
||||
// Step 4: extract literalPrefix if the first segment is a literal
|
||||
var literalPrefix string
|
||||
if len(segments) > 0 {
|
||||
if pl, ok := segments[0].(pathLiteral); ok {
|
||||
literalPrefix = pl.text
|
||||
segments = segments[1:] // remove from segments to avoid duplication
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: return fully parsed pathTemplate
|
||||
return &pathTemplate{
|
||||
literalPrefix: literalPrefix,
|
||||
segments: segments,
|
||||
customVerb: customVerb,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isMultiSegmentPattern returns true if pattern can match multiple path segments (contains '/' or '**').
|
||||
// Examples:
|
||||
// | Pattern | Result | Usecase |
|
||||
// |----------------|--------|------------------------------------------|
|
||||
// | "" | false | {var} => single segment |
|
||||
// | "*" | false | {var=*} => single segment |
|
||||
// | "**" | true | {var=**} => multiple segments |
|
||||
// | "foo/*" | true | {var=foo/*} => multiple segments |
|
||||
// | "foo/**" | true | {var=foo/**} => multiple segments |
|
||||
// | "users/*/orders"| true | {users/*/orders} => multiple segments |
|
||||
func isMultiSegmentPattern(pattern string) bool {
|
||||
if pattern == "" {
|
||||
return false
|
||||
}
|
||||
if pattern == singleWildcard {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(pattern, "/") || strings.Contains(pattern, doubleWildcard)
|
||||
}
|
||||
|
||||
// ----------------------------- Path template resolving -----------------------------
|
||||
|
||||
// resolvePathPlaceholders expands placeholders in a path template using values from proto.Message.
|
||||
// Placeholders must be bound to non-repeated scalar fields (not lists, maps, or messages).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// tmpl: "/v1/users/{user_id}/orders:get"
|
||||
// msg: &pb.Message{UserId: 12345}
|
||||
//
|
||||
// path: "/v1/users/12345/orders:get"
|
||||
// usedFields: {"user_id"}
|
||||
func resolvePathPlaceholders(tmpl *pathTemplate, msg proto.Message) (path string, usedFields *usedFields, err error) {
|
||||
usedFields = newUsedFields()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(tmpl.literalPrefix)
|
||||
|
||||
msgReflect := msg.ProtoReflect()
|
||||
|
||||
for _, segment := range tmpl.segments {
|
||||
switch s := segment.(type) {
|
||||
case pathLiteral:
|
||||
sb.WriteString(s.text)
|
||||
|
||||
case pathVar:
|
||||
val, fd, ok := findFieldByPath(msgReflect, s.fieldPath)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("path placeholder %s not found", s.fieldPath)
|
||||
}
|
||||
if isZeroValue(val, fd) {
|
||||
// it's the only case that allows zero-value matches.
|
||||
if s.pattern == doubleWildcard {
|
||||
usedFields.add(s.fieldPath)
|
||||
continue
|
||||
}
|
||||
return "", nil, fmt.Errorf("path placeholder %s has zero value", s.fieldPath)
|
||||
}
|
||||
|
||||
// must be scalar (non-repeated, non-map, non-message)
|
||||
if fd.IsList() || fd.IsMap() || fd.Kind() == protoreflect.MessageKind {
|
||||
return "", nil, fmt.Errorf("path placeholder %s must be scalar", s.fieldPath)
|
||||
}
|
||||
|
||||
usedFields.add(s.fieldPath)
|
||||
|
||||
var strVal string
|
||||
strVal, err = stringifyValue(val, fd)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("stringify placeholder %s: %w", s.fieldPath, err)
|
||||
}
|
||||
|
||||
if err = validatePattern(s.pattern, strVal); err != nil {
|
||||
return "", nil, fmt.Errorf("validate pattern, %s:%s: %w", s.fieldPath, strVal, err)
|
||||
}
|
||||
|
||||
parts := strings.Split(strVal, "/")
|
||||
for i := range parts {
|
||||
parts[i] = url.PathEscape(parts[i])
|
||||
}
|
||||
sb.WriteString(strings.Join(parts, "/"))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(tmpl.customVerb)
|
||||
return sb.String(), usedFields, nil
|
||||
}
|
||||
|
||||
// validatePattern checks whether input matches the given path pattern.
|
||||
//
|
||||
// Rules:
|
||||
// - "" or "*" => exactly one segment, no "/" allowed
|
||||
// - "**" => zero or more segments (may include "/")
|
||||
// - composite patterns like "*/orders/*" must match literally
|
||||
//
|
||||
// Example for composite pattern case:
|
||||
//
|
||||
// pattern: "*/orders/*"
|
||||
// input: "42/orders/123"
|
||||
//
|
||||
// patternSegments = ["*", "orders", "*"]
|
||||
// valueParts = ["42", "orders", "123"]
|
||||
//
|
||||
// Match:
|
||||
// "*" -> "42"
|
||||
// "orders" -> "orders"
|
||||
// "*" -> "123"
|
||||
func validatePattern(pattern, input string) error {
|
||||
var (
|
||||
parts = strings.Split(input, "/")
|
||||
lenParts = len(parts)
|
||||
)
|
||||
|
||||
if pattern == "" || pattern == singleWildcard {
|
||||
if lenParts != 1 {
|
||||
return errors.New("must be a single path segment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if pattern == doubleWildcard {
|
||||
if lenParts < 1 {
|
||||
return errors.New("must contain at least one segment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
patternSegments = strings.Split(pattern, "/")
|
||||
patternIndex int
|
||||
)
|
||||
|
||||
for i := 0; i < len(patternSegments); i++ {
|
||||
switch patternSegments[i] {
|
||||
case singleWildcard:
|
||||
if patternIndex >= lenParts || parts[patternIndex] == "" {
|
||||
return fmt.Errorf("segment %d must not be empty", patternIndex)
|
||||
}
|
||||
patternIndex++
|
||||
case doubleWildcard:
|
||||
if patternIndex >= lenParts {
|
||||
return fmt.Errorf("must contain at least one segment at position %d", patternIndex)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
if patternIndex >= lenParts || parts[patternIndex] != patternSegments[i] {
|
||||
return fmt.Errorf("expected literal %s at position %d", patternSegments[i], patternIndex)
|
||||
}
|
||||
patternIndex++
|
||||
}
|
||||
}
|
||||
|
||||
if patternIndex != lenParts {
|
||||
return errors.New("extra segments in value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Reference in New Issue
Block a user