Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user