A Ruby client with Net::HTTP and Faraday
Call Danadresse from Ruby and Rails with Net::HTTP or Faraday, with timeouts, keep-alive, error codes and Retry-After on 429.
This guide shows two Ruby clients for Danadresse. The first only uses the standard library (Net::HTTP and JSON) and works in scripts, Sinatra and Rails without new gems. The second uses Faraday, which many Rails projects already have. Both cover autocomplete, lookup by id, datavask (address cleansing), reverse geocoding, timeouts, error codes and 429.
The key is read from the DANADRESSE_API_KEY environment variable. In Rails you can keep it in credentials and set the environment variable from there, but keep it on the server.
1. Net::HTTP
Save the file as danadresse.rb. Run it directly with ruby danadresse.rb and it makes six calls as a demo:
# danadresse.rb: en lille klient til Danadresse API med Net::HTTP (kun standardbiblioteket) require "json" require "net/http" require "uri" module Danadresse Adresse = Struct.new(:id, :betegnelse, :postnr, :lon, :lat, keyword_init: true) # kategori kun på topniveau (ingen score); afstand 0 = præcis match Vask = Struct.new(:kategori, :kandidater, :adresse_id, :tekst, :afstand, keyword_init: true) do # A = præcis match, B = én sikker match med små stavefejl. C: lad brugeren vælge def sikker? %w[A B].include?(kategori) && !adresse_id.nil? end end class Error < StandardError attr_reader :status, :code, :retry_after, :details def initialize(status, code, message, retry_after = nil, details = nil) super("HTTP #{status} #{code}: #{message}") @status = status @code = code # stabil kode, fx "invalid_api_key" @retry_after = retry_after @details = details # fx resets_at og upgrade_url ved quota_exceeded end end class Client MAX_RETRIES = 2 def initialize(api_key: ENV.fetch("DANADRESSE_API_KEY"), base_url: ENV.fetch("DANADRESSE_BASE_URL", "https://api.danadresse.dk")) @api_key = api_key @base = URI(base_url) end def autocomplete(q, per_side: 5) get("/autocomplete", q: q, per_side: per_side) end def adresse(id) d = get("/adresser/#{URI.encode_www_form_component(id)}") til_adresse(d, d["adgangsadresse"]) end def vask(betegnelse) svar = get("/datavask/adresser", betegnelse: betegnelse) res = svar["resultater"] || [] # aktueladresse = den gældende version (afviger, hvis den fundne er nedlagt) a = res.dig(0, "aktueladresse") || res.dig(0, "adresse") Vask.new(kategori: svar["kategori"], kandidater: res.size, adresse_id: a&.dig("id"), tekst: a && "#{a['vejnavn']} #{a['husnr']}, #{a['postnr']} #{a['postnrnavn']}", afstand: res.dig(0, "vaskeresultat", "afstand")) end def reverse(lon, lat) d = get("/adgangsadresser/reverse", x: lon, y: lat) til_adresse(d, d) end private def til_adresse(d, ag) lon, lat = ag.dig("adgangspunkt", "koordinater") Adresse.new(id: d["id"], betegnelse: d["adressebetegnelse"], postnr: ag.dig("postnummer", "nr"), lon: lon, lat: lat) end def get(path, params = {}) uri = @base.dup uri.path = path uri.query = URI.encode_www_form(params) unless params.empty? attempt = 0 begin res = http.request(Net::HTTP::Get.new(uri, "X-Api-Key" => @api_key, "Accept" => "application/json")) return JSON.parse(res.body) if res.is_a?(Net::HTTPSuccess) raise fejl(res) rescue Error => e # 429 (minutgrænse) og 503 med Retry-After kan prøves igen; quota_exceeded har ingen Retry-After raise unless [429, 503].include?(e.status) && e.retry_after && attempt < MAX_RETRIES attempt += 1 sleep([e.retry_after, 30].min) retry rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED raise if attempt >= MAX_RETRIES attempt += 1 @http = nil # åbn en ny forbindelse sleep(2**attempt) retry end end # Én keep-alive-forbindelse pr. klient (ikke trådsikker: brug én klient pr. tråd) def http @http ||= Net::HTTP.start(@base.host, @base.port, use_ssl: @base.scheme == "https", open_timeout: 3, read_timeout: 10) end def fejl(res) body = JSON.parse(res.body) rescue {} body = {} unless body.is_a?(Hash) # DAWA-stier: {type,title,details,code} /api/v1: {error:{code,message}} err = body["error"].is_a?(Hash) ? body["error"] : body ra = res["Retry-After"] Error.new(res.code.to_i, err["code"], err["title"] || err["message"] || res.message, ra&.match?(/\A\d+\z/) ? ra.to_i : nil, err["details"]) end end end if $PROGRAM_NAME == __FILE__ api = Danadresse::Client.new api.autocomplete("Rådhuspladsen 1, 4200", per_side: 3).each { |f| puts "forslag: #{f['forslagstekst']}" } a = api.adresse("0a3f50ae-b4ee-32b8-e044-0003ba298018") puts "opslag: #{a.betegnelse} #{a.lon} #{a.lat}" ["Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV"].each do |tekst| v = api.vask(tekst) puts "datavask: #{v.kategori} #{v.kandidater} #{v.sikker? ? v.tekst : 'lad brugeren vælge'}" end puts "reverse: #{api.reverse(12.5683, 55.6761).betegnelse}" begin api.adresse("00000000-0000-0000-0000-000000000000") rescue Danadresse::Error => e puts "fejl: #{e.status} #{e.code}" end end
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 Strandvejen 100, 2900 Hellerup datavask: C 7 lad brugeren vælge reverse: Rådhuspladsen 3, 1550 København V fejl: 404 not_found
The important parts of the class:
- Keep-alive.
Net::HTTP.startopens one connection that is reused for every call. That saves a TLS handshake per call. ANet::HTTPconnection is not thread-safe, so use one client per thread, for example in a Puma worker withThread.current[:danadresse] ||= Danadresse::Client.new. - Timeouts.
open_timeout: 3andread_timeout: 10cap the waiting time. On a timeout the connection is dropped and a new one is opened on the next attempt. - Error formats. The DAWA paths answer
{"type","title","details","code"}, and/api/v1/*answers{"error":{"code","message","details"}}, for every error including 500.fejlreads both, so you can branch one.codeand reade.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_exceededhas noRetry-Afterand is raised immediately. Refused calls are not counted or billed.
2. Faraday
With Faraday 2 and faraday-retry, the middleware handles network errors and timeouts. 429 is handled in the class itself. The reason is that quota_exceeded is also a 429, and it must not be retried:
# danadresse_faraday.rb (Gemfile: gem "faraday", "~> 2.0"; gem "faraday-retry") require "faraday" require "faraday/retry" module Danadresse class Error < StandardError attr_reader :status, :code, :retry_after, :details def initialize(status, code, message, retry_after = nil, details = nil) super("HTTP #{status} #{code}: #{message}") @status = status @code = code @retry_after = retry_after @details = details end end class FaradayClient MAX_RETRIES = 2 def initialize(api_key: ENV.fetch("DANADRESSE_API_KEY"), base_url: ENV.fetch("DANADRESSE_BASE_URL", "https://api.danadresse.dk")) @conn = Faraday.new(url: base_url, headers: { "X-Api-Key" => api_key }) do |f| f.options.open_timeout = 3 f.options.timeout = 10 # Kun netværksfejl og timeouts; 429 håndteres nedenfor, så quota_exceeded ikke gentages f.request :retry, max: MAX_RETRIES, interval: 0.5, backoff_factor: 2, exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError] f.response :json end end def autocomplete(q, per_side: 5) get("/autocomplete", q: q, per_side: per_side) end def adresse(id) get("/adresser/#{id}") end def vask(betegnelse) get("/datavask/adresser", betegnelse: betegnelse) end def reverse(lon, lat) get("/adgangsadresser/reverse", x: lon, y: lat) end private def get(path, params = {}) attempt = 0 loop do res = @conn.get(path, params) return res.body if res.success? body = res.body.is_a?(Hash) ? res.body : {} # DAWA-stier: {type,title,details,code} /api/v1: {error:{code,message}} err = body["error"].is_a?(Hash) ? body["error"] : body ra = res.headers["retry-after"] retry_after = ra&.match?(/\A\d+\z/) ? ra.to_i : nil if [429, 503].include?(res.status) && retry_after && attempt < MAX_RETRIES attempt += 1 sleep([retry_after, 30].min) next end raise Error.new(res.status, err["code"], err["title"] || err["message"] || "HTTP #{res.status}", retry_after, err["details"]) end end end end
f.response :json parses JSON responses into a Hash, errors included, so res.body["code"] works directly.
3. In Rails
Create the client in one place and use it from models, jobs and controllers:
# config/initializers/danadresse.rb require Rails.root.join("lib/danadresse") # app/models/kunde.rb class Kunde < ApplicationRecord validate :adresse_findes, if: :adresse_id_changed? private def adresse_findes Danadresse::Client.new.adresse(adresse_id) rescue Danadresse::Error => e errors.add(:adresse_id, "findes ikke") if e.code == "not_found" raise unless e.code == "not_found" end end
Store the address id rather than only the text. With the id you can always fetch the current address. See Store the address id.
Error codes
| Status | code | Action |
|---|---|---|
| 401 | missing_api_key, invalid_api_key | Check the environment variable. Do not retry |
| 403 | key_blocked, sandbox_scope, ip_not_allowed | Fix the key or plan |
| 404 | not_found | The id does not exist |
| 429 | rate_limit_per_minute | The client waits Retry-After |
| 429 | quota_exceeded | Free: the monthly quota and the grace buffer are used up. Paid: the budget cap is reached. Never retry. e.details["resets_at"] says when calls are served again. See Limits, quotas and 429 |
| 500 | internal_error | An error on our side. Log it and try again later |
| 503 | upstream_unavailable | A public register is down. The client waits |
Datavask
vask returns the top-level category (A, B or C), the number of candidates, afstand and the best address. 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. sikker? is true for A and B. C is uncertain: show the candidates and let the user pick. In the example, "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. betegnelse reads floor and door itself.
Try the call from the terminal:
curl -s -G "https://api.danadresse.dk/datavask/adresser" \ --data-urlencode "betegnelse=Strandvejen 100, 2900 Hellerup" \ -H "X-Api-Key: $DANADRESSE_API_KEY" | jq '{kategori, kandidater: (.resultater | length)}'
Next steps
- The error format: DAWA paths and /api/v1
- Limits, quotas and 429
- The same client in Python: Python client
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)