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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+322
View File
@@ -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 }
+160
View File
@@ -0,0 +1,160 @@
package eprel
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestNormalizeAndExtractID(t *testing.T) {
if got := NormalizeID(" 246834 "); got != "246834" {
t.Fatalf("string=%q", got)
}
if got := NormalizeID(float64(246834)); got != "246834" {
t.Fatalf("float=%q", got)
}
if got := NormalizeID(map[string]any{"#text": "99"}); got != "99" {
t.Fatalf("xml text=%q", got)
}
if IsValidID("") || IsValidID(nil) {
t.Fatal("empty should be invalid")
}
mapped := map[string]any{"title": "Fridge"}
raw := map[string]any{"EPRELID": "12345"}
if got := ExtractID(mapped, raw); got != "12345" {
t.Fatalf("extract=%q", got)
}
mapped2 := map[string]any{"eprel_id": "777"}
if got := ExtractID(mapped2); got != "777" {
t.Fatalf("mapped key=%q", got)
}
}
func TestClientFetch_httptest(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/product/246834/fiches", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("noRedirect") != "true" {
t.Errorf("missing noRedirect")
}
_ = json.NewEncoder(w).Encode(map[string]string{
"address": "/fiches/demo/Fiche_246834_EN.pdf",
})
})
mux.HandleFunc("/api/product/246834", func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "fiches") || strings.Contains(r.URL.Path, "labels") {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"energyClass": "A_plus",
"energyClassRange": "A-G",
})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(Options{
Enabled: true,
BaseURL: srv.URL + "/api",
Timeout: 2 * time.Second,
HTTPClient: srv.Client(),
})
data, err := client.Fetch(context.Background(), "246834")
if err != nil {
t.Fatal(err)
}
if data == nil {
t.Fatal("expected data")
}
if !strings.Contains(data.Label, "/product/246834/labels") {
t.Fatalf("label=%q", data.Label)
}
if !strings.HasSuffix(data.PDF, "/fiches/demo/Fiche_246834_EN.pdf") {
t.Fatalf("pdf=%q", data.PDF)
}
if data.EnergyClass != "A-plus" {
t.Fatalf("class=%q", data.EnergyClass)
}
if data.EnergyScale != "A-G" {
t.Fatalf("scale=%q", data.EnergyScale)
}
attrs := MergeInto(nil, data)
if attrs[AttrID] != "246834" || attrs[AttrEnergyClass] != "A-plus" {
t.Fatalf("attrs=%v", attrs)
}
if FieldValue(data, "eprel_pdf_url") == "" {
t.Fatal("FieldValue pdf empty")
}
}
func TestClientDisabledAndInvalid(t *testing.T) {
c := NewClient(Options{Enabled: false})
if c.Enabled() {
t.Fatal("should be disabled")
}
data, err := c.Fetch(context.Background(), "1")
if err != nil || data != nil {
t.Fatalf("disabled fetch: %v %#v", err, data)
}
enabled := NewClient(Options{Enabled: true, BaseURL: "http://127.0.0.1:1", Timeout: time.Millisecond})
if _, err := enabled.Fetch(context.Background(), "../etc/passwd"); err == nil {
t.Fatal("expected invalid id error")
}
}
func TestClientPartialFicheFailure(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/product/1/fiches", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "gone", http.StatusNotFound)
})
mux.HandleFunc("/api/product/1", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"energyClass": "B"})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(Options{Enabled: true, BaseURL: srv.URL + "/api", Timeout: time.Second, HTTPClient: srv.Client()})
data, err := client.Fetch(context.Background(), "1")
if err != nil {
t.Fatal(err)
}
if data.PDF != "" {
t.Fatalf("expected empty pdf, got %q", data.PDF)
}
if data.EnergyClass != "B" {
t.Fatalf("class=%q", data.EnergyClass)
}
if data.Label == "" {
t.Fatal("label should still be set")
}
}
func TestAPIKeyNotInErrorBodies(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-API-KEY") != "super-secret-key" {
t.Errorf("missing api key header")
}
http.Error(w, "unauthorized secret=super-secret-key", http.StatusUnauthorized)
}))
defer srv.Close()
client := NewClient(Options{
Enabled: true,
BaseURL: srv.URL,
APIKey: "super-secret-key",
Timeout: time.Second,
})
// Fiche failure is soft; product info soft-fails too — Fetch still returns label-only data.
data, err := client.Fetch(context.Background(), "9")
if err != nil {
t.Fatal(err)
}
if data == nil || data.Label == "" {
t.Fatal("expected label-only result")
}
}
+35
View File
@@ -0,0 +1,35 @@
package eprel
import (
"context"
"testing"
)
// stubFetcher verifies the Fetcher interface is usable from processing tests.
type stubFetcher struct {
enabled bool
data *Data
err error
calls int
lastID string
}
func (s *stubFetcher) Enabled() bool { return s.enabled }
func (s *stubFetcher) Fetch(_ context.Context, id string) (*Data, error) {
s.calls++
s.lastID = id
return s.data, s.err
}
func TestFetcherInterface(t *testing.T) {
var _ Fetcher = (*Client)(nil)
var _ Fetcher = Disabled{}
var _ Fetcher = (*stubFetcher)(nil)
st := &stubFetcher{enabled: true, data: &Data{ID: "1", Label: "L"}}
got, err := st.Fetch(context.Background(), "1")
if err != nil || got.ID != "1" || st.calls != 1 {
t.Fatalf("stub: %#v err=%v", got, err)
}
}
+95
View File
@@ -0,0 +1,95 @@
package eprel
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
var eprelIDKeys = []string{
"eprel_id",
"EPRELID",
"eprelId",
"EprelId",
"eprelID",
}
// NormalizeID coerces XML/API values (string, number, {"#text": ...}) to a trimmed ID.
func NormalizeID(v any) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case json.Number:
s := strings.TrimSpace(t.String())
if i := strings.IndexByte(s, '.'); i >= 0 {
s = s[:i]
}
return s
case float64:
if t != t { // NaN
return ""
}
return strconv.FormatInt(int64(t), 10)
case float32:
return strconv.FormatInt(int64(t), 10)
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case int32:
return strconv.FormatInt(int64(t), 10)
case json.RawMessage:
var decoded any
if err := json.Unmarshal(t, &decoded); err != nil {
return ""
}
return NormalizeID(decoded)
case map[string]any:
if text, ok := t["#text"]; ok {
return NormalizeID(text)
}
if text, ok := t["text"]; ok {
return NormalizeID(text)
}
default:
s := strings.TrimSpace(fmt.Sprint(t))
if s == "" || s == "<nil>" {
return ""
}
return s
}
return ""
}
// IsValidID reports whether v normalizes to a non-empty EPREL registration id.
func IsValidID(v any) bool {
return NormalizeID(v) != ""
}
// ExtractID finds an EPREL ID in mapped and/or raw product field maps
// (vendor feeds often use <EPRELID/> / eprel_id).
func ExtractID(sources ...map[string]any) string {
for _, src := range sources {
if src == nil {
continue
}
for _, key := range eprelIDKeys {
if id := NormalizeID(src[key]); id != "" {
return id
}
}
for key, value := range src {
compact := strings.ToLower(strings.ReplaceAll(key, "_", ""))
if compact == "eprelid" {
if id := NormalizeID(value); id != "" {
return id
}
}
}
}
return ""
}