Files
micro-client-http/builder/request_builder.go
pugnack 24801750a7
Some checks failed
coverage / build (push) Successful in 2m19s
test / test (push) Failing after 17m15s
integrate request builder into HTTP client for googleapis support (#157)
2025-09-23 13:30:15 +03:00

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()
}