feat: added Bring address validation for Norwegian addresses

This commit is contained in:
2026-03-01 20:52:36 +01:00
parent 92d5c46e19
commit f6ca8836c9
6 changed files with 205 additions and 16 deletions

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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) {

152
pkg/bring/bring.go Normal file
View File

@@ -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
}

View File

@@ -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

View File

@@ -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()
}