# QR-Code oder GS1 Digital Link auflösen

> Vom gescannten GS1 Digital Link zum öffentlichen Pass-JSON, über den Resolver oder über GTIN und Seriennummer.

Der QR-Code auf einer Batterie enthält einen GS1 Digital Link wie
`https://batterypass.example.com/01/04012345000023/21/SV2027-000184`. Ein Browser, der ihn öffnet, landet auf der
öffentlichen Passseite. Eine Scanner-App, das System eines Recyclers oder ein Marktplatz will dagegen meist die
Daten: Diese Anleitung holt das öffentliche Pass-JSON über den Link. Ein Schlüssel ist nicht nötig. Den
Hintergrund erklärt [GS1 Digital Link und Resolver](https://passport.kabin.sh/docs/de/concepts/digital-link).

## Drei Wege zum JSON

| Weg                                     | Anfrage                                                                                                                      | Antwort                                                                      |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Dem Link folgen (empfohlen)             | Der Link mit `?linkType=describedby`                                                                                         | `307` zum öffentlichen JSON                                                  |
| Das Linkset abfragen                    | Der Link mit `?linkType=all` oder mit `Accept: application/json` ohne `linkType`                                             | `200` mit `application/linkset+json`; der Eintrag `describedby` ist das JSON |
| Über GTIN und Seriennummer nachschlagen | [`getPublicPassportByProductId`](https://passport.kabin.sh/docs/de/api-reference/public/get-public-passport-by-product-id) | `200` mit dem öffentlichen JSON                                              |

Dem Link zu folgen funktioniert für jede BatteryPass-Installation und jeden Resolver-Hostnamen, denn der Link
sagt selbst, wohin es geht. Zum Nachschlagen müssen Sie die BatteryPass-Adresse kennen, die den Pass führt.

## Schritt für Schritt

1. **Link zerlegen**

   Ein Digital Link von BatteryPass hat den Pfad `/01/{gtin}/21/{serial}`: den Application Identifier `01` mit der
   GTIN des Modells (14 Ziffern) und `21` mit der Seriennummer der Einheit. Seriennummern bestehen aus Buchstaben,
   Ziffern, `.`, `-` und `/`; ein `/` reist prozentkodiert als `%2F`. Dekodieren Sie das Segment deshalb erst nach
   dem Zerlegen des Pfads. Übernehmen Sie den Ursprung des Links unverändert: Er kann ein eigener Resolver-Hostname
   sein und nicht die Adresse der API.

2. **JSON anfordern**

   Hängen Sie `linkType=describedby` an und senden Sie `Accept: application/json`, damit ein unbekannter Pass als
   JSON-Problem statt als HTML-Seite zurückkommt:

   ```bash title="cURL"
   curl -sS -i -H "Accept: application/json" \
     "https://batterypass.example.com/01/04012345000023/21/SV2027-000184?linkType=describedby"
   ```

   ```http title="307"
   HTTP/1.1 307 Temporary Redirect
   Location: https://batterypass.example.com/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10
   Link: <https://batterypass.example.com/pass/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10>; rel="https://gs1.org/voc/pip"; type="text/html", <https://batterypass.example.com/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10>; rel="describedby"; type="application/json", <https://batterypass.example.com/01/04012345000023/21/SV2027-000184?linkType=all>; rel="linkset"; type="application/linkset+json"
   Cache-Control: public, max-age=300
   Vary: Accept
   ```

   HTTP-Clients folgen dem `307` von selbst (`curl -L`, `fetch`, `requests`, `http.Client` in Go). Das Ziel ist
   [`getPublicPassport`](https://passport.kabin.sh/docs/de/api-reference/public/get-public-passport).

3. **Pass lesen**

   Das JSON enthält `passport_id` (`urn:dpp:{uuid}`), `status`, `links` und, solange der Pass öffentlich ist, das
   signierte `document` mit seiner `verification`. Ein Pass mit gesperrten Kennungen, der noch nicht veröffentlicht
   ist (`qr_locked`), oder einer im Status `suspended`, `withdrawn` oder `deleted_recycled` lässt sich weiterhin
   auflösen, sein JSON enthält aber nur den Status, ohne `document`. Ob die Daten echt sind, prüfen Sie mit der
   Signatur, wie in [Passsignatur offline prüfen](https://passport.kabin.sh/docs/de/guides/verify-signature) gezeigt.

## Vollständiger Code

Jedes Programm erwartet den gescannten Link als Argument und gibt GTIN, Seriennummer, Pass-ID, Status und die URL
des JSON aus.

```ts title="TypeScript"
// resolve.mts: npx tsx resolve.mts <digital-link>
const link = new URL(process.argv[2]); // e.g. https://batterypass.example.com/01/04012345000023/21/SV2027-000184

const match = /\/01\/(\d{8,14})\/21\/([^/]+)$/.exec(link.pathname);
if (!match) throw new Error('not a GS1 Digital Link with GTIN and serial number');
const gtin = match[1];
const serial = decodeURIComponent(match[2]); // a "/" in the serial number arrives as %2F

// linkType=describedby: the resolver answers 307 with the public JSON as Location; fetch follows it.
link.searchParams.set('linkType', 'describedby');
const res = await fetch(link, { headers: { Accept: 'application/json' } });
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.code}: ${body.detail}`);

console.log(gtin, serial, body.passport_id, body.status, res.url);
```

```python title="Python"
# resolve.py: python resolve.py <digital-link>
import re
import sys
from urllib.parse import unquote, urlsplit

import requests

link = sys.argv[1]  # e.g. https://batterypass.example.com/01/04012345000023/21/SV2027-000184

match = re.search(r"/01/(\d{8,14})/21/([^/]+)$", urlsplit(link).path)
if not match:
    sys.exit("not a GS1 Digital Link with GTIN and serial number")
gtin, serial = match.group(1), unquote(match.group(2))  # a "/" in the serial number arrives as %2F

# linkType=describedby: the resolver answers 307 with the public JSON as Location; requests follows it.
res = requests.get(link, params={"linkType": "describedby"}, headers={"Accept": "application/json"}, timeout=10)
body = res.json()
if not res.ok:
    sys.exit(f"{res.status_code} {body['code']}: {body.get('detail')}")

print(gtin, serial, body["passport_id"], body["status"], res.url)
```

```go title="Go"
// resolve: go run . <digital-link>
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"regexp"
)

var digitalLink = regexp.MustCompile(`/01/(\d{8,14})/21/([^/]+)$`)

func main() {
	link, err := url.Parse(os.Args[1]) // e.g. https://batterypass.example.com/01/04012345000023/21/SV2027-000184
	if err != nil {
		log.Fatal(err)
	}
	m := digitalLink.FindStringSubmatch(link.EscapedPath())
	if m == nil {
		log.Fatal("not a GS1 Digital Link with GTIN and serial number")
	}
	gtin := m[1]
	serial, err := url.PathUnescape(m[2]) // a "/" in the serial number arrives as %2F
	if err != nil {
		log.Fatal(err)
	}

	// linkType=describedby: the resolver answers 307 with the public JSON as Location; the client follows it.
	q := link.Query()
	q.Set("linkType", "describedby")
	link.RawQuery = q.Encode()
	req, err := http.NewRequest(http.MethodGet, link.String(), nil)
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("Accept", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()
	var body struct {
		PassportID string `json:"passport_id"`
		Status     string `json:"status"`
		Code       string `json:"code"`
		Detail     string `json:"detail"`
	}
	if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
		log.Fatal(err)
	}
	if res.StatusCode != http.StatusOK {
		log.Fatalf("%s %s: %s", res.Status, body.Code, body.Detail)
	}
	fmt.Println(gtin, serial, body.PassportID, body.Status, res.Request.URL)
}
```

## Das Linkset

Mit `?linkType=all` oder mit `Accept: application/json` (auch `application/linkset+json` oder
`application/ld+json`) ohne `linkType` antwortet der Resolver mit `200` und allen Links des Passes:

```json title="200"
{
  "linkset": [
    {
      "anchor": "https://batterypass.example.com/01/04012345000023/21/SV2027-000184",
      "https://gs1.org/voc/pip": [
        { "href": "https://batterypass.example.com/pass/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10", "type": "text/html", "title": "Battery passport" }
      ],
      "describedby": [
        { "href": "https://batterypass.example.com/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10", "type": "application/json", "title": "Public passport (JSON)" }
      ]
    }
  ]
}
```

## Über GTIN und Seriennummer nachschlagen

Kennen Sie die BatteryPass-Adresse und haben GTIN und Seriennummer aus einer anderen Quelle (Etikett,
Lieferschein, ERP-Datensatz), nutzen Sie
[`getPublicPassportByProductId`](https://passport.kabin.sh/docs/de/api-reference/public/get-public-passport-by-product-id). Der
Pfadparameter lautet `{gtin}:{serial}`. Die GTIN darf 8, 12, 13 oder 14 Ziffern haben und braucht eine gültige
Prüfziffer; die Seriennummer senden Sie URL-kodiert (Prozentkodierung).

```bash title="cURL"
curl -sS "$BATTERYPASS_URL/api/v1/public/dpps/by-product-id/04012345000023:SV2027-000184"
```

Die Antwort ist dasselbe öffentliche JSON wie oben. Ein fehlerhafter Wert ergibt `400 validation_error` auf
`productId` (`invalid_format`), ein unbekanntes Paar `404 not_found`.

## Fehler und Limits

| Antwort                | Bedeutung                                                                                                                          |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `404` vom Resolver     | Kein Pass für diese GTIN und Seriennummer: ein Problem `not_found` für JSON-Clients, eine kurze Seite für Browser.                 |
| `200` ohne `document`  | Den Pass gibt es, aber er ist gerade nicht öffentlich; `status` nennt den Grund.                                                   |
| `400 validation_error` | `getPublicPassportByProductId` mit einem Wert, der nicht `{gtin}:{serial}` mit gültiger GTIN ist.                                  |
| `429 rate_limited`     | Mehr als 300 öffentliche Anfragen pro Minute von Ihrer Adresse (Resolver und öffentliche Abrufe zusammen); `Retry-After` abwarten. |

Antworten des Resolvers und das aktuelle öffentliche JSON dürfen fünf Minuten zwischengespeichert werden. Wenn Sie
viele Codes scannen, speichern Sie die Ergebnisse auch auf Ihrer Seite zwischen.
