feat: added Bring address validation for Norwegian addresses
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
152
pkg/bring/bring.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user