Java og Spring: java.net.http og RestClient

Kald Danadresse fra Java 17 med java.net.http og Jackson, eller fra Spring Boot med RestClient, med timeouts, fejlkoder og retry på 429.

5 min Niveau: Øvet ✓ Kode testet

Denne guide viser to klienter til Danadresse i Java. Den første bruger java.net.http.HttpClient fra JDK 17 og Jackson og virker i alle Java-projekter. Den anden bruger RestClient fra Spring Boot 3.2 og nyere. Begge deler de samme records og den samme undtagelse og dækker autocomplete, opslag på id, datavask, reverse geokodning, timeouts, fejlkoder og 429.

Nøglen læses fra miljøvariablen DANADRESSE_API_KEY på serveren.

Afhængigheder

java.net.http er en del af JDK'en. Til JSON bruger eksemplerne Jackson, som Spring Boot allerede har med. Uden Spring tilføjer du den selv:

XML#maven
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.17.2</version>
</dependency>

Records kræver Jackson 2.12 eller nyere.

1. Records

Records indeholder kun de felter, eksemplet bruger. Feltet dør hedder doer i Java og mappes med @JsonProperty("dør"):

Java#model
// DanadresseModel.java: svar-typer (kun de felter, vi bruger; ukendte felter ignoreres)
package dk.example.danadresse;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

public final class DanadresseModel {
    private DanadresseModel() { }

    public record ForslagData(String id, String postnr, String adgangsadresseid, Double x, Double y) { }

    public record Forslag(String type, String tekst, String forslagstekst, ForslagData data) { }

    public record KodeNavn(String kode, String navn) { }

    public record Postnummer(String nr, String navn) { }

    public record Adgangspunkt(List<Double> koordinater) { }  // [længde, bredde]

    public record Adgangsadresse(String id, String adressebetegnelse, Postnummer postnummer,
                                 KodeNavn kommune, Adgangspunkt adgangspunkt) { }

    public record Adresse(String id, String adressebetegnelse, String etage,
                          @JsonProperty("dør") String doer, Adgangsadresse adgangsadresse) { }

    // Datavask-adressen har ingen koordinater: slå /adresser/{id} op, hvis du skal bruge dem
    public record VaskAdresse(String id, String vejnavn, String husnr, String etage,
                              @JsonProperty("dør") String doer, String postnr, String postnrnavn,
                              String adgangsadresseid) { }

    public record Vaskeresultat(Integer afstand) { }  // 0 = præcis match

    public record VaskResultat(VaskAdresse adresse, VaskAdresse aktueladresse, Vaskeresultat vaskeresultat) {
        /** aktueladresse = den gældende version (afviger, hvis den fundne er nedlagt). */
        public VaskAdresse bedste() {
            return aktueladresse != null ? aktueladresse : adresse;
        }
    }

    /** kategori findes kun på topniveau; der er intet score-felt. */
    public record VaskSvar(String kategori, List<VaskResultat> resultater) {
        /** A = præcis match, B = én sikker match med små stavefejl. C: lad brugeren vælge. */
        public boolean sikker() {
            return ("A".equals(kategori) || "B".equals(kategori)) && resultater != null && !resultater.isEmpty();
        }
    }
}

2. Undtagelsen

Fejl på DAWA-stierne har formen {"type","title","details","code"}, mens /api/v1/* svarer {"error":{"code","message","details"}}. Det gælder alle fejl, også 500. from() læser begge og gemmer details og Retry-After:

Java#exception
// DanadresseException.java
package dk.example.danadresse;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.MissingNode;

public final class DanadresseException extends RuntimeException {
    private static final ObjectMapper JSON = new ObjectMapper();

    private final int status;
    private final String code;          // stabil kode, fx "invalid_api_key"
    private final long retryAfterSeconds; // 0 hvis headeren mangler
    private final JsonNode details;     // fx resets_at og upgrade_url ved quota_exceeded

    public DanadresseException(int status, String code, String message, long retryAfterSeconds, JsonNode details) {
        super("HTTP " + status + " " + code + ": " + message);
        this.status = status;
        this.code = code;
        this.retryAfterSeconds = retryAfterSeconds;
        this.details = details;
    }

    /** Læser begge fejlformater: DAWA-stier {type,title,details,code} og /api/v1 {error:{code,message}}. */
    public static DanadresseException from(int status, String body, String retryAfterHeader) {
        String code = null;
        String message = "HTTP " + status;
        JsonNode details = MissingNode.getInstance();
        try {
            JsonNode root = JSON.readTree(body);
            JsonNode err = root.path("error").isObject() ? root.path("error") : root;
            code = err.path("code").asText(null);
            message = err.path("title").asText(err.path("message").asText(message));
            details = err.path("details");
        } catch (Exception ignored) {
            // ikke JSON, fx en fejlside fra en proxy
        }
        long retryAfter = 0;
        if (retryAfterHeader != null && retryAfterHeader.matches("\\d+")) {
            retryAfter = Long.parseLong(retryAfterHeader);
        }
        return new DanadresseException(status, code, message, retryAfter, details);
    }

    public int status() { return status; }
    public String code() { return code; }
    public long retryAfterSeconds() { return retryAfterSeconds; }
    public JsonNode details() { return details; }  // fx details().path("resets_at").asText()

    /** 429 (minutgrænse) og 503 (register nede) med Retry-After. quota_exceeded har ingen. */
    public boolean retryable() {
        return (status == 429 || status == 503) && retryAfterSeconds > 0;
    }
}

Forgren på code(), ikke på teksten. retryable() er sand for 429 og 503 med Retry-After (1 til 60 sekunder ved minutgrænsen). quota_exceeded har ingen Retry-After og er aldrig retryable: en gratiskonto har brugt sin månedskvote og bufferen, eller en betalt konto har nået sit budgetloft. details().path("resets_at") fortæller, hvornår der svares igen, og details().path("upgrade_url"), hvor du opgraderer. Afviste kald tælles og faktureres ikke.

3. Klient med java.net.http

Java#client
// DanadresseClient.java: java.net.http (Java 17+) + Jackson
package dk.example.danadresse;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import dk.example.danadresse.DanadresseModel.Adgangsadresse;
import dk.example.danadresse.DanadresseModel.Adresse;
import dk.example.danadresse.DanadresseModel.Forslag;
import dk.example.danadresse.DanadresseModel.VaskSvar;
import java.io.IOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;

public final class DanadresseClient {
    private static final ObjectMapper JSON = new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    private static final int MAX_RETRIES = 2;

    private final HttpClient http;
    private final URI baseUrl;
    private final String apiKey;

    public DanadresseClient(String baseUrl, String apiKey) {
        this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
        this.baseUrl = URI.create(baseUrl);
        this.apiKey = apiKey;
    }

    public static DanadresseClient fromEnv() {
        String key = System.getenv("DANADRESSE_API_KEY");
        if (key == null || key.isBlank()) {
            throw new IllegalStateException("DANADRESSE_API_KEY er ikke sat");
        }
        String base = System.getenv().getOrDefault("DANADRESSE_BASE_URL", "https://api.danadresse.dk");
        return new DanadresseClient(base, key);
    }

    public List<Forslag> autocomplete(String q, int perSide) throws IOException, InterruptedException {
        return get("/autocomplete?q=" + enc(q) + "&per_side=" + perSide, new TypeReference<List<Forslag>>() { });
    }

    public Adresse adresse(String id) throws IOException, InterruptedException {
        return get("/adresser/" + enc(id), new TypeReference<Adresse>() { });
    }

    public VaskSvar vask(String betegnelse) throws IOException, InterruptedException {
        return get("/datavask/adresser?betegnelse=" + enc(betegnelse), new TypeReference<VaskSvar>() { });
    }

    public Adgangsadresse reverse(double lon, double lat) throws IOException, InterruptedException {
        return get("/adgangsadresser/reverse?x=" + lon + "&y=" + lat, new TypeReference<Adgangsadresse>() { });
    }

    private <T> T get(String pathAndQuery, TypeReference<T> type) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(baseUrl.resolve(pathAndQuery))
                .header("X-Api-Key", apiKey)
                .header("Accept", "application/json")
                .timeout(Duration.ofSeconds(10))
                .GET()
                .build();

        for (int attempt = 0; ; attempt++) {
            HttpResponse<String> response;
            try {
                response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            } catch (HttpTimeoutException | ConnectException e) {
                if (attempt >= MAX_RETRIES) {
                    throw e;
                }
                Thread.sleep(1000L << attempt);
                continue;
            }
            if (response.statusCode() / 100 == 2) {
                return JSON.readValue(response.body(), type);
            }
            DanadresseException err = DanadresseException.from(response.statusCode(), response.body(),
                    response.headers().firstValue("Retry-After").orElse(null));
            if (err.retryable() && attempt < MAX_RETRIES) {
                Thread.sleep(Math.min(err.retryAfterSeconds(), 30) * 1000L);
                continue;
            }
            throw err;
        }
    }

    private static String enc(String s) {
        return URLEncoder.encode(s, StandardCharsets.UTF_8);
    }
}

connectTimeout på klienten og timeout på hver request sætter loft over ventetiden. HttpClient er trådsikker og holder forbindelserne åbne, så opret én instans og del den.

En lille demo:

Java#demo
// Demo.java
package dk.example.danadresse;

import dk.example.danadresse.DanadresseModel.Adresse;
import dk.example.danadresse.DanadresseModel.Forslag;
import dk.example.danadresse.DanadresseModel.VaskSvar;
import java.util.List;

public final class Demo {
    public static void main(String[] args) throws Exception {
        DanadresseClient api = DanadresseClient.fromEnv();

        for (Forslag f : api.autocomplete("Rådhuspladsen 1, 4200", 3)) {
            System.out.println("forslag: " + f.forslagstekst());
        }

        Adresse a = api.adresse("0a3f50ae-b4ee-32b8-e044-0003ba298018");
        List<Double> k = a.adgangsadresse().adgangspunkt().koordinater();
        System.out.println("opslag: " + a.adressebetegnelse() + " " + k.get(0) + " " + k.get(1));

        for (String tekst : List.of("Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV")) {
            VaskSvar v = api.vask(tekst);
            System.out.println("datavask: " + v.kategori() + " " + v.resultater().size() + " sikker=" + v.sikker());
        }

        System.out.println("reverse: " + api.reverse(12.5683, 55.6761).adressebetegnelse());

        try {
            api.adresse("00000000-0000-0000-0000-000000000000");
        } catch (DanadresseException e) {
            System.out.println("fejl: " + e.status() + " " + e.code());  // fejl: 404 not_found
        }
    }
}
Text#demo-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 sikker=true
datavask: C 7 sikker=false
reverse: Rådhuspladsen 3, 1550 København V
fejl: 404 not_found

4. Spring Boot med RestClient

I Spring Boot 3.2 og nyere bygger du en RestClient-bean med base-URL, nøgle, timeouts og en status-handler, der laver alle 4xx- og 5xx-svar om til DanadresseException:

Java#spring-config
// DanadresseConfig.java (Spring Boot 3.2+)
package dk.example.danadresse;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

@Configuration
public class DanadresseConfig {

    @Bean
    RestClient danadresseRestClient(
            RestClient.Builder builder,
            @Value("${danadresse.base-url:https://api.danadresse.dk}") String baseUrl,
            @Value("${DANADRESSE_API_KEY}") String apiKey) {
        var factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(Duration.ofSeconds(3));
        factory.setReadTimeout(Duration.ofSeconds(10));

        return builder
                .baseUrl(baseUrl)
                .defaultHeader("X-Api-Key", apiKey)
                .requestFactory(factory)
                // Alle 4xx/5xx bliver til DanadresseException med API'ets code
                .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
                    String body = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
                    throw DanadresseException.from(response.getStatusCode().value(), body,
                            response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER));
                })
                .build();
    }
}

@Value("${DANADRESSE_API_KEY}") læser miljøvariablen direkte. Du kan også sætte danadresse.base-url i application.yml, fx til en mock-server i dine tests.

Servicen bruger samme records og lægger et retry-lag om hvert kald:

Java#spring-service
// DanadresseService.java
package dk.example.danadresse;

import dk.example.danadresse.DanadresseModel.Adgangsadresse;
import dk.example.danadresse.DanadresseModel.Adresse;
import dk.example.danadresse.DanadresseModel.Forslag;
import dk.example.danadresse.DanadresseModel.VaskSvar;
import java.util.List;
import java.util.function.Supplier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
public class DanadresseService {
    private static final int MAX_RETRIES = 2;
    private final RestClient rest;

    public DanadresseService(RestClient danadresseRestClient) {
        this.rest = danadresseRestClient;
    }

    public List<Forslag> autocomplete(String q, int perSide) {
        return withRetry(() -> rest.get()
                .uri(u -> u.path("/autocomplete").queryParam("q", q).queryParam("per_side", perSide).build())
                .retrieve()
                .body(new ParameterizedTypeReference<List<Forslag>>() { }));
    }

    public Adresse adresse(String id) {
        return withRetry(() -> rest.get().uri("/adresser/{id}", id).retrieve().body(Adresse.class));
    }

    public VaskSvar vask(String betegnelse) {
        return withRetry(() -> rest.get()
                .uri(u -> u.path("/datavask/adresser").queryParam("betegnelse", betegnelse).build())
                .retrieve()
                .body(VaskSvar.class));
    }

    public Adgangsadresse reverse(double lon, double lat) {
        return withRetry(() -> rest.get()
                .uri(u -> u.path("/adgangsadresser/reverse").queryParam("x", lon).queryParam("y", lat).build())
                .retrieve()
                .body(Adgangsadresse.class));
    }

    private <T> T withRetry(Supplier<T> call) {
        for (int attempt = 0; ; attempt++) {
            try {
                return call.get();
            } catch (DanadresseException e) {
                if (!e.retryable() || attempt >= MAX_RETRIES) {
                    throw e;
                }
                try {
                    Thread.sleep(Math.min(e.retryAfterSeconds(), 30) * 1000L);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw e;
                }
            }
        }
    }
}

Spring Boot konfigurerer selv Jackson til at ignorere ukendte felter, så records virker uden ekstra opsætning. En forbindelsesfejl eller timeout kommer som Springs ResourceAccessException og prøves ikke igen af withRetry. Tilføj den, hvis dit kald er idempotent og du vil prøve igen.

Datavask: hvornår må du gemme?

SvarGør sådan
APræcis match. Gem resultater().get(0).bedste().id()
BPræcis én sikker match med små stavefejl. Gem også den, med den officielle tekst
CUsikker: flere kandidater eller større forskelle. Vis kandidaterne, og lad brugeren vælge

sikker() er sand for A og B. Kategorien findes kun på topniveau, og der er intet score-felt. vaskeresultat().afstand() er 0 ved en præcis match. "Rentemestervej 8, 2. tv, 2400 København NV" giver C med syv kandidater, fordi det nummer ikke har en 2. tv. betegnelse læser selv etage og dør. Har din formular allerede separate felter, kan du sende vejnavn, husnr, etage, dør og postnr i stedet. Gem adressens id, ikke kun teksten. Se Gem adresse-id'et.

Tjek svaret med curl

Kør det samme opslag fra terminalen, før du fejlsøger din Java-kode:

Shell#curl-check
curl -s -G "https://api.danadresse.dk/datavask/adresser" \
  --data-urlencode "betegnelse=Strandvejen 100, 2900 Hellerup" \
  -H "X-Api-Key: $DANADRESSE_API_KEY" | head -c 300

Næste skridt

Relaterede guides

Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)

Prøv det med en gratis nøgle

2 000 kald/måned på gratisplanen. Intet kreditkort.

Opret konto →