Adding Openverse: the third image provider, and the first one nobody has to configure
I was picking up an issue that had been sitting blocked in the backlog for a while. The idea is simple: add Openverse (a WordPress project, formerly CC Search) as a third source of hero images alongside Pexels and Unsplash, giving access to 800M+ Creative Commons and public-domain works. It was blocked on two things that had to exist first: self-hosted hero images (since Openverse just points at wherever the image originally lives, and those links rot) and published-post attribution (since CC licenses actually require crediting the creator). Both shipped weeks ago, so this was finally unblocked.
Before writing any code I hit the real Openverse API directly instead of just trusting the issue’s description. That paid off immediately: I confirmed anonymous search genuinely works with zero credentials, got the exact field names back (url, thumbnail, creator_url, license, license_version, all confirmed against a live response for a Flickr photo), and confirmed the OAuth2 token endpoint’s shape with a couple of deliberately-invalid requests (a 400 with field-required errors on /v1/auth_tokens/register/, a 401 invalid_client on /v1/auth_tokens/token/) without actually registering an app or touching any real account.
The client mirrors the existing Pexels/Unsplash house style closely: same Net::HTTP request pattern, same connection: dependency-injection seam for tests, same nested Error/AuthError classes. The one deliberate deviation is that blank credentials don’t raise - Openverse’s anonymous tier is a first-class, supported way to use the API, not a degraded fallback, so Openverse::Client.new with nothing passed in just works. That single design choice turned out to have more downstream implications than I expected (more on that below).
The other real design decision was around license handling. Pexels and Unsplash both use one blanket license for every result (a Pexels photo is always “Pexels License”, full stop), so their clients just return a constant. Openverse aggregates work under all sorts of CC variants - BY, BY-SA, CC0, Public Domain Mark - so license had to be read per-result. I got this wrong on the first pass: I built the display name by always prefixing “CC” onto whatever code came back, which produces “CC CC0” for public-domain images. My own test caught it in the sense that I wrote the test to assert that exact wrong value, and then the automated review caught that the test was asserting a bug, not a feature. Fixed with an explicit map for the non-attribution licenses (cc0 → “CC0”, pdm → “Public Domain Mark”) and the CC-prefix path only for the actual attribution variants.
The token caching had a subtler bug. Rails.cache.fetch can’t set a TTL derived from what the block itself returns, so I hand-rolled a read-then-write instead. My first version floored the computed TTL at the safety margin rather than at zero - so a token with a short expires_in (say 30 seconds, with a 60-second margin) got cached for 60 seconds anyway, meaning the app would keep using an already-dead token and get silently 401’d for the back half of that window. The review also caught that the token-fetch error path had drifted from the search path’s error handling - a 500 from the token endpoint was getting misreported as an AuthError, the same class as a genuinely bad client secret, which would send someone down the wrong debugging path entirely.
What surprised me most wasn’t a bug, though - it was a design tradeoff I hadn’t fully thought through until the review’s altitude angle named it directly: because Openverse works anonymously, it’s the first provider in the registry that’s unconditionally “configured.” Pexels and Unsplash only show up in search results if an admin has pasted in a key; Openverse always shows up. That means an install that has never configured any image provider at all now makes a real outbound HTTPS call to api.openverse.org on every hero-image search, where before it did nothing. I decided not to fix this in-PR - it’s exactly what the issue asked for (“anonymous by default”), and building a way to opt out of a keyless provider is a bigger feature than this issue scoped. But it’s a real behavior change worth having named explicitly rather than discovering by accident later.
What’s next: nothing directly blocked on this. The half-filled-credentials edge case (client_id set without a secret, silently falling back to anonymous with no indication) and the batch hero-assignment view’s exposure to Openverse’s rate limit under heavy scrolling are both documented on the PR as accepted, low-severity risks rather than filed as new issues - neither one currently causes incorrect behavior, just a degraded experience in an edge case.
Related reading
The hero image bug that was actually three bugs
A card that lied about a post having no hero, a relative path resolved against the wrong origin, and a Referer-based hotlink 403 from my own blog against my own app.
Adding Unsplash as a second hero-image provider
Mirroring the Pexels client line for line, a fire-and-forget download-tracking ping, and a browser-controlled URL that almost got the API key attached to it.
Building self-hosted hero images before I had anything that needed them
A download-and-rehost pipeline for CC-licensed images: vips from a buffer, a UTF-8 force-encode that would have corrupted JPEG bytes, and a stale queue that made the guard look like a bug.