package eprel import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/security" ) const ( defaultBaseURL = "https://eprel.ec.europa.eu/api" defaultTimeout = 10 * time.Second defaultFicheLanguage = "EN" maxBodyBytes = 2 << 20 ) var allowedFicheLanguages = map[string]struct{}{ "EN": {}, "DE": {}, "FR": {}, "NL": {}, "ES": {}, "IT": {}, } func isAllowedFicheLanguage(code string) bool { _, ok := allowedFicheLanguages[code] return ok } // Data is the public energy-label payload attached to processed products. type Data struct { ID string `json:"id"` Label string `json:"label"` PDF string `json:"pdf,omitempty"` EnergyClass string `json:"energy_class,omitempty"` EnergyScale string `json:"energy_scale,omitempty"` } // Fetcher is the test seam for EPREL HTTP calls. type Fetcher interface { Enabled() bool Fetch(ctx context.Context, eprelID string) (*Data, error) } // Client calls the public EPREL product API with timeouts and bounded bodies. type Client struct { BaseURL string FicheLanguage string APIKey string // optional; never logged HTTP *http.Client enabled bool } // Options configures a Client. type Options struct { Enabled bool BaseURL string Timeout time.Duration FicheLanguage string APIKey string HTTPClient *http.Client } // NewClient builds an HTTP Fetcher. When Enabled is false, Fetch is a no-op. func NewClient(opts Options) *Client { timeout := opts.Timeout if timeout <= 0 { timeout = defaultTimeout } base := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") if base == "" { base = defaultBaseURL } lang := strings.TrimSpace(opts.FicheLanguage) if lang == "" { lang = defaultFicheLanguage } else { lang = strings.ToUpper(lang) if !isAllowedFicheLanguage(lang) { lang = defaultFicheLanguage } } httpClient := opts.HTTPClient if httpClient == nil { httpClient = security.SafeHTTPClient(timeout, false) } else if httpClient.Timeout == 0 { cloned := *httpClient cloned.Timeout = timeout httpClient = &cloned } return &Client{ BaseURL: base, FicheLanguage: lang, APIKey: strings.TrimSpace(opts.APIKey), HTTP: httpClient, enabled: opts.Enabled, } } // Enabled reports whether EPREL enrichment is active. func (c *Client) Enabled() bool { return c != nil && c.enabled } // Fetch loads label URL, product fiche PDF, and energy class for a registration id. // Partial success is returned when some sub-calls fail (label URL is always set for a valid id). func (c *Client) Fetch(ctx context.Context, eprelID string) (*Data, error) { if !c.Enabled() { return nil, nil } id := NormalizeID(eprelID) if id == "" { return nil, nil } if err := validateID(id); err != nil { return nil, err } out := &Data{ ID: id, Label: fmt.Sprintf("%s/product/%s/labels?format=png", c.BaseURL, url.PathEscape(id)), } if pdf, err := c.fetchFichePDF(ctx, id); err == nil && pdf != "" { out.PDF = pdf } if class, scale, err := c.fetchProductInfo(ctx, id); err == nil { out.EnergyClass = class out.EnergyScale = scale } return out, nil } func validateID(id string) error { if len(id) > 64 { return fmt.Errorf("eprel id too long") } for _, r := range id { if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '-' || r == '_' { continue } return fmt.Errorf("eprel id has invalid characters") } return nil } func (c *Client) fetchFichePDF(ctx context.Context, id string) (string, error) { path := fmt.Sprintf("/product/%s/fiches", url.PathEscape(id)) q := url.Values{} q.Set("noRedirect", "true") q.Set("language", c.FicheLanguage) raw, err := c.get(ctx, path, q) if err != nil { return "", err } var payload struct { Address string `json:"address"` } if err := json.Unmarshal(raw, &payload); err != nil { return "", err } addr := strings.TrimSpace(payload.Address) if addr == "" { return "", nil } if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") { return addr, nil } if !strings.HasPrefix(addr, "/") { addr = "/" + addr } origin := originFromBase(c.BaseURL) return origin + addr, nil } func (c *Client) fetchProductInfo(ctx context.Context, id string) (class, scale string, err error) { raw, err := c.get(ctx, fmt.Sprintf("/product/%s", url.PathEscape(id)), nil) if err != nil { return "", "", err } var payload struct { EnergyClass string `json:"energyClass"` EnergyClassRange string `json:"energyClassRange"` EnergyScale string `json:"energyScale"` } if err := json.Unmarshal(raw, &payload); err != nil { return "", "", err } class = strings.ReplaceAll(strings.TrimSpace(payload.EnergyClass), "_", "-") scale = strings.TrimSpace(payload.EnergyClassRange) if scale == "" { scale = strings.TrimSpace(payload.EnergyScale) } return class, scale, nil } func (c *Client) get(ctx context.Context, path string, query url.Values) ([]byte, error) { u, err := url.Parse(c.BaseURL + path) if err != nil { return nil, err } if query != nil { u.RawQuery = query.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "Descrybe-EPREL/2.0") if c.APIKey != "" { req.Header.Set("X-API-KEY", c.APIKey) } res, err := c.HTTP.Do(req) if err != nil { return nil, err } defer res.Body.Close() limited := io.LimitReader(res.Body, maxBodyBytes+1) raw, err := io.ReadAll(limited) if err != nil { return nil, err } if len(raw) > maxBodyBytes { return nil, fmt.Errorf("eprel response too large") } if res.StatusCode < 200 || res.StatusCode >= 300 { return nil, fmt.Errorf("eprel api %s", strconv.Itoa(res.StatusCode)) } return raw, nil } func originFromBase(base string) string { u, err := url.Parse(base) if err != nil || u.Scheme == "" || u.Host == "" { return "https://eprel.ec.europa.eu" } return u.Scheme + "://" + u.Host } // AttributeKeys are the processed_attributes keys written for exports/mapping. const ( AttrID = "eprel_id" AttrLabel = "eprel_label" AttrLabelURL = "eprel_label_url" AttrPDF = "eprel_pdf" AttrPDFURL = "eprel_pdf_url" AttrEnergyClass = "eprel_energy_class" AttrEnergyScale = "eprel_energy_scale" ) // MergeInto copies EPREL fields into dest (creates map if nil). Returns dest. // Writes both flat eprel_* keys (export mapping) and a nested "eprel" object (API shape). func MergeInto(dest map[string]any, data *Data) map[string]any { if data == nil || data.ID == "" { return dest } if dest == nil { dest = map[string]any{} } dest[AttrID] = data.ID if data.Label != "" { dest[AttrLabel] = data.Label dest[AttrLabelURL] = data.Label } if data.PDF != "" { dest[AttrPDF] = data.PDF dest[AttrPDFURL] = data.PDF } if data.EnergyClass != "" { dest[AttrEnergyClass] = data.EnergyClass } if data.EnergyScale != "" { dest[AttrEnergyScale] = data.EnergyScale } nested := map[string]any{ "id": data.ID, "label": data.Label, } if data.PDF != "" { nested["pdf"] = data.PDF } if data.EnergyClass != "" { nested["energy_class"] = data.EnergyClass } if data.EnergyScale != "" { nested["energy_scale"] = data.EnergyScale } dest["eprel"] = nested return dest } // FieldValue returns a single export field from Data (empty when missing). func FieldValue(data *Data, fieldName string) string { if data == nil { return "" } switch strings.ToLower(strings.TrimSpace(fieldName)) { case AttrID, "eprelid": return data.ID case AttrLabel, AttrLabelURL: return data.Label case AttrPDF, AttrPDFURL: return data.PDF case AttrEnergyClass: return data.EnergyClass case AttrEnergyScale: return data.EnergyScale default: return "" } } // Disabled is a no-op Fetcher used when EPREL_ENABLED is false. type Disabled struct{} func (Disabled) Enabled() bool { return false } func (Disabled) Fetch(context.Context, string) (*Data, error) { return nil, nil }