Rust with reqwest and serde, plus curl in shell scripts

Build an async Rust client with reqwest and serde, and call Danadresse from shell scripts with curl and jq, with retry on 429.

5 min Level: Advanced ✓ Code tested

This guide has two parts. The first is an async Rust client with reqwest, serde and tokio that covers autocomplete, lookup by id, datavask (address cleansing), reverse geocoding, typed errors and retry on 429. The second is a small shell function with curl and jq for cron jobs, deploy scripts and quick checks, with the same error handling.

Both read the key from the DANADRESSE_API_KEY environment variable.

Rust: dependencies

TOML#cargo
[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 means you do not need OpenSSL on the build server.

Rust: the client

The structs only hold the fields the example uses. serde ignores unknown fields, so new fields in the API do not break anything. The dør (door) field gets #[serde(rename = "dør")], and type is a reserved word, so it is called r#type:

Rust#rust-client
// 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#rust-output
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")

How the main parts work:

  • One Client. reqwest::Client has a connection pool and is cheap to clone. Create it once and share it, for example in your Axum or Actix state.
  • Timeouts. connect_timeout caps establishing the connection, and timeout caps the whole attempt including the response. If you want a cap for the whole operation including retries, wrap the call in tokio::time::timeout.
  • Errors. The DAWA paths answer {"type","title","details","code"}, and /api/v1/* answers {"error":{"code","message","details"}}, for every error including 500. FejlKrop reads both, so DanadresseError::Api always carries code and details.
  • Retry. 429 and 503 with Retry-After (1 to 60 seconds on the per-minute limit) are retried after the given delay, at most 30 seconds and at most twice. quota_exceeded has no Retry-After and is returned immediately. It means a free account has used its monthly quota and the grace buffer, or a paid account has reached its budget cap. details["resets_at"] says when calls are served again. Refused calls are not counted or billed. See Limits, quotas and 429.
  • The key. set_sensitive(true) keeps the header out of the output if you log the request with {:?}.

Shell: curl and jq

For scripts, a function that wraps curl is enough. It sends the key, sets timeouts, waits for Retry-After on 429 and 503, and writes the error code to stderr:

Shell#curl-recipes
#!/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#curl-output
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

A few tips for scripts:

  • -G together with --data-urlencode builds the query string and URL-encodes æ, ø, å and spaces for you.
  • --max-time 10 stops a call that hangs. Without it a cron job can hang until the next run.
  • If you look up many addresses from a file, run them in sequence or with low parallelism (xargs -P 4). Run hundreds at once and you hit the per-minute limit and get 429.
  • jq -r '.[] | .forslagstekst' gives one line per suggestion, which is easy to pipe on.

Cleaning or geocoding a whole CSV file? See Geocode a CSV file.

Datavask

Both examples show that "Strandvejen 100, 2900 Hellerup" gives A with one candidate, and "Rentemestervej 8, 2. tv, 2400 København NV" gives C with seven, because that number has no 2. tv. The category exists only at the top level, and there is no score field. A is an exact match. B is exactly one safe match with small spelling differences: store it too, with the official text. C is uncertain: show the candidates and let a person pick. vaskeresultat.afstand is 0 for an exact match. betegnelse reads floor and door itself. If your file already has separate columns, you can send vejnavn, husnr, etage, dør and postnr instead.

Next steps

Related guides

Markdown source Spotted an error? Tell us API-reference (Swagger)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →