// Copyright (c) 2025-2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. package baseurl import ( "encoding/json" "fmt" "net/url" "strings" ) // BaseURL represents a validated base URL for the application. // It provides convenient methods for building URLs with paths and query parameters. type BaseURL struct { raw string parsed *url.URL } // Parse creates a new BaseURL from a string, validating that it's a valid absolute URL. func Parse(rawURL string) (*BaseURL, error) { if rawURL == "" { return nil, fmt.Errorf("base URL cannot be empty") } parsed, err := url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("invalid base URL: %w", err) } if !parsed.IsAbs() { return nil, fmt.Errorf("base URL must be absolute (include scheme)") } if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, fmt.Errorf("base URL scheme must be http or https, got: %s", parsed.Scheme) } if parsed.Host == "" { return nil, fmt.Errorf("base URL must include a host") } return &BaseURL{ raw: rawURL, parsed: parsed, }, nil } // MustParse creates a new BaseURL from a string, panicking if it's invalid. // This should only be used in tests or with known-valid URLs. func MustParse(rawURL string) *BaseURL { b, err := Parse(rawURL) if err != nil { panic(err) } return b } // String returns the base URL as a string. func (b *BaseURL) String() string { if b == nil { return "" } return b.raw } // Scheme returns the URL scheme (http or https). func (b *BaseURL) Scheme() string { if b == nil || b.parsed == nil { return "" } return b.parsed.Scheme } // Host returns the host:port portion of the URL. func (b *BaseURL) Host() string { if b == nil || b.parsed == nil { return "" } return b.parsed.Host } // Hostname returns just the hostname without the port. func (b *BaseURL) Hostname() string { if b == nil || b.parsed == nil { return "" } return b.parsed.Hostname() } // Port returns the port portion of the URL, or empty string if not specified. func (b *BaseURL) Port() string { if b == nil || b.parsed == nil { return "" } return b.parsed.Port() } // URLBuilder provides a fluent interface for building URLs. type URLBuilder struct { base *BaseURL path string query url.Values err error } // WithPath returns a URLBuilder with the specified path. // The path will be properly joined with the base URL. func (b *BaseURL) WithPath(path string) *URLBuilder { if b == nil { return &URLBuilder{err: fmt.Errorf("base URL is nil")} } // Ensure path starts with / if !strings.HasPrefix(path, "/") { path = "/" + path } // Ensure path does not end with / path = strings.TrimSuffix(path, "/") return &URLBuilder{ base: b, path: path, query: make(url.Values), } } // AppendPath returns a URLBuilder with the specified path. // The path will be properly joined with the base URL's path. func (b *BaseURL) AppendPath(path string) *URLBuilder { if b == nil { return &URLBuilder{err: fmt.Errorf("base URL is nil")} } basePath := strings.TrimSuffix(b.parsed.Path, "/") // Ensure path starts with / if !strings.HasPrefix(path, "/") { path = "/" + path } path = basePath + path // Ensure path does not end with / path = strings.TrimSuffix(path, "/") return &URLBuilder{ base: b, path: path, query: make(url.Values), } } // WithQuery adds a query parameter to the URL. func (ub *URLBuilder) WithQuery(key, value string) *URLBuilder { if ub.err != nil { return ub } ub.query.Add(key, value) return ub } // WithQueryValues sets multiple query parameters at once. func (ub *URLBuilder) WithQueryValues(values url.Values) *URLBuilder { if ub.err != nil { return ub } for key, vals := range values { for _, val := range vals { ub.query.Add(key, val) } } return ub } // String builds and returns the final URL string. func (ub *URLBuilder) String() (string, error) { if ub.err != nil { return "", ub.err } u := &url.URL{ Scheme: ub.base.Scheme(), Host: ub.base.Host(), Path: ub.path, RawQuery: ub.query.Encode(), } return u.String(), nil } // MustString builds and returns the final URL string, panicking on error. // This should only be used when you're certain the URL is valid. func (ub *URLBuilder) MustString() string { s, err := ub.String() if err != nil { panic(err) } return s } // UnmarshalJSON implements json.Unmarshaler for BaseURL. func (b *BaseURL) UnmarshalJSON(data []byte) error { var s string if err := json.Unmarshal(data, &s); err != nil { return err } parsed, err := Parse(s) if err != nil { return err } *b = *parsed return nil } // MarshalJSON implements json.Marshaler for BaseURL. func (b *BaseURL) MarshalJSON() ([]byte, error) { if b == nil { return json.Marshal("") } return json.Marshal(b.raw) } // UnmarshalText implements encoding.TextUnmarshaler for BaseURL. func (b *BaseURL) UnmarshalText(text []byte) error { parsed, err := Parse(string(text)) if err != nil { return err } *b = *parsed return nil } // MarshalText implements encoding.TextMarshaler for BaseURL. func (b *BaseURL) MarshalText() ([]byte, error) { if b == nil { return []byte(""), nil } return []byte(b.raw), nil } func (b *URLBuilder) URL() url.URL { return url.URL{ Scheme: b.base.Scheme(), Host: b.base.Host(), Path: b.path, RawQuery: b.query.Encode(), } }