Skip to content

Commit db1ed95

Browse files
committed
Add caching + don't push :latest tag on feature branches
1 parent d2bc17e commit db1ed95

15 files changed

+481
-18
lines changed

.env.test

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CLOUDFLARE_API_TOKEN=cloudflare-token
2+
CLOUDFLARE_ZONE_ID=zone-id

.github/workflows/depot-build-and-push.yaml

+16-4
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,37 @@ on: push
44

55
jobs:
66
docker:
7-
runs-on: ubuntu-20.04
7+
runs-on: ubuntu-24.04
88
permissions:
99
contents: read
1010
pages: write
1111
id-token: write
1212
steps:
13-
- uses: actions/checkout@v3
13+
- uses: actions/checkout@v4
1414
- uses: depot/setup-action@v1
1515

16-
- uses: docker/login-action@v2
16+
- uses: docker/login-action@v3
1717
with:
1818
username: ${{ secrets.DOCKERHUB_USERNAME }}
1919
password: ${{ secrets.DOCKERHUB_TOKEN }}
2020

21-
- uses: depot/build-push-action@v1
21+
- name: Build and push with sha + latest tag on master
22+
if: github.ref == 'refs/heads/master'
23+
uses: depot/build-push-action@v1
2224
with:
2325
project: b4qlt63xvg
2426
platforms: linux/amd64,linux/arm64
2527
push: true
2628
tags: |
2729
reclaimthestack/rails-example:latest
2830
reclaimthestack/rails-example:sha-${{ github.sha }}
31+
32+
- name: Build and push with sha tag on feature branches
33+
if: github.ref != 'refs/heads/master'
34+
uses: depot/build-push-action@v1
35+
with:
36+
project: b4qlt63xvg
37+
platforms: linux/amd64,linux/arm64
38+
push: true
39+
tags: |
40+
reclaimthestack/rails-example:sha-${{ github.sha }}

.gitignore

+6
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,9 @@
2929

3030
# Ignore master key for decrypting credentials and more.
3131
/config/master.key
32+
33+
# RSpec
34+
spec/examples.txt
35+
36+
# Dotenv
37+
.env.development.local

.rspec

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
--require rails_helper --color

Gemfile

+6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ ruby File.read(".ruby-version")
66
gem "rails", github: "rails/rails", branch: "main"
77

88
gem "bootsnap", require: false
9+
gem "httpx"
910
gem "importmap-rails"
1011
gem "opengraph_parser"
1112
gem "pg"
@@ -22,4 +23,9 @@ end
2223

2324
group :development, :test do
2425
gem "dotenv-rails"
26+
gem "rspec-rails"
27+
end
28+
29+
group :test do
30+
gem "webmock"
2531
end

Gemfile.lock

+36
Original file line numberDiff line numberDiff line change
@@ -99,21 +99,30 @@ GEM
9999
specs:
100100
addressable (2.8.2)
101101
public_suffix (>= 2.0.2, < 6.0)
102+
bigdecimal (3.1.8)
102103
bindex (0.8.1)
103104
bootsnap (1.16.0)
104105
msgpack (~> 1.2)
105106
builder (3.2.4)
106107
concurrent-ruby (1.2.2)
107108
connection_pool (2.4.0)
109+
crack (1.0.0)
110+
bigdecimal
111+
rexml
108112
crass (1.0.6)
109113
date (3.3.3)
114+
diff-lcs (1.5.0)
110115
dotenv (2.8.1)
111116
dotenv-rails (2.8.1)
112117
dotenv (= 2.8.1)
113118
railties (>= 3.2)
114119
erubi (1.12.0)
115120
globalid (1.1.0)
116121
activesupport (>= 5.0)
122+
hashdiff (1.1.0)
123+
http-2-next (1.0.3)
124+
httpx (1.2.5)
125+
http-2-next (>= 1.0.3)
117126
i18n (1.12.0)
118127
concurrent-ruby (~> 1.0)
119128
importmap-rails (1.1.5)
@@ -183,13 +192,33 @@ GEM
183192
connection_pool
184193
reline (0.3.3)
185194
io-console (~> 0.5)
195+
rexml (3.2.8)
196+
strscan (>= 3.0.9)
197+
rspec-core (3.12.1)
198+
rspec-support (~> 3.12.0)
199+
rspec-expectations (3.12.2)
200+
diff-lcs (>= 1.2.0, < 2.0)
201+
rspec-support (~> 3.12.0)
202+
rspec-mocks (3.12.4)
203+
diff-lcs (>= 1.2.0, < 2.0)
204+
rspec-support (~> 3.12.0)
205+
rspec-rails (6.0.1)
206+
actionpack (>= 6.1)
207+
activesupport (>= 6.1)
208+
railties (>= 6.1)
209+
rspec-core (~> 3.11)
210+
rspec-expectations (~> 3.11)
211+
rspec-mocks (~> 3.11)
212+
rspec-support (~> 3.11)
213+
rspec-support (3.12.0)
186214
sidekiq (7.0.7)
187215
concurrent-ruby (< 2)
188216
connection_pool (>= 2.3.0)
189217
rack (>= 2.2.4)
190218
redis-client (>= 0.11.0)
191219
stimulus-rails (1.2.1)
192220
railties (>= 6.0.0)
221+
strscan (3.1.0)
193222
thor (1.2.1)
194223
timeout (0.3.2)
195224
turbo-rails (1.4.0)
@@ -203,6 +232,10 @@ GEM
203232
activemodel (>= 6.0.0)
204233
bindex (>= 0.4.0)
205234
railties (>= 6.0.0)
235+
webmock (3.23.1)
236+
addressable (>= 2.8.0)
237+
crack (>= 0.3.2)
238+
hashdiff (>= 0.4.0, < 2.0.0)
206239
webrick (1.8.1)
207240
websocket-driver (0.7.5)
208241
websocket-extensions (>= 0.1.0)
@@ -217,17 +250,20 @@ PLATFORMS
217250
DEPENDENCIES
218251
bootsnap
219252
dotenv-rails
253+
httpx
220254
importmap-rails
221255
opengraph_parser
222256
pg
223257
propshaft
224258
puma
225259
rails!
226260
redis
261+
rspec-rails
227262
sidekiq
228263
stimulus-rails
229264
turbo-rails
230265
web-console
266+
webmock
231267

232268
RUBY VERSION
233269
ruby 3.2.1p31

app/api/cloudflare.rb

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
module Cloudflare
2+
BASE_URL = "https://api.cloudflare.com/client/v4".freeze
3+
4+
# https://developers.cloudflare.com/api/operations/zone-purge#purge-cached-content-by-tag-host-or-prefix
5+
#
6+
# Rate-limiting: Cache-Tag, host and prefix purging each have a rate limit
7+
# of 30,000 purge API calls in every 24 hour period. You may purge up to
8+
# 30 tags, hosts, or prefixes in one API call. This rate limit can be
9+
# raised for customers who need to purge at higher volume.
10+
#
11+
# Provide tags as an Array of Strings, eg: ["mnd-assets-id-xxx", ...] or a single String
12+
def self.purge_by_tags(tags, zone_id: ENV.fetch("CLOUDFLARE_ZONE_ID"))
13+
tags = Array.wrap(tags)
14+
15+
post("zones/#{zone_id}/purge_cache", tags:)
16+
end
17+
18+
# https://developers.cloudflare.com/api/operations/zone-purge#purge-cached-content-by-url
19+
def self.purge_by_urls(urls, zone_id: ENV.fetch("CLOUDFLARE_ZONE_ID"))
20+
urls = Array.wrap(urls)
21+
22+
post("zones/#{zone_id}/purge_cache", files: urls)
23+
end
24+
25+
# https://developers.cloudflare.com/api/operations/zone-purge#purge-all-cached-content
26+
def self.purge_everything(zone_id: ENV.fetch("CLOUDFLARE_ZONE_ID"))
27+
post("zones/#{zone_id}/purge_cache", purge_everything: true)
28+
end
29+
30+
%w[get post delete patch].each do |verb|
31+
define_singleton_method(verb) do |path, params = {}|
32+
request(verb.upcase, path, params)
33+
end
34+
end
35+
36+
def self.request(verb, path, params)
37+
HTTPX.send(
38+
verb.downcase,
39+
"#{BASE_URL}/#{path}",
40+
headers: {
41+
"Authorization" => "Bearer #{ENV.fetch('CLOUDFLARE_API_TOKEN')}",
42+
"Accept" => "application/json",
43+
},
44+
json: params,
45+
).raise_for_status
46+
end
47+
end

app/controllers/posts_controller.rb

+32-13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
class PostsController < ApplicationController
2-
before_action :set_post, only: %i[ show edit update destroy ]
2+
before_action :enable_caching, only: %i[index show new edit]
3+
skip_before_action :verify_authenticity_token
34

45
# GET /posts
56
def index
@@ -8,6 +9,7 @@ def index
89

910
# GET /posts/1
1011
def show
12+
@post = Post.find(params[:id])
1113
end
1214

1315
# GET /posts/new
@@ -17,42 +19,59 @@ def new
1719

1820
# GET /posts/1/edit
1921
def edit
22+
@post = Post.find(params[:id])
2023
end
2124

2225
# POST /posts
2326
def create
2427
@post = Post.new(post_params)
2528

2629
if @post.save
27-
redirect_to @post, notice: "Post was successfully created."
30+
CachedUrl.expire_by_tags(["posts:all"])
31+
redirect_to post_path(@post, nocache: true), notice: "Post was successfully created."
2832
else
2933
render :new, status: :unprocessable_entity
3034
end
3135
end
3236

3337
# PATCH/PUT /posts/1
3438
def update
39+
@post = Post.find(params[:id])
40+
3541
if @post.update(post_params)
36-
redirect_to @post, notice: "Post was successfully updated."
42+
CachedUrl.expire_by_tags(["posts:all", "posts:#{@post.id}"])
43+
redirect_to post_path(@post, nocache: true), notice: "Post was successfully updated."
3744
else
3845
render :edit, status: :unprocessable_entity
3946
end
4047
end
4148

4249
# DELETE /posts/1
4350
def destroy
44-
@post.destroy!
45-
redirect_to posts_url, notice: "Post was successfully destroyed.", status: :see_other
51+
post = Post.find(params[:id])
52+
53+
post.destroy!
54+
55+
CachedUrl.expire_by_tags(["posts:all", "posts:#{post.id}"])
56+
57+
redirect_to posts_path(nocache: true), notice: "Post was successfully destroyed.", status: :see_other
4658
end
4759

4860
private
49-
# Use callbacks to share common setup or constraints between actions.
50-
def set_post
51-
@post = Post.find(params[:id])
52-
end
5361

54-
# Only allow a list of trusted parameters through.
55-
def post_params
56-
params.require(:post).permit(:title, :body)
57-
end
62+
def post_params
63+
params.require(:post).permit(:title, :body)
64+
end
65+
66+
def enable_caching
67+
return if params.key?(:nocache)
68+
69+
# don't cache cookies (note: Cloudflare won't cache responses with cookies)
70+
request.session_options[:skip] = true
71+
72+
tags = action_name == "index" ? ["section:posts", "posts:all"] : ["section:posts", "posts:#{params[:id]}"]
73+
74+
CachedUrl.upsert({ url: request.url, tags:, expires_at: 1.hour.from_now }, unique_by: :url)
75+
expires_in 1.hour, public: true
76+
end
5877
end

app/models/cached_url.rb

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
class CachedUrl < ApplicationRecord
2+
scope :tagged_one_of, -> (tags) { where("tags && ARRAY[?]::varchar[]", tags) }
3+
4+
def self.expire_by_tags(tags)
5+
transaction do
6+
cached_urls = tagged_one_of(tags)
7+
8+
now = Time.now
9+
urls_to_purge = cached_urls.map { |cu| cu.url unless cu.expires_at < now }.compact
10+
11+
Cloudflare.purge_by_urls(urls_to_purge)
12+
13+
cached_urls.delete_all
14+
end
15+
end
16+
end

0 commit comments

Comments
 (0)