choksi2212 commented on code in PR #4497:
URL: https://github.com/apache/iggy/pull/4497#discussion_r4258601603


##########
foreign/go/client/connection_string.go:
##########
@@ -0,0 +1,460 @@
+// 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 client
+
+import (
+       "fmt"
+       "math/big"
+       "net"
+       "strconv"
+       "strings"
+
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       ierror "github.com/apache/iggy/foreign/go/errors"
+)
+
+const (
+       defaultProtocol = "iggy"
+       protocolPrefix  = "iggy+"
+       tcpProtocol     = "tcp"
+)
+
+// ParseConnectionString parses an Iggy TCP connection string into the TCP
+// client options it describes, so the result can be passed to WithTcp:
+//
+//     client.NewIggyClient(client.WithTcp(opts...))
+//
+// with opts from 
client.ParseConnectionString("iggy://iggy:iggy@localhost:8090").
+//
+// The accepted shape is:
+//
+//     iggy://[username:password@ | personal_access_token@]host:port[?options]
+//     iggy+tcp://[username:password@ | 
personal_access_token@]host:port[?options]
+//
+// The scheme iggy:// means TCP, the same as the explicit iggy+tcp://.
+// Other iggy+<transport> schemes such as iggy+quic:// are rejected,
+// because the Go SDK implements the TCP transport only, which covers both
+// plain TCP and TLS. Credentials are username:password or a single
+// personal access token before the @; either way auto-login is configured,
+// matching what the Rust and Node SDKs do with a connection string. The
+// host may be a bracketed IPv6 literal, as in [::1]:8090.
+//
+// Query options follow the grammar the other SDKs share:
+//
+//     tls=true|false              enable TLS
+//     tls_domain=<name>           TLS server name indication (SNI)
+//     tls_ca_file=<path>          CA certificate file for verification
+//     reconnection_retries=<n|unlimited>
+//     reconnection_interval=<non-zero duration>
+//     reestablish_after=<duration>
+//     heartbeat_interval=<non-zero duration>
+//     nodelay=true|false
+//
+// Durations take the units the other SDKs share, from nanos to years,
+// including the hr, hrs, wk, wks and month spellings, with optional
+// fractions and parts run together or spaced, as in 1h30m or 1h 30m.
+// The reconnection_interval and heartbeat_interval spellings must be
+// non-zero, because the Rust SDK types them as non-zero durations;
+// reestablish_after takes the zero spellings, which Rust accepts there.
+// The Go client keeps its own reconnect and heartbeat pacing, so those
+// three spellings are validated for compatibility and then ignored, and
+// a value the other SDKs would reject is still rejected here, so one
+// connection string is either valid everywhere or nowhere. The nodelay
+// spelling is applied, because the Go client carries a matching
+// TCP_NODELAY option.
+func ParseConnectionString(connectionString string) ([]tcp.Option, error) {
+       protocolParts := strings.Split(connectionString, "://")
+       if len(protocolParts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       protocol, err := parseProtocol(protocolParts[0])
+       if err != nil {
+               return nil, err
+       }
+       if protocol != tcpProtocol {
+               // Only a plain transport name is echoed, because a malformed
+               // scheme may carry credentials, as the C# SDK notes on the same
+               // check.
+               if !isASCIILetters(protocol) {
+                       return nil, ierror.InvalidConnectionString{}
+               }
+               return nil, fmt.Errorf("%w: unsupported transport %q, Go SDK 
supports tcp only",
+                       ierror.InvalidConnectionString{}, protocol)
+       }
+
+       parts := strings.Split(protocolParts[1], "@")
+       if len(parts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       credentials, err := parseCredentials(parts[0])
+       if err != nil {
+               return nil, err
+       }
+
+       serverAndOptions := strings.Split(parts[1], "?")
+       if len(serverAndOptions) > 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       var tlsConfig parsedTLS
+       tlsConfig.noDelay = true
+       if len(serverAndOptions) == 2 {
+               tlsConfig, err = parseConnectionOptions(serverAndOptions[1])
+               if err != nil {
+                       return nil, err
+               }
+       }
+
+       serverAddress := serverAndOptions[0]
+       if serverAddress == "" {
+               return nil, ierror.InvalidConnectionString{}
+       }
+       host, portStr, err := net.SplitHostPort(serverAddress)
+       if err != nil || host == "" {
+               return nil, ierror.InvalidConnectionString{}
+       }
+       // Node and Rust accept any u16 port, 0 included; the dial fails later
+       // instead of the parse. A bad port reports the same error as every 
other
+       // parse failure, which is what the Rust SDK returns here.
+       if _, err := strconv.ParseUint(portStr, 10, 16); err != nil {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       options := []tcp.Option{tcp.WithServerAddress(serverAddress)}
+       options = append(options, tcp.WithAutoLogin(credentials))
+       if tlsConfig.enabled {
+               var tlsOpts []tcp.TLSOption
+               if tlsConfig.domain != "" {
+                       tlsOpts = append(tlsOpts, 
tcp.WithTLSDomain(tlsConfig.domain))
+               }
+               if tlsConfig.caFile != "" {
+                       tlsOpts = append(tlsOpts, 
tcp.WithTLSCAFile(tlsConfig.caFile))
+               }
+               options = append(options, tcp.WithTLS(tlsOpts...))
+       }
+       options = append(options, tcp.WithNoDelay(tlsConfig.noDelay))
+       return options, nil
+}
+
+// parseProtocol extracts the transport from the scheme, which is either the
+// bare default protocol or an explicit iggy+<transport> spelling.
+func parseProtocol(scheme string) (string, error) {
+       if scheme == defaultProtocol {
+               return tcpProtocol, nil
+       }
+       if !strings.HasPrefix(scheme, protocolPrefix) {
+               return "", ierror.InvalidConnectionString{}
+       }
+       return strings.TrimPrefix(scheme, protocolPrefix), nil
+}
+
+// parseCredentials turns the userinfo before the @ into auto-login
+// credentials: username:password or a single personal access token.
+func parseCredentials(userinfo string) (tcp.Credentials, error) {
+       username, password, hasSeparator := strings.Cut(userinfo, ":")
+       if !hasSeparator {
+               if username == "" {
+                       return tcp.Credentials{}, 
ierror.InvalidConnectionString{}
+               }
+               return tcp.NewPersonalAccessTokenCredentials(username), nil
+       }
+       if username == "" || password == "" {
+               return tcp.Credentials{}, ierror.InvalidConnectionString{}
+       }
+       return tcp.NewUsernamePasswordCredentials(username, password), nil
+}
+
+type parsedTLS struct {
+       enabled bool
+       domain  string
+       caFile  string
+       noDelay bool
+}
+
+// parseConnectionOptions validates the shared connection-string grammar,
+// keeping the tls options and ignoring the reconnect spellings the Go
+// client does not take from a connection string.
+func parseConnectionOptions(optionsString string) (parsedTLS, error) {
+       parsed := parsedTLS{}
+       if optionsString == "" {
+               return parsed, ierror.InvalidConnectionString{}
+       }
+       for _, option := range strings.Split(optionsString, "&") {
+               name, value, found := strings.Cut(option, "=")
+               if !found {
+                       return parsed, ierror.InvalidConnectionString{}
+               }
+               switch name {
+               case "tls":
+                       if value != "true" && value != "false" {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+                       parsed.enabled = value == "true"
+               case "tls_domain":
+                       parsed.domain = value
+               case "tls_ca_file":
+                       parsed.caFile = value
+               case "reconnection_retries":
+                       if value != "unlimited" {
+                               if _, err := strconv.ParseUint(value, 10, 32); 
err != nil {
+                                       return parsed, 
ierror.InvalidNumberValue{}
+                               }
+                       }
+               case "reconnection_interval", "heartbeat_interval":
+                       // Rust types both as non-zero durations, so the zero 
spellings
+                       // and any zero-valued duration are rejected.
+                       if nanos, err := parseIggyDuration(value); err != nil 
|| nanos == 0 {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+               case "reestablish_after":
+                       // Rust takes this one as a plain duration, where the 
zero
+                       // spellings parse.
+                       if _, err := parseIggyDuration(value); err != nil {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+               case "nodelay":
+                       if value != "true" && value != "false" {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+                       parsed.noDelay = value == "true"
+               default:
+                       return parsed, ierror.InvalidConnectionString{}
+               }
+       }
+       return parsed, nil
+}
+
+// isASCIILetters reports whether s is non-empty and ASCII letters only, so
+// an unsupported-transport error can echo it without ever echoing the rest
+// of a malformed scheme, which may carry credentials.
+func isASCIILetters(s string) bool {
+       if s == "" {
+               return false
+       }
+       for i := 0; i < len(s); i++ {
+               if (s[i] < 'a' || s[i] > 'z') && (s[i] < 'A' || s[i] > 'Z') {
+                       return false
+               }
+       }
+       return true
+}
+
+// parseIggyDuration parses a duration the Rust SDK accepts, returning the
+// total nanoseconds. It mirrors IggyDuration::from_str: the value is
+// lowercased, the zero spellings become a zero duration, and everything
+// else goes through a humantime-style grammar of amounts and units, with
+// optional fractions and parts run together as in 1h30m or spaced as in
+// 1h 30m. Fractions must land on a whole nanosecond, as in the Rust and
+// Node parsers, so 1.5us parses while 0.5ns does not.
+func parseIggyDuration(value string) (uint64, error) {
+       lower := strings.ToLower(value)
+       switch lower {
+       case "0", "unlimited", "disabled", "none":
+               return 0, nil
+       }
+       remainder := strings.TrimLeft(lower, "  ")
+       if remainder == "" {
+               return 0, errInvalidDuration(value)
+       }
+
+       var seconds, nanos big.Int
+       for remainder != "" {
+               wholePart, fracPart, fracScale, rest, ok := 
cutLeadingAmount(remainder)
+               if !ok {
+                       return 0, errInvalidDuration(value)
+               }
+               remainder = rest
+
+               index, rest, ok := cutDurationUnit(remainder)
+               if !ok {
+                       return 0, errInvalidDuration(value)
+               }
+               remainder = strings.TrimLeft(rest, "    ")
+               unit := durationUnits[index]
+
+               whole := new(big.Int).SetUint64(wholePart)
+               seconds.Add(&seconds, whole.Mul(whole, 
new(big.Int).SetUint64(unit.seconds)))
+               whole = new(big.Int).SetUint64(wholePart)
+               nanos.Add(&nanos, whole.Mul(whole, 
new(big.Int).SetUint64(unit.nanos)))
+
+               if fracScale != 0 {
+                       // A fraction of a nanosecond always loses precision, 
as the
+                       // Rust parser reports outright.
+                       if unit.seconds == 0 && unit.nanos == 1 {
+                               return 0, errInvalidDuration(value)
+                       }
+                       numerator := new(big.Int).SetUint64(fracPart)
+                       denominator := new(big.Int).SetUint64(fracScale)
+                       frac := new(big.Int)
+                       switch {
+                       case unit.nanos != 0: // usec and millis land in 
nanoseconds
+                               frac.Mul(numerator, 
new(big.Int).SetUint64(unit.nanos))
+                               if new(big.Int).Mod(frac, denominator).Sign() 
!= 0 {
+                                       return 0, errInvalidDuration(value)
+                               }
+                               nanos.Add(&nanos, frac.Div(frac, denominator))
+                       case unit.seconds == 1: // seconds land in nanoseconds
+                               frac.Mul(numerator, big.NewInt(1_000_000_000))
+                               if new(big.Int).Mod(frac, denominator).Sign() 
!= 0 {
+                                       return 0, errInvalidDuration(value)
+                               }
+                               nanos.Add(&nanos, frac.Div(frac, denominator))
+                       default: // minutes and above land in whole seconds
+                               frac.Mul(numerator, 
new(big.Int).SetUint64(unit.seconds))
+                               if new(big.Int).Mod(frac, denominator).Sign() 
!= 0 {
+                                       return 0, errInvalidDuration(value)
+                               }
+                               seconds.Add(&seconds, frac.Div(frac, 
denominator))
+                       }
+               }
+       }
+
+       // A carry out of the nanoseconds, as the Rust implementation does
+       // past a full second.
+       billion := big.NewInt(1_000_000_000)
+       if nanos.Cmp(billion) >= 0 {

Review Comment:
   Agreed, the carry only normalized the representation — seconds * 1e9 + nanos 
is the same total either way. Dropped, and the duration tests still pass 
unchanged.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to