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.
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
[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:
// 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")Sådan virker de vigtigste dele:
- Én
Client.reqwest::Clienthar en forbindelsespulje og er billig at klone. Opret den én gang og del den, fx i din Axum- eller Actix-state. - Timeouts.
connect_timeouter loftet for at oprette forbindelsen, ogtimeouter loftet for hele forsøget inklusive svaret. Vil du have et loft for hele forløbet med retries, så pak kaldet ind itokio::time::timeout. - Fejl. DAWA-stierne svarer
{"type","title","details","code"}, og/api/v1/*svarer{"error":{"code","message","details"}}, for alle fejl, også 500.FejlKroplæser begge, såDanadresseError::Apialtid harcodeogdetails. - 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_exceededhar ingenRetry-Afterog 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. - 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:
#!/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
Et par tips til scripts:
-Gsammen med--data-urlencodebygger query-strengen og URL-koder æ, ø, å og mellemrum for dig.--max-time 10stopper 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.
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
Relaterede guides
Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)