181 lines
		
	
	
		
			4.5 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			181 lines
		
	
	
		
			4.5 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Package builder implements google.api.http-style request building (gRPC JSON transcoding)
 | |
| // for HTTP requests, closely following the google.api.http spec.
 | |
| // See full spec for details: https://github.com/googleapis/googleapis/blob/master/google/api/http.proto
 | |
| package builder
 | |
| 
 | |
| import (
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"net/http"
 | |
| 	"net/url"
 | |
| 	"strings"
 | |
| 
 | |
| 	"google.golang.org/protobuf/proto"
 | |
| 	"google.golang.org/protobuf/reflect/protoreflect"
 | |
| )
 | |
| 
 | |
| type RequestBuilder struct {
 | |
| 	path       string        // e.g. "/v1/{name=projects/*/topics/*}:publish" or "/users/{user.id}"
 | |
| 	method     string        // GET, POST, PATCH, etc. (not used in mapping rules, but convenient for callers)
 | |
| 	bodyOption bodyOption    // "", "*", or top-level field name
 | |
| 	msg        proto.Message // request struct
 | |
| }
 | |
| 
 | |
| func NewRequestBuilder(
 | |
| 	path string,
 | |
| 	method string,
 | |
| 	bodyOpt string,
 | |
| 	msg proto.Message,
 | |
| ) (
 | |
| 	*RequestBuilder,
 | |
| 	error,
 | |
| ) {
 | |
| 	rb := &RequestBuilder{
 | |
| 		path:       path,
 | |
| 		method:     method,
 | |
| 		bodyOption: bodyOption(bodyOpt),
 | |
| 		msg:        msg,
 | |
| 	}
 | |
| 
 | |
| 	if err := rb.validate(); err != nil {
 | |
| 		return nil, fmt.Errorf("validate: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return rb, nil
 | |
| }
 | |
| 
 | |
| // Build applies mapping rules and returns:
 | |
| //
 | |
| //	resolvedPath — path with placeholders substituted and query appended
 | |
| //	newMsg       — same concrete type as input, filtered to contain only the body fields
 | |
| //	err          — if mapping/validation failed
 | |
| func (b *RequestBuilder) Build() (resolvedPath string, newMsg proto.Message, err error) {
 | |
| 	tmpl, isCached := getCachedPathTemplate(b.path)
 | |
| 	if !isCached {
 | |
| 		tmpl, err = parsePathTemplate(b.path)
 | |
| 		if err != nil {
 | |
| 			return "", nil, fmt.Errorf("parse path template: %w", err)
 | |
| 		}
 | |
| 		setPathTemplateCache(b.path, tmpl)
 | |
| 	}
 | |
| 
 | |
| 	var usedFieldsPath *usedFields
 | |
| 	resolvedPath, usedFieldsPath, err = resolvePathPlaceholders(tmpl, b.msg)
 | |
| 	if err != nil {
 | |
| 		return "", nil, fmt.Errorf("resolve path placeholders: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// if all set fields are already used in path, no need to process query/body
 | |
| 	if allFieldsUsed(b.msg, usedFieldsPath) {
 | |
| 		return resolvedPath, initZeroMsg(b.msg), nil
 | |
| 	}
 | |
| 
 | |
| 	switch {
 | |
| 	case b.bodyOption.isWithoutBody():
 | |
| 		var query url.Values
 | |
| 		query, err = buildQuery(b.msg, usedFieldsPath, "")
 | |
| 		if err != nil {
 | |
| 			return "", nil, fmt.Errorf("build query: %w", err)
 | |
| 		}
 | |
| 
 | |
| 		return resolvedPath + encodeQuery(query), initZeroMsg(b.msg), nil
 | |
| 
 | |
| 	case b.bodyOption.isSingleField():
 | |
| 		fieldBody := b.bodyOption.String()
 | |
| 
 | |
| 		newMsg, err = buildSingleFieldBody(b.msg, fieldBody)
 | |
| 		if err != nil {
 | |
| 			return "", nil, fmt.Errorf("build single field body: %w", err)
 | |
| 		}
 | |
| 
 | |
| 		var query url.Values
 | |
| 		query, err = buildQuery(b.msg, usedFieldsPath, fieldBody)
 | |
| 		if err != nil {
 | |
| 			return "", nil, fmt.Errorf("build query: %w", err)
 | |
| 		}
 | |
| 
 | |
| 		return resolvedPath + encodeQuery(query), newMsg, nil
 | |
| 
 | |
| 	case b.bodyOption.isFullBody():
 | |
| 		newMsg, err = buildFullBody(b.msg, usedFieldsPath)
 | |
| 		if err != nil {
 | |
| 			return "", nil, fmt.Errorf("build full body: %w", err)
 | |
| 		}
 | |
| 
 | |
| 		return resolvedPath, newMsg, nil
 | |
| 
 | |
| 	default:
 | |
| 		return "", nil, fmt.Errorf("unsupported body option %s", b.bodyOption.String())
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (b *RequestBuilder) validate() error {
 | |
| 	if b.path == "" {
 | |
| 		return errors.New("path is empty")
 | |
| 	}
 | |
| 	if err := validateHTTPMethod(b.method); err != nil {
 | |
| 		return fmt.Errorf("validate http method: %w", err)
 | |
| 	}
 | |
| 	if err := validateHTTPMethodAndBody(b.method, b.bodyOption); err != nil {
 | |
| 		return fmt.Errorf("validate http method and body: %w", err)
 | |
| 	}
 | |
| 	if b.msg == nil {
 | |
| 		return errors.New("msg is nil")
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func validateHTTPMethod(method string) error {
 | |
| 	switch strings.ToUpper(method) {
 | |
| 	case http.MethodGet,
 | |
| 		http.MethodHead,
 | |
| 		http.MethodPost,
 | |
| 		http.MethodPut,
 | |
| 		http.MethodPatch,
 | |
| 		http.MethodDelete,
 | |
| 		http.MethodConnect,
 | |
| 		http.MethodOptions,
 | |
| 		http.MethodTrace:
 | |
| 		return nil
 | |
| 	default:
 | |
| 		return errors.New("invalid http method")
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func validateHTTPMethodAndBody(method string, bodyOpt bodyOption) error {
 | |
| 	switch method {
 | |
| 	case http.MethodGet, http.MethodDelete, http.MethodHead, http.MethodOptions:
 | |
| 		if !bodyOpt.isWithoutBody() {
 | |
| 			return fmt.Errorf("%s method must not have a body", method)
 | |
| 		}
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func allFieldsUsed(msg proto.Message, used *usedFields) bool {
 | |
| 	if used.len() == 0 {
 | |
| 		return false
 | |
| 	}
 | |
| 	count := 0
 | |
| 	msg.ProtoReflect().Range(func(protoreflect.FieldDescriptor, protoreflect.Value) bool {
 | |
| 		count++
 | |
| 		return true
 | |
| 	})
 | |
| 	return used.len() == count
 | |
| }
 | |
| 
 | |
| func encodeQuery(query url.Values) string {
 | |
| 	if len(query) == 0 {
 | |
| 		return ""
 | |
| 	}
 | |
| 	enc := query.Encode()
 | |
| 	if enc == "" {
 | |
| 		return ""
 | |
| 	}
 | |
| 	return "?" + enc
 | |
| }
 | |
| 
 | |
| func initZeroMsg(msg proto.Message) proto.Message {
 | |
| 	return msg.ProtoReflect().New().Interface()
 | |
| }
 |