Files
probo/pkg/baseurl/baseurl.go
Sacha Al Himdani 9ac71f948f Update contact email to hello@probo.com
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
2026-06-09 16:45:23 +02:00

280 lines
6.0 KiB
Go

// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS 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(),
}
}