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:
200returns the PNG bytes and theX-Quota-Remainingheader.429raisesQuotaUsedUp. The code isquota_used_up, and retrying cannot help.502(file_unavailable) and503(plan_check_failed) wait 2, 4 and 8 seconds, then raiseApiErrorif 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
- API docs: endpoints, keys, limits and errors.
- All dog breeds, each with nine views.
- Pricing for full-size transparent PNG files.
- A dog breed card with Tailwind CSS
- A dog breed gallery in SvelteKit
- A dog breed picker in Angular
- A dog breed selector in Vue 3