<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.3.1">Jekyll</generator><link href="https://stevepolito.design/feed.xml" rel="self" type="application/atom+xml" /><link href="https://stevepolito.design/" rel="alternate" type="text/html" /><updated>2026-03-02T11:06:50+00:00</updated><id>https://stevepolito.design/feed.xml</id><title type="html">Steve Polito Design</title><entry><title type="html">Your chat bot needs a better rate limit strategy</title><link href="https://stevepolito.design/blog/chat-bot-per-user-rate-limits" rel="alternate" type="text/html" title="Your chat bot needs a better rate limit strategy" /><published>2026-03-02T00:00:00+00:00</published><updated>2026-03-02T00:00:00+00:00</updated><id>https://stevepolito.design/blog/chat-bot-per-user-rate-limits</id><content type="html" xml:base="https://stevepolito.design/blog/chat-bot-per-user-rate-limits"><![CDATA[<p>I’m on a project where we’re connecting to OpenAI to build a chat bot.</p>

<p>Because OpenAI’s <a href="https://developers.openai.com/api/docs/guides/rate-limits#rate-limits-in-headers">rate limits</a> are <a href="https://developers.openai.com/api/docs/guides/rate-limits#how-do-these-rate-limits-work">more
complicated</a> than other APIs, we made sure to <a href="https://thoughtbot.com/blog/openai-rate-limits#avoid-rate-limits-by-being-proactive">proactively avoid
rate limits</a> by keeping track of how many tokens we’re using.
However, this approach is incomplete in that it does nothing to limit an
<strong>individual</strong> user’s usage.</p>

<p>A chat bot feature is unique in that it can quickly exhaust your organization’s
rate limits across multiple dimensions: requests per minute (RPM) and tokens per
minute (TPM). This is because…</p>

<ul>
  <li>Chatting enables the user to send messages in rapid succession,
which increases RPM.</li>
  <li>Messages can be long, contain attachments, and as the conversation
grows, so does the context window, which increases TPM.</li>
</ul>

<p>Once a user hits the organization’s limit, <strong>everything</strong> that uses OpenAI is
affected, and will no longer work. In the case of my project, not only does that
mean the chat bot feature would be down, but also other tools we use that
leverage OpenAI.</p>

<p>In short, one user can trigger a denial of service simply by using a feature as
it was intended to be used.</p>

<p>Fortunately, there are a few solutions to this problem.</p>

<h2 id="our-base">Our base</h2>

<p>For the sake of this demonstration, we’ll use <a href="https://rubyllm.com">RubyLLM</a>, but the concepts
we’ll learn apply to any implementation and platform.</p>

<pre><code class="language-ruby">chat = RubyLLM.chat

response = chat.ask "What's a good rate limit strategy for a chat bot?"

puts response.content
</code></pre>

<h2 id="rate-limit-messages">Rate limit messages</h2>

<p>The first thing we can do is limit the number of messages a user can send in a
given time frame. This is known as “fixed-window rate limiting”. Although Rails
ships with a <a href="https://api.rubyonrails.org/classes/ActionController/RateLimiting/ClassMethods.html#method-i-rate_limit">rate limit mechanism</a>, that won’t help us when
we need to rate limit token usage. Instead, we can rely on a <a href="https://guides.rubyonrails.org/caching_with_rails.html#other-cache-stores">cache
store</a> like Redis, since its built-in <a href="https://redis.io/docs/latest/commands/incr/">INCR</a> and
<a href="https://redis.io/docs/latest/commands/expire//">EXPIRE</a> APIs lend themselves well to a <a href="https://redis.io/glossary/rate-limiting/">rate limiting
mechanism</a>.</p>

<pre><code class="language-ruby"># app/models/usage.rb
class Usage
  MAX_RPM = ENV.fetch("USAGE_MAX_RPM", 10).to_i

  def initialize(user, cache_store = ActiveSupport::Cache::RedisCacheStore.new)
    @user = user
    @cache_store = cache_store
  end

  def track!
    track_rpm!
  end

  def exceeded?
    rpm_exceeded?
  end

  private

  attr_reader :user, :cache_store

  def rpm_key
    "usage:user:#{user.id}:rpm"
  end

  def track_rpm!
    cache_store.increment(rpm_key, 1, expires_in: 1.minute)
  end

  def rpm_exceeded?
    cache_store.read(rpm_key, raw: true).to_i &gt;= MAX_RPM
  end
end

# app/models/user.rb
class User &lt; ApplicationRecord
  def usage
    Usage.new(self)
  end
end
</code></pre>

<p>We can create a simple object that tracks user requests per minute simply by
<a href="https://api.rubyonrails.org/v8.1.2/classes/ActiveSupport/Cache/RedisCacheStore.html#method-i-increment">incrementing</a> the value by one for each request made,
making sure to expire the key after one minute.</p>

<p>Here’s how that might look when used with our chat bot:</p>

<pre><code class="language-ruby">chat = RubyLLM.chat
user = User.last!
usage = user.usage

unless usage.exceeded?
  response = chat.ask "What's a good rate limit strategy for a chat bot?"

  usage.track!

  puts response.content
else
  # Alert user that they've exceeded their usage.
end
</code></pre>

<p>Before making a request, we check to see if the user has exhausted their
individual rate limit. If not, we make the request and track the usage.</p>

<h2 id="limit-token-usage">Limit token usage</h2>

<p>Limiting requests is just half of the problem, since token usage is also a
metric that requires rate limiting.</p>

<p>We can’t just <a href="https://api.rubyonrails.org/classes/ActiveModel/Validations/HelperMethods.html#method-i-validates_length_of">validate the length</a> of the message, since
token usage doesn’t map 1:1 with character length. Additionally, token
usage is also based on output tokens.</p>

<p>Fortunately, OpenAI returns usage data in the <a href="https://developers.openai.com/api/reference/resources/responses/methods/create">response</a>, which
RubyLLM exposes in a <a href="https://www.rubydoc.info/gems/ruby_llm/RubyLLM/Message"><code>Message</code></a> instance.</p>

<pre><code class="language-diff"> class Usage
   MAX_RPM = ENV.fetch("USAGE_MAX_RPM", 10).to_i
+  MAX_TPM = ENV.fetch("USAGE_MAX_TPM", 10_000).to_i

   def initialize(user, cache_store = ActiveSupport::Cache::RedisCacheStore.new)
     @user = user
     @cache_store = cache_store
   end

-  def track!
-    track_rpm!
+  def track!(total_tokens)
+    [ track_rpm!, track_tpm!(total_tokens) ]
   end

   def exceeded?
-    rpm_exceeded?
+    rpm_exceeded? || tpm_exceeded?
   end

   private
@@ -29,4 +30,16 @@ class Usage
   def rpm_exceeded?
     cache_store.read(rpm_key, raw: true).to_i &gt;= MAX_RPM
   end
+
+  def tpm_key
+    "usage:user:#{user.id}:tpm"
+  end
+
+  def track_tpm!(total_tokens)
+    cache_store.increment(tpm_key, total_tokens, expires_in: 1.minute)
+  end
+
+  def tpm_exceeded?
+    cache_store.read(tpm_key, raw: true).to_i &gt;= MAX_TPM
+  end
 end
</code></pre>

<p>We can use the same pattern we used for tracking requests to track tokens.
The only difference is that we need to supply that information.</p>

<p>Here’s how that would look in our chat bot:</p>

<pre><code class="language-diff"> unless usage.exceeded?
   response = chat.ask "What's a good rate limit strategy for a chat bot?"

-  usage.track!
+  tokens = response.tokens
+  total_tokens = tokens.input + tokens.output + tokens.thinking
+
+  usage.track!(total_tokens)

   puts response.content
 else
</code></pre>

<p>After making a request, we extract
the token usage from the response, and pass it to our <code>Usage</code> instance
to be used in the next request.</p>

<h2 id="calculating-per-user-rate-limits">Calculating per-user rate limits</h2>

<p>Since the rate limits set by OpenAI and other providers are at the
organization level, how might we evenly distribute those values on a per-user
basis?</p>

<p>A simple approach suitable for most early-stage products would be to divide
the organization limit by the expected concurrent users. In order to ensure we
account for unexpected spikes in traffic, we can add in a buffer.</p>

<pre><code>Per-user limit = (Organization limit × buffer) / Expected concurrent users
</code></pre>

<p>Below is what that looks like using the values in this demonstration.</p>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Org Limit</th>
      <th>Concurrent Users</th>
      <th>Buffer</th>
      <th>Per-user Limit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>RPM</td>
      <td>125</td>
      <td>10</td>
      <td>0.8</td>
      <td>(125 * 0.8) / 10</td>
    </tr>
    <tr>
      <td>TPM</td>
      <td>125,000</td>
      <td>10</td>
      <td>0.8</td>
      <td>(125,000 * 0.8) / 10</td>
    </tr>
  </tbody>
</table>

<h2 id="additional-considerations">Additional considerations</h2>

<p>The core problem is that a chat bot is inherently expensive. If we stick to
these numbers, we’re constrained to 10 concurrent users, which feels pretty
limited, even for an early-stage product.</p>

<p>One solution is to queue the requests rather than reject them, which might look
something like this:</p>

<pre><code class="language-ruby">class ProcessPromptJob &lt; ApplicationJob
  queue_as :default

  MAX_ATTEMPTS = 2

  rescue_from(Usage::RateLimitExhaustedError) do
    if executions &lt; MAX_ATTEMPTS
      retry_job wait: 15.seconds
    else
      # Broadcast failure message to user
    end
  end

  def perform(user, prompt)
    raise Usage::RateLimitExhaustedError if user.usage.exceeded?

    chat = RubyLLM.chat

    response = chat.ask(prompt)

    tokens = response.tokens
    total_tokens = tokens.input + tokens.output + tokens.thinking

    user.usage.track!(total_tokens)

    # Broadcast LLM response to user
  end
end
</code></pre>

<pre><code class="language-diff"> class Usage
+  class RateLimitExhaustedError &lt; StandardError; end
+
   MAX_RPM = ENV.fetch("USAGE_MAX_RPM", 10).to_i
   MAX_TPM = ENV.fetch("USAGE_MAX_TPM", 10_000).to_i

</code></pre>

<p>The flow would be something like this:</p>

<pre><code>User sends message
  └─▶ Show loading state
        └─▶ Enqueue background job
              └─▶ Rate limit exceeded?
                    ├─ No  ──▶ Process request ──▶ Broadcast response
                    └─ Yes ──▶ Wait 15 seconds
                                 └─▶ Rate limit exceeded?
                                       ├─ No  ──▶ Process request ──▶ Broadcast response
                                       └─ Yes ──▶ Broadcast failure
</code></pre>

<h2 id="wrapping-up">Wrapping up</h2>

<p>Limiting a user’s application usage is not a new problem, but it’s more relevant
today with the rise in chat bot features.</p>

<p>The examples above highlight simple solutions, but they’re just a start — a more
nuanced approach will likely be needed.</p>

<p>For example, if a user hits their limit, do you attempt to upsell them? Do you
flat out block them? Or, should you fall back to a cheaper model? Maybe a
combination of these suggestions?</p>

<p>Regardless, these decisions should involve stakeholders such as designers and
the product team.</p>]]></content><author><name></name></author><category term="[&quot;Ruby on Rails&quot;, &quot;Artificle Intelligence&quot;]" /><category term="Web Development" /><category term="Chat Bot" /><summary type="html"><![CDATA[Don't let one ambitious user trigger a denial of service.]]></summary></entry><entry><title type="html">How to design a join code system</title><link href="https://stevepolito.design/blog/join-code-system-design" rel="alternate" type="text/html" title="How to design a join code system" /><published>2025-11-10T00:00:00+00:00</published><updated>2025-11-10T00:00:00+00:00</updated><id>https://stevepolito.design/blog/join-code-system-design</id><content type="html" xml:base="https://stevepolito.design/blog/join-code-system-design"><![CDATA[<p>I was recently on a project that needed a “join code/game pin” feature similar to those
found in multiplayer quiz games.</p>

<p><img src="https://images.thoughtbot.com/9iz42ci8cdbr2vxshg24tgm0jx89_Untitled-2025-10-31-0942.png" alt="A low-fidelity mock-up of a screen where you enter a join code to join a game." /></p>

<p>I naively thought this could be achieved in a matter of hours, but soon realized
there was a lot of nuance. This is the article I wish existed when I started
working on this feature.</p>

<h2 id="understanding-the-requirements">Understanding the requirements</h2>

<p>A join code needs to be short, easy to type, as well as easy to read or even say
aloud (in cases where the user cannot see the code). Because of this, APIs like
<a href="https://api.rubyonrails.org/classes/ActiveRecord/TokenFor.html#method-i-generate_token_for"><code>generate_token_for</code></a> weren’t going to work, because
they’re not meant to be typed, or viewed. They’re meant to be clicked.</p>

<p>Because of this, most join codes are 6 characters long, consisting of numbers,
letters, or a combination of the two.</p>

<h2 id="naive-implementation">Naive implementation</h2>

<p>Knowing this, I figured I would just generate a random 6 character digit and
call it a day.</p>

<pre><code class="language-ruby">join_code = SecureRandom.random_number(1000000).to_s.rjust(6, "0")

# =&gt; "891907"

# Create game
Game.create!(join_code:)

# Find game
Game.find_by(join_code: params[:join_code])
</code></pre>

<p>This technique will yield 1,000,000 (10 digits with 6 possible positions = 10^6)
possible combinations, which seems like it would be plenty.</p>

<p>But then I got to thinking:</p>

<ul>
  <li>What happens if the feature is a massive success, and we eventually exhaust
all of the possible 1,000,000 combinations?</li>
  <li>How likely are collisions if we can’t recycle join codes?</li>
</ul>

<h2 id="understanding-collision-probability">Understanding collision probability</h2>

<p>In order to answer these questions, I found it easier to break the problem down
into something simpler.</p>

<p>Let’s assume join codes are 1 character in length, and can only be a digit (0-9).</p>

<p>This means there’s always a 10% chance of randomly generating any number. But,
once a value has been used, the probability of colliding increases linearly.</p>

<table>
  <thead>
    <tr>
      <th>Join Codes in DB (before)</th>
      <th>Chance Generated Join Code Exists</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>0.0%</td>
    </tr>
    <tr>
      <td>1</td>
      <td>10.0%</td>
    </tr>
    <tr>
      <td>2</td>
      <td>20.0%</td>
    </tr>
    <tr>
      <td>3</td>
      <td>30.0%</td>
    </tr>
    <tr>
      <td>4</td>
      <td>40.0%</td>
    </tr>
    <tr>
      <td>5</td>
      <td>50.0%</td>
    </tr>
    <tr>
      <td>6</td>
      <td>60.0%</td>
    </tr>
    <tr>
      <td>7</td>
      <td>70.0%</td>
    </tr>
    <tr>
      <td>8</td>
      <td>80.0%</td>
    </tr>
    <tr>
      <td>9</td>
      <td>90.0%</td>
    </tr>
    <tr>
      <td>10</td>
      <td>100.0%</td>
    </tr>
  </tbody>
</table>

<p>This means that once we have 500,000 rows in the database, there will be a 50%
chance of a collision when generating the next join code. As each new row is
added, the collision rate will increase.</p>

<p>This is a problem if we can’t recycle join codes, because we’ll need to handle
these collisions on each attempt, which could result in an infinite loop.</p>

<h2 id="alphanumeric-implementation">Alphanumeric implementation</h2>

<p>After realizing that 1,000,000 combinations may not be enough, I figured I’d go
with an alphanumeric approach, since that would result in more combinations, and
more time until a 50% collision rate.</p>

<pre><code class="language-ruby">join_code = SecureRandom.alphanumeric(6).upcase

# =&gt; "TOAJR7"

# Create game
Game.create!(join_code:)

# Find game
Game.find_by(join_code: params[:join_code])
</code></pre>

<p>Now there are 2,176,782,336 (10 digits + 26 letters with 6 possible positions =
36^6) possible combinations.</p>

<h2 id="ux-considerations-with-an-alphanumeric-join-code">UX considerations with an alphanumeric join code</h2>

<p>I thought the alphanumeric approach would be easy, until I realized there are
some UX “gotchas”.</p>

<p>The first “gotcha” is that some numbers look like capitalized letters.</p>

<ul>
  <li>0 ↔ O</li>
  <li>1 ↔ I</li>
  <li>2 ↔ Z</li>
  <li>5 ↔ S</li>
  <li>8 ↔ B</li>
  <li>U ↔ V</li>
</ul>

<p>The second “gotcha” is there’s a chance the randomly generated join code will
contain a profanity or two.</p>

<p>Tools like <a href="https://github.com/sqids/sqids-ruby">Sqids Ruby</a> account for both of these concerns.</p>

<p>The third “gotcha” is normalization. It’s a better UX not to have to capitalize
individual characters when entering a join code. But this means you’ll want
to normalize the value when making the query.</p>

<pre><code class="language-ruby">class Game &lt; ActiveRecord::Base
  normalizes :join_code, with: -&gt; join_code { join_code.strip.upcase }
end

join_code = Game.normalize_value_for(join_code: params[:join_code])
Game.find_by(join_code:)
</code></pre>

<p>You’ll also want to make the value appears capitalized.</p>

<pre><code class="language-html">&lt;style&gt;
    #join_code {
        text-transform: uppercase;
    }
&lt;/style&gt;

&lt;input type="text" id="join_code" name="join_code" /&gt;
</code></pre>

<h2 id="scoping-to-time-not-space">Scoping to time, not space</h2>

<p>We can avoid the collision and UX concerns by limiting <strong>when</strong> a join code
can be used.</p>

<p>Instead of indexing on the join code alone, we can scope it to another column.
In most cases, using a join code should be limited to a point of time, since
games are temporary.</p>

<p>The simplest way to do this would be with an <code>active</code> boolean column. Here’s
what that schema might look like:</p>

<pre><code class="language-ruby">class CreateGames &lt; ActiveRecord::Migration[8.0]
  def change
    create_table :games do |t|
      t.string :join_code
      t.boolean :active, default: false, null: false

      t.timestamps
    end

    add_index :games, :join_code, unique: true, where: "active = true AND join_code IS NOT NULL"

    add_check_constraint :games, "join_code IS NOT NULL OR active = false",
      name: "active_games_require_join_code"
  end
end
</code></pre>

<p>This would allow join codes to be recycled, while ensuring they’re unique
across all “active” games.</p>

<pre><code class="language-ruby"># Create game
Game.create!

# Find an active game
Game.active.find_by(join_code: params[:join_code])
</code></pre>

<p>This means the probability of a collision would eclipse 50% when there are
500,000 <strong>active</strong> games, which is far less of a concern. We can actually use
our naive number-based implementation from the beginning now that we understand
how to scope the join code.</p>

<p>Here’s a complete example:</p>

<pre><code class="language-ruby">class Game &lt; ApplicationRecord
  JOIN_CODE_REGEX = /\A\d{6}\z/

  validates :join_code, presence: true, if: :active?
  validates :join_code, uniqueness:  {
    scope: :active,
    conditions: -&gt; { where(active: true) }
  }
  validates :join_code, format: {
    with: JOIN_CODE_REGEX
  }, if: :active?

  before_validation :set_join_code, unless: :join_code?

  scope :active, -&gt; { where(active: true) }

  private

  def set_join_code
    max_attempts = ENV.fetch("JOIN_CODE_GENERATION_MAX_ATTEMPTS")

    max_attempts.times do
      self.join_code = generate_join_code

      next if @existing_game = Game.active.exists?(join_code:)
    end

    raise "Failed to generate unique join_code after #{max_attempts} attempts" if @existing_game
  end

  def generate_join_code
    SecureRandom.random_number(1000000).to_s.rjust(6, "0")
  end
end
</code></pre>

<h2 id="wrapping-up">Wrapping up</h2>

<p>Let’s circle back to the original requirements:</p>

<blockquote>
  <p>A join code needs to be short, easy to type, as well as easy to read or even
say aloud (in cases where the user cannot see the code).</p>
</blockquote>

<p>By sticking with digits only, our complete example accounts for all of these
requirements. It also provides a better UX because users won’t need to toggle
between their number keys and character keys, or concern themselves with
capitalization. It also alleviates us from needing to account for profanities or
characters that look alike, as well as normalization.</p>

<p>It turns out, our naive implementation from the beginning was mostly right, but
we needed to take the scenic route to come to that conclusion.</p>]]></content><author><name></name></author><category term="[&quot;Ruby on Rails&quot;]" /><category term="Web Development" /><category term="Design" /><summary type="html"><![CDATA[Learn the ins and outs of building a game-based join code feature.]]></summary></entry><entry><title type="html">Introducing Top Secret</title><link href="https://stevepolito.design/blog/top-secret" rel="alternate" type="text/html" title="Introducing Top Secret" /><published>2025-08-22T00:00:00+00:00</published><updated>2025-08-22T00:00:00+00:00</updated><id>https://stevepolito.design/blog/top-secret</id><content type="html" xml:base="https://stevepolito.design/blog/top-secret"><![CDATA[<p>We’ve written about how to <a href="https://thoughtbot.com/blog/parameter-filtering#filter-sensitive-information-from-external-network-requests">prevent logging sensitive information when making
network requests</a>, but that approach only works if you’re dealing with
parameters.</p>

<p>What happens when you’re dealing with free text? Filtering the entire string may
not be an option if an external API needs to process the value. Think chatbots or LLMs.</p>

<p>You could use a regex to filter sensitive information (such as credit card
numbers or emails), but that won’t capture everything, since not all sensitive
information can be captured with a regex.</p>

<p>Fortunately, <a href="https://en.wikipedia.org/wiki/Named-entity_recognition">named-entity recognition</a> (NER) can be used to identify and
classify real-world objects, such as a person, or location. Tools like <a href="https://github.com/ankane/mitie-ruby">MITIE
Ruby</a> make interfacing with NER models trivial.</p>

<p>By using a combination of regex patterns and NER entities, <a href="https://github.com/thoughtbot/top_secret">Top Secret</a>
effectively filters sensitive information from free text—here are some
real-world examples.</p>

<p>If you want to see <a href="https://github.com/thoughtbot/top_secret">Top Secret</a> in action, you might enjoy this <a href="https://www.youtube.com/live/m2UIpTaIZ8o?si=EzEkWHlNQJORVgSG&amp;t=120">live
stream</a>. Otherwise, see the examples below.</p>

<h2 id="working-with-llms">Working with LLMs</h2>

<p>It’s not uncommon to send user data to chatbots. Since the data might be
free-form, we should be diligent about filtering it using the approach mentioned
above.</p>

<p>However, it’s likely we’ll want to “restore” the filtered values when returning
a response from the chatbot. <a href="https://github.com/thoughtbot/top_secret">Top Secret</a> returns a <a href="https://github.com/thoughtbot/top_secret?tab=readme-ov-file#usage">mapping</a> that would
allow for this.</p>

<p>You’d likely want to provide <a href="https://platform.openai.com/docs/guides/text#message-roles-and-instruction-following">instructions</a> in the request.</p>

<pre><code class="language-ruby">instructions = &lt;&lt;~TEXT
  I'm going to send filtered information to you in the form of free text.
  If you need to refer to the filtered information in a response, just reference it by the filter.
TEXT
</code></pre>

<p>The exchange might look something like this.</p>

<ol>
  <li>
    <p>Caller sends filtered text</p>

    <pre><code class="language-ruby"> result = TopSecret::Text.filter("Ralph lives in Boston.")

 # Send this to the API
 result.output # =&gt; [PERSON_1] lives in [LOCATION_1].

 # Save the mapping to "restore" response
 mapping = result.mapping # =&gt; { PERSON_1: "Ralph", LOCATION_1: "Boston" }
</code></pre>
  </li>
  <li>
    <p>API responds with filter</p>

    <pre><code> "Hi [PERSON_1]! How is the weather in [LOCATION_1] today?"
</code></pre>
  </li>
  <li>
    <p>Caller can “restore” from the mapping</p>

    <pre><code class="language-ruby"> response = "Hi [PERSON_1]! How is the weather in [LOCATION_1] today?"

 # Restore the response from the mapping
 result = TopSecret::FilteredText.restore(response, mapping: mapping)

 result.output
 # =&gt; Hi Ralph! How is the weather in Boston today?
</code></pre>
  </li>
</ol>

<h3 id="filtering-conversation-history">Filtering conversation history</h3>

<p>When working with <a href="https://platform.openai.com/docs/guides/conversation-state">conversation state</a> you should filter <strong>every</strong> message
before including it in the request. This ensures no sensitive data slips through
from previous messages. Here’s what that might look like.</p>

<pre><code class="language-ruby">require "openai"
require "top_secret"

openai = OpenAI::Client.new(
  api_key: Rails.application.credentials.openai.api_key!
)

original_messages = [
  "Ralph lives in Boston.",
  "You can reach them at ralph@thoughtbot.com or 877-976-2687"
]

# Filter all messages
result = TopSecret::Text.filter_all(original_messages)
filtered_messages = result.items.map(&amp;:output)

user_messages = filtered_messages.map { {role: "user", content: it} }

# Instruct LLM how to handle filtered messages
instructions = &lt;&lt;~TEXT
  I'm going to send filtered information to you in the form of free text.
  If you need to refer to the filtered information in a response, just reference it by the filter.
TEXT

messages = [
  {role: "system", content: instructions},
  *user_messages
]

chat_completion = openai.chat.completions.create(messages:, model: :"gpt-5")
response = chat_completion.choices.last.message.content

# Restore the response from the mapping
mapping = result.mapping
restored_response = TopSecret::FilteredText.restore(response, mapping:).output

puts(restored_response)
</code></pre>

<h2 id="prevent-storing-sensitive-information-with-validations">Prevent storing sensitive information with validations</h2>

<p>Top Secret can also be used as a validation tool to prevent storing sensitive
information in your database.</p>

<pre><code class="language-ruby">class Message &lt; ApplicationRecord
  validate :content_cannot_contain_sensitive_information

  private

  def content_cannot_contain_sensitive_information
    result = TopSecret::Text.filter(content)
    return if result.mapping.empty?

    errors.add(:content, "contains the following sensitive information #{result.mapping.values.to_sentence}")
  end
end
</code></pre>

<p>If the validation is too strict, you can <a href="https://github.com/thoughtbot/top_secret#overriding-the-default-filters">override</a> or <a href="https://github.com/thoughtbot/top_secret#disabling-a-default-filter">disable</a> any of
the filters as needed.</p>

<pre><code class="language-diff">--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -4,7 +4,7 @@ class Message &lt; ApplicationRecord
   private

   def content_cannot_contain_sensitive_information
-    result = TopSecret::Text.filter(content)
+    result = TopSecret::Text.filter(content, people_filter: nil, location_filter: nil)
     return if result.mapping.empty?

     errors.add(:content, "contains the following sensitive information #{result.mapping.values.to_sentence}")
</code></pre>

<h2 id="wrapping-up">Wrapping up</h2>

<p>It’s our responsibility to protect user data. This is more important than ever
given the rise in popularity of chatbots and LLMs. Tools like <a href="https://github.com/thoughtbot/top_secret">Top Secret</a> aim to
reduce this burden.</p>]]></content><author><name></name></author><category term="[&quot;Ruby&quot;]" /><category term="Artificial Intelligence" /><category term="Security" /><summary type="html"><![CDATA[We’ve written about how to prevent logging sensitive information when making network requests, but that approach only works if you’re dealing with parameters.]]></summary></entry><entry><title type="html">Prevent logging sensitive information in Rails, and beyond</title><link href="https://stevepolito.design/blog/parameter-filtering" rel="alternate" type="text/html" title="Prevent logging sensitive information in Rails, and beyond" /><published>2025-06-10T00:00:00+00:00</published><updated>2025-06-10T00:00:00+00:00</updated><id>https://stevepolito.design/blog/parameter-filtering</id><content type="html" xml:base="https://stevepolito.design/blog/parameter-filtering"><![CDATA[<p>By default, Rails <a href="https://guides.rubyonrails.org/action_controller_advanced_topics.html#parameters-filtering">filters out sensitive request parameters</a> from your log
files. I’ve found the <a href="https://guides.rubyonrails.org/configuring.html#config-filter-parameters">default values</a> are a good foundation, and account for
<em>almost</em> all use cases.</p>

<pre><code class="language-ruby"># config/initializers/filter_parameter_logging.rb

Rails.application.config.filter_parameters += [
  :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
]
</code></pre>

<p>If a request is made that contains a parameter that partially matches any of
these values, it will be filtered.</p>

<pre><code>Parameters: {"authenticity_token"=&gt;"[FILTERED]", "email_address"=&gt;"[FILTERED]", "password"=&gt;"[FILTERED]", "commit"=&gt;"Sign in"}
</code></pre>

<p>It also <a href="https://api.rubyonrails.org/classes/ActiveRecord/Core/ClassMethods.html#method-i-filter_attributes">filters the associated attributes</a>.</p>

<pre><code class="language-ruby">&lt;User:0x000000011f735030
 id: 980190962,
 email_address: "[FILTERED]",
 password_digest: "[FILTERED]",
 created_at: "2025-04-23 13:59:46.324377000 +0000",
 updated_at: "2025-04-23 13:59:46.324377000 +0000"&gt;
</code></pre>

<h2 id="dont-just-filter-sensitive-information-encrypt-it">Don’t just filter sensitive information, encrypt it</h2>

<p>There’s a case to be made that you should rarely need to update the default
list. This is because if you plan on storing anything worth filtering, it should
be <a href="https://guides.rubyonrails.org/active_record_encryption.html">encrypted</a>.</p>

<pre><code class="language-ruby">class User &lt; ApplicationRecord
  encrypts :phone_number
end
</code></pre>

<p>Fortunately, Rails has accounted for this by <a href="https://guides.rubyonrails.org/active_record_encryption.html#filtering-params-named-as-encrypted-columns">automatically filtering encrypted
attributes</a>.</p>

<p>Note how the <code>phone_number</code> is filtered when logging internal requests.</p>

<pre><code>Parameters: {"authenticity_token"=&gt;"[FILTERED]", "user"=&gt;{"email_address"=&gt;"[FILTERED]", "phone_number"=&gt;"[FILTERED]", "password_digest"=&gt;"[FILTERED]"}, "commit"=&gt;"Create User"}
</code></pre>

<p>It’s also filtered when inspecting an object.</p>

<pre><code class="language-ruby">&lt;User:0x0000000121282608
 id: 980190962,
 email_address: "[FILTERED]",
 password_digest: "[FILTERED]",
 created_at: "2025-04-23 14:10:49.414784000 +0000",
 updated_at: "2025-04-23 14:10:49.414784000 +0000",
 phone_number: "[FILTERED]"&gt;
</code></pre>

<h2 id="filter-sensitive-information-from-external-network-requests">Filter sensitive information from external network requests</h2>

<p>Just because Rails provides a good foundation doesn’t mean it accounts for
everything.</p>

<p>For example, if you’re using Faraday, it’s your responsibility to <a href="https://lostisland.github.io/faraday/#/middleware/included/logging?id=filter-sensitive-information">filter
sensitive information</a> when logging requests. This does not happen by
default.</p>

<pre><code class="language-ruby">conn = Faraday.new(url: "http://httpbingo.org") do |builder|
  builder.request :json
  builder.response :json
  builder.response :raise_error
  builder.response :logger, nil, {
    headers: true,
    bodies: true,
    errors: true
  }
end

conn.get("get", api_key: "secret")
conn.post("anything", user: User.last!.as_json)
</code></pre>

<p>We’re exposing the <code>api_key</code> when logging both the request and the
response when making a <code>GET</code> request.</p>

<pre><code>INFO -- : request: GET http://httpbingo.org/get?api_key=secret
INFO -- : response: {
  "args": {
    "api_key": [
      "secret"
    ]
  },
  "url": "http://httpbingo.org/get?api_key=secret"
}
</code></pre>

<p>We’re also exposing all the sensitive attributes on <code>user</code>, even though those
are filtered internally.</p>

<pre><code>INFO -- : request: POST http://httpbingo.org/anything
INFO -- : response: {
  "data": "{\"user\":{\"id\":980190962,\"email_address\":\"one@example.com\",\"password_digest\":\"$2a$12$Qo1yNHtJ58InjxM2d3895emekpMVpEzwLTtMJ/piHeDet0oePuKne\",\"created_at\":\"2025-04-23T14:10:49.414Z\",\"updated_at\":\"2025-04-23T14:10:49.414Z\",\"phone_number\":\"555-555-5555\"}}",
  "json": {
    "user": {
      "created_at": "2025-04-23T14:10:49.414Z",
      "email_address": "one@example.com",
      "id": 980190962,
      "password_digest": "$2a$12$Qo1yNHtJ58InjxM2d3895emekpMVpEzwLTtMJ/piHeDet0oePuKne",
      "phone_number": "555-555-5555",
      "updated_at": "2025-04-23T14:10:49.414Z"
    }
  }
}
</code></pre>

<p>Faraday offers an API for <a href="https://lostisland.github.io/faraday/#/middleware/included/logging?id=filter-sensitive-information">filtering sensitive information</a>, but using it
would mean you would need to duplicate efforts.</p>

<p>Fortunately, we can create a <a href="https://lostisland.github.io/faraday/#/middleware/included/logging?id=customize-the-formatter">custom formatter</a> to re-use our Rails
configuration.</p>

<pre><code class="language-ruby">class ApplicationFormatter &lt; Faraday::Logging::Formatter
  def request(env)
    info("Request") { log_url(env.url) }
    info("Request") { log_body(env.body) } if env.body &amp;&amp; log_body?
  end

  def response(env)
    info("Response") { log_url(env.url) }
    info("Response") { log_body(env.body) } if env.body &amp;&amp; log_body?
  end

  private

  # Re-uses existing configuration from config/initializers/filter_parameter_logging.rb  
  def filter_parameters
    @filter_parameters ||= Rails.configuration.filter_parameters
  end

  # Filters parameters
  def parameter_filter(**options)
    ActiveSupport::ParameterFilter.new(filter_parameters, **options)
  end

  def parse_json(json)
    JSON.parse(json, object_class: HashWithIndifferentAccess)
  end

  def log_body?
    @options[:bodies]
  end

  def log_body(body)
    result = walk(body)

    parameter_filter.filter(result).pretty_inspect
  end

  def log_url(url)
    filtered_url = filter_url(url)

    filtered_url.to_s
  end

  def filter_url(url)
    return url if url.query.nil?

    params = URI.decode_www_form(url.query).to_h
    filtered_params = parameter_filter(mask: "FILTERED").filter(params)
    url.query = URI.encode_www_form(filtered_params)
  end

  def walk(obj)
    case obj
    when Hash
      obj.transform_values { walk(_1) }
    when Array
      obj.map { walk(_1) }
    when String
      parse_json(obj)
    else
      obj
    end
  rescue JSON::ParserError
    obj
  end
end
</code></pre>

<pre><code class="language-diff">--- a/lib/faraday.rb
+++ b/lib/faraday.rb
-   errors: true
+   errors: true,
+   formatter: ApplicationFormatter
+ }
end
</code></pre>

<p>Now the <code>api_key</code> is filtered when logging both the response and the request.
This is because we’re already filtering against partial matches on <code>_key</code>.</p>

<pre><code>INFO -- Request: api_key=FILTERED
INFO -- Response: {"args"=&gt;{"api_key"=&gt;"FILTERED"},
 "url"=&gt;"http://httpbingo.org/get?api_key=FILTERED"}
</code></pre>

<p>We’re also no longer exposing all the sensitive attributes on <code>user</code>.</p>

<pre><code>INFO -- Request: http://httpbingo.org/anything
INFO -- Response: {"args"=&gt;{},
 "data"=&gt;
  {"user"=&gt;
    {"id"=&gt;980190962,
     "email_address"=&gt;"[FILTERED]",
     "password_digest"=&gt;"[FILTERED]",
     "created_at"=&gt;"2025-04-23T14:10:49.414Z",
     "updated_at"=&gt;"2025-04-23T14:10:49.414Z",
     "phone_number"=&gt;"[FILTERED]"}},
 "json"=&gt;
  {"user"=&gt;
    {"created_at"=&gt;"2025-04-23T14:10:49.414Z",
     "email_address"=&gt;"[FILTERED]",
     "id"=&gt;980190962,
     "password_digest"=&gt;"[FILTERED]",
     "phone_number"=&gt;"[FILTERED]",
     "updated_at"=&gt;"2025-04-23T14:10:49.414Z"}}}
</code></pre>

<h2 id="creating-an-allow-list">Creating an allow list</h2>

<p>Let’s imagine we add a <code>name</code> column to the <code>users</code> table. Depending on the
application, this could be considered sensitive information, but may not warrant
encryption.</p>

<p>In this case, you’d need to remember to update
<code>config/initializers/filter_parameter_logging.rb</code>. In my experience, this is
almost always forgotten. Instead, what we want is an <a href="https://github.com/rails/rails/pull/45545">allow list</a>.</p>

<p>The idea is that you’d filter everything except timestamps and IDs.</p>

<pre><code class="language-ruby"># config/initializers/filter_parameter_logging.rb

Rails.application.config.filter_parameters += [
  lambda { |k, v| v.replace("[FILTERED]") unless k.match?(/\A(id|.*_id|.*_at|.*_on)\z/) }
]
</code></pre>

<p>This can be confirmed when inspecting a <code>user</code>. Note how the <code>name</code> is also
filtered.</p>

<pre><code class="language-ruby">#&lt;User:0x00000001306db560
 id: 980190962,
 email_address: [FILTERED],
 password_digest: [FILTERED],
 created_at: "2025-04-23 14:10:49.414784000 +0000",
 updated_at: "2025-06-06 11:45:52.243742000 +0000",
 phone_number: "[FILTERED]",
 name: [FILTERED]&gt;
</code></pre>

<p>However, this approach might be a little too aggressive, since it filters
<strong>everything</strong>. Notice how the <code>commit</code> parameter is now filtered from our requests.</p>

<pre><code>Parameters: {"authenticity_token"=&gt;"[FILTERED]", "user"=&gt;{"email_address"=&gt;"[FILTERED]", "phone_number"=&gt;"[FILTERED]", "password_digest"=&gt;"[FILTERED]"}, "commit"=&gt;"[FILTERED]"}
</code></pre>

<p>This change also affects our Faraday logging.</p>

<p>Now the entire <code>url</code> is filtered, instead of just the <code>api_key</code> parameter.</p>

<pre><code>INFO -- Request: api_key=%5BFILTERED%5D
INFO -- Response: {"args"=&gt;{"api_key"=&gt;["[FILTERED]"]},
 "url"=&gt;"[FILTERED]"}
</code></pre>

<p>And the entire <code>data</code> hash is filtered, instead of just the relevant attributes.</p>

<pre><code>INFO -- Request: http://httpbingo.org/anything
INFO -- Response: {"args"=&gt;{},
 "data"=&gt;"[FILTERED]",
 "json"=&gt;
  {"user"=&gt;
    {"created_at"=&gt;"2025-04-23T14:10:49.414Z",
     "email_address"=&gt;"[FILTERED]",
     "id"=&gt;980190962,
     "name"=&gt;"[FILTERED]",
     "password_digest"=&gt;"[FILTERED]",
     "phone_number"=&gt;"[FILTERED]",
     "updated_at"=&gt;"2025-06-06T11:45:52.243Z"}}}
</code></pre>

<p>Depending on your team’s security requirements, this might be desirable, but
it can create a poor debugging experience.</p>

<h2 id="wrapping-up">Wrapping Up</h2>

<p>The Rails defaults are a good foundation, and will serve you well. If you need
to store sensitive information, make sure to encrypt it. This not only filters
it from logs, but also keeps the data secure in the database.</p>

<p>All that aside, it’s still <strong>your</strong> responsibility to filter sensitive information
from logs when using external APIs, services, and tools.</p>

<p>Finally, using an Allow List might be a better option for applications that need
to adhere to strict compliance measures, such as Healthcare.</p>]]></content><author><name></name></author><category term="[&quot;Ruby on Rails&quot;]" /><category term="Tutorial" /><category term="Security" /><summary type="html"><![CDATA[The Rails defaults are a good foundation, but it's still your responsibility to filter sensitive information from logs when using external APIs, services, and tools.]]></summary></entry><entry><title type="html">Faster feedback loops with Rails Runner</title><link href="https://stevepolito.design/blog/rails-runner" rel="alternate" type="text/html" title="Faster feedback loops with Rails Runner" /><published>2025-04-17T00:00:00+00:00</published><updated>2025-04-17T00:00:00+00:00</updated><id>https://stevepolito.design/blog/rails-runner</id><content type="html" xml:base="https://stevepolito.design/blog/rails-runner"><![CDATA[<p>I recently needed to explore how best to craft and parse a series of network
requests as part of a feature I was working on.</p>

<p>It didn’t make sense to write a test for this, since I was only exploring the
idea, and because our test suite is configured to prevent making HTTP requests.</p>

<p>Because of this, I first tried to do all the work in the Rails console, but
found it to be too cumbersome. Then I decided to use the <a href="https://guides.rubyonrails.org/command_line.html#bin-rails-runner">rails runner</a>
with a temporary file located at <code>lib/scratchpad.rb</code>.</p>

<pre><code class="language-ruby"># lib/scratchpad.rb

# Explore how to craft network request
# Parse response
# Process response
</code></pre>

<p>Now that I was back in a proper developer environment, I could leverage all the
<a href="https://guides.rubyonrails.org/debugging_rails_applications.html">debugging</a> tools that ship with Rails. The end result was that I created a
fast feedback loop that allowed me to explore the idea more effectively.</p>

<p>When I was done, I knew how to build my requests, and was able to re-use that
code for both the implementation and for stubbing out the requests and responses
in my tests.</p>

<h2 id="tighten-the-loop">Tighten the loop</h2>

<p>Since I found this process so valuable, I decided to make it part of my Rails
workflow moving forward.</p>

<p>If you’re using Vim, or any editor that supports custom key mappings, you could
make a mapping to <code>bin/rails runner lib/scratchpad.rb</code>.</p>

<pre><code>" ~/.vimrc

" Rails Scratchpad
nnoremap &lt;silent&gt; &lt;Leader&gt;xx :!bin/rails runner lib/scratchpad.rb&lt;CR&gt;
</code></pre>

<p>Then you can configure Git to ignore this file globally.</p>

<pre><code># ~/.gitignore

lib/scratchpad.rb
</code></pre>

<p>Now executing this file is no different than executing my tests.</p>

<h2 id="next-steps">Next steps</h2>

<p>This approach doesn’t have to be limited to exploring network requests. Since the
file has access to the entire Rails application, you could use it to explore new
Jobs, Models, Services, and more. This concept was largely inspired by Kasper
Timm Hansen’s <a href="https://github.com/kaspth/riffing-on-rails">Riffing on Rails</a> approach.</p>]]></content><author><name></name></author><category term="[&quot;Ruby on Rails&quot;]" /><summary type="html"><![CDATA[Don't waste one more second coding in the Rails console.]]></summary></entry><entry><title type="html">How to respect OpenAI’s rate limits in Rails</title><link href="https://stevepolito.design/blog/open-ai-rate-limits" rel="alternate" type="text/html" title="How to respect OpenAI’s rate limits in Rails" /><published>2025-04-10T00:00:00+00:00</published><updated>2025-04-10T00:00:00+00:00</updated><id>https://stevepolito.design/blog/open-ai-rate-limits</id><content type="html" xml:base="https://stevepolito.design/blog/open-ai-rate-limits"><![CDATA[<p>I’m on a Rails project using OpenAI. We’re sending over large amounts of text to
provide as much context as possible, and recently ran into issues with
rate limiting.</p>

<p>As it turns out, OpenAI’s <a href="https://platform.openai.com/docs/guides/rate-limits?context=tier-free#rate-limits-in-headers">rate limits</a> are a little more complicated than
other APIs.</p>

<blockquote>
  <p>Rate limits are measured in five ways: RPM (requests per minute), RPD
(requests per day), TPM (tokens per minute), TPD (tokens per day), and IPM
(images per minute). Rate limits can be hit across any of the options
depending on what occurs first.</p>
</blockquote>

<p>In our case, we were hitting our <strong>TPM</strong> (tokens per minute) rate limit.</p>

<p>Regardless of whether a rate limit is exceeded, OpenAI will return the following
<a href="https://platform.openai.com/docs/guides/rate-limits#rate-limits-in-headers">headers</a>.</p>

<pre><code>x-ratelimit-reset-requests: 1s
x-ratelimit-reset-tokens: 6m0s
</code></pre>

<p>It should be noted that these headers represent the amount of time that needs to
pass before the rate limit returns to its initial state.</p>

<p>At the time of this writing, OpenAI does not have a first-party Ruby library,
but the community has gravitated towards <a href="https://github.com/alexrudall/ruby-openai">ruby-openai</a>, which is what our
project is using. When a rate limit is hit, it raises
<code>Faraday::TooManyRequestsError</code>, which gives us access to those headers via
<a href="https://www.rubydoc.info/gems/faraday/Faraday%2FError:response_headers"><code>#response_headers</code></a>.</p>

<p>Because OpenAI will return two headers (one for requests per minute and one for
tokens per minute), we play it safe and wait based on the greater of the two
values.</p>

<p>Rather than roll our own script to parse the header values, we can use <a href="https://github.com/henrypoydar/chronic_duration">Chronic
Duration</a> to do this for us. We can then define our own custom error class in
an initializer to build the wait value for us.</p>

<p>In order to use this value, we need to leverage <a href="https://api.rubyonrails.org/classes/ActiveSupport/Rescuable/ClassMethods.html#method-i-rescue_from"><code>#rescue_from</code></a> in
combination with <a href="https://api.rubyonrails.org/classes/ActiveJob/Exceptions.html#method-i-retry_job"><code>#retry_job</code></a>. This is because we need to set the <code>wait</code>
value dynamically based on the headers, and <a href="https://api.rubyonrails.org/classes/ActiveJob/Exceptions/ClassMethods.html#method-i-retry_on"><code>#retry_on</code></a> does not provide a
way to do this.</p>

<p>Below is a distilled example.</p>

<pre><code class="language-ruby"># app/jobs/send_prompt_job.rb
class SendPromptJob &lt; ApplicationJob
  queue_as :default

  MAX_ATTEMPTS = 2

  rescue_from OpenAI::RateLimitError do |error|
    if executions &lt; MAX_ATTEMPTS
      backoff = Backoff.polynomially_longer(executions:)

      retry_job wait: error.wait.seconds + backoff
    else
      Rails.logger.info "Exhausted attempts"
    end
  end

  def perform()
    OpenAI::Client.new.chat(...)
  rescue Faraday::TooManyRequestsError =&gt; error
    raise OpenAI::RateLimitError.new(error)
  end
end

# lib/backoff.rb
class Backoff
  DEFAULT_JITTER = 0.15

  def self.polynomially_longer(executions:, jitter: DEFAULT_JITTER)
    ((executions**4) + (Kernel.rand * (executions**4) * jitter)) + 2
  end
end

# config/initializers/openai.rb
module OpenAI
  class RateLimitError &lt; StandardError
    attr_reader :reset_requests_in_seconds, :reset_tokens_in_seconds

    def initialize(faraday_error)
      headers = faraday_error.response_headers&amp;.with_indifferent_access || {}

      @reset_requests_in_seconds = headers.fetch("x-ratelimit-reset-requests", "0s")
      @reset_tokens_in_seconds = headers.fetch("x-ratelimit-reset-tokens", "0s")

      super("The API has hit the rate limit")
    end

    def wait
      [
        parse_duration(reset_requests_in_seconds),
        parse_duration(reset_tokens_in_seconds)
      ].max
    end

    private

    def parse_duration(value)
      ChronicDuration.parse(value) || 0
    end
  end
end
</code></pre>

<p>Since we can’t leverage <code>retry_on</code>, we need to ensure we eventually stop
retrying the job if it continues to fail.</p>

<pre><code class="language-ruby">executions &lt; MAX_ATTEMPTS
</code></pre>

<p>Additionally, you’ll also note that we add a “backoff” mechanism per OpenAI’s
<a href="https://cookbook.openai.com/examples/how_to_handle_rate_limits#retrying-with-exponential-backoff">recommendation</a>.</p>

<pre><code class="language-ruby">backoff = Backoff.polynomially_longer(executions:)

retry_job wait: error.wait.seconds + backoff
</code></pre>

<h2 id="avoid-rate-limits-by-being-proactive">Avoid rate limits by being proactive</h2>

<p>We took a reactive approach to the problem, but I do want to highlight that
there’s an opportunity to be proactive by examining the <a href="https://platform.openai.com/docs/guides/rate-limits#rate-limits-in-headers">headers</a> that return
the amount of remaining requests or tokens that are permitted before exhausting
the rate limit.</p>

<pre><code>x-ratelimit-remaining-requests
x-ratelimit-remaining-tokens
</code></pre>

<p>Unfortunately, <code>ruby-openai</code> <a href="https://github.com/alexrudall/ruby-openai/issues/438">does not return response headers</a>, but there
is a workaround. You can create a custom Faraday middleware, and pass it to the
client in a block.</p>

<pre><code class="language-ruby">class ExtractRateLimitHeaders&lt; Faraday::Middleware
  def on_complete(env)
    # Store these values somewhere
    remaining_requests = env.response_headers["x-ratelimit-remaining-requests"]
    remaining_tokens = env.response_headers["x-ratelimit-remaining-tokens"]
  end
end

client = OpenAI::Client.new do |faraday|
  faraday.use ExtractRateLimitHeaders
end
</code></pre>

<p>You could then use this information to reduce the number of tokens you plan on
sending to OpenAI by comparing its size with <code>remaining_tokens</code>. Or, if you’re
keeping track of how many requests you’re making, you could compare that value
with <code>remaining_requests</code>.</p>

<pre><code class="language-ruby"># Ensure you're within the request and/or token limit before making a request
if (current_requests &lt; remaining_requests &amp;&amp; current_tokens &lt; remaining_tokens)
  client.chat(...)
end
</code></pre>

<p>Alternatively, you could temporarily switch to a model with higher token and
request limits, or temporarily reduce the amount of tokens sent in the request.</p>]]></content><author><name></name></author><category term="[&quot;Ruby on Rails&quot;]" /><category term="Artificial Intelligence" /><summary type="html"><![CDATA[As it turns out, OpenAI's rate limits are a little more complicated than other APIs.]]></summary></entry><entry><title type="html">Build a (progressively enhanced) drawer component with Hotwire</title><link href="https://stevepolito.design/blog/hotwire-drawer" rel="alternate" type="text/html" title="Build a (progressively enhanced) drawer component with Hotwire" /><published>2025-01-28T00:00:00+00:00</published><updated>2025-01-28T00:00:00+00:00</updated><id>https://stevepolito.design/blog/hotwire-drawer</id><content type="html" xml:base="https://stevepolito.design/blog/hotwire-drawer"><![CDATA[<p>In this tutorial, we’ll learn how to build a fully animated drawer component
without using any JavaScript. Then, to increase its fidelity, we’ll leverage
Hotwire.</p>

<p><img src="https://images.thoughtbot.com/x4i3prq8abqoxaxfubhvgs0ogxnq_final.gif" alt="Image of drawer component animating in and out" /></p>

<p>Feel free to follow along below, or view the <a href="https://github.com/thoughtbot/hotwire-example-template/compare/main...drawer">final code</a> which lives in our
<a href="https://github.com/thoughtbot/hotwire-example-template">Hotwire Example Template</a>.</p>

<h2 id="create-a-faux-drawer">Create a faux drawer</h2>

<p>Since Hotwire encourages the use of server-rendered templates, why not just make
a page that <em>looks</em> like a drawer?</p>

<p>For our example, we’ll use Tailwind CSS to create an application-level partial
to store our drawer component.</p>

<pre><code class="language-erb">&lt;% #app/views/application/_drawer.html.erb %&gt;
&lt;%# locals: (title: )%&gt;

&lt;div class="relative z-10" aria-labelledby="slide-over-title" role="dialog" aria-modal="true"&gt;
  &lt;div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
       aria-hidden="true"&gt;&lt;/div&gt;

  &lt;div class="fixed inset-0 overflow-hidden"&gt;
    &lt;div class="absolute inset-0 overflow-hidden"&gt;
      &lt;div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10"&gt;
        &lt;div class="pointer-events-auto relative w-screen max-w-md"&gt;
          &lt;div class="absolute left-0 top-0 -ml-8 flex pr-2 pt-4 sm:-ml-10 sm:pr-4"&gt;
            &lt;%= link_to :back, class: "relative rounded-md text-gray-300 hover:text-white focus:outline-none focus:ring-2 focus:ring-white" do %&gt;
              &lt;span class="absolute -inset-2.5"&gt;&lt;/span&gt;
              &lt;span class="sr-only"&gt;Close panel&lt;/span&gt;
              &lt;svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon"&gt;
                &lt;path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /&gt;
              &lt;/svg&gt;
            &lt;% end %&gt;
          &lt;/div&gt;

          &lt;div class="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl"&gt;
            &lt;div class="px-4 sm:px-6"&gt;
              &lt;h2 class="text-base font-semibold text-gray-900" id="slide-over-title"&gt;&lt;%= title %&gt;&lt;/h2&gt;
            &lt;/div&gt;
            &lt;div class="relative mt-6 flex-1 px-4 sm:px-6"&gt;
              &lt;%= yield %&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
</code></pre>

<p>We can even <a href="https://thoughtbot.com/blog/conditionally-render-turbo-frame">conditionally render</a> the drawer template using <a href="https://edgeguides.rubyonrails.org/layouts_and_rendering.html#the-variants-option">variants</a>
in an effort to reuse our existing controller and views.</p>

<pre><code class="language-erb">&lt;%# app/views/products/edit.html+drawer.erb %&gt;

&lt;%= render "drawer", title: "Edit product" do %&gt;
  &lt;%= render "form", product: @product %&gt;
&lt;% end %&gt;
</code></pre>

<pre><code class="language-erb">&lt;%# app/views/products/new.html+drawer.erb %&gt;

&lt;%= render "drawer", title: "New product" do %&gt;
  &lt;%= render "form", product: @product %&gt;
&lt;% end %&gt;
</code></pre>

<pre><code class="language-diff">--- a/app/controllers/products_controller.rb
+++ b/app/controllers/products_controller.rb
@@ -1,5 +1,6 @@
 class ProductsController &lt; ApplicationController
   before_action :set_product, only: %i[ show edit update destroy ]
+  before_action :set_variant, only: %i[ new edit update create ]

   def index
     @products = Product.all
@@ -9,10 +10,12 @@ class ProductsController &lt; ApplicationController
   end

   def new
+    request.variant = @variant
     @product = Product.new
   end

   def edit
+    request.variant = @variant
   end

   def create
@@ -21,7 +24,7 @@ class ProductsController &lt; ApplicationController
     if @product.save
       redirect_to products_path, notice: "Product was successfully created."
     else
-      render :new, status: :unprocessable_entity
+      render :new, variants: @variant, status: :unprocessable_entity
     end
   end

@@ -29,7 +32,7 @@ class ProductsController &lt; ApplicationController
     if @product.update(product_params)
       redirect_to products_path, notice: "Product was successfully updated."
     else
-      render :edit, status: :unprocessable_entity
+      render :edit, variants: @variant, status: :unprocessable_entity
     end
   end

@@ -48,4 +51,8 @@ class ProductsController &lt; ApplicationController
   def product_params
     params.require(:product).permit(:name, :description)
   end
+
+  def set_variant
+    @variant ||= :drawer if params[:variant] == "drawer"
+  end
 end
</code></pre>

<pre><code class="language-diff">--- a/app/views/products/index.html.erb
+++ b/app/views/products/index.html.erb
@@ -8,7 +8,7 @@
   &lt;div class="flex justify-between items-center"&gt;
     &lt;h1 class="font-bold text-4xl"&gt;Products&lt;/h1&gt;
     &lt;%= link_to "New product",
-      new_product_path,
+      new_product_path(variant: :drawer),
       class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %&gt;
   &lt;/div&gt;

</code></pre>

<pre><code class="language-diff">--- a/app/views/products/_product.html.erb
+++ b/app/views/products/_product.html.erb
@@ -11,7 +11,7 @@

   &lt;p&gt;
     &lt;%= link_to "Edit this product",
-      edit_product_path(product),
+      edit_product_path(product, variant: :drawer),
       class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %&gt;
   &lt;/p&gt;

</code></pre>

<pre><code class="language-diff">--- a/app/views/products/_form.html.erb
+++ b/app/views/products/_form.html.erb
@@ -21,6 +21,8 @@
     &lt;%= form.text_area :description, rows: 4, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %&gt;
   &lt;/div&gt;

+  &lt;%= hidden_field_tag :variant, @variant %&gt;
+
   &lt;div class="inline"&gt;
     &lt;%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %&gt;
   &lt;/div&gt;
</code></pre>

<p>Because <a href="https://turbo.hotwired.dev/handbook/drive">Turbo Drive</a> “…updates the page without doing a full reload”, the
experience is pretty snappy.</p>

<p><img src="https://images.thoughtbot.com/rdtb69f5mbia4csp850lnuyiachi_faux.gif" alt="Navigating to a page that looks like a drawer" /></p>

<h2 id="animate-the-drawer-with-view-transitions">Animate the drawer with View Transitions</h2>

<p>As snappy as this experience is, a certain level of fidelity is expected when
interacting with drawers.</p>

<p>Fortunately, we can leverage the <a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API">View Transition API</a> to animate the drawer
between page requests.</p>

<aside class="info">
<p>At the time of this writing, the View Transition API is <a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API#browser_compatibility">supported</a>
in all browsers except Firefox.</p>
</aside>

<p>All we need to do is enable the feature by adding a meta tag.</p>

<pre><code class="language-diff">--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -3,6 +3,7 @@
   &lt;head&gt;
     &lt;title&gt;HotwireExampleTemplate&lt;/title&gt;
     &lt;meta name="viewport" content="width=device-width,initial-scale=1"&gt;
+    &lt;meta name="view-transition" content="same-origin" /&gt;
     &lt;%= csrf_meta_tags %&gt;
     &lt;%= csp_meta_tag %&gt;
     &lt;%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %&gt;
</code></pre>

<p>From there, we can <a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#customizing_your_animations">customize the animations</a>.</p>

<pre><code class="language-css">@keyframes fade-out {
  from {
    opacity: 100%;
  }

  to {
    opacity: 0%;
  }
}

@keyframes fade-in {
  from {
    opacity: 0%;
  }

  to {
    opacity: 100%;
  }
}

@keyframes slide-out {
  from {
    transform: translateX(0%);
  }

  to {
    transform: translateX(100%);
  }
}

@keyframes slide-in {
  from {
    transform: translateX(100%);
  }

  to {
    transform: translateX(0%);
  }
}

::view-transition-old(backdrop) {
  animation: 0.4s ease-in both fade-out;
}

::view-transition-new(backdrop) {
  animation: 0.4s ease-in both fade-in;
}

::view-transition-old(panel) {
  animation: 0.4s ease-in both slide-out;
}

::view-transition-new(panel) {
  animation: 0.4s ease-in both slide-in;
}

#panel {
  view-transition-name: panel;
}

#backdrop {
  view-transition-name: backdrop;
}
</code></pre>

<p>We just need to be sure to identify the relevant drawer elements we want to
animate.</p>

<pre><code class="language-diff">--- a/app/views/application/_drawer.html.erb
+++ b/app/views/application/_drawer.html.erb
@@ -1,13 +1,15 @@
 &lt;%# locals: (title: )%&gt;

 &lt;div class="relative z-10" aria-labelledby="slide-over-title" role="dialog" aria-modal="true"&gt;
-  &lt;div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
+  &lt;div id="backdrop"
+       class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
        aria-hidden="true"&gt;&lt;/div&gt;

   &lt;div class="fixed inset-0 overflow-hidden"&gt;
     &lt;div class="absolute inset-0 overflow-hidden"&gt;
       &lt;div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10"&gt;
-        &lt;div class="pointer-events-auto relative w-screen max-w-md"&gt;
+        &lt;div id="panel"
+             class="pointer-events-auto relative w-screen max-w-md"&gt;
           &lt;div class="absolute left-0 top-0 -ml-8 flex pr-2 pt-4 sm:-ml-10 sm:pr-4"&gt;
             &lt;%= link_to :back, class: "relative rounded-md text-gray-300 hover:text-white focus:outline-none focus:ring-2 focus:ring-white" do %&gt;
               &lt;span class="absolute -inset-2.5"&gt;&lt;/span&gt;
</code></pre>

<p>With that, our drawer animates in and out. You’ll also note that it does not
animate when there’s a form error.</p>

<p><img src="https://images.thoughtbot.com/3gmip0jrfavdjezt5l9xn2emeo5b_view_transitions.gif" alt="Drawer animating and and out of the page." /></p>

<h2 id="render-the-drawer-on-the-current-page">Render the drawer on the current page</h2>

<p>At this point, I’d argue that we have a fully functioning drawer component, but
there’s still an opportunity to take it a step further by rendering it on the
current page. One way to achieve this is to render the drawer in a <a href="https://turbo.hotwired.dev/handbook/frames">Turbo Frame</a>.</p>

<p>First, we’ll need to wrap the existing drawer component in a <code>turbo_frame_tag</code>.</p>

<pre><code class="language-diff">--- a/app/views/application/_drawer.html.erb
+++ b/app/views/application/_drawer.html.erb
@@ -1,5 +1,6 @@
 &lt;%# locals: (title: )%&gt;

+&lt;%= turbo_frame_tag :drawer do %&gt;
   &lt;div class="relative z-10" aria-labelledby="slide-over-title" role="dialog" aria-modal="true"&gt;
     &lt;div id="backdrop"
          class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
@@ -33,3 +34,4 @@
       &lt;/div&gt;
     &lt;/div&gt;
   &lt;/div&gt;
+&lt;% end %&gt;
</code></pre>

<p>Next, we’ll want to add the corresponding <code>turbo_frame_tag</code> to the relevant
page, and ensure all existing links point to that frame.</p>

<pre><code class="language-diff">--- a/app/views/products/index.html.erb
+++ b/app/views/products/index.html.erb
@@ -9,7 +9,8 @@
     &lt;h1 class="font-bold text-4xl"&gt;Products&lt;/h1&gt;
     &lt;%= link_to "New product",
       new_product_path(variant: :drawer),
-      class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %&gt;
+      class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium",
+      data: { turbo_frame: :drawer } %&gt;
   &lt;/div&gt;

   &lt;div id="products" class="min-w-full"&gt;
@@ -18,3 +19,5 @@
     &lt;% end %&gt;
   &lt;/div&gt;
 &lt;/div&gt;
+
+&lt;%= turbo_frame_tag :drawer %&gt;
</code></pre>

<pre><code class="language-diff">--- a/app/views/products/_product.html.erb
+++ b/app/views/products/_product.html.erb
@@ -12,7 +12,8 @@
   &lt;p&gt;
     &lt;%= link_to "Edit this product",
       edit_product_path(product, variant: :drawer),
-      class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %&gt;
+      class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium",
+      data: {turbo_frame: :drawer} %&gt;
   &lt;/p&gt;

 &lt;/div&gt;
</code></pre>

<p>Because we’re now operating in the context of a Turbo Frame when submitting the
form, we need to refresh the page to ensure the newly created or modified
product appears on the page. This is because requests made from within a Turbo
Frame replace the content of just that frame, not the entire page.</p>

<p>We can do this by conditionally triggering a <a href="https://turbo.hotwired.dev/reference/streams#refresh">page refresh</a> when the request
is coming from within a Turbo Frame.</p>

<pre><code class="language-diff">--- a/app/controllers/products_controller.rb
+++ b/app/controllers/products_controller.rb
@@ -22,7 +22,10 @@ class ProductsController &lt; ApplicationController
     @product = Product.new(product_params)

     if @product.save
-      redirect_to products_path, notice: "Product was successfully created."
+      respond_to do |format|
+        format.turbo_stream if turbo_frame_request?
+        format.html { redirect_to products_path, notice: "Product was successfully created." }
+      end
     else
       render :new, variants: @variant, status: :unprocessable_entity
     end
@@ -30,7 +33,10 @@ class ProductsController &lt; ApplicationController

   def update
     if @product.update(product_params)
-      redirect_to products_path, notice: "Product was successfully updated."
+      respond_to do |format|
+        format.turbo_stream if turbo_frame_request?
+        format.html { redirect_to products_path, notice: "Product was successfully updated." }
+      end
     else
       render :edit, variants: @variant, status: :unprocessable_entity
     end
</code></pre>

<pre><code class="language-erb">&lt;%# app/views/products/create.turbo_stream.erb %&gt;

&lt;turbo-stream action="refresh"&gt;&lt;/turbo-stream&gt;
</code></pre>

<pre><code class="language-erb">&lt;%# app/views/products/update.turbo_stream.erb %&gt;

&lt;turbo-stream action="refresh"&gt;&lt;/turbo-stream&gt;
</code></pre>

<p>If we examine the current behavior, we’ll notice that the animations are broken.
The drawer no longer animates in, but does animate out on form submission. It
also no longer animates out when dismissed.</p>

<p>This is because the drawer is now being inserted into the page, rather than being
navigated to. Conversely, dismissing the drawer removes it from the DOM. In each
case, this means the view transitions do not have an opportunity to render.</p>

<p>However, submitting the form still results in a page navigation, which triggers
the view transitions.</p>

<p><img src="https://images.thoughtbot.com/rnbu4jmgx4goocqp3gwi2yjzmn4w_broken_state.gif" alt="Image of Drawer in a broken state. It does not animate in, nor does it animate out when dismissed" /></p>

<p>In order to account for this, we’ll need to introduce <a href="https://github.com/mmccall10/el-transition">el-transition</a> and
write a custom Stimulus Controller.</p>

<p>We can use <a href="https://stimulus.hotwired.dev/reference/lifecycle-callbacks">lifecycle callbacks</a> to animate the drawer in when we detect
that it’s entered that page.</p>

<p>On the flip side, we can <a href="https://turbo.hotwired.dev/handbook/frames#pausing-rendering">pause rendering</a> so that we can animate those
elements off the page before they’re removed from the DOM.</p>

<pre><code class="language-js">// app/javascript/controllers/drawer_controller.js

import { Controller } from "@hotwired/stimulus";
import { enter, leave } from "el-transition";

// Connects to data-controller="drawer"
export default class extends Controller {
  static targets = ["backdrop", "panel"];

  #isEntering;
  #isLeaving;

  backdropTargetConnected(target) {
    if (this.#isEntering) enter(target);
  }

  panelTargetConnected(target) {
    if (this.#isEntering) enter(target);
  }

  async animate(event) {
    const {
      detail: { newFrame },
    } = event;

    const currentChildCount = this.element.children.length;
    const newChildCount = newFrame.children.length;

    this.#isEntering = currentChildCount == 0 &amp;&amp; newChildCount &gt; 0;
    this.#isLeaving = currentChildCount &gt; 0 &amp;&amp; newChildCount == 0;

    if (this.#isLeaving) {
      event.preventDefault();

      await Promise.all([
        leave(this.backdropTarget).then(() =&gt; this.backdropTarget.remove()),
        leave(this.panelTarget).then(() =&gt; this.panelTarget.remove()),
      ]);

      event.detail.resume();
    }
  }
}
</code></pre>

<p>The key is that we need to inspect the <code>newFrame</code> dispatched from
<a href="https://turbo.hotwired.dev/reference/events#turbo%3Abefore-frame-render"><code>turbo:before-frame-render</code></a> to determine if the drawer is entering or
leaving.</p>

<pre><code class="language-js">const {
  detail: { newFrame },
} = event;

const currentChildCount = this.element.children.length;
const newChildCount = newFrame.children.length;
</code></pre>

<p>Now we need to wire up our controller to our existing Turbo Frame.</p>

<pre><code class="language-diff">--- a/app/views/products/index.html.erb
+++ b/app/views/products/index.html.erb
@@ -21,4 +21,4 @@
   &lt;/div&gt;
 &lt;/div&gt;
 
-&lt;%= turbo_frame_tag :drawer %&gt;
+&lt;%= turbo_frame_tag :drawer, data: {controller: "drawer", action: "turbo:before-frame-render-&gt;drawer#animate"} %&gt;
</code></pre>

<p>Finally, we just need to set the <a href="https://stimulus.hotwired.dev/reference/targets">targets</a> and add the expected <a href="https://github.com/mmccall10/el-transition?tab=readme-ov-file#dataset-attributes">dataset
attributes</a>.</p>

<pre><code class="language-diff">--- a/app/views/application/_drawer.html.erb
+++ b/app/views/application/_drawer.html.erb
@@ -4,13 +4,27 @@
   &lt;div class="relative z-10" aria-labelledby="slide-over-title" role="dialog" aria-modal="true"&gt;
     &lt;div id="backdrop"
          class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
+         data-drawer-target="backdrop"
+         data-transition-enter="ease-in-out duration-500"
+         data-transition-enter-start="opacity-0"
+         data-transition-enter-end="opacity-100"
+         data-transition-leave="ease-in-out duration-500"
+         data-transition-leave-start="opacity-100"
+         data-transition-leave-end="opacity-0"
          aria-hidden="true"&gt;&lt;/div&gt;
 
     &lt;div class="fixed inset-0 overflow-hidden"&gt;
       &lt;div class="absolute inset-0 overflow-hidden"&gt;
         &lt;div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10"&gt;
           &lt;div id="panel"
-               class="pointer-events-auto relative w-screen max-w-md"&gt;
+               class="pointer-events-auto relative w-screen max-w-md"
+               data-transition-enter="transform transition ease-in-out duration-500 sm:duration-700"
+               data-transition-enter-start="translate-x-full"
+               data-transition-enter-end="translate-x-0"
+               data-transition-leave="transform transition ease-in-out duration-500 sm:duration-700"
+               data-transition-leave-start="translate-x-0"
+               data-transition-leave-end="translate-x-full"
+               data-drawer-target="panel"&gt;
             &lt;div class="absolute left-0 top-0 -ml-8 flex pr-2 pt-4 sm:-ml-10 sm:pr-4"&gt;
               &lt;%= link_to :back, class: "relative rounded-md text-gray-300 hover:text-white focus:outline-none focus:ring-2 focus:ring-white" do %&gt;
                 &lt;span class="absolute -inset-2.5"&gt;&lt;/span&gt;
</code></pre>

<p>And with that, our drawer now animates in and out of the current page.</p>

<p><img src="https://images.thoughtbot.com/x4i3prq8abqoxaxfubhvgs0ogxnq_final.gif" alt="Image of drawer component animating in and out" /></p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>I hope this tutorial highlighted the power of server-side rendering coupled with
emerging web APIs. By simply creating a page that <em>looks</em> like a drawer and
using the <a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API">View Transition API</a>, we were able to create a fully functioning
drawer component without using any JavaScript. I hope you found it as compelling
as I did.</p>]]></content><author><name></name></author><category term="[&quot;Ruby on Rails&quot;]" /><category term="Tutorial" /><summary type="html"><![CDATA[You can get 90% of the way there with server-rendered templates and View Transitions.]]></summary></entry><entry><title type="html">Process slow network requests with Turbo and Active Model</title><link href="https://stevepolito.design/blog/process-network-requests-with-turbo" rel="alternate" type="text/html" title="Process slow network requests with Turbo and Active Model" /><published>2024-11-20T00:00:00+00:00</published><updated>2024-11-20T00:00:00+00:00</updated><id>https://stevepolito.design/blog/process-network-requests-with-turbo</id><content type="html" xml:base="https://stevepolito.design/blog/process-network-requests-with-turbo"><![CDATA[<p>I recently had the opportunity to improve the UX on a client project by
backgrounding a slow network request and broadcasting the response to the
browser asynchronously with Turbo.</p>

<p>At first, I was a little overwhelmed because I didn’t know <em>exactly</em> how to do
this. The response was mapped to a Ruby object, and was not Active Record
backed, so I wasn’t sure how to leverage Turbo. However, I found it to be
surprisingly easy, and I wanted to share the highlights through a distilled
example.</p>

<p>Although we’ll be focusing on network requests, I want to highlight that this
approach works for all types of slow operations.</p>

<p>Here’s an outline of what we’re trying to accomplish.</p>

<ol>
  <li>A request is made that triggers a slow operation that normally would result in a timeout.</li>
  <li>Move that slow operation to a background job to be processed asynchronously.</li>
  <li>Render a loading screen while the job is being processed.</li>
  <li>Once the background job is finished processing, update the client accordingly.</li>
</ol>

<p>Feel free to follow along below, or view the <a href="https://github.com/thoughtbot/hotwire-example-template/compare/main...hotwire-example-process-network-request">final code</a> which lives
in our <a href="https://github.com/thoughtbot/hotwire-example-template">Hotwire Example Template</a>.</p>

<h2 id="our-base">Our base</h2>

<p>We’ll start with a simple <a href="https://thoughtbot.com/blog/rails-search-form-tutorial">Active Model backed form</a> where the user
enters the ID for a record stored in an external system. Since the record is
stored in an external system, we issue a network request to retrieve it. Note
that the page can’t respond until the request is processed, resulting in a poor
user experience.</p>

<p><img src="https://images.thoughtbot.com/lqtrehn5uog3ndf3pukwaynpw83e_CleanShot%202024-11-08%20at%2016.39.37.gif" alt="Filling out a form results in the page locking up, and taking a long time to render the response" /></p>

<p>The controller and corresponding model aren’t particularly interesting.
The only thing worth mentioning is that we’re calling <code>OrderSearch#process</code> in
our controller, which issues the network request in-line.</p>

<pre><code class="language-ruby"># app/controllers/orders_controller.rb

class OrdersController &lt; ApplicationController
  def index
    @order_search = OrderSearch.new(order_id: params[:order_id])
    @order_search.process
  end
end
</code></pre>

<pre><code class="language-ruby"># app/models/order_search.rb

class OrderSearch
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :order_id, :big_integer
  attribute :result

  alias_method :processed?, :result

  def processing?
    order_id &amp;&amp; result.nil?
  end

  def process
    return unless processing?

    # Simulate network request
    sleep 1

    # Simulate building result from response
    self.result = Order.new(id: order_id, product: "Some Widget", quantity: 1)
  end
end
</code></pre>

<p>It’s worth highlighting that we’re mapping the response from our
network request to a non-persisted <a href="https://guides.rubyonrails.org/active_model_basics.html">Active Model</a> object.</p>

<pre><code class="language-ruby"># Simulate building result from response
self.result = Order.new(id: order_id, product: "Some Widget", quantity: 1)
</code></pre>

<p>The corresponding views are just as unremarkable.</p>

<pre><code class="language-erb">&lt;% # app/views/orders/index.html.erb %&gt;

&lt;%= form_with model: @order_search, scope: "", method: :get do |form| %&gt;
  &lt;%= form.label :order_id, "Order ID" %&gt;
  &lt;%= form.number_field :order_id, required: true %&gt;

  &lt;%= form.submit "Find order" %&gt;
&lt;% end %&gt;

&lt;%= render @order_search %&gt;
</code></pre>

<pre><code class="language-erb">&lt;% # app/views/order_searches/_order_search.html.erb %&gt;

&lt;% if order_search.processing? %&gt;
  &lt;p&gt;Searching...&lt;/p&gt;
&lt;% elsif order_search.processed? %&gt;
  &lt;%= render order_search.result %&gt;
&lt;% end %&gt;
</code></pre>

<h2 id="process-request-in-the-background">Process request in the background</h2>

<p>With our setup out of the way, we can improve the UX by backgrounding the
network request in a <a href="https://guides.rubyonrails.org/active_job_basics.html">job</a> which will allow the controller to respond
immediately.</p>

<pre><code class="language-diff">--- a/app/models/order_search.rb
+++ b/app/models/order_search.rb
@@ -14,10 +14,6 @@ class OrderSearch
   def process
     return unless processing?

-    # Simulate network request
-    sleep 1
-
-    # Simulate building result from response
-    self.result = Order.new(id: order_id, product: "Some Widget", quantity: 1)
+    GetOrderJob.perform_later(self)
   end
 end
</code></pre>

<p>Our job is not only responsible for processing the request, but also for
broadcasting the response back to the page.</p>

<p>In order to do this, we’ll need to rely on some lower-level APIs provided by
Turbo Rails and Rails.</p>

<p>First, we’ll use <a href="https://rubydoc.info/github/hotwired/turbo-rails/Turbo/StreamsChannel"><code>Turbo::StreamsChannel</code></a> which is extended by
<a href="https://rubydoc.info/github/hotwired/turbo-rails/Turbo/Streams/Broadcasts"><code>Turbo::Streams::Broadcasts</code></a> and <a href="https://rubydoc.info/github/hotwired/turbo-rails/Turbo/Streams/StreamName"><code>Turbo::Streams::StreamName</code></a> to
broadcast the response back to the page by passing the <code>order_search</code> as the
first argument.</p>

<p>We use <a href="https://api.rubyonrails.org/classes/ActionView/RecordIdentifier.html#method-i-dom_id"><code>dom_id</code></a> to generate an identifier from the <code>order_search</code>
instance. We’re not required to use <code>dom_id</code>, but we are required to ensure
the view we’re broadcasting to has an element with the same identifier.</p>

<p>Finally, we need to ensure we actually broadcast something to the page. We
<em>could</em> build up the HTML by hand, but since we already have an existing
partial, we can just call <a href="https://api.rubyonrails.org/classes/ActionController/Renderer.html#method-i-render"><code>render</code></a> and pass in the partial path and
object to build the content.</p>

<pre><code class="language-ruby"># app/jobs/get_order_job.rb

class GetOrderJob &lt; ActiveJob::Base
  def perform(order_search)
    # Simulate network request
    sleep 1

    # Simulate building result from response
    order_search.result = Order.new(id: order_search.order_id, product: "Some Widget", quantity: 1)

    Turbo::StreamsChannel.broadcast_replace_to(
      order_search,
      target: ActionView::RecordIdentifier.dom_id(order_search),
      content: build_content(order_search)
    )
  end

  private

  def build_content(order_search)
    ApplicationController.render(
      partial: "order_searches/order_search",
      locals: { order_search: }
    )
  end
end
</code></pre>

<h3 id="creating-a-custom-serializer">Creating a custom serializer</h3>

<p>Since Active Job does not support Active Model instances as a type of
<a href="https://guides.rubyonrails.org/active_job_basics.html#supported-types-for-arguments">argument</a>, we’ll need to create a custom <a href="https://guides.rubyonrails.org/active_job_basics.html#serializers">serializer</a> and make some
changes to our application’s configuration.</p>

<pre><code class="language-ruby"># app/serializers/order_search_serializer.rb

class OrderSearchSerializer &lt; ActiveJob::Serializers::ObjectSerializer
  def serialize(order_search)
    super(
      "order_id" =&gt; order_search.order_id,
      "result" =&gt; order_search.result
    )
  end

  def deserialize(hash)
    OrderSearch.new(order_id: hash["order_id"], result: hash["result"])
  end

  private

  def klass
    OrderSearch
  end
end
</code></pre>

<pre><code class="language-diff">--- a/config/application.rb
+++ b/config/application.rb
@@ -23,5 +23,6 @@ module HotwireExamples
     #
     # config.time_zone = "Central Time (US &amp; Canada)"
     # config.eager_load_paths &lt;&lt; Rails.root.join("extras")
+    config.autoload_once_paths &lt;&lt; "#{root}/app/serializers"
   end
 end
</code></pre>

<pre><code class="language-ruby"># config/initializers/custom_serializers.rb

Rails.application.config.active_job.custom_serializers &lt;&lt; OrderSearchSerializer
</code></pre>

<p>Finally, we need to add a corresponding identifier to our view to map to the
<code>target</code> option from above. We also need to add a <a href="https://rubydoc.info/github/hotwired/turbo-rails/Turbo/StreamsHelper#turbo_stream_from-instance_method"><code>turbo_stream_from</code></a>
element to the page so that it can receive the broadcast from our job.</p>

<pre><code class="language-diff">--- a/app/views/order_searches/_order_search.html.erb
+++ b/app/views/order_searches/_order_search.html.erb
@@ -1,5 +1,8 @@
-&lt;% if order_search.processing? %&gt;
-  &lt;p&gt;Searching...&lt;/p&gt;
-&lt;% elsif order_search.processed? %&gt;
-  &lt;%= render order_search.result %&gt;
-&lt;% end %&gt;
+&lt;div id="&lt;%= dom_id(order_search) %&gt;"&gt;
+  &lt;% if order_search.processing? %&gt;
+    &lt;%= turbo_stream_from order_search %&gt;
+    &lt;p&gt;Searching...&lt;/p&gt;
+  &lt;% elsif order_search.processed? %&gt;
+    &lt;%= render order_search.result %&gt;
+  &lt;% end %&gt;
+&lt;/div&gt;
</code></pre>

<p>With these changes in place, we should have a much smoother user experience.</p>

<p><img src="https://images.thoughtbot.com/hkhcig70n3ybeikwsq5iz229dajk_CleanShot%202024-11-08%20at%2016.38.33.gif" alt="Filling out a form results in a loading screen that is eventually replaced with content" /></p>

<h2 id="leverage-turbobroadcastable">Leverage Turbo::Broadcastable</h2>

<p>If you were reading the last section and thought it was a smell to leverage all
those low level APIs, you’re right.</p>

<p>What we did was essentially recreate the <a href="https://rubydoc.info/github/hotwired/turbo-rails/Turbo/Broadcastable"><code>Turbo::Broadcastable</code></a> API. We
did this because we didn’t have access to it, since it’s only included in Active
Record, and we’re using Active Model.</p>

<p>We can dramatically improve our implementation simply by including it in our
model, and calling <a href="https://rubydoc.info/github/hotwired/turbo-rails/Turbo%2FBroadcastable:broadcast_replace"><code>broadcast_replace</code></a>.</p>

<pre><code class="language-diff">diff --git a/app/models/order_search.rb b/app/models/order_search.rb
index da22b7c..3621101 100644
--- a/app/models/order_search.rb
+++ b/app/models/order_search.rb
@@ -1,6 +1,7 @@
 class OrderSearch
   include ActiveModel::Model
   include ActiveModel::Attributes
+  include Turbo::Broadcastable

   attribute :order_id, :big_integer
   attribute :result
</code></pre>

<pre><code class="language-diff">--- a/app/jobs/get_order_job.rb
+++ b/app/jobs/get_order_job.rb
@@ -6,19 +6,6 @@ class GetOrderJob &lt; ActiveJob::Base
     # Simulate building result from response
     order_search.result = Order.new(id: order_search.order_id, product: "Some Widget", quantity: 1)

-    Turbo::StreamsChannel.broadcast_replace_to(
-      order_search,
-      target: ActionView::RecordIdentifier.dom_id(order_search),
-      content: build_content(order_search)
-    )
-  end
-
-  private
-
-  def build_content(order_search)
-    ApplicationController.render(
-      partial: "order_searches/order_search",
-      locals: { order_search: }
-    )
+    order_search.broadcast_replace
   end
 end
</code></pre>

<p>I want to highlight that this works so seamlessly because we closely adhered to
Rails conventions from the start. Most notably, regarding where we placed
our partials, and the inclusion of <code>ActiveModel::Model</code> in <code>OrderSearch</code>.</p>

<p>However, even if we hadn’t, we could still have leveraged
<a href="https://rubydoc.info/github/hotwired/turbo-rails/Turbo%2FBroadcastable:broadcast_replace_to"><code>broadcast_replace_to</code></a> to control the broadcast without all the ceremony
from before.</p>

<h2 id="scope-broadcast-to-the-current-user">Scope broadcast to the current user</h2>

<p>Finally, I wanted to share some pragmatic advice in regards to scoping the
broadcast to the current user.</p>

<p>Since it’s more than likely your application is using authentication, you’ll
want to scope these broadcasts to the user that issued them.</p>

<p>Below is what that might look like.</p>

<pre><code class="language-diff">--- a/app/models/order_search.rb
+++ b/app/models/order_search.rb
@@ -5,6 +5,7 @@ class OrderSearch

   attribute :order_id, :big_integer
   attribute :result
+  attribute :user

   alias_method :processed?, :result
</code></pre>

<pre><code class="language-diff">--- a/app/controllers/orders_controller.rb
+++ b/app/controllers/orders_controller.rb
@@ -1,6 +1,6 @@
 class OrdersController &lt; ApplicationController
   def index
-    @order_search = OrderSearch.new(params.permit!.slice(:order_id))
+    @order_search = OrderSearch.new(params.permit!.slice(:order_id).with_defaults(user: current_user))
     @order_search.process
   end
 end
</code></pre>

<p>The key is that we now pass the user <strong>and</strong> the object to <code>turbo_stream_from</code>,
and ensure we’re broadcasting to that stream by using <code>broadcast_replace_to</code>
which also accepts the user <strong>and</strong> the object.</p>

<pre><code class="language-diff">--- a/app/views/order_searches/_order_search.html.erb
+++ b/app/views/order_searches/_order_search.html.erb
@@ -1,6 +1,6 @@
 &lt;div id="&lt;%= dom_id(order_search) %&gt;"&gt;
   &lt;% if order_search.processing? %&gt;
-    &lt;%= turbo_stream_from order_search %&gt;
+    &lt;%= turbo_stream_from order_search.user, order_search %&gt;
     &lt;p&gt;Searching...&lt;/p&gt;
   &lt;% elsif order_search.processed? %&gt;
     &lt;%= render order_search.result %&gt;
</code></pre>

<pre><code class="language-diff">--- a/app/jobs/get_order_job.rb
+++ b/app/jobs/get_order_job.rb
@@ -6,6 +6,6 @@ class GetOrderJob &lt; ActiveJob::Base
     # Simulate building result from response
     order_search.result = Order.new(id: order_search.order_id, product: "Some Widget", quantity: 1)

-    order_search.broadcast_replace
+    order_search.broadcast_replace_to order_search.user, order_search
   end
 end
</code></pre>

<p>We just need to make sure to update our serializer too.</p>

<pre><code class="language-diff">--- a/app/serializers/order_search_serializer.rb
+++ b/app/serializers/order_search_serializer.rb
@@ -2,12 +2,13 @@ class OrderSearchSerializer &lt; ActiveJob::Serializers::ObjectSerializer
   def serialize(order_search)
     super(
       "order_id" =&gt; order_search.order_id,
-      "result" =&gt; order_search.result
+      "result" =&gt; order_search.result,
+      "user" =&gt; order_search.user
     )
   end

   def deserialize(hash)
-    OrderSearch.new(order_id: hash["order_id"], result: hash["result"])
+    OrderSearch.new(order_id: hash["order_id"], result: hash["result"], user: hash["user"])
   end

   private
</code></pre>

<h2 id="wrapping-up">Wrapping up</h2>

<p>I used to think of Turbo as something that was exclusive to Active Record.
However, as we just demonstrated, that’s not the case.</p>

<p>Turbo can work just as seamlessly with Active Model-like objects, especially
when you’re closely adhering to Rails conventions.</p>

<p>Finally, this approach doesn’t need to be limited to network requests. We can
use the same pattern to handle any type of process that needs to be run in the
background, such as a large calculation or query.</p>]]></content><author><name></name></author><category term="[&quot;Ruby on Rails&quot;]" /><category term="Tutorial" /><summary type="html"><![CDATA[Learn how to build a dynamic loading screen without writing a line of JavaScript.]]></summary></entry><entry><title type="html">Build a (better) search form in Rails with Active Model</title><link href="https://stevepolito.design/blog/rails-search-form-tutorial" rel="alternate" type="text/html" title="Build a (better) search form in Rails with Active Model" /><published>2024-10-17T00:00:00+00:00</published><updated>2024-10-17T00:00:00+00:00</updated><id>https://stevepolito.design/blog/rails-search-form-tutorial</id><content type="html" xml:base="https://stevepolito.design/blog/rails-search-form-tutorial"><![CDATA[<p>I recently had the opportunity to refactor a custom search form on a client
project, and wanted to share some highlights through a distilled example.</p>

<h2 id="our-base">Our base</h2>

<p>We’ll start with all logic placed in the controller and view.</p>

<pre><code class="language-ruby">def index
  @posts = sort_posts(Post.all).then { filter_posts(_1) }
end

private

  def sort_posts(scope)
    if (order = params.dig(:query, :sort))
      column, direction = order.split(" ")

      if column.presence_in(%w[title created_at]) &amp;&amp; direction.presence_in(%w[asc desc])
        scope.order("#{column} #{direction}")
      else
        []
      end
    else
      scope.order(created_at: :desc)
    end
  end

  def filter_posts(scope)
    filter_by_title_or_body(scope)
      .then { filter_by_status(_1) }
      .then { filter_by_author(_1) }
  end

  def filter_by_title_or_body(scope)
    if (title_or_body = params.dig(:query, :title_or_body_contains).presence)
      scope.where("title LIKE ? or body LIKE ?", "%#{title_or_body}%", "%#{title_or_body}%")
    else
      scope
    end
  end

  def filter_by_status(scope)
    if (status = params.dig(:query, :status_in)&amp;.compact_blank&amp;.presence)
      scope.where(status:)
    else
      scope
    end
  end

  def filter_by_author(scope)
    if (author_id = params.dig(:query, :author_id_eq).presence)
      scope.where(author_id:)
    else
      scope
    end
  end
</code></pre>

<p>Note that the controller is responsible for validating the parameters to ensure
they aren’t tampered with.</p>

<pre><code class="language-ruby">if column.presence_in(%w[title created_at]) &amp;&amp; direction.presence_in(%w[asc desc])
</code></pre>

<p>It also sets default sort values.</p>

<pre><code class="language-ruby">if (order = params.dig(:query, :sort))
  # ..
else
  scope.order(created_at: :desc)
end
</code></pre>

<p>Now let’s take a look at the corresponding form:</p>

<pre><code class="language-erb">&lt;h1&gt;Posts&lt;/h1&gt;

&lt;%= form_with scope: :query, url: posts_path, method: :get do |form| %&gt;
  &lt;div&gt;
    &lt;%= form.label :title_or_body_contains, "Title or body contains" %&gt;
    &lt;%= form.search_field :title_or_body_contains, value: params.dig(:query, :title_or_body_contains) %&gt;
  &lt;/div&gt;

  &lt;div&gt;
    &lt;%= form.label :sort, "Sort by" %&gt;
    &lt;%= form.select :sort, options_for_select(
      [
        ["Title - A to Z", "title asc"],
        ["Title - Z to A", "title desc"],
        ["Created At - Newest to Oldest", "created_at desc"],
        ["Created At - Oldest to Newest", "created_at asc"]
      ], params.dig(:query, :sort) || "created_at desc"
    ) %&gt;
  &lt;/div&gt;

  &lt;div&gt;
    &lt;%= form.label :author_id_eq, "Authored by" %&gt;
    &lt;%= form.select :author_id_eq, options_from_collection_for_select(Author.all, "id", "name", params.dig(:query, :author_id_eq).to_i), {prompt: "Any"}  %&gt;
  &lt;/div&gt;

  &lt;%= field_set_tag "Status" do %&gt;
    &lt;%= form.collection_check_boxes(:status_in, Post.statuses.keys, :to_s, :capitalize) do |builder| %&gt;
      &lt;%= builder.label { builder.check_box(checked: params.dig(:query, :status_in)&amp;.include?(builder.value)) + builder.text } %&gt;
    &lt;% end %&gt;
  &lt;% end %&gt;

  &lt;%= submit_tag "Search" %&gt;
  &lt;%= link_to "Reset", posts_path %&gt;
&lt;% end %&gt;
</code></pre>

<p>Note that it’s responsible for setting the <code>value</code> based on the <code>params</code>.
Additionally, the options for <code>sort</code>, <code>author_id_eq</code>, and <code>status_in</code> are
rendered directly in the view.</p>

<p>Although this code works, we can simplify it <strong>while</strong> improving ergonomics by
extracting it into a model.</p>

<h2 id="extract-logic-into-a-model">Extract logic into a model</h2>

<p>As mentioned before, the controller is responsible for validation and setting
default values. Those sound like the responsibilities of a model.</p>

<p>Let’s start by extracting the logic, and mapping the parameters to attributes.</p>

<pre><code class="language-ruby"># app/models/post/query.rb

class Post::Query
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :author_id_eq, :big_integer
  attribute :column, :string
  attribute :direction, :string
  attribute :sort, :string, default: "created_at desc"
  attribute :status_in, default: []
  attribute :title_or_body_contains, :string

  validates :column, inclusion: { in: %w[created_at title] }
  validates :direction, inclusion: { in: %w[asc desc] }

  def initialize(...)
    super
    self.sort = sort
  end

  def sort=(value)
    super
    column, direction = sort.split(" ")
    assign_attributes(column:, direction:)
  end

  def results
    if valid?
      sort_posts.then { filter_posts(_1) }
    else
      []
    end
  end

  private
    def sort_posts(scope = Post.all)
      scope.order("#{column} #{direction}")
    end

    def filter_posts(scope)
      filter_by_title_or_body(scope)
        .then { filter_by_status(_1) }
        .then { filter_by_author(_1) }
    end

    def filter_by_title_or_body(scope)
      if (title_or_body = title_or_body_contains.presence)
        scope.where("title LIKE ? or body LIKE ?", "%#{title_or_body}%", "%#{title_or_body}%")
      else
        scope
      end
    end

    def filter_by_status(scope)
      if (status = status_in.compact_blank.presence)
        scope.where(status:)
      else
        scope
      end
    end

    def filter_by_author(scope)
      if (author_id = author_id_eq.presence)
        scope.where(author_id:)
      else
        scope
      end
    end
end
</code></pre>

<p>Now we can effectively validate our attributes through the
<a href="https://guides.rubyonrails.org/active_model_basics.html#validations">ActiveModel::Validations</a> API instead of doing this in the controller.</p>

<p>We’re also able to set default values thanks to the <a href="https://guides.rubyonrails.org/active_model_basics.html#attributes">ActiveModel::Attributes</a>
API. Note that we assign the <code>column</code> and <code>direction</code> attributes from the <code>sort</code>
value on initialization, <strong>or</strong> when setting the <code>sort</code> value directly.</p>

<p><strong>Before</strong></p>

<pre><code class="language-ruby">def sort_posts(scope)
  if (order = params.dig(:query, :sort))
    column, direction = order.split(" ")

    if column.presence_in(%w[title created_at]) &amp;&amp; direction.presence_in(%w[asc desc])
      scope.order("#{column} #{direction}")
    else
     []
    end
  else
    scope.order(created_at: :desc)
  end
end
</code></pre>

<p><strong>After</strong></p>

<pre><code class="language-ruby">def results
  if valid?
    sort_posts.then { filter_posts(_1) }
  else
    []
  end
end
</code></pre>

<p>With our logic extracted, we’re now able to refactor our controller.</p>

<pre><code class="language-diff">--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -3,7 +3,8 @@ class PostsController &lt; ApplicationController

   # GET /posts or /posts.json
   def index
-    @posts = sort_posts(Post.all).then { filter_posts(_1) }
+    @query = Post::Query.new(params.fetch(:query, {}).permit(:author_id_eq, :sort, :title_or_body_contains, status_in: []))
+    @posts = @query.results
   end

   # GET /posts/1 or /posts/1.json
@@ -67,48 +68,4 @@ class PostsController &lt; ApplicationController
     def post_params
       params.require(:post).permit(:title, :body, :status, :author_id)
     end
-
-    def sort_posts(scope)
-      if (order = params.dig(:query, :sort))
-        column, direction = order.split(" ")
-
-        if column.presence_in(%w[title created_at]) &amp;&amp; direction.presence_in(%w[asc desc])
-          scope.order("#{column} #{direction}")
-        else
-          []
-        end
-      else
-        scope.order(created_at: :desc)
-      end
-    end
-
-    def filter_posts(scope)
-      filter_by_title_or_body(scope)
-        .then { filter_by_status(_1) }
-        .then { filter_by_author(_1) }
-    end
-
-    def filter_by_title_or_body(scope)
-      if (title_or_body = params.dig(:query, :title_or_body_contains).presence)
-        scope.where("title LIKE ? or body LIKE ?", "%#{title_or_body}%", "%#{title_or_body}%")
-      else
-        scope
-      end
-    end
-
-    def filter_by_status(scope)
-      if (status = params.dig(:query, :status_in)&amp;.compact_blank&amp;.presence)
-        scope.where(status:)
-      else
-        scope
-      end
-    end
-
-    def filter_by_author(scope)
-      if (author_id = params.dig(:query, :author_id_eq).presence)
-        scope.where(author_id:)
-      else
-        scope
-      end
-    end
 end
</code></pre>

<h2 id="update-the-form">Update the form</h2>

<p>Since <a href="https://api.rubyonrails.org/v7.2.1/classes/ActionView/Helpers/FormHelper.html#method-i-form_with">form_with</a> <a href="https://guides.rubyonrails.org/form_helpers.html#creating-forms-with-model-objects">pairs well with model objects</a>, we can simplify
our existing form by passing in our new model to the <code>model</code> option.</p>

<p>This will alleviate the need to manually set the <code>value</code> based on the <code>params</code>.
Whatever values are set to the <code>Post::Query</code> during initialization will
automatically be set to the corresponding field’s <code>value</code>.</p>

<pre><code class="language-diff">--- a/app/views/posts/index.html.erb
+++ b/app/views/posts/index.html.erb
@@ -4,33 +4,24 @@

 &lt;h1&gt;Posts&lt;/h1&gt;

-&lt;%= form_with scope: :query, url: posts_path, method: :get do |form| %&gt;
+&lt;%= form_with model: @query, scope: :query, url: posts_path, method: :get do |form| %&gt;
   &lt;div&gt;
     &lt;%= form.label :title_or_body_contains, "Title or body contains" %&gt;
-    &lt;%= form.search_field :title_or_body_contains, value: params.dig(:query, :title_or_body_contains) %&gt;
+    &lt;%= form.search_field :title_or_body_contains %&gt;
   &lt;/div&gt;

   &lt;div&gt;
     &lt;%= form.label :sort, "Sort by" %&gt;
-    &lt;%= form.select :sort, options_for_select(
-      [
-        ["Title - A to Z", "title asc"],
-        ["Title - Z to A", "title desc"],
-        ["Created At - Newest to Oldest", "created_at desc"],
-        ["Created At - Oldest to Newest", "created_at asc"]
-      ], params.dig(:query, :sort) || "created_at desc"
-    ) %&gt;
+    &lt;%= form.select :sort, @query.options_for_sort %&gt;
   &lt;/div&gt;

   &lt;div&gt;
     &lt;%= form.label :author_id_eq, "Authored by" %&gt;
-    &lt;%= form.select :author_id_eq, options_from_collection_for_select(Author.all, "id", "name", params.dig(:query, :author_id_eq).to_i), {prompt: "Any"}  %&gt;
+    &lt;%= form.select :author_id_eq, @query.options_for_authored_by, {prompt: "Any"}  %&gt;
   &lt;/div&gt;

   &lt;%= field_set_tag "Status" do %&gt;
-    &lt;%= form.collection_check_boxes(:status_in, Post.statuses.keys, :to_s, :capitalize) do |builder| %&gt;
-      &lt;%= builder.label { builder.check_box(checked: params.dig(:query, :status_in)&amp;.include?(builder.value)) + builder.text } %&gt;
-    &lt;% end %&gt;
+    &lt;%= form.collection_check_boxes(:status_in, @query.options_for_status, :to_s, :capitalize) %&gt;
   &lt;% end %&gt;

   &lt;%= submit_tag "Search" %&gt;
</code></pre>

<p>You’ll also note that we were able to simplify how the <code>sort</code>, <code>author_id_eq</code>
and <code>status_in</code> options are set by placing that logic in the model.</p>

<pre><code class="language-diff">--- a/app/models/post/query.rb
+++ b/app/models/post/query.rb
@@ -12,6 +12,23 @@ class Post::Query
   validates :column, inclusion: { in: %w[created_at title] }
   validates :direction, inclusion: { in: %w[asc desc] }

+  def options_for_sort
+    [
+      [ "Title - A to Z", "title asc" ],
+      [ "Title - Z to A", "title desc" ],
+      [ "Created At - Newest to Oldest", "created_at desc" ],
+      [ "Created At - Oldest to Newest", "created_at asc" ]
+    ]
+  end
+
+  def options_for_authored_by
+    Author.all.collect { [ _1.name, _1.id ] }
+  end
+
+  def options_for_status
+    Post.statuses.keys
+  end
+
   def initialize(...)
     super
     self.sort = sort
</code></pre>

<h2 id="a-final-touch">A final touch</h2>

<p>With our refactor nearly complete, there’s still an opportunity to make a minor
improvement by having the <code>url</code> generated for us.</p>

<p>To achieve this, we’ll need to reach for <a href="https://guides.rubyonrails.org/routing.html#using-resolve">resolve</a>. What this does is map
<code>Post::Query</code> to <code>posts_url</code>, so that <code>form_with</code> knows how to build the <code>url</code>
automatically. This happens by default with Active Record objects.</p>

<pre><code class="language-diff">--- a/config/routes.rb
+++ b/config/routes.rb
@@ -12,4 +12,8 @@ Rails.application.routes.draw do

   # Defines the root path route ("/")
   root "posts#index"
+
+  resolve "Post::Query" do |model|
+    route_for :posts
+  end
 end
</code></pre>

<p>With the change to our routes, we can remove the <code>url</code> from our form, and rely
on <a href="https://guides.rubyonrails.org/form_helpers.html#relying-on-record-identification">record identification</a>.</p>

<pre><code class="language-diff">--- a/app/views/posts/index.html.erb
+++ b/app/views/posts/index.html.erb
@@ -4,7 +4,7 @@

 &lt;h1&gt;Posts&lt;/h1&gt;

-&lt;%= form_with model: @query, scope: :query, url: posts_path, method: :get do |form| %&gt;
+&lt;%= form_with model: @query, scope: :query, method: :get do |form| %&gt;
   &lt;div&gt;
     &lt;%= form.label :title_or_body_contains, "Title or body contains" %&gt;
     &lt;%= form.search_field :title_or_body_contains %&gt;
</code></pre>

<h2 id="wrapping-up">Wrapping up</h2>

<p>What we’ve done here is created a <a href="https://thoughtbot.com/ruby-science/introduce-form-object.html">form object</a>, but for a <code>GET</code> request.</p>

<p>While this concept isn’t new and extends beyond search forms, the key takeaway
is how effectively <code>form_with</code> integrates with model objects. By using a model
object, we were able to drastically simplify our controller and form, and create
something that better adheres to Rails’ conventions.</p>]]></content><author><name></name></author><category term="[&quot;Ruby on Rails&quot;]" /><category term="Tutorial" /><summary type="html"><![CDATA[Harness the power of Active Model to supercharge your search forms.]]></summary></entry><entry><title type="html">Conditionally render a Turbo Frame shared between multiple views</title><link href="https://stevepolito.design/blog/conditionally-render-turbo-frame" rel="alternate" type="text/html" title="Conditionally render a Turbo Frame shared between multiple views" /><published>2024-08-13T00:00:00+00:00</published><updated>2024-08-13T00:00:00+00:00</updated><id>https://stevepolito.design/blog/conditionally-render-turbo-frame</id><content type="html" xml:base="https://stevepolito.design/blog/conditionally-render-turbo-frame"><![CDATA[<p>The <a href="https://turbo.hotwired.dev/handbook/frames">Turbo Frames API</a> requires that a request made from within
a <code>turbo-frame</code> must receive a response containing a corresponding
<code>turbo-frame</code> of the same <code>id</code>.</p>

<p>Because Rails encourages the reuse of partials and views, this can lead to
situations where you need to <a href="https://github.com/hotwired/turbo/issues/378">conditionally render a Turbo Frame</a>. One
such example is inline editing, which we’ll explore in this tutorial.</p>

<h2 id="our-base">Our Base</h2>

<p>Our starting point does not yet warrant the need to conditionally render any of
the Turbo Frames because all three instances use the same HTML. Most notably
between the <code>show</code> and <code>index</code> views. This is because both of those views render
the <code>_post</code> partial.</p>

<p><img src="https://images.thoughtbot.com/cjiqbjnz7fk4iaia3hw29rrsz28w_1.a.gif" alt="Editing a post inline, from the index view" /></p>

<pre><code class="language-erb"># app/views/posts/index.html.erb

&lt;% @posts.each do |post| %&gt;
  &lt;%= turbo_frame_tag dom_id(post) do %&gt;
    &lt;%= render post %&gt;
    &lt;%= link_to "Edit", edit_post_path(post) %&gt;
  &lt;% end %&gt;
&lt;% end %&gt;
</code></pre>

<pre><code class="language-erb"># app/views/posts/_post.html.erb

&lt;div&gt;
  &lt;p&gt;
    &lt;strong&gt;Title:&lt;/strong&gt;
    &lt;%= post.title %&gt;
  &lt;/p&gt;

  &lt;p&gt;
    &lt;strong&gt;Body:&lt;/strong&gt;
    &lt;%= post.body %&gt;
  &lt;/p&gt;

&lt;/div&gt;
</code></pre>

<p>When we click the “Edit” link from the <code>index</code> view, we load the corresponding
<code>turbo-frame</code> from the <code>edit</code> view. When the form is submitted, the <code>#update</code>
action redirects to the <code>show</code> view, which also contains a corresponding
<code>turbo-frame</code>.</p>

<pre><code class="language-erb"># app/views/edit.html.erb

&lt;%= turbo_frame_tag dom_id(@post) do %&gt;
  &lt;%= render "form", post: @post %&gt;
  &lt;%= link_to "Cancel", :back %&gt;
&lt;% end %&gt;
</code></pre>

<pre><code class="language-ruby">def update
  if @post.update(post_params)
    redirect_to post_url(@post), notice: "Post was successfully updated."
  else
    render :edit, status: :unprocessable_entity
  end
end
</code></pre>

<pre><code class="language-erb"># app/views/posts/show.html.erb

&lt;%= turbo_frame_tag dom_id(@post) do %&gt;
  &lt;%= render @post %&gt;
  &lt;%= link_to "Edit", edit_post_path(@post) %&gt;
&lt;% end %&gt;
</code></pre>

<p>The key here is that the <code>index</code> and <code>show</code> views are both using the same
<code>_post</code> partial. This makes for a seamless experience. The only “gotcha” is that
this also means we’ve inadvertently enabled inline editing on the <code>show</code> page
too.</p>

<p><img src="https://images.thoughtbot.com/kusduh44pkuk3kicvjzqznbmhvbq_1.b.gif" alt="Editing a post inline, from the edit view" /></p>

<p>However, this is a contrived example and does not reflect a real-world design.
It’s common to render content differently when viewed in different contexts.</p>

<p>Let’s explore that next.</p>

<h2 id="the-problem">The Problem</h2>

<p>Let’s update our <code>_post</code> partial and <code>show</code> view so that we see a teaser of the
post on the <code>index</code> page, and the full post on the <code>show</code> page.</p>

<pre><code class="language-diff">--- a/app/views/posts/_post.html.erb
+++ b/app/views/posts/_post.html.erb
@@ -1,12 +1,12 @@
-&lt;div&gt;
-  &lt;p&gt;
-    &lt;strong&gt;Title:&lt;/strong&gt;
-    &lt;%= post.title %&gt;
-  &lt;/p&gt;
+&lt;article&gt;
+  &lt;h2&gt;&lt;%= post.title %&gt;&lt;/h2&gt;

   &lt;p&gt;
-    &lt;strong&gt;Body:&lt;/strong&gt;
-    &lt;%= post.body %&gt;
+    &lt;%= post.body.truncate(20) %&gt;
   &lt;/p&gt;

-&lt;/div&gt;
+  &lt;td&gt;
+    &lt;%= link_to "Edit", edit_post_path(post) %&gt;
+    &lt;%= link_to "Show this post", post, data: { turbo_frame: "_top" } %&gt;
+  &lt;/td&gt;
+&lt;/article&gt;
</code></pre>

<pre><code class="language-diff">--- a/app/views/posts/index.html.erb
+++ b/app/views/posts/index.html.erb
@@ -8,11 +8,7 @@
   &lt;% @posts.each do |post| %&gt;
     &lt;%= turbo_frame_tag dom_id(post) do %&gt;
       &lt;%= render post %&gt;
-      &lt;%= link_to "Edit", edit_post_path(post) %&gt;
     &lt;% end %&gt;
-    &lt;p&gt;
-      &lt;%= link_to "Show this post", post %&gt;
-    &lt;/p&gt;
   &lt;% end %&gt;
 &lt;/div&gt;

</code></pre>

<pre><code class="language-diff">--- a/app/views/posts/show.html.erb
+++ b/app/views/posts/show.html.erb
@@ -1,7 +1,10 @@
 &lt;p style="color: green"&gt;&lt;%= notice %&gt;&lt;/p&gt;

 &lt;%= turbo_frame_tag dom_id(@post) do %&gt;
-  &lt;%= render @post %&gt;
+  &lt;h1&gt;&lt;%= @post.title %&gt;&lt;/h1&gt;
+  &lt;p&gt;
+    &lt;%= @post.body %&gt;
+  &lt;/p&gt;
   &lt;%= link_to "Edit", edit_post_path(@post) %&gt;
 &lt;% end %&gt;

</code></pre>

<p>Now that we’re no longer sharing the same markup between the <code>index</code> view and
the <code>show</code> view, we end up rendering the markup for the <code>show</code> view when we edit
a post from the <code>index</code> view. Instead of rendering a teaser, we render the
whole post.</p>

<p><img src="https://images.thoughtbot.com/r5i025rerml6xxcf9k42lum9nq12_2.a.gif" alt="Editing a post inline from the index view results in the full post being rendered after submitting the form" /></p>

<p>However, this is not an issue when editing from the <code>edit</code> page, since we expect
to see the whole post after making an edit.</p>

<p><img src="https://images.thoughtbot.com/fsytp67sptp65uzye74n9l1r9va6_2.b.gif" alt="Editing a post inline from the edit view does not result in a disjointed UI" /></p>

<h2 id="a-simple-solution">A Simple Solution</h2>

<p>Here’s where we need introduce the concept of conditionally rendering a Turbo
Frame.</p>

<p>What we want to do is render the simple <code>_post</code> partial when a request is made
from the <code>index</code> view. Otherwise, if the request is made from the <code>edit</code> view,
we want to render the <code>show</code> view.</p>

<p>Fortunately, this can be easily solved with <a href="https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_back_or_to"><code>redirect_back_or_to</code></a>.</p>

<blockquote>
  <p>Redirects the browser to the page that issued the request (the referrer) if
possible, otherwise redirects to the provided default fallback location.</p>
</blockquote>

<pre><code class="language-diff">--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -33,7 +33,7 @@ class PostsController &lt; ApplicationController
   # PATCH/PUT /posts/1 or /posts/1.json
   def update
     if @post.update(post_params)
-      redirect_to post_url(@post), notice: "Post was successfully updated."
+      redirect_back_or_to post_url(@post), notice: "Post was successfully updated."
     else
       render :edit, status: :unprocessable_entity
     end
</code></pre>

<p>In this case, when we edit a post from the <code>index</code> view, it will respond with
<code>index</code> which <strong>already</strong> has a <code>turbo-frame</code> rendering the <code>_post</code> partial. The
same concept applies for when editing a post from the <code>edit</code> view.</p>

<p><img src="https://images.thoughtbot.com/djv5g70hedywno5ha8dzygb1phps_3.a.gif" alt="Editing the post inline from the index view results in the teaser being rendered after submission" /></p>

<h2 id="a-more-complex-example">A More Complex Example</h2>

<p>Our current implementation only works because there’s a <code>turbo-frame</code> on the
<code>index</code>, <code>show</code> and <code>edit</code> views. What if we didn’t have that luxury? For
example, what if we didn’t want to inline edit on the <code>show</code> page?</p>

<p>We can’t use <code>redirect_back_or_to</code> because we want to redirect to the <code>show</code>
view when making an edit on the <code>edit</code> view, but still maintain inline editing
on the <code>index</code> view.</p>

<p>Fortunately, we can leverage <a href="https://edgeguides.rubyonrails.org/layouts_and_rendering.html#the-variants-option">variants</a> in concert with <a href="https://edgeguides.rubyonrails.org/action_controller_overview.html#parameters">parameters</a> to
conditionally render our Turbo Frames based on specific context.</p>

<p>First, we can update our <code>_post</code> partial by having it link to the <code>edit</code> view,
but with a query string of <code>?variant=inline</code>.</p>

<pre><code class="language-diff">--- a/app/views/posts/_post.html.erb
+++ b/app/views/posts/_post.html.erb
@@ -6,7 +6,7 @@
   &lt;/p&gt;

   &lt;td&gt;
-    &lt;%= link_to "Edit", edit_post_path(post) %&gt;
+    &lt;%= link_to "Edit", edit_post_path(post, variant: :inline) %&gt;
     &lt;%= link_to "Show this post", post, data: { turbo_frame: "_top" } %&gt;
   &lt;/td&gt;
 &lt;/article&gt;
</code></pre>

<p>This means that when the request is made, we’ll have the additional context
about how we want to render this response.</p>

<aside class="info">
 Note that we can call our parameter anything we want. It does not need to be
<code>variant</code>.
</aside>

<p>Now that we’ve encoded the context into the URL, we need to do something with
it. We can start by first creating a new <a href="https://edgeguides.rubyonrails.org/layouts_and_rendering.html#the-variants-option">variant</a> for the <code>edit</code> view that
will include the <code>turbo-frame</code>.</p>

<pre><code class="language-erb"># app/views/posts/edit.html+inline.erb

&lt;% content_for :title, "Editing post" %&gt;

&lt;h1&gt;Editing post&lt;/h1&gt;

&lt;%= turbo_frame_tag dom_id(@post) do %&gt;
  &lt;%= render "form", post: @post %&gt;
  &lt;%= link_to "Cancel", :back %&gt;
&lt;% end %&gt;
</code></pre>

<p>Since we’re loading the form on this page, we can conditionally set a <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/hidden">hidden
field</a> to capture this value and pass it over to the <code>#update</code> action so it is
informed of the context as well.</p>

<pre><code class="language-diff">--- a/app/views/posts/_form.html.erb
+++ b/app/views/posts/_form.html.erb
@@ -21,6 +21,10 @@
     &lt;%= form.text_area :body %&gt;
   &lt;/div&gt;

+  &lt;% if params[:variant] == "inline" %&gt;
+    &lt;%= hidden_field_tag :variant, "inline", readonly: true %&gt;
+  &lt;% end %&gt;
+
   &lt;div&gt;
     &lt;%= form.submit %&gt;
   &lt;/div&gt;
</code></pre>

<p>Now that we have a variant responsible for including the <code>turbo-frame</code> in a
variant, we can remove it from the base <code>edit</code> view.</p>

<pre><code class="language-diff">--- a/app/views/posts/edit.html.erb
+++ b/app/views/posts/edit.html.erb
@@ -2,11 +2,7 @@

 &lt;h1&gt;Editing post&lt;/h1&gt;

-
-&lt;%= turbo_frame_tag dom_id(@post) do %&gt;
-  &lt;%= render "form", post: @post %&gt;
-  &lt;%= link_to "Cancel", :back %&gt;
-&lt;% end %&gt;
+&lt;%= render "form", post: @post %&gt;

 &lt;br&gt;

</code></pre>

<p>Now we just need to apply the same changes to the <code>show</code> views so that the
<code>update</code> action can conditionally render the appropriate <a href="https://edgeguides.rubyonrails.org/layouts_and_rendering.html#the-variants-option">variant</a> based on
the query parameter.</p>

<p>Similar to the above, we can create a <a href="https://edgeguides.rubyonrails.org/layouts_and_rendering.html#the-variants-option">variant</a> for the <code>show</code> view that will
contain a <code>turbo-frame</code>.</p>

<pre><code class="language-erb"># app/views/posts/show.html+inline.erb

&lt;%= turbo_frame_tag dom_id(@post) do %&gt;
  &lt;%= render @post %&gt;
&lt;% end %&gt;
</code></pre>

<p>This means we can remove it from the base <code>show</code> view.</p>

<pre><code class="language-diff">--- a/app/views/posts/show.html.erb
+++ b/app/views/posts/show.html.erb
@@ -1,12 +1,10 @@
 &lt;p style="color: green"&gt;&lt;%= notice %&gt;&lt;/p&gt;

-&lt;%= turbo_frame_tag dom_id(@post) do %&gt;
-  &lt;h1&gt;&lt;%= @post.title %&gt;&lt;/h1&gt;
-  &lt;p&gt;
-    &lt;%= @post.body %&gt;
-  &lt;/p&gt;
-  &lt;%= link_to "Edit", edit_post_path(@post) %&gt;
-&lt;% end %&gt;
+&lt;h1&gt;&lt;%= @post.title %&gt;&lt;/h1&gt;
+&lt;p&gt;
+  &lt;%= @post.body %&gt;
+&lt;/p&gt;
+&lt;%= link_to "Edit", edit_post_path(@post) %&gt;

 &lt;div&gt;
   &lt;%= link_to "Back to posts", posts_path %&gt;
</code></pre>

<p>Now that we’ve modified the views, we need to update our controller to
conditionally chose the correct <a href="https://edgeguides.rubyonrails.org/layouts_and_rendering.html#the-variants-option">variant</a> based on the parameters.</p>

<pre><code class="language-diff">--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -1,5 +1,6 @@
 class PostsController &lt; ApplicationController
   before_action :set_post, only: %i[ show edit update destroy ]
+  before_action :set_variant, only: %i[ show edit update ]

   # GET /posts or /posts.json
   def index
@@ -8,6 +9,7 @@ class PostsController &lt; ApplicationController

   # GET /posts/1 or /posts/1.json
   def show
+    request.variant = @variant
   end

   # GET /posts/new
@@ -17,6 +19,7 @@ class PostsController &lt; ApplicationController

   # GET /posts/1/edit
   def edit
+    request.variant = @variant
   end

   # POST /posts or /posts.json
@@ -33,7 +36,7 @@ class PostsController &lt; ApplicationController
   # PATCH/PUT /posts/1 or /posts/1.json
   def update
     if @post.update(post_params)
-      redirect_back_or_to post_url(@post), notice: "Post was successfully updated."
+      redirect_to post_url(@post, variant: @variant), notice: "Post was successfully updated."
     else
       render :edit, status: :unprocessable_entity
     end
@@ -56,4 +59,8 @@ class PostsController &lt; ApplicationController
     def post_params
       params.require(:post).permit(:title, :body)
     end
+
+    def set_variant
+      @variant ||= :inline if params[:variant] == "inline"
+    end
 end
</code></pre>

<p>With this change in place, making edits on the <code>index</code> view returns the teaser
content.</p>

<p><img src="https://images.thoughtbot.com/djv5g70hedywno5ha8dzygb1phps_3.a.gif" alt="A teaser is still rendered when making edits from the index view" /></p>

<p>This change also means making edits from the <code>edit</code> page no longer happen
inline, as made evident by the presence of the flash message.</p>

<p><img src="https://images.thoughtbot.com/h11939hdmhvwxlevbjnmft8c5zr1_4.b.gif" alt="We redirect to the show page after making an edit on the edit view." /></p>

<h2 id="wrapping-up">Wrapping Up</h2>

<p>Turbo Frames require a new mental model when it comes to managing the
state of a page. That, plus that fact that it’s a relatively new technology
means that we’re still exploring solutions to common problems as a community.</p>

<p>In this case, Turbo does not offer an off-the-shelf solution to conditionally
rendering Frames, but Rails does. I hope that moving forward, this post will
serve as guide when others are faced with the same problem.</p>]]></content><author><name></name></author><category term="Ruby on Rails" /><summary type="html"><![CDATA[We explore several solutions to a common Hotwire problem.]]></summary></entry></feed>