diff --git a/catalog/builder.go b/catalog/builder.go index 96e3883..b038ba9 100644 --- a/catalog/builder.go +++ b/catalog/builder.go @@ -115,6 +115,23 @@ func Apply(s Schema, source map[string]any) (Projection, error) { truthy = DefaultTrueValues() } + var attrs json.RawMessage + if len(s.AttributesFields) > 0 { + m := make(map[string]any) + for _, key := range s.AttributesFields { + if val, ok := source[key]; ok && val != nil { + m[key] = val + } + } + if len(m) > 0 { + b, err := json.Marshal(m) + if err != nil { + return Projection{}, fmt.Errorf("%w: marshal attributes: %w", ErrProjectionBuild, err) + } + attrs = b + } + } + p := Projection{ ItemID: uint32Of(source, s.ItemIdField), ID: id, @@ -122,6 +139,7 @@ func Apply(s Schema, source map[string]any) (Projection, error) { Title: stringOf(source, s.TitleField), PriceIncVat: price, Currency: cur, + Attributes: attrs, TaxClass: tax, InventoryTracked: boolOf(source, s.InventoryTrackedF, truthy), DropShip: boolOf(source, s.DropShipF, truthy), diff --git a/catalog/builder_test.go b/catalog/builder_test.go index 719f545..8755977 100644 --- a/catalog/builder_test.go +++ b/catalog/builder_test.go @@ -37,6 +37,39 @@ func TestBuilderJSONNumber(t *testing.T) { } } +func TestBuilderAttributes(t *testing.T) { + s := DefaultSchema() + s.AttributesFields = []string{"cart", "color"} + src := map[string]any{ + "sku": "X-1", + "price_inc_vat": int64(100), + "updated_at": int64(1700000000000), + "cart": map[string]any{"data": "some-cart-data"}, + "color": "red", + "ignored": "should-not-be-in-attrs", + } + p, err := Apply(s, src) + if err != nil { + t.Fatalf("Apply: %v", err) + } + var m map[string]any + if err := json.Unmarshal(p.Attributes, &m); err != nil { + t.Fatalf("unmarshal Attributes: %v", err) + } + if len(m) != 2 { + t.Errorf("expected 2 attributes, got %d", len(m)) + } + if cartVal, ok := m["cart"].(map[string]any); !ok || cartVal["data"] != "some-cart-data" { + t.Errorf("cart attribute: got %v", m["cart"]) + } + if m["color"] != "red" { + t.Errorf("color attribute: got %v", m["color"]) + } + if _, ok := m["ignored"]; ok { + t.Errorf("ignored attribute was extracted: %v", m) + } +} + // Updated=UseSourceField: missing source updated_at MUST be a loud error, // not a stamp-now fallback. (Foot-gun 3 resolution: silence here invalidates // every consumer's cache on rebuild.) diff --git a/catalog/projection.go b/catalog/projection.go index ee22087..4ee845a 100644 --- a/catalog/projection.go +++ b/catalog/projection.go @@ -92,13 +92,14 @@ type Projection struct { // TaxClass uses a string identifier (see platform/tax class constants). // Empty string means "unclassified"; the consumer's tax.Provider is what // resolves the identifier to a numeric rate. - TaxClass string `json:"tax_class"` - InventoryTracked bool `json:"inventory_tracked"` - DropShip bool `json:"drop_ship"` - Category string `json:"category,omitempty"` - Brand string `json:"brand,omitempty"` - Image string `json:"image,omitempty"` - UpdatedAt int64 `json:"updated_at"` + Attributes json.RawMessage `json:"attributes,omitempty"` + TaxClass string `json:"tax_class"` + InventoryTracked bool `json:"inventory_tracked"` + DropShip bool `json:"drop_ship"` + Category string `json:"category,omitempty"` + Brand string `json:"brand,omitempty"` + Image string `json:"image,omitempty"` + UpdatedAt int64 `json:"updated_at"` } // PayloadKey is the envelope key producers use when publishing a slice of diff --git a/catalog/schema.go b/catalog/schema.go index d5bc3a0..a2cbf82 100644 --- a/catalog/schema.go +++ b/catalog/schema.go @@ -61,6 +61,9 @@ type Schema struct { CategoryField string `json:"category_field,omitempty"` BrandField string `json:"brand_field,omitempty"` ImageField string `json:"image_field,omitempty"` + // AttributesFields names the keys in the source map[string]any that + // should be extracted and built into the Projection's json.RawMessage Attributes field. + AttributesFields []string `json:"attributes_fields,omitempty"` // ItemIdField is the source-side key carrying the producer's catalog // item id (uint32-equivalent). DefaultSchema points at "id" (matching // the product service's /api/get/{sku} shape). Producers that omit @@ -127,6 +130,7 @@ func DefaultSchema() Schema { // Standard product service carries the integer catalog id under "id". ItemIdField: "id", UpdatedAtField: "updated_at", + AttributesFields: []string{"data"}, FixedCurrency: "", // read from source if present DefaultTaxClass: "standard", @@ -165,6 +169,7 @@ func BygglagretSchema() Schema { ImageField: "PrimaryImage", ItemIdField: "item_id", UpdatedAtField: "LastModifiedMs", + AttributesFields: []string{"data"}, FixedCurrency: "SEK", DefaultTaxClass: "standard",