# Rust med reqwest og serde, plus curl i shell-scripts

> Byg en asynkron Rust-klient med reqwest og serde, og kald Danadresse fra shell-scripts med curl og jq, med retry på 429.
>
> https://danadresse.dk/guides/rust-klient · verified 2026-11-02
Denne guide har to dele. Den første er en asynkron Rust-klient med `reqwest`, `serde` og `tokio`, der dækker autocomplete, opslag på id, datavask, reverse geokodning, typede fejl og retry på 429. Den anden er en lille shell-funktion med `curl` og `jq` til cron-jobs, deploy-scripts og hurtige tjek, med samme fejlhåndtering.

Begge læser nøglen fra miljøvariablen `DANADRESSE_API_KEY`.

## Rust: afhængigheder

```toml id=cargo run=no
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
```

`rustls-tls` gør, at du ikke skal bruge OpenSSL på byggeserveren.

## Rust: klienten

Structs indeholder kun de felter, eksemplet bruger. serde ignorerer ukendte felter, så nye felter i API'et ikke ødelægger noget. Feltet `dør` får `#[serde(rename = "dør")]`, og `type` er et reserveret ord, så det hedder `r#type`:

```rust id=rust-client run=no
// src/main.rs
use std::time::Duration;

use reqwest::{header, Client, StatusCode};
use serde::de::DeserializeOwned;
use serde::Deserialize;

// ---- Svar-typer (kun de felter, vi bruger; ukendte felter ignoreres) ----

#[derive(Debug, Deserialize)]
pub struct Forslag {
    pub r#type: String,
    pub forslagstekst: String,
    pub data: ForslagData,
}

#[derive(Debug, Deserialize)]
pub struct ForslagData {
    pub id: Option<String>,
    pub postnr: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct Postnummer {
    pub nr: String,
    pub navn: String,
}

#[derive(Debug, Deserialize)]
pub struct Adgangspunkt {
    pub koordinater: Vec<f64>, // [længde, bredde]
}

#[derive(Debug, Deserialize)]
pub struct Adgangsadresse {
    pub id: String,
    pub adressebetegnelse: String,
    pub postnummer: Postnummer,
    pub adgangspunkt: Adgangspunkt,
}

#[derive(Debug, Deserialize)]
pub struct Adresse {
    pub id: String,
    pub adressebetegnelse: String,
    pub etage: Option<String>,
    #[serde(rename = "dør")]
    pub doer: Option<String>,
    pub adgangsadresse: Adgangsadresse,
}

// Datavask-adressen har ingen koordinater: slå /adresser/{id} op, hvis du skal bruge dem
#[derive(Debug, Deserialize)]
pub struct VaskAdresse {
    pub id: String,
    pub vejnavn: String,
    pub husnr: String,
    pub etage: Option<String>,
    #[serde(rename = "dør")]
    pub doer: Option<String>,
    pub postnr: String,
    pub postnrnavn: String,
}

#[derive(Debug, Deserialize)]
pub struct Vaskeresultat {
    pub afstand: u32, // 0 = præcis match
}

#[derive(Debug, Deserialize)]
pub struct VaskResultat {
    pub adresse: VaskAdresse,
    pub aktueladresse: Option<VaskAdresse>, // den gældende version
    pub vaskeresultat: Vaskeresultat,
}

impl VaskResultat {
    /// Den gældende version (afviger, hvis den fundne er nedlagt).
    pub fn bedste(&self) -> &VaskAdresse {
        self.aktueladresse.as_ref().unwrap_or(&self.adresse)
    }
}

#[derive(Debug, Deserialize)]
pub struct VaskSvar {
    pub kategori: String, // "A", "B" eller "C", kun på topniveau (ingen score)
    pub resultater: Vec<VaskResultat>,
}

impl VaskSvar {
    /// A = præcis match, B = én sikker match med små stavefejl. C: lad brugeren vælge.
    pub fn sikker(&self) -> bool {
        (self.kategori == "A" || self.kategori == "B") && !self.resultater.is_empty()
    }
}

// ---- Fejl ----

#[derive(Debug, Deserialize, Default)]
struct FejlKrop {
    code: Option<String>,
    title: Option<String>,
    message: Option<String>,
    details: Option<serde_json::Value>,
    error: Option<Box<FejlKrop>>,
}

#[derive(Debug)]
pub enum DanadresseError {
    // details: fx resets_at og upgrade_url ved quota_exceeded
    Api { status: u16, code: Option<String>, message: String, details: Option<serde_json::Value> },
    Http(reqwest::Error),
}

impl std::fmt::Display for DanadresseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Api { status, code, message, .. } => write!(f, "HTTP {status} {code:?}: {message}"),
            Self::Http(e) => write!(f, "{e}"),
        }
    }
}

impl std::error::Error for DanadresseError {}

impl From<reqwest::Error> for DanadresseError {
    fn from(e: reqwest::Error) -> Self {
        Self::Http(e)
    }
}

// ---- Klienten ----

pub struct Danadresse {
    http: Client,
    base_url: String,
    max_retries: u32,
}

impl Danadresse {
    pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
        let key = std::env::var("DANADRESSE_API_KEY")?;
        let base_url = std::env::var("DANADRESSE_BASE_URL")
            .unwrap_or_else(|_| "https://api.danadresse.dk".to_string());
        let mut headers = header::HeaderMap::new();
        let mut value = header::HeaderValue::from_str(&key)?;
        value.set_sensitive(true); // nøglen skrives ikke i debug-output
        headers.insert(header::HeaderName::from_static("x-api-key"), value);
        let http = Client::builder()
            .default_headers(headers)
            .connect_timeout(Duration::from_secs(3))
            .timeout(Duration::from_secs(10)) // loft pr. forsøg
            .build()?;
        Ok(Self { http, base_url, max_retries: 2 })
    }

    async fn get<T: DeserializeOwned>(&self, path: &str, query: &[(&str, String)]) -> Result<T, DanadresseError> {
        let url = format!("{}{}", self.base_url, path);
        let mut attempt = 0;
        loop {
            let res = match self.http.get(&url).query(query).send().await {
                Ok(res) => res,
                Err(e) if (e.is_timeout() || e.is_connect()) && attempt < self.max_retries => {
                    attempt += 1;
                    tokio::time::sleep(Duration::from_secs(1 << attempt)).await;
                    continue;
                }
                Err(e) => return Err(e.into()),
            };
            let status = res.status();
            if status.is_success() {
                return Ok(res.json::<T>().await?);
            }
            let retry_after = res
                .headers()
                .get(header::RETRY_AFTER)
                .and_then(|v| v.to_str().ok())
                .and_then(|v| v.parse::<u64>().ok());
            // 429 og 503 med Retry-After kan prøves igen; quota_exceeded har ingen Retry-After
            let retryable = status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE;
            if let (true, Some(secs)) = (retryable, retry_after) {
                if attempt < self.max_retries {
                    attempt += 1;
                    tokio::time::sleep(Duration::from_secs(secs.min(30))).await;
                    continue;
                }
            }
            // DAWA-stier: {type,title,details,code}   /api/v1: {error:{code,message}}
            let mut krop: FejlKrop = res.json().await.unwrap_or_default();
            if let Some(inner) = krop.error.take() {
                krop = *inner;
            }
            return Err(DanadresseError::Api {
                status: status.as_u16(),
                code: krop.code,
                message: krop.title.or(krop.message).unwrap_or_default(),
                details: krop.details,
            });
        }
    }

    pub async fn autocomplete(&self, q: &str, per_side: u32) -> Result<Vec<Forslag>, DanadresseError> {
        self.get("/autocomplete", &[("q", q.to_string()), ("per_side", per_side.to_string())]).await
    }

    pub async fn adresse(&self, id: &str) -> Result<Adresse, DanadresseError> {
        self.get(&format!("/adresser/{id}"), &[]).await
    }

    pub async fn vask(&self, betegnelse: &str) -> Result<VaskSvar, DanadresseError> {
        self.get("/datavask/adresser", &[("betegnelse", betegnelse.to_string())]).await
    }

    pub async fn reverse(&self, lon: f64, lat: f64) -> Result<Adgangsadresse, DanadresseError> {
        self.get("/adgangsadresser/reverse", &[("x", lon.to_string()), ("y", lat.to_string())]).await
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api = Danadresse::from_env()?;

    for f in api.autocomplete("Rådhuspladsen 1, 4200", 3).await? {
        println!("forslag: {} ({})", f.forslagstekst, f.r#type);
    }

    let a = api.adresse("0a3f50ae-b4ee-32b8-e044-0003ba298018").await?;
    let k = &a.adgangsadresse.adgangspunkt.koordinater;
    println!("opslag: {} {} {}", a.adressebetegnelse, k[0], k[1]);

    for tekst in ["Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV"] {
        let v = api.vask(tekst).await?;
        println!("datavask: {} {} sikker={}", v.kategori, v.resultater.len(), v.sikker());
    }

    let r = api.reverse(12.5683, 55.6761).await?;
    println!("reverse: {}", r.adressebetegnelse);

    match api.adresse("00000000-0000-0000-0000-000000000000").await {
        Err(DanadresseError::Api { status, code, .. }) => println!("fejl: {status} {code:?}"),
        other => println!("uventet: {other:?}"),
    }
    Ok(())
}
```

Output:

```text id=rust-output run=no
forslag: Rådhuspladsen 1, 4200 Slagelse (adresse)
forslag: Rådhuspladsen 1, st. tv, 4200 Slagelse (adresse)
forslag: Rådhuspladsen 1, st. th, 4200 Slagelse (adresse)
opslag: Rådhuspladsen 1, st. th, 4200 Slagelse 11.35623868 55.40464424
datavask: A 1 sikker=true
datavask: C 7 sikker=false
reverse: Rådhuspladsen 3, 1550 København V
fejl: 404 Some("not_found")
```

Sådan virker de vigtigste dele:

- **Én `Client`.** `reqwest::Client` har en forbindelsespulje og er billig at klone. Opret den én gang og del den, fx i din Axum- eller Actix-state.
- **Timeouts.** `connect_timeout` er loftet for at oprette forbindelsen, og `timeout` er loftet for hele forsøget inklusive svaret. Vil du have et loft for hele forløbet med retries, så pak kaldet ind i `tokio::time::timeout`.
- **Fejl.** DAWA-stierne svarer `{"type","title","details","code"}`, og `/api/v1/*` svarer `{"error":{"code","message","details"}}`, for alle fejl, også 500. `FejlKrop` læser begge, så `DanadresseError::Api` altid har `code` og `details`.
- **Retry.** 429 og 503 med `Retry-After` (1 til 60 sekunder ved minutgrænsen) prøves igen efter den angivne ventetid, højst 30 sekunder og højst to gange. `quota_exceeded` har ingen `Retry-After` og returneres med det samme. Den betyder, at en gratiskonto har brugt sin månedskvote og bufferen, eller at en betalt konto har nået sit budgetloft. `details["resets_at"]` fortæller, hvornår der svares igen. Afviste kald tælles og faktureres ikke. Se [Grænser, kvoter og 429](/guides/graenser-og-429).
- **Nøglen.** `set_sensitive(true)` gør, at headeren ikke skrives ud, hvis du logger requesten med `{:?}`.

## Shell: curl og jq

Til scripts er en funktion, der pakker `curl` ind, nok. Den sender nøglen, sætter timeouts, venter `Retry-After` ved 429 og 503 og skriver fejlkoden på stderr:

```bash id=curl-recipes run=live expect="datavask: A"
#!/usr/bin/env bash
# danadresse.sh: kald Danadresse fra shell-scripts (kræver curl og jq)
set -euo pipefail

API="${DANADRESSE_BASE_URL:-https://api.danadresse.dk}"
: "${DANADRESSE_API_KEY:?Sæt DANADRESSE_API_KEY}"

# dawa_get <sti> [curl-argumenter …]
# Skriver svaret på stdout. Venter Retry-After ved 429/503 (højst 2 nye forsøg).
# Fejl: skriver "HTTP <status> <code>" på stderr og returnerer 1.
dawa_get() {
  local path="$1"; shift
  local hdr body status retry attempt
  hdr="$(mktemp)"
  for attempt in 0 1 2; do
    body="$(curl -sS -G "$API$path" "$@" \
      -H "X-Api-Key: $DANADRESSE_API_KEY" \
      --connect-timeout 3 --max-time 10 \
      -D "$hdr" -w '\n%{http_code}')" || { sleep $((2 ** attempt)); continue; }
    status="${body##*$'\n'}"
    body="${body%$'\n'*}"
    if [[ "$status" == 2* ]]; then
      rm -f "$hdr"
      printf '%s\n' "$body"
      return 0
    fi
    retry="$(grep -i '^retry-after:' "$hdr" | tr -dc '0-9' || true)"
    if [[ ("$status" == 429 || "$status" == 503) && -n "$retry" && $attempt -lt 2 ]]; then
      sleep "$retry"
      continue
    fi
    rm -f "$hdr"
    # DAWA-stier har code i roden, /api/v1 under error
    echo "HTTP $status $(jq -r '.code // .error.code // "unknown"' <<<"$body" 2>/dev/null)" >&2
    return 1
  done
  rm -f "$hdr"
  echo "giver op efter 3 forsøg: $path" >&2
  return 1
}

# Autocomplete: én linje pr. forslag
dawa_get /autocomplete --data-urlencode "q=Rådhuspladsen 1, 4200" -d per_side=3 \
  | jq -r '.[] | "forslag: \(.forslagstekst)"'

# Opslag på id
dawa_get /adresser/0a3f50ae-b4ee-32b8-e044-0003ba298018 \
  | jq -r '"opslag: \(.adressebetegnelse) \(.adgangsadresse.adgangspunkt.koordinater | @csv)"'

# Datavask: kategori (kun på topniveau) og antal kandidater
for tekst in "Strandvejen 100, 2900 Hellerup" "Rentemestervej 8, 2. tv, 2400 København NV"; do
  dawa_get /datavask/adresser --data-urlencode "betegnelse=$tekst" \
    | jq -r '"datavask: \(.kategori) \(.resultater | length)"'
done

# Reverse geokodning (x = længdegrad, y = breddegrad)
dawa_get /adgangsadresser/reverse -d x=12.5683 -d y=55.6761 \
  | jq -r '"reverse: \(.adressebetegnelse)"'

# En fejl: ukendt id giver 404 not_found på stderr
dawa_get /adresser/00000000-0000-0000-0000-000000000000 || echo "fejl håndteret"
```

Output:

```text id=curl-output run=no
forslag: Rådhuspladsen 1, 4200 Slagelse
forslag: Rådhuspladsen 1, st. tv, 4200 Slagelse
forslag: Rådhuspladsen 1, st. th, 4200 Slagelse
opslag: Rådhuspladsen 1, st. th, 4200 Slagelse 11.35623868,55.40464424
datavask: A 1
datavask: C 7
reverse: Rådhuspladsen 3, 1550 København V
HTTP 404 not_found
fejl håndteret
```

Et par tips til scripts:

- `-G` sammen med `--data-urlencode` bygger query-strengen og URL-koder æ, ø, å og mellemrum for dig.
- `--max-time 10` stopper et kald, der hænger. Uden den kan et cron-job hænge til næste kørsel.
- Skal du slå mange adresser op fra en fil, så kør dem i serie eller med lav parallelitet (`xargs -P 4`). Kører du hundredvis på én gang, rammer du minutgrænsen og får 429.
- `jq -r '.[] | .forslagstekst'` giver én linje pr. forslag, som er let at sende videre i en pipe.

Rens eller geokod en hel CSV-fil? Se [Geokod en CSV-fil](/guides/geokod-csv).

## Datavask

Begge eksempler viser, at "Strandvejen 100, 2900 Hellerup" giver A med én kandidat, og at "Rentemestervej 8, 2. tv, 2400 København NV" giver C med syv, fordi det nummer ikke har en 2. tv. Kategorien findes kun på topniveau, og der er intet score-felt. A er en præcis match. B er præcis én sikker match med små stavefejl: gem også den, med den officielle tekst. C er usikker: vis kandidaterne, og lad en person vælge. `vaskeresultat.afstand` er 0 ved en præcis match. `betegnelse` læser selv etage og dør. Har din fil allerede separate kolonner, kan du sende `vejnavn`, `husnr`, `etage`, `dør` og `postnr` i stedet.

## Næste skridt

- [Fejlformatet: DAWA-stier og /api/v1](/guides/fejlformat)
- [Grænser, kvoter og 429](/guides/graenser-og-429)
- Samme klient i Go: [Go-klient med net/http](/guides/go-klient)
