source_monitor 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/agents/rails-concern.md +464 -0
- data/.claude/agents/rails-controller.md +424 -0
- data/.claude/agents/rails-hotwire.md +446 -0
- data/.claude/agents/rails-implement.md +374 -0
- data/.claude/agents/rails-job.md +334 -0
- data/.claude/agents/rails-lint.md +294 -0
- data/.claude/agents/rails-mailer.md +371 -0
- data/.claude/agents/rails-migration.md +449 -0
- data/.claude/agents/rails-model.md +420 -0
- data/.claude/agents/rails-policy.md +443 -0
- data/.claude/agents/rails-presenter.md +427 -0
- data/.claude/agents/rails-query.md +412 -0
- data/.claude/agents/rails-review.md +490 -0
- data/.claude/agents/rails-service.md +458 -0
- data/.claude/agents/rails-state-records.md +465 -0
- data/.claude/agents/rails-tdd.md +314 -0
- data/.claude/agents/rails-test.md +441 -0
- data/.claude/agents/rails-view-component.md +418 -0
- data/.claude/hooks/block-secrets.sh +52 -0
- data/.claude/settings.json +85 -0
- data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
- data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
- data/.claude/skills/active-storage-setup/SKILL.md +311 -0
- data/.claude/skills/api-versioning/SKILL.md +294 -0
- data/.claude/skills/authentication-flow/SKILL.md +335 -0
- data/.claude/skills/authentication-flow/reference/current.md +248 -0
- data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
- data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
- data/.claude/skills/authorization-pundit/SKILL.md +462 -0
- data/.claude/skills/caching-strategies/SKILL.md +350 -0
- data/.claude/skills/database-migrations/SKILL.md +354 -0
- data/.claude/skills/form-object-patterns/SKILL.md +399 -0
- data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
- data/.claude/skills/i18n-patterns/SKILL.md +320 -0
- data/.claude/skills/install/SKILL.md +367 -0
- data/.claude/skills/performance-optimization/SKILL.md +311 -0
- data/.claude/skills/rails-architecture/SKILL.md +259 -0
- data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
- data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
- data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
- data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
- data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
- data/.claude/skills/rails-concern/SKILL.md +399 -0
- data/.claude/skills/rails-controller/SKILL.md +336 -0
- data/.claude/skills/rails-model-generator/SKILL.md +321 -0
- data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
- data/.claude/skills/rails-presenter/SKILL.md +274 -0
- data/.claude/skills/rails-query-object/SKILL.md +289 -0
- data/.claude/skills/rails-service-object/SKILL.md +349 -0
- data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
- data/.claude/skills/tdd-cycle/SKILL.md +359 -0
- data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +2 -0
- data/.ruby-version +1 -1
- data/.vbw-planning/.notification-log.jsonl +192 -0
- data/.vbw-planning/.session-log.jsonl +871 -0
- data/.vbw-planning/PROJECT.md +51 -0
- data/.vbw-planning/REQUIREMENTS.md +50 -0
- data/.vbw-planning/SHIPPED.md +28 -0
- data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
- data/.vbw-planning/codebase/CONCERNS.md +99 -0
- data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
- data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
- data/.vbw-planning/codebase/INDEX.md +86 -0
- data/.vbw-planning/codebase/META.md +42 -0
- data/.vbw-planning/codebase/PATTERNS.md +262 -0
- data/.vbw-planning/codebase/STACK.md +101 -0
- data/.vbw-planning/codebase/STRUCTURE.md +324 -0
- data/.vbw-planning/codebase/TESTING.md +154 -0
- data/.vbw-planning/config.json +12 -0
- data/.vbw-planning/discovery.json +24 -0
- data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
- data/.vbw-planning/milestones/default/STATE.md +83 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +179 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +114 -101
- data/Rakefile +2 -0
- data/app/assets/builds/source_monitor/application.css +2076 -0
- data/app/assets/builds/source_monitor/application.js +2758 -0
- data/app/assets/builds/source_monitor/application.js.map +7 -0
- data/app/controllers/source_monitor/application_controller.rb +2 -0
- data/app/controllers/source_monitor/health_controller.rb +2 -0
- data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
- data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
- data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
- data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
- data/app/controllers/source_monitor/items_controller.rb +2 -0
- data/app/controllers/source_monitor/sources_controller.rb +0 -14
- data/app/helpers/source_monitor/application_helper.rb +4 -112
- data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
- data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
- data/app/jobs/source_monitor/application_job.rb +2 -0
- data/app/models/source_monitor/application_record.rb +2 -0
- data/app/models/source_monitor/log_entry.rb +0 -2
- data/config/coverage_baseline.json +217 -1862
- data/config/routes.rb +2 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
- data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
- data/lib/source_monitor/assets/bundler.rb +2 -0
- data/lib/source_monitor/assets.rb +2 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
- data/lib/source_monitor/configuration/events.rb +60 -0
- data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
- data/lib/source_monitor/configuration/health_settings.rb +27 -0
- data/lib/source_monitor/configuration/http_settings.rb +43 -0
- data/lib/source_monitor/configuration/model_definition.rb +108 -0
- data/lib/source_monitor/configuration/models.rb +36 -0
- data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
- data/lib/source_monitor/configuration/retention_settings.rb +45 -0
- data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
- data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
- data/lib/source_monitor/configuration/validation_definition.rb +32 -0
- data/lib/source_monitor/configuration.rb +12 -579
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
- data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
- data/lib/source_monitor/dashboard/queries.rb +2 -195
- data/lib/source_monitor/engine.rb +2 -0
- data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
- data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
- data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
- data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
- data/lib/source_monitor/items/item_creator.rb +28 -455
- data/lib/source_monitor/setup/bundle_installer.rb +2 -0
- data/lib/source_monitor/setup/cli.rb +2 -0
- data/lib/source_monitor/setup/dependency_checker.rb +2 -0
- data/lib/source_monitor/setup/detectors.rb +2 -0
- data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
- data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
- data/lib/source_monitor/setup/install_generator.rb +2 -0
- data/lib/source_monitor/setup/migration_installer.rb +2 -0
- data/lib/source_monitor/setup/node_installer.rb +2 -0
- data/lib/source_monitor/setup/prompter.rb +2 -0
- data/lib/source_monitor/setup/requirements.rb +2 -0
- data/lib/source_monitor/setup/shell_runner.rb +2 -0
- data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/printer.rb +2 -0
- data/lib/source_monitor/setup/verification/result.rb +2 -0
- data/lib/source_monitor/setup/verification/runner.rb +2 -0
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
- data/lib/source_monitor/setup/workflow.rb +2 -0
- data/lib/source_monitor/version.rb +3 -1
- data/lib/source_monitor.rb +140 -58
- data/lib/tasks/source_monitor_assets.rake +2 -0
- data/lib/tasks/source_monitor_setup.rake +2 -0
- data/lib/tasks/source_monitor_tasks.rake +2 -0
- data/source_monitor.gemspec +3 -1
- metadata +144 -4
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: active-storage-setup
|
|
3
|
+
description: Configures Active Storage for file uploads with variants and direct uploads. Use when adding file uploads, image attachments, document storage, generating thumbnails, or when user mentions Active Storage, file upload, attachments, or image processing.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Active Storage Setup for Rails 8
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Active Storage handles file uploads in Rails:
|
|
12
|
+
- Cloud storage (S3, GCS, Azure) or local disk
|
|
13
|
+
- Image variants (thumbnails, resizing)
|
|
14
|
+
- Direct uploads from browser
|
|
15
|
+
- Polymorphic attachments
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bin/rails active_storage:install
|
|
21
|
+
bin/rails db:migrate
|
|
22
|
+
bundle add image_processing
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
# config/storage.yml
|
|
29
|
+
local:
|
|
30
|
+
service: Disk
|
|
31
|
+
root: <%= Rails.root.join("storage") %>
|
|
32
|
+
|
|
33
|
+
test:
|
|
34
|
+
service: Disk
|
|
35
|
+
root: <%= Rails.root.join("tmp/storage") %>
|
|
36
|
+
|
|
37
|
+
amazon:
|
|
38
|
+
service: S3
|
|
39
|
+
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
|
|
40
|
+
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
|
|
41
|
+
region: eu-west-1
|
|
42
|
+
bucket: <%= Rails.application.credentials.dig(:aws, :bucket) %>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# config/environments/development.rb
|
|
47
|
+
config.active_storage.service = :local
|
|
48
|
+
|
|
49
|
+
# config/environments/production.rb
|
|
50
|
+
config.active_storage.service = :amazon
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Model Attachments
|
|
54
|
+
|
|
55
|
+
### Single Attachment
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class User < ApplicationRecord
|
|
59
|
+
has_one_attached :avatar do |attachable|
|
|
60
|
+
attachable.variant :thumb, resize_to_limit: [100, 100]
|
|
61
|
+
attachable.variant :medium, resize_to_limit: [300, 300]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Multiple Attachments
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class Event < ApplicationRecord
|
|
70
|
+
has_many_attached :photos
|
|
71
|
+
has_many_attached :documents
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Validations
|
|
76
|
+
|
|
77
|
+
### Manual Validation
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
class User < ApplicationRecord
|
|
81
|
+
has_one_attached :avatar
|
|
82
|
+
|
|
83
|
+
validate :acceptable_avatar
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def acceptable_avatar
|
|
88
|
+
return unless avatar.attached?
|
|
89
|
+
|
|
90
|
+
unless avatar.blob.byte_size <= 5.megabytes
|
|
91
|
+
errors.add(:avatar, "is too large (max 5MB)")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
acceptable_types = ["image/jpeg", "image/png", "image/webp"]
|
|
95
|
+
unless acceptable_types.include?(avatar.content_type)
|
|
96
|
+
errors.add(:avatar, "must be a JPEG, PNG, or WebP")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### With active_storage_validations Gem
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
gem "active_storage_validations"
|
|
106
|
+
|
|
107
|
+
class User < ApplicationRecord
|
|
108
|
+
has_one_attached :avatar
|
|
109
|
+
|
|
110
|
+
validates :avatar,
|
|
111
|
+
content_type: ["image/png", "image/jpeg", "image/webp"],
|
|
112
|
+
size: { less_than: 5.megabytes }
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Image Variants
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# Resize to fit (maintains aspect ratio)
|
|
120
|
+
resize_to_limit: [300, 300]
|
|
121
|
+
|
|
122
|
+
# Resize and crop to exact dimensions
|
|
123
|
+
resize_to_fill: [300, 300]
|
|
124
|
+
|
|
125
|
+
# With format conversion
|
|
126
|
+
resize_to_limit: [300, 300], format: :webp, saver: { quality: 80 }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Using in Views
|
|
130
|
+
|
|
131
|
+
```erb
|
|
132
|
+
<% if user.avatar.attached? %>
|
|
133
|
+
<%= image_tag user.avatar.variant(:thumb), alt: user.name %>
|
|
134
|
+
<% else %>
|
|
135
|
+
<%= image_tag "default-avatar.png", alt: "Default" %>
|
|
136
|
+
<% end %>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Testing Attachments (Minitest)
|
|
140
|
+
|
|
141
|
+
### Model Test
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# test/models/user_test.rb
|
|
145
|
+
require "test_helper"
|
|
146
|
+
|
|
147
|
+
class UserAttachmentTest < ActiveSupport::TestCase
|
|
148
|
+
setup do
|
|
149
|
+
@user = users(:one)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
test "attaches an avatar" do
|
|
153
|
+
@user.avatar.attach(
|
|
154
|
+
io: File.open(Rails.root.join("test/fixtures/files/avatar.jpg")),
|
|
155
|
+
filename: "avatar.jpg",
|
|
156
|
+
content_type: "image/jpeg"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
assert @user.avatar.attached?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
test "rejects oversized avatar" do
|
|
163
|
+
@user.avatar.attach(
|
|
164
|
+
io: StringIO.new("x" * 6.megabytes),
|
|
165
|
+
filename: "large.jpg",
|
|
166
|
+
content_type: "image/jpeg"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
assert_not @user.valid?
|
|
170
|
+
assert_includes @user.errors[:avatar], "is too large (max 5MB)"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
test "rejects invalid content type" do
|
|
174
|
+
@user.avatar.attach(
|
|
175
|
+
io: File.open(Rails.root.join("test/fixtures/files/document.pdf")),
|
|
176
|
+
filename: "doc.pdf",
|
|
177
|
+
content_type: "application/pdf"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
assert_not @user.valid?
|
|
181
|
+
assert @user.errors[:avatar].any?
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Controller Test
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# test/controllers/users_controller_test.rb
|
|
190
|
+
require "test_helper"
|
|
191
|
+
|
|
192
|
+
class UsersUploadTest < ActionDispatch::IntegrationTest
|
|
193
|
+
setup do
|
|
194
|
+
@user = users(:one)
|
|
195
|
+
sign_in @user
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
test "uploads avatar" do
|
|
199
|
+
avatar = fixture_file_upload("avatar.jpg", "image/jpeg")
|
|
200
|
+
|
|
201
|
+
patch user_path(@user), params: { user: { avatar: avatar } }
|
|
202
|
+
|
|
203
|
+
assert @user.reload.avatar.attached?
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
test "removes avatar" do
|
|
207
|
+
@user.avatar.attach(
|
|
208
|
+
io: File.open(Rails.root.join("test/fixtures/files/avatar.jpg")),
|
|
209
|
+
filename: "avatar.jpg",
|
|
210
|
+
content_type: "image/jpeg"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
delete remove_avatar_user_path(@user)
|
|
214
|
+
|
|
215
|
+
assert_not @user.reload.avatar.attached?
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Fixtures Setup
|
|
221
|
+
|
|
222
|
+
Place test files in `test/fixtures/files/`:
|
|
223
|
+
```
|
|
224
|
+
test/fixtures/files/
|
|
225
|
+
├── avatar.jpg
|
|
226
|
+
├── document.pdf
|
|
227
|
+
└── photo.png
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Controller Handling
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
class UsersController < ApplicationController
|
|
234
|
+
def update
|
|
235
|
+
if @user.update(user_params)
|
|
236
|
+
redirect_to @user, notice: "Profile updated"
|
|
237
|
+
else
|
|
238
|
+
render :edit, status: :unprocessable_entity
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def remove_avatar
|
|
243
|
+
@user.avatar.purge
|
|
244
|
+
redirect_to edit_user_path(@user), notice: "Avatar removed"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
def user_params
|
|
250
|
+
params.require(:user).permit(:name, :email, :avatar)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Multiple Uploads
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
def event_params
|
|
259
|
+
params.require(:event).permit(:name, photos: [], documents: [])
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Forms
|
|
264
|
+
|
|
265
|
+
```erb
|
|
266
|
+
<%= form_with model: @user do |f| %>
|
|
267
|
+
<div>
|
|
268
|
+
<%= f.label :avatar %>
|
|
269
|
+
<%= f.file_field :avatar, accept: "image/png,image/jpeg,image/webp" %>
|
|
270
|
+
|
|
271
|
+
<% if @user.avatar.attached? %>
|
|
272
|
+
<%= image_tag @user.avatar.variant(:thumb), class: "rounded mt-2" %>
|
|
273
|
+
<% end %>
|
|
274
|
+
</div>
|
|
275
|
+
<%= f.submit %>
|
|
276
|
+
<% end %>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Direct Uploads
|
|
280
|
+
|
|
281
|
+
```javascript
|
|
282
|
+
// app/javascript/application.js
|
|
283
|
+
import * as ActiveStorage from "@rails/activestorage"
|
|
284
|
+
ActiveStorage.start()
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
```erb
|
|
288
|
+
<%= f.file_field :photos, multiple: true, direct_upload: true %>
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Performance Tips
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
# Prevent N+1 on attachments
|
|
295
|
+
User.with_attached_avatar.limit(10)
|
|
296
|
+
|
|
297
|
+
# Multiple attachments
|
|
298
|
+
Event.with_attached_photos.with_attached_documents
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Checklist
|
|
302
|
+
|
|
303
|
+
- [ ] Active Storage installed and migrated
|
|
304
|
+
- [ ] Storage service configured
|
|
305
|
+
- [ ] Image processing gem added
|
|
306
|
+
- [ ] Attachment added to model
|
|
307
|
+
- [ ] Validations added (type, size)
|
|
308
|
+
- [ ] Variants defined
|
|
309
|
+
- [ ] Controller permits attachment params
|
|
310
|
+
- [ ] Tests written for attachments
|
|
311
|
+
- [ ] All tests GREEN
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: api-versioning
|
|
3
|
+
description: Implements RESTful API design with versioning and request tests. Use when building APIs, adding API endpoints, versioning APIs, or when user mentions REST, JSON API, or API design.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# API Versioning for Rails 8
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Well-structured APIs need versioning for backwards compatibility and clear organization.
|
|
12
|
+
|
|
13
|
+
**Recommended**: URL Path versioning (`/api/v1/users`)
|
|
14
|
+
|
|
15
|
+
## Quick Setup
|
|
16
|
+
|
|
17
|
+
### Routes
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# config/routes.rb
|
|
21
|
+
Rails.application.routes.draw do
|
|
22
|
+
namespace :api do
|
|
23
|
+
namespace :v1 do
|
|
24
|
+
resources :users, only: [:index, :show, :create, :update, :destroy]
|
|
25
|
+
resources :events, only: [:index, :show, :create]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Directory Structure
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
app/controllers/
|
|
35
|
+
├── api/
|
|
36
|
+
│ ├── base_controller.rb
|
|
37
|
+
│ ├── v1/
|
|
38
|
+
│ │ ├── base_controller.rb
|
|
39
|
+
│ │ ├── users_controller.rb
|
|
40
|
+
│ │ └── events_controller.rb
|
|
41
|
+
│ └── v2/
|
|
42
|
+
│ ├── base_controller.rb
|
|
43
|
+
│ └── users_controller.rb
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Base Controller
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# app/controllers/api/base_controller.rb
|
|
50
|
+
module Api
|
|
51
|
+
class BaseController < ApplicationController
|
|
52
|
+
skip_before_action :verify_authenticity_token
|
|
53
|
+
|
|
54
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
55
|
+
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
|
|
56
|
+
rescue_from ActionController::ParameterMissing, with: :bad_request
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def not_found(exception)
|
|
61
|
+
render json: { error: exception.message }, status: :not_found
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def unprocessable_entity(exception)
|
|
65
|
+
render json: { errors: exception.record.errors }, status: :unprocessable_entity
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def bad_request(exception)
|
|
69
|
+
render json: { error: exception.message }, status: :bad_request
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Version Base Controller
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# app/controllers/api/v1/base_controller.rb
|
|
79
|
+
module Api
|
|
80
|
+
module V1
|
|
81
|
+
class BaseController < Api::BaseController
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Resource Controller
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# app/controllers/api/v1/users_controller.rb
|
|
91
|
+
module Api
|
|
92
|
+
module V1
|
|
93
|
+
class UsersController < BaseController
|
|
94
|
+
before_action :set_user, only: [:show, :update, :destroy]
|
|
95
|
+
|
|
96
|
+
def index
|
|
97
|
+
@users = User.page(params[:page]).per(25)
|
|
98
|
+
render json: { data: @users, meta: pagination_meta(@users) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def show
|
|
102
|
+
render json: { data: @user }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def create
|
|
106
|
+
@user = User.create!(user_params)
|
|
107
|
+
render json: { data: @user }, status: :created
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def update
|
|
111
|
+
@user.update!(user_params)
|
|
112
|
+
render json: { data: @user }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def destroy
|
|
116
|
+
@user.destroy
|
|
117
|
+
head :no_content
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def set_user
|
|
123
|
+
@user = User.find(params[:id])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def user_params
|
|
127
|
+
params.require(:user).permit(:name, :email)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def pagination_meta(collection)
|
|
131
|
+
{
|
|
132
|
+
current_page: collection.current_page,
|
|
133
|
+
total_pages: collection.total_pages,
|
|
134
|
+
total_count: collection.total_count
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## API Authentication
|
|
143
|
+
|
|
144
|
+
### Bearer Token Auth
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# app/controllers/api/base_controller.rb
|
|
148
|
+
module Api
|
|
149
|
+
class BaseController < ApplicationController
|
|
150
|
+
before_action :authenticate_api_user!
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def authenticate_api_user!
|
|
155
|
+
token = request.headers["Authorization"]&.split(" ")&.last
|
|
156
|
+
@current_api_user = Session.find_by(token: token)&.user
|
|
157
|
+
|
|
158
|
+
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_api_user
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def current_api_user
|
|
162
|
+
@current_api_user
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Response Format
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
// Success (single)
|
|
172
|
+
{ "data": { "id": 1, "name": "John", "email": "john@example.com" } }
|
|
173
|
+
|
|
174
|
+
// Success (collection)
|
|
175
|
+
{ "data": [...], "meta": { "current_page": 1, "total_pages": 10 } }
|
|
176
|
+
|
|
177
|
+
// Error
|
|
178
|
+
{ "error": "Record not found" }
|
|
179
|
+
|
|
180
|
+
// Validation errors
|
|
181
|
+
{ "errors": { "email": ["has already been taken"] } }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Testing APIs (Minitest)
|
|
185
|
+
|
|
186
|
+
### Request Test Template
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# test/controllers/api/v1/users_controller_test.rb
|
|
190
|
+
require "test_helper"
|
|
191
|
+
|
|
192
|
+
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
|
|
193
|
+
setup do
|
|
194
|
+
@user = users(:one)
|
|
195
|
+
@headers = {
|
|
196
|
+
"Accept" => "application/json",
|
|
197
|
+
"Content-Type" => "application/json",
|
|
198
|
+
"Authorization" => "Bearer #{api_token_for(@user)}"
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# -- index --
|
|
203
|
+
test "GET /api/v1/users returns all users" do
|
|
204
|
+
get "/api/v1/users", headers: @headers
|
|
205
|
+
|
|
206
|
+
assert_response :success
|
|
207
|
+
data = json_response["data"]
|
|
208
|
+
assert_kind_of Array, data
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# -- show --
|
|
212
|
+
test "GET /api/v1/users/:id returns the user" do
|
|
213
|
+
get "/api/v1/users/#{@user.id}", headers: @headers
|
|
214
|
+
|
|
215
|
+
assert_response :success
|
|
216
|
+
assert_equal @user.id, json_response["data"]["id"]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
test "GET /api/v1/users/:id returns 404 for missing user" do
|
|
220
|
+
get "/api/v1/users/999999", headers: @headers
|
|
221
|
+
|
|
222
|
+
assert_response :not_found
|
|
223
|
+
assert json_response["error"].present?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# -- create --
|
|
227
|
+
test "POST /api/v1/users creates a user" do
|
|
228
|
+
params = { user: { name: "New User", email: "new@example.com" } }
|
|
229
|
+
|
|
230
|
+
assert_difference("User.count", 1) do
|
|
231
|
+
post "/api/v1/users", params: params.to_json, headers: @headers
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
assert_response :created
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
test "POST /api/v1/users with invalid params returns errors" do
|
|
238
|
+
params = { user: { name: "", email: "" } }
|
|
239
|
+
|
|
240
|
+
assert_no_difference("User.count") do
|
|
241
|
+
post "/api/v1/users", params: params.to_json, headers: @headers
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
assert_response :unprocessable_entity
|
|
245
|
+
assert json_response["errors"].present?
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# -- update --
|
|
249
|
+
test "PATCH /api/v1/users/:id updates the user" do
|
|
250
|
+
params = { user: { name: "Updated" } }
|
|
251
|
+
|
|
252
|
+
patch "/api/v1/users/#{@user.id}", params: params.to_json, headers: @headers
|
|
253
|
+
|
|
254
|
+
assert_response :success
|
|
255
|
+
assert_equal "Updated", @user.reload.name
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# -- destroy --
|
|
259
|
+
test "DELETE /api/v1/users/:id destroys the user" do
|
|
260
|
+
assert_difference("User.count", -1) do
|
|
261
|
+
delete "/api/v1/users/#{@user.id}", headers: @headers
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
assert_response :no_content
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# -- authentication --
|
|
268
|
+
test "returns 401 without token" do
|
|
269
|
+
get "/api/v1/users", headers: { "Accept" => "application/json" }
|
|
270
|
+
|
|
271
|
+
assert_response :unauthorized
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private
|
|
275
|
+
|
|
276
|
+
def json_response
|
|
277
|
+
JSON.parse(response.body)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def api_token_for(user)
|
|
281
|
+
user.sessions.create!.token
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Checklist
|
|
287
|
+
|
|
288
|
+
- [ ] Routes namespaced under `api/v1`
|
|
289
|
+
- [ ] Base controller with error handling
|
|
290
|
+
- [ ] Authentication configured
|
|
291
|
+
- [ ] Standard response format
|
|
292
|
+
- [ ] Request tests written
|
|
293
|
+
- [ ] 404/422/401 error cases tested
|
|
294
|
+
- [ ] All tests GREEN
|