blob: 9b079b211d78b57938c0b4ea0d04b1b34e9b40fc [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package triple_protocol
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type TimeoutKey struct{}
// Client is a reusable, concurrency-safe client for a service.
// Depending on the procedure's type, use the CallUnary, CallClientStream,
// CallServerStream, or CallBidiStream method.
//
// By default, clients use the gRPC protocol with the binary Protobuf Codec,
// ask for gzipped responses, and send uncompressed requests. To use the Triple,
// use the [WithTriple] options.
type Client struct {
config *clientConfig
callUnary func(context.Context, *Request, string, *Response) error
protocolClient protocolClient
err error
}
// NewClient constructs a new Client.
func NewClient(httpClient HTTPClient, url string, options ...ClientOption) *Client {
client := &Client{}
config, err := newClientConfig(url, options)
if err != nil {
client.err = err
return client
}
client.config = config
protocolCli, protocolErr := client.config.Protocol.NewClient(
&protocolClientParams{
CompressionName: config.RequestCompressionName,
CompressionPools: newReadOnlyCompressionPools(
config.CompressionPools,
config.CompressionNames,
),
Codec: config.Codec,
Protobuf: config.protobuf(),
CompressMinBytes: config.CompressMinBytes,
HTTPClient: httpClient,
URL: config.URL,
BufferPool: config.BufferPool,
ReadMaxBytes: config.ReadMaxBytes,
SendMaxBytes: config.SendMaxBytes,
GetURLMaxBytes: config.GetURLMaxBytes,
},
)
if protocolErr != nil {
client.err = protocolErr
return client
}
client.protocolClient = protocolCli
// Rather than applying unary interceptors along the hot path, we can do it
// once at client creation.
//
// Note: unarySpec is captured by the closure below but is never modified.
// Each call to callUnary creates a new methodLevelSpec by copying unarySpec,
// ensuring thread-safety for concurrent calls.
unarySpec := config.newSpec(StreamTypeUnary)
unaryFunc := UnaryFunc(func(ctx context.Context, request AnyRequest, response AnyResponse) error {
conn := client.protocolClient.NewConn(ctx, request.Spec(), request.Header())
// Send always returns an io.EOF unless the error is from the client-side.
// We want the user to continue to call Receive in those cases to get the
// full error from the server-side.
if err := conn.Send(request.Any()); err != nil && !errors.Is(err, io.EOF) {
// for HTTP/1.1 case, CloseRequest must happen before CloseResponse
// since HTTP/1.1 is of request-response type
_ = conn.CloseRequest()
_ = conn.CloseResponse()
return err
}
if err := conn.CloseRequest(); err != nil {
_ = conn.CloseResponse()
return err
}
if err := receiveUnaryResponse(conn, response); err != nil {
_ = conn.CloseResponse()
return err
}
return conn.CloseResponse()
})
if interceptor := config.Interceptor; interceptor != nil {
unaryFunc = interceptor.WrapUnary(unaryFunc)
}
client.callUnary = func(ctx context.Context, request *Request, method string, response *Response) error {
// To make the specification, peer, and RPC headers visible to the full
// interceptor chain (as though they were supplied by the caller), we'll
// add them here.
methodLevelSpec, buildErr := buildMethodLevelReqSpec(&unarySpec, method)
if buildErr != nil {
return buildErr
}
request.spec = methodLevelSpec
request.peer = client.protocolClient.Peer()
protocolCli.WriteRequestHeader(StreamTypeUnary, request.Header())
if err := unaryFunc(ctx, request, response); err != nil {
return err
}
return nil
}
return client
}
// CallUnary calls a request-response procedure.
func (c *Client) CallUnary(ctx context.Context, request *Request, method string, response *Response) error {
if c.err != nil {
return c.err
}
ctx, flag, cancel := applyDefaultTimeout(ctx, c.config.Timeout)
if flag {
defer cancel()
}
mergeHeaders(request.Header(), ExtractFromOutgoingContext(ctx))
applyGroupVersionHeaders(request.Header(), c.config)
return c.callUnary(ctx, request, method, response)
}
// CallClientStream calls a client streaming procedure.
func (c *Client) CallClientStream(ctx context.Context, method string) (*ClientStreamForClient, error) {
if c.err != nil {
return &ClientStreamForClient{err: c.err}, c.err
}
conn, err := c.newConn(ctx, StreamTypeClient, method)
if err != nil {
return &ClientStreamForClient{err: err}, err
}
return &ClientStreamForClient{conn: conn}, nil
}
// CallServerStream calls a server streaming procedure.
func (c *Client) CallServerStream(ctx context.Context, request *Request, method string) (*ServerStreamForClient, error) {
if c.err != nil {
return nil, c.err
}
conn, err := c.newConn(ctx, StreamTypeServer, method)
if err != nil {
return nil, err
}
request.spec = conn.Spec()
request.peer = conn.Peer()
mergeHeaders(conn.RequestHeader(), request.header)
// Send always returns an io.EOF unless the error is from the client-side.
// We want the user to continue to call Receive in those cases to get the
// full error from the server-side.
if err := conn.Send(request.Msg); err != nil && !errors.Is(err, io.EOF) {
_ = conn.CloseRequest()
_ = conn.CloseResponse()
return nil, err
}
if err := conn.CloseRequest(); err != nil {
return nil, err
}
return &ServerStreamForClient{conn: conn}, nil
}
// CallBidiStream calls a bidirectional streaming procedure.
func (c *Client) CallBidiStream(ctx context.Context, method string) (*BidiStreamForClient, error) {
if c.err != nil {
return &BidiStreamForClient{err: c.err}, c.err
}
conn, err := c.newConn(ctx, StreamTypeBidi, method)
if err != nil {
return &BidiStreamForClient{err: err}, err
}
return &BidiStreamForClient{conn: conn}, nil
}
func (c *Client) newConn(ctx context.Context, streamType StreamType, method string) (StreamingClientConn, error) {
serviceLevelSpec := c.config.newSpec(streamType)
methodLevelSpec, buildErr := buildMethodLevelReqSpec(&serviceLevelSpec, method)
if buildErr != nil {
return nil, buildErr
}
newConn := func(ctx context.Context, spec Spec) StreamingClientConn {
header := make(http.Header, 8) // arbitrary power of two, prevent immediate resizing
mergeHeaders(header, ExtractFromOutgoingContext(ctx))
applyGroupVersionHeaders(header, c.config)
c.protocolClient.WriteRequestHeader(streamType, header)
return c.protocolClient.NewConn(ctx, spec, header)
}
if interceptor := c.config.Interceptor; interceptor != nil {
newConn = interceptor.WrapStreamingClient(newConn)
}
return newConn(ctx, methodLevelSpec), nil
}
type clientConfig struct {
URL *url.URL
Protocol protocol
Procedure string
CompressMinBytes int
Interceptor Interceptor
CompressionPools map[string]*compressionPool
CompressionNames []string
Codec Codec
RequestCompressionName string
BufferPool *bufferPool
ReadMaxBytes int
SendMaxBytes int
GetURLMaxBytes int
GetUseFallback bool
IdempotencyLevel IdempotencyLevel
Timeout time.Duration
Group string
Version string
}
func newClientConfig(rawURL string, options []ClientOption) (*clientConfig, *Error) {
url, err := parseRequestURL(rawURL)
if err != nil {
return nil, err
}
protoPath := normalizeClientProcedure(url.Path)
config := clientConfig{
URL: url,
// use gRPC by default
Protocol: &protocolGRPC{},
Procedure: protoPath,
CompressionPools: make(map[string]*compressionPool),
BufferPool: newBufferPool(),
}
// use proto binary by default
withProtoBinaryCodec().applyToClient(&config)
// use gzip by default
withGzip().applyToClient(&config)
for _, opt := range options {
opt.applyToClient(&config)
}
if err := config.validate(); err != nil {
return nil, err
}
return &config, nil
}
func (c *clientConfig) validate() *Error {
if c.Codec == nil || c.Codec.Name() == "" {
return errorf(CodeUnknown, "no codec configured")
}
if c.RequestCompressionName != "" && c.RequestCompressionName != compressionIdentity {
if _, ok := c.CompressionPools[c.RequestCompressionName]; !ok {
return errorf(CodeUnknown, "unknown compression %q", c.RequestCompressionName)
}
}
return nil
}
func (c *clientConfig) protobuf() Codec {
if c.Codec.Name() == codecNameProto {
return c.Codec
}
return &protoBinaryCodec{}
}
func (c *clientConfig) newSpec(t StreamType) Spec {
return Spec{
StreamType: t,
Procedure: c.Procedure,
IsClient: true,
IdempotencyLevel: c.IdempotencyLevel,
}
}
func parseRequestURL(rawURL string) (*url.URL, *Error) {
url, err := url.ParseRequestURI(rawURL)
if err == nil {
return url, nil
}
if !strings.Contains(rawURL, "://") {
// URL doesn't have a scheme, so the user is likely accustomed to
// grpc-go's APIs.
err = fmt.Errorf(
"URL %q missing scheme: use http:// or https:// (unlike grpc-go)",
rawURL,
)
}
return nil, NewError(CodeUnavailable, err)
}
func applyDefaultTimeout(ctx context.Context, timeout time.Duration) (context.Context, bool, context.CancelFunc) {
var cancel context.CancelFunc
var applyFlag bool
_, ok := ctx.Deadline()
// Todo(finalt) Temporarily solve the problem that the timeout time is not valid
if !ok {
timeoutVal := ctx.Value(TimeoutKey{})
if timeoutVal != nil {
if s, exist := timeoutVal.(string); exist && s != "" {
if newTimeout, err := time.ParseDuration(s); err == nil {
ctx, cancel = context.WithDeadline(ctx, time.Now().Add(newTimeout))
applyFlag = true
return ctx, applyFlag, cancel
}
}
}
}
if !ok && timeout != 0 {
ctx, cancel = context.WithDeadline(ctx, time.Now().Add(timeout))
applyFlag = true
}
return ctx, applyFlag, cancel
}
func applyGroupVersionHeaders(header http.Header, cfg *clientConfig) {
if cfg.Group != "" {
header.Set(tripleServiceGroup, cfg.Group)
}
if cfg.Version != "" {
header.Set(tripleServiceVersion, cfg.Version)
}
}
func buildMethodLevelReqSpec(serviceLevelReqSpec *Spec, method string) (Spec, error) {
if serviceLevelReqSpec == nil {
return Spec{}, fmt.Errorf("cannot build method-level spec: service-level spec is nil")
}
methodLevelSpec := *serviceLevelReqSpec
methodLevelURL, err := url.JoinPath(methodLevelSpec.Procedure, method)
if err != nil {
return Spec{}, fmt.Errorf("JoinPath failed for procedure %s, method %s", methodLevelSpec.Procedure, method)
}
methodLevelSpec.Procedure = methodLevelURL
return methodLevelSpec, nil
}
// normalizeClientProcedure ensures the path starts with "/" and has no trailing slash.
//
// Unlike extractProtoPath (used on the handler side), this function preserves the full path
// rather than extracting only the last two segments. This allows the client to support
// multi-level URL prefixes (e.g., "/api/v1/com.example.Service") while the method name
// will be appended later via buildMethodLevelReqSpec.
func normalizeClientProcedure(path string) string {
// Handle empty string or a single slash early
if path == "" || path == "/" {
return "/"
}
// Pre-clean: strip all trailing slashes (handles cases like "path///")
path = strings.TrimRight(path, "/")
// Ensure the path has a leading slash
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
// If the path becomes empty after trimming (meaning it was all slashes),
// reset it to a single slash.
if path == "" {
return "/"
}
return path
}