Files
descrybe/apps/api/internal/eprel/client.go
T

323 lines
8.0 KiB
Go
Raw Normal View History

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 }