Guides / Tutorials

Dog breed images in a Rails app

By the Dog Image API team. Updated .

To use dog breed images in Rails, write a small Net::HTTP client for the full-size endpoint, call it from a rake task that attaches each PNG to an Active Storage record, and add a view helper that renders the free preview with its credit link. The key stays in an environment variable.

The idea

Dog Image API serves AI-generated dog illustrations, one dog per set, in nine views on a transparent background. Previews are WebP and keyless. Full-size 2048 x 2048 PNGs need a key and a plan, and you download each one once.

So the app has two paths. A rake task fills your storage with full-size files ahead of time. A helper shows the preview for any breed you have not stored yet. Before you use a real key, run the task once with a throwaway value to see the expected 401 and confirm the wiring.

Set the key where the task runs:

export DOG_IMAGE_API_KEY="paste-your-key-here"

The client

Put this in lib/dog_image_client.rb. It uses only the standard library.

require "net/http"
require "json"

# Minimal client for the full-size endpoint. Stays on the server.
class DogImageClient
  BASE = URI("https://dogimage.dev")

  class QuotaUsedUp < StandardError; end
  class ApiError < StandardError; end

  Download = Struct.new(:body, :remaining)

  def initialize(key = ENV.fetch("DOG_IMAGE_API_KEY"))
    @key = key
  end

  def full(breed, variant, view, attempts: 4)
    uri = URI("#{BASE}/api/v1/images/#{breed}/#{variant}/#{view}?size=full")
    attempt = 0
    loop do
      attempt += 1
      res = get(uri)
      case res.code.to_i
      when 200
        return Download.new(res.body, res["X-Quota-Remaining"])
      when 429
        raise QuotaUsedUp, describe(res)
      when 502, 503
        raise ApiError, describe(res) if attempt >= attempts
        sleep 2**attempt
      else
        raise ApiError, describe(res)
      end
    end
  end

  private

  def get(uri)
    request = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{@key}")
    Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 60) do |http|
      http.request(request)
    end
  end

  def describe(res)
    error = JSON.parse(res.body).fetch("error")
    "#{res.code} #{error['code']}: #{error['message']}"
  rescue JSON::ParserError, KeyError
    "#{res.code} #{res.message}"
  end
end

ENV.fetch raises a KeyError if the variable is missing, so a forgotten key fails at once instead of sending an empty header.

Three outcomes are handled on purpose:

  • 200 returns the PNG bytes and the X-Quota-Remaining header.
  • 429 raises QuotaUsedUp. The code is quota_used_up, and retrying cannot help.
  • 502 (file_unavailable) and 503 (plan_check_failed) wait 2, 4 and 8 seconds, then raise ApiError if they keep failing.

Anything else, such as 401 or 404, raises ApiError with the code and message from the JSON body. The errors and quotas reference lists them all.

The model and the migration

Add Active Storage if the app does not have it yet, with bin/rails active_storage:install. Then generate a small model:

bin/rails generate model DogImage breed:string variant:string view:string

Edit the model and the migration so that one row means one stored image.

# app/models/dog_image.rb
class DogImage < ApplicationRecord
  has_one_attached :file
  validates :breed, :variant, :view, presence: true
end

In the migration, add add_index :dog_images, [:breed, :variant, :view], unique: true, then run bin/rails db:migrate.

The rake task

# lib/tasks/dogs.rake
require Rails.root.join("lib/dog_image_client")

namespace :dogs do
  desc "Download full-size dog PNGs once and attach them. BREEDS=pug,beagle VIEWS=front,left"
  task fetch: :environment do
    breeds = ENV.fetch("BREEDS", "pug,beagle,samoyed").split(",")
    views = ENV.fetch("VIEWS", "front,left,head").split(",")
    client = DogImageClient.new

    begin
      breeds.each do |breed|
        views.each do |view|
          record = DogImage.find_or_initialize_by(breed: breed, variant: "default", view: view)
          if record.file.attached?
            puts "skip #{breed}/#{view}"
            next
          end

          download = client.full(breed, "default", view)
          record.file.attach(
            io: StringIO.new(download.body),
            filename: "#{breed}-default-#{view}.png",
            content_type: "image/png"
          )
          record.save!
          puts "saved #{breed}/#{view}, remaining this month: #{download.remaining}"
        end
      end
    rescue DogImageClient::QuotaUsedUp => e
      puts "Stopping: #{e.message}"
    end
  end
end

Run it with BREEDS=pug,beagle bin/rails dogs:fetch. The rescue sits outside both loops, so a 429 ends the whole run and keeps what is saved. Any other error propagates and shows a full trace.

To skip Active Storage, replace the attach block with File.binwrite(Rails.root.join("storage", "#{breed}-#{view}.png"), download.body).

Reading the quota header

download.remaining is a string. It is unlimited on the All images plan, so check before you do arithmetic:

if download.remaining =~ /\A\d+\z/ && download.remaining.to_i < 10
  puts "Fewer than 10 images left this month."
end

The preview helper

For breeds you have not stored, link the free 512 pixel preview and print the credit next to it.

# app/helpers/dog_images_helper.rb
module DogImagesHelper
  DOG_IMAGE_SITE = "https://dogimage.dev".freeze

  def dog_preview_url(breed, view: "front", variant: "default", size: 512)
    "#{DOG_IMAGE_SITE}/api/v1/images/#{breed}/#{variant}/#{view}?size=#{size}"
  end

  def dog_preview(breed, view: "front")
    name = breed.tr("-", " ").titleize
    safe_join([
      image_tag(dog_preview_url(breed, view: view),
                alt: "#{name}, AI-generated illustration", width: 512, height: 512, loading: "lazy"),
      tag.p(link_to("#{name} image: Dog Image API", "#{DOG_IMAGE_SITE}/dogs/#{breed}"))
    ])
  end
end

In a view, call <%= dog_preview("golden-retriever", view: "left") %>. The URL redirects to a WebP file served with a one-year cache header, and previews may be hot-linked with the credit. For a stored record, use url_for(record.file) instead and skip the credit, which full-size files do not need. Read the license for the exact limits, such as no reselling the images as a collection.

Where to go next

Browse slugs on /dogs, and compare plans on the pricing page. Each distinct full-size image counts once per calendar month, so a second run of the task costs nothing for files it already holds.

Questions

Do I have to use Active Storage?

No. The client class below only returns bytes and the quota header. You can write them to storage/ or public/ with File.binwrite instead of attaching them, and the rest of the code stays the same.

Can I call the full-size endpoint from a controller on each request?

You should not. Full-size files are meant to be downloaded once and stored on your side, and the key must stay on the server. Run the rake task from a deploy step or by hand, then serve your stored copy.

How do I know how much quota is left?

Read the X-Quota-Remaining header on each full-size reply. It is a number, or the word unlimited on the All images plan. The usage endpoint also reports the month, the plan, the limit, the count used and the remaining count.

Is the preview helper allowed in a commercial app?

Yes. Previews may be used commercially with a visible link crediting Dog Image API next to the image or on the same page. The helper renders that link for you. The license page is the reference.

Next steps