From f6ca8836c9e380bdbc719a4246ddb9bf6ed83bbf Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Sun, 1 Mar 2026 20:52:36 +0100 Subject: [PATCH] feat: added Bring address validation for Norwegian addresses --- api/handlers/addresses.go | 55 +++++++++++--- api/main.go | 2 +- api/routes/routes.go | 4 +- pkg/bring/bring.go | 152 ++++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 4 + pkg/tui/model.go | 4 +- 6 files changed, 205 insertions(+), 16 deletions(-) create mode 100644 pkg/bring/bring.go diff --git a/api/handlers/addresses.go b/api/handlers/addresses.go index d4ba6b4..5d42ee4 100644 --- a/api/handlers/addresses.go +++ b/api/handlers/addresses.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "terminalShop/api/middleware" + "terminalShop/pkg/bring" "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/shippo" @@ -16,12 +17,18 @@ import ( ) type AddressHandler struct { - shippoKey string + shippoKey string + bringAPIUID string + bringAPIKey string } -// NewAddressHandler creates a new AddressHandler with the Shippo API key -func NewAddressHandler(shippoAPIKey string) *AddressHandler { - return &AddressHandler{shippoKey: shippoAPIKey} +// NewAddressHandler creates a new AddressHandler with address validation API keys. +func NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey string) *AddressHandler { + return &AddressHandler{ + shippoKey: shippoAPIKey, + bringAPIUID: bringAPIUID, + bringAPIKey: bringAPIKey, + } } // Retrieve all saved addresses for a user @@ -64,9 +71,14 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) { return } - // Validate and normalize address via Shippo + // Validate and normalize address via country-specific provider var address models.Address - if h.shippoKey != "" { + switch req.Country { + case "US": + if h.shippoKey == "" { + utils.RespondError(w, http.StatusBadRequest, "CONFIG_ERROR", "shippo not configured for US address validation", nil) + return + } client := shippo.NewClient(h.shippoKey) validated, err := client.ValidateAddress(shippo.Address{ Name: req.Name, @@ -94,18 +106,39 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) { Country: validated.Country, Phone: validated.Phone, } - } else { - address = models.Address{ - UserID: userID, + case "NO": + if h.bringAPIUID == "" || h.bringAPIKey == "" { + utils.RespondError(w, http.StatusBadRequest, "CONFIG_ERROR", "bring not configured for Norwegian address validation", nil) + return + } + client := bring.NewClient(h.bringAPIUID, h.bringAPIKey) + validated, err := client.ValidateAddress(bring.Address{ Name: req.Name, - Street: req.Street, + Street1: req.Street, Street2: req.Street2, City: req.City, - State: req.State, Zip: req.Zip, Country: req.Country, Phone: req.Phone, + }) + if err != nil { + log.Printf("bring address validation failed: %v", err) + utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address is invalid", nil) + return } + address = models.Address{ + UserID: userID, + Name: validated.Name, + Street: validated.Street1, + Street2: validated.Street2, + City: validated.City, + Zip: validated.Zip, + Country: validated.Country, + Phone: validated.Phone, + } + default: + utils.RespondError(w, http.StatusBadRequest, "UNSUPPORTED_COUNTRY", "address validation is not supported for this country", nil) + return } if err := db.Create(&address).Error; err != nil { diff --git a/api/main.go b/api/main.go index 7a27e2c..0789680 100644 --- a/api/main.go +++ b/api/main.go @@ -46,7 +46,7 @@ func main() { jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) // Setup routes - router := routes.SetupRoutes(version, cfg.StripeSecretKey, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey) + router := routes.SetupRoutes(version, cfg.StripeSecretKey, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey) // Create HTTP server server := &http.Server{ diff --git a/api/routes/routes.go b/api/routes/routes.go index 837aa85..355dc91 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -9,7 +9,7 @@ import ( ) // SetupRoutes configures all API routes -func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTManager, authFingerprintKey string, shippoAPIKey string) *chi.Mux { +func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTManager, authFingerprintKey string, shippoAPIKey string, bringAPIUID string, bringAPIKey string) *chi.Mux { r := chi.NewRouter() // Apply global middleware @@ -25,7 +25,7 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan cartHandler := handlers.NewCartHandler(stripeSecretKey) cardHandler := handlers.NewCardHandler(stripeSecretKey) orderHandler := handlers.NewOrderHandler() - addressHandler := handlers.NewAddressHandler(shippoAPIKey) + addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) // API v1 routes r.Route("/api/v1", func(r chi.Router) { diff --git a/pkg/bring/bring.go b/pkg/bring/bring.go new file mode 100644 index 0000000..c41d431 --- /dev/null +++ b/pkg/bring/bring.go @@ -0,0 +1,152 @@ +package bring + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +const baseURL = "https://api.bring.com/address" + +// Client handles communication with the Bring Address API. +type Client struct { + apiUID string + apiKey string + httpClient *http.Client +} + +// NewClient creates a new Bring API client. +func NewClient(apiUID, apiKey string) *Client { + return &Client{ + apiUID: apiUID, + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// Address represents a Norwegian shipping address +type Address struct { + Name string + Street1 string + Street2 string + City string + Zip string + Country string + Phone string +} + +type validationMatch struct { + StreetName string `json:"street_name"` + HouseNumber int `json:"house_number"` + Letter string `json:"letter"` + PostalCode string `json:"postal_code"` + City string `json:"city"` + Country string `json:"country"` +} + +// validationReason explains why an address was invalid +type validationReason struct { + Code string `json:"code"` + Description string `json:"description"` +} + +// validationResponse is the top-level response from Bring's validation endpoint +type validationResponse struct { + Valid bool `json:"valid"` + Match *validationMatch `json:"match,omitempty"` + Reasons []validationReason `json:"reasons,omitempty"` + Suggestions []validationMatch `json:"suggestions,omitempty"` +} + +// streetNumberRegex splits "Storgata 5" into name="Storgata" number="5" +// and "Storgata 5B" into name="Storgata" number="5" (letter handled by Bring). +var streetNumberRegex = regexp.MustCompile(`^(.+?)\s+(\d+)\s*[A-Za-z]?\s*$`) + +// parseStreet splits a street line like "Storgata 5" into name and number. +// Returns the full string as name and empty number if not match. +func parseStreet(street string) (name string, number string) { + matches := streetNumberRegex.FindStringSubmatch(strings.TrimSpace(street)) + if len(matches) >= 3 { + return matches[1], matches[2] + } + return strings.TrimSpace(street), "" +} + +func (c *Client) ValidateAddress(addr Address) (*Address, error) { + streetName, streetNumber := parseStreet(addr.Street1) + + params := url.Values{} + params.Set("address_type", "street") + params.Set("street_or_place", streetName) + params.Set("postal_code", addr.Zip) + params.Set("city", addr.City) + if streetNumber != "" { + params.Set("street_number", streetNumber) + } + + reqURL := fmt.Sprintf("%s/api/no/validation?%s", baseURL, params.Encode()) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Mybring-API-Uid", c.apiUID) + req.Header.Set("X-Mybring-API-Key", c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("bring request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bring returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var result validationResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if !result.Valid { + reasons := make([]string, 0, len(result.Reasons)) + for _, r := range result.Reasons { + reasons = append(reasons, r.Description) + } + return nil, fmt.Errorf("invalid address: %s", strings.Join(reasons, "; ")) + } + + normalizedStreet := result.Match.StreetName + if result.Match.HouseNumber > 0 { + normalizedStreet = fmt.Sprintf("%s %d", result.Match.StreetName, result.Match.HouseNumber) + } + + if result.Match.Letter != "" { + normalizedStreet += result.Match.Letter + } + + validated := &Address{ + Name: addr.Name, + Street1: normalizedStreet, + Street2: addr.Street2, + City: result.Match.City, + Zip: result.Match.PostalCode, + Country: addr.Country, + Phone: addr.Phone, + } + return validated, nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 628b4c5..4c863b0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -19,6 +19,8 @@ type Config struct { StripePublicKey string ShippoAPIKey string AuthFingerprintKey string + BringAPIUID string + BringAPIKey string } // Load reads configuration from environment variables @@ -46,6 +48,8 @@ func Load() (*Config, error) { StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"), ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"), AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"), + BringAPIUID: os.Getenv("BRING_API_UID"), + BringAPIKey: os.Getenv("BRING_API_KEY"), } return cfg, nil diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 6e2014d..7b370ba 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -243,7 +243,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ShippingForm.submitting = false m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) } - m.ErrorMsg = "Invalid address. Currently only US addresses are supported." + m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported." return m, m.ShippingForm.form.Init() } m.ShippingInfo = &msg.Address @@ -438,7 +438,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ShippingForm.submitting = false m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) } - m.ErrorMsg = "Invalid address. Currently only US addresses are supported." + m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported." if m.ShippingForm != nil { return m, m.ShippingForm.form.Init() }