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.
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
[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:
// 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:
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::Clienthas a connection pool and is cheap to clone. Create it once and share it, for example in your Axum or Actix state. - Timeouts.
connect_timeoutcaps establishing the connection, andtimeoutcaps the whole attempt including the response. If you want a cap for the whole operation including retries, wrap the call intokio::time::timeout. - Errors. The DAWA paths answer
{"type","title","details","code"}, and/api/v1/*answers{"error":{"code","message","details"}}, for every error including 500.FejlKropreads both, soDanadresseError::Apialways carriescodeanddetails. - 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_exceededhas noRetry-Afterand 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:
#!/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:
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:
-Gtogether with--data-urlencodebuilds the query string and URL-encodes æ, ø, å and spaces for you.--max-time 10stops 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
- The error format: DAWA paths and /api/v1
- Limits, quotas and 429
- The same client in Go: Go client with net/http
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)