rails_claude_skills 0.1.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 +7 -0
- data/.github/ISSUE_TEMPLATE/bug_report.yml +134 -0
- data/.github/ISSUE_TEMPLATE/config.yml +11 -0
- data/.github/ISSUE_TEMPLATE/feature_request.yml +129 -0
- data/.github/ISSUE_TEMPLATE/question.yml +90 -0
- data/.github/dependabot.yml +19 -0
- data/.github/workflows/ci.yml +77 -0
- data/.github/workflows/release.yml +66 -0
- data/.rubocop.yml +52 -0
- data/CHANGELOG.md +94 -0
- data/CLAUDE.md +332 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +580 -0
- data/LICENSE.txt +21 -0
- data/README.md +544 -0
- data/Rakefile +8 -0
- data/lib/generators/claude/agent/agent_generator.rb +71 -0
- data/lib/generators/claude/agent/templates/agent.md.tt +62 -0
- data/lib/generators/claude/command/command_generator.rb +50 -0
- data/lib/generators/claude/command/templates/command.md.tt +28 -0
- data/lib/generators/claude/commands_library/create-pr.md +27 -0
- data/lib/generators/claude/commands_library/dbchange.md +19 -0
- data/lib/generators/claude/commands_library/quality.md +20 -0
- data/lib/generators/claude/commands_library/stimulus.md +19 -0
- data/lib/generators/claude/commands_library/turbo-feature.md +17 -0
- data/lib/generators/claude/install/install_generator.rb +211 -0
- data/lib/generators/claude/install/templates/README.md.tt +59 -0
- data/lib/generators/claude/install/templates/USAGE +28 -0
- data/lib/generators/claude/install/templates/agents/api-dev.md.tt +46 -0
- data/lib/generators/claude/install/templates/agents/fullstack-dev.md.tt +48 -0
- data/lib/generators/claude/install/templates/agents/rails-developer.md.tt +40 -0
- data/lib/generators/claude/install/templates/settings.local.json.tt +13 -0
- data/lib/generators/claude/rule/rule_generator.rb +175 -0
- data/lib/generators/claude/rule/templates/rule.md.tt +7 -0
- data/lib/generators/claude/rules_library/code-style.md +37 -0
- data/lib/generators/claude/rules_library/database.md +47 -0
- data/lib/generators/claude/rules_library/hotwire.md +56 -0
- data/lib/generators/claude/rules_library/security.md +54 -0
- data/lib/generators/claude/rules_library/testing.md +47 -0
- data/lib/generators/claude/skill/skill_generator.rb +196 -0
- data/lib/generators/claude/skill/templates/SKILL.md.tt +27 -0
- data/lib/generators/claude/skills_library/create-task-files/SKILL.md +311 -0
- data/lib/generators/claude/skills_library/create-task-files/templates/bug.md +60 -0
- data/lib/generators/claude/skills_library/create-task-files/templates/epic.md +47 -0
- data/lib/generators/claude/skills_library/create-task-files/templates/issue.md +45 -0
- data/lib/generators/claude/skills_library/create-task-files/templates/user-story.md +57 -0
- data/lib/generators/claude/skills_library/minitest-testing/SKILL.md +398 -0
- data/lib/generators/claude/skills_library/minitest-testing/references/examples.md +889 -0
- data/lib/generators/claude/skills_library/plan-feature/SKILL.md +253 -0
- data/lib/generators/claude/skills_library/rails-api-controllers/SKILL.md +1041 -0
- data/lib/generators/claude/skills_library/rails-api-controllers/references/api-documentation.md +422 -0
- data/lib/generators/claude/skills_library/rails-api-controllers/references/serialization.md +456 -0
- data/lib/generators/claude/skills_library/rails-auth-with-devise/SKILL.md +191 -0
- data/lib/generators/claude/skills_library/rails-auth-with-devise/references/advanced.md +331 -0
- data/lib/generators/claude/skills_library/rails-auth-with-devise/references/api-auth.md +266 -0
- data/lib/generators/claude/skills_library/rails-auth-with-devise/references/omniauth.md +194 -0
- data/lib/generators/claude/skills_library/rails-authorization-cancancan/SKILL.md +603 -0
- data/lib/generators/claude/skills_library/rails-authorization-cancancan/references/api-authorization.md +543 -0
- data/lib/generators/claude/skills_library/rails-authorization-cancancan/references/complex-permissions.md +572 -0
- data/lib/generators/claude/skills_library/rails-authorization-cancancan/references/multi-tenancy.md +373 -0
- data/lib/generators/claude/skills_library/rails-controllers/SKILL.md +514 -0
- data/lib/generators/claude/skills_library/rails-debugging/SKILL.md +260 -0
- data/lib/generators/claude/skills_library/rails-deployment/SKILL.md +437 -0
- data/lib/generators/claude/skills_library/rails-deployment/references/examples.md +901 -0
- data/lib/generators/claude/skills_library/rails-hotwire/SKILL.md +367 -0
- data/lib/generators/claude/skills_library/rails-jobs/MISSION_CONTROL_SETUP.md +639 -0
- data/lib/generators/claude/skills_library/rails-jobs/SKILL.md +704 -0
- data/lib/generators/claude/skills_library/rails-mailers/SKILL.md +549 -0
- data/lib/generators/claude/skills_library/rails-models/SKILL.md +379 -0
- data/lib/generators/claude/skills_library/rails-pagination-kaminari/SKILL.md +622 -0
- data/lib/generators/claude/skills_library/rails-pagination-kaminari/references/api-pagination.md +523 -0
- data/lib/generators/claude/skills_library/rails-pagination-kaminari/references/custom-themes.md +498 -0
- data/lib/generators/claude/skills_library/rails-pagination-kaminari/references/performance.md +478 -0
- data/lib/generators/claude/skills_library/rails-views/SKILL.md +508 -0
- data/lib/generators/claude/skills_library/refine-requirements/SKILL.md +226 -0
- data/lib/generators/claude/skills_library/refine-requirements/references/examples.md +344 -0
- data/lib/generators/claude/skills_library/refine-requirements/references/reference.md +298 -0
- data/lib/generators/claude/skills_library/rspec-testing/SKILL.md +572 -0
- data/lib/generators/claude/skills_library/rspec-testing/references/better_specs_guide.md +273 -0
- data/lib/generators/claude/skills_library/rspec-testing/references/thoughtbot_patterns.md +407 -0
- data/lib/generators/claude/skills_library/tailwindcss/SKILL.md +371 -0
- data/lib/generators/claude/views/views_generator.rb +113 -0
- data/lib/rails_claude_skills/railtie.rb +16 -0
- data/lib/rails_claude_skills/version.rb +5 -0
- data/lib/rails_claude_skills.rb +27 -0
- data/sig/rails_claude_skills.rbs +4 -0
- metadata +199 -0
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-api-controllers
|
|
3
|
+
description: "RESTful API controller patterns for Ruby on Rails. Use when: (1) Building JSON APIs, (2) API versioning, (3) Error handling and status codes, (4) Authentication with tokens/JWT, (5) Rate limiting, (6) CORS configuration, (7) Pagination and filtering, (8) API documentation, (9) Testing API endpoints"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rails API Controllers
|
|
7
|
+
|
|
8
|
+
Build production-ready RESTful JSON APIs with Rails. This skill covers API controller patterns, versioning, authentication, error handling, and best practices for modern API development.
|
|
9
|
+
|
|
10
|
+
<when-to-use>
|
|
11
|
+
- Building JSON APIs for mobile apps, SPAs, or third-party integrations
|
|
12
|
+
- Creating microservices or API-first applications
|
|
13
|
+
- Versioning APIs for backward compatibility
|
|
14
|
+
- Implementing token-based authentication (JWT, API keys)
|
|
15
|
+
- Adding rate limiting and throttling
|
|
16
|
+
- Configuring CORS for cross-origin requests
|
|
17
|
+
- Implementing pagination, filtering, and sorting
|
|
18
|
+
- Testing API endpoints with RSpec
|
|
19
|
+
</when-to-use>
|
|
20
|
+
|
|
21
|
+
<benefits>
|
|
22
|
+
- **RESTful Design** - Follow REST conventions for predictable, maintainable APIs
|
|
23
|
+
- **Proper Status Codes** - Use correct HTTP status codes for all responses
|
|
24
|
+
- **Error Handling** - Consistent error responses with meaningful messages
|
|
25
|
+
- **Versioning** - Support multiple API versions simultaneously
|
|
26
|
+
- **Authentication** - Token-based auth without sessions or cookies
|
|
27
|
+
- **Performance** - Efficient JSON rendering and database queries
|
|
28
|
+
- **Documentation** - Auto-generated API docs with tools like Rswag
|
|
29
|
+
</benefits>
|
|
30
|
+
|
|
31
|
+
<verification-checklist>
|
|
32
|
+
Before completing API controller work:
|
|
33
|
+
- ✅ Proper HTTP status codes used (200, 201, 204, 400, 401, 403, 404, 422, 500)
|
|
34
|
+
- ✅ Consistent JSON response structure
|
|
35
|
+
- ✅ Authentication/authorization implemented
|
|
36
|
+
- ✅ Error handling covers all edge cases
|
|
37
|
+
- ✅ API tests passing (request specs)
|
|
38
|
+
- ✅ CORS configured if needed
|
|
39
|
+
- ✅ Rate limiting configured for production
|
|
40
|
+
- ✅ API documentation generated/updated
|
|
41
|
+
</verification-checklist>
|
|
42
|
+
|
|
43
|
+
<standards>
|
|
44
|
+
- Use `ApplicationController` parent with `ActionController::API` for API-only apps
|
|
45
|
+
- Return proper HTTP status codes for all responses
|
|
46
|
+
- Use consistent JSON structure across all endpoints
|
|
47
|
+
- Implement authentication via tokens (JWT, API keys), NOT sessions
|
|
48
|
+
- Version APIs via URL path (`/api/v1/`) or Accept header
|
|
49
|
+
- Handle errors consistently with JSON error responses
|
|
50
|
+
- Use strong parameters for input validation
|
|
51
|
+
- Test with request specs, not controller specs
|
|
52
|
+
- Document APIs with OpenAPI/Swagger
|
|
53
|
+
- Implement rate limiting to prevent abuse
|
|
54
|
+
</standards>
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## API-Only Rails Setup
|
|
59
|
+
|
|
60
|
+
<pattern name="api-only-application">
|
|
61
|
+
<description>Create new API-only Rails application</description>
|
|
62
|
+
|
|
63
|
+
**Generate API-Only App:**
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# New API-only Rails app (skips views, helpers, assets)
|
|
67
|
+
rails new my_api --api
|
|
68
|
+
|
|
69
|
+
# Or add to existing app
|
|
70
|
+
# config/application.rb
|
|
71
|
+
module MyApi
|
|
72
|
+
class Application < Rails::Application
|
|
73
|
+
config.api_only = true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Base API Controller:**
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# app/controllers/application_controller.rb
|
|
82
|
+
class ApplicationController < ActionController::API
|
|
83
|
+
include ActionController::HttpAuthentication::Token::ControllerMethods
|
|
84
|
+
|
|
85
|
+
# Global error handling
|
|
86
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
87
|
+
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
|
|
88
|
+
rescue_from ActionController::ParameterMissing, with: :bad_request
|
|
89
|
+
|
|
90
|
+
before_action :authenticate
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def authenticate
|
|
95
|
+
authenticate_token || render_unauthorized
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def authenticate_token
|
|
99
|
+
authenticate_with_http_token do |token, options|
|
|
100
|
+
@current_user = User.find_by(api_token: token)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def render_unauthorized
|
|
105
|
+
render json: { error: 'Unauthorized' }, status: :unauthorized
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def not_found(exception)
|
|
109
|
+
render json: { error: exception.message }, status: :not_found
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def unprocessable_entity(exception)
|
|
113
|
+
render json: {
|
|
114
|
+
error: 'Validation failed',
|
|
115
|
+
details: exception.record.errors.full_messages
|
|
116
|
+
}, status: :unprocessable_entity
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def bad_request(exception)
|
|
120
|
+
render json: { error: exception.message }, status: :bad_request
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Why:** API-only mode removes unnecessary middleware and optimizes for JSON responses. Centralized error handling ensures consistent responses.
|
|
126
|
+
</pattern>
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## RESTful API Design
|
|
131
|
+
|
|
132
|
+
<pattern name="restful-resource-controller">
|
|
133
|
+
<description>Standard RESTful API controller with all CRUD actions</description>
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# app/controllers/api/v1/articles_controller.rb
|
|
137
|
+
module Api
|
|
138
|
+
module V1
|
|
139
|
+
class ArticlesController < ApplicationController
|
|
140
|
+
before_action :set_article, only: [:show, :update, :destroy]
|
|
141
|
+
|
|
142
|
+
# GET /api/v1/articles
|
|
143
|
+
def index
|
|
144
|
+
@articles = Article.published
|
|
145
|
+
.includes(:author)
|
|
146
|
+
.page(params[:page])
|
|
147
|
+
.per(params[:per_page] || 20)
|
|
148
|
+
|
|
149
|
+
render json: @articles, status: :ok
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# GET /api/v1/articles/:id
|
|
153
|
+
def show
|
|
154
|
+
render json: @article, status: :ok
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# POST /api/v1/articles
|
|
158
|
+
def create
|
|
159
|
+
@article = Article.new(article_params)
|
|
160
|
+
@article.author = current_user
|
|
161
|
+
|
|
162
|
+
if @article.save
|
|
163
|
+
render json: @article, status: :created, location: api_v1_article_url(@article)
|
|
164
|
+
else
|
|
165
|
+
render json: {
|
|
166
|
+
error: 'Failed to create article',
|
|
167
|
+
details: @article.errors.full_messages
|
|
168
|
+
}, status: :unprocessable_entity
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# PATCH/PUT /api/v1/articles/:id
|
|
173
|
+
def update
|
|
174
|
+
if @article.update(article_params)
|
|
175
|
+
render json: @article, status: :ok
|
|
176
|
+
else
|
|
177
|
+
render json: {
|
|
178
|
+
error: 'Failed to update article',
|
|
179
|
+
details: @article.errors.full_messages
|
|
180
|
+
}, status: :unprocessable_entity
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# DELETE /api/v1/articles/:id
|
|
185
|
+
def destroy
|
|
186
|
+
@article.destroy
|
|
187
|
+
head :no_content
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def set_article
|
|
193
|
+
@article = Article.find(params[:id])
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def article_params
|
|
197
|
+
params.require(:article).permit(:title, :body, :published)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Routes:**
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
# config/routes.rb
|
|
208
|
+
Rails.application.routes.draw do
|
|
209
|
+
namespace :api do
|
|
210
|
+
namespace :v1 do
|
|
211
|
+
resources :articles
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Why:** Follows REST conventions with proper status codes (200 OK, 201 Created, 204 No Content, 422 Unprocessable Entity). Namespace by version for future API changes.
|
|
218
|
+
</pattern>
|
|
219
|
+
|
|
220
|
+
<pattern name="http-status-codes">
|
|
221
|
+
<description>Use correct HTTP status codes for API responses</description>
|
|
222
|
+
|
|
223
|
+
**Common Status Codes:**
|
|
224
|
+
|
|
225
|
+
| Code | Symbol | Usage |
|
|
226
|
+
|------|--------|-------|
|
|
227
|
+
| 200 | `:ok` | Successful GET, PATCH, PUT |
|
|
228
|
+
| 201 | `:created` | Successful POST (resource created) |
|
|
229
|
+
| 204 | `:no_content` | Successful DELETE (no response body) |
|
|
230
|
+
| 400 | `:bad_request` | Invalid request syntax, missing parameters |
|
|
231
|
+
| 401 | `:unauthorized` | Missing or invalid authentication |
|
|
232
|
+
| 403 | `:forbidden` | Authenticated but lacks permission |
|
|
233
|
+
| 404 | `:not_found` | Resource doesn't exist |
|
|
234
|
+
| 422 | `:unprocessable_entity` | Validation errors |
|
|
235
|
+
| 429 | `:too_many_requests` | Rate limit exceeded |
|
|
236
|
+
| 500 | `:internal_server_error` | Server error |
|
|
237
|
+
|
|
238
|
+
**Examples:**
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# Success responses
|
|
242
|
+
render json: @article, status: :ok # 200
|
|
243
|
+
render json: @article, status: :created # 201
|
|
244
|
+
head :no_content # 204
|
|
245
|
+
|
|
246
|
+
# Error responses
|
|
247
|
+
render json: { error: 'Bad request' }, status: :bad_request # 400
|
|
248
|
+
render json: { error: 'Unauthorized' }, status: :unauthorized # 401
|
|
249
|
+
render json: { error: 'Forbidden' }, status: :forbidden # 403
|
|
250
|
+
render json: { error: 'Not found' }, status: :not_found # 404
|
|
251
|
+
render json: { error: 'Validation failed' }, status: :unprocessable_entity # 422
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Why:** Correct status codes help API clients handle responses appropriately and provide clear semantics about what happened.
|
|
255
|
+
</pattern>
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## API Versioning
|
|
260
|
+
|
|
261
|
+
<pattern name="url-versioning">
|
|
262
|
+
<description>Version APIs via URL namespace for backward compatibility</description>
|
|
263
|
+
|
|
264
|
+
**Directory Structure:**
|
|
265
|
+
|
|
266
|
+
```
|
|
267
|
+
app/controllers/
|
|
268
|
+
└── api/
|
|
269
|
+
├── v1/
|
|
270
|
+
│ ├── articles_controller.rb
|
|
271
|
+
│ └── users_controller.rb
|
|
272
|
+
└── v2/
|
|
273
|
+
├── articles_controller.rb
|
|
274
|
+
└── users_controller.rb
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**V1 Controller:**
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
# app/controllers/api/v1/articles_controller.rb
|
|
281
|
+
module Api
|
|
282
|
+
module V1
|
|
283
|
+
class ArticlesController < ApplicationController
|
|
284
|
+
def index
|
|
285
|
+
@articles = Article.all
|
|
286
|
+
render json: @articles
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**V2 Controller (Breaking Changes):**
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
# app/controllers/api/v2/articles_controller.rb
|
|
297
|
+
module Api
|
|
298
|
+
module V2
|
|
299
|
+
class ArticlesController < ApplicationController
|
|
300
|
+
def index
|
|
301
|
+
# V2 adds pagination and filtering
|
|
302
|
+
@articles = Article
|
|
303
|
+
.where(status: params[:status]) if params[:status].present?
|
|
304
|
+
.page(params[:page])
|
|
305
|
+
|
|
306
|
+
render json: {
|
|
307
|
+
data: @articles,
|
|
308
|
+
meta: {
|
|
309
|
+
current_page: @articles.current_page,
|
|
310
|
+
total_pages: @articles.total_pages,
|
|
311
|
+
total_count: @articles.total_count
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Routes:**
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
# config/routes.rb
|
|
324
|
+
Rails.application.routes.draw do
|
|
325
|
+
namespace :api do
|
|
326
|
+
namespace :v1 do
|
|
327
|
+
resources :articles
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
namespace :v2 do
|
|
331
|
+
resources :articles
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
**Why:** URL versioning is explicit, easy to test, and allows multiple versions to coexist. Clients can migrate at their own pace.
|
|
338
|
+
</pattern>
|
|
339
|
+
|
|
340
|
+
<antipattern>
|
|
341
|
+
<description>Breaking API changes without versioning</description>
|
|
342
|
+
<bad-example>
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
# ❌ WRONG - Breaking existing clients
|
|
346
|
+
class Api::ArticlesController < ApplicationController
|
|
347
|
+
def index
|
|
348
|
+
# Changed response structure without versioning
|
|
349
|
+
render json: {
|
|
350
|
+
articles: @articles, # Was just array, now nested
|
|
351
|
+
total: @articles.count # New field
|
|
352
|
+
}
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
</bad-example>
|
|
358
|
+
<good-example>
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
# ✅ CORRECT - New version for breaking changes
|
|
362
|
+
module Api
|
|
363
|
+
module V1
|
|
364
|
+
class ArticlesController < ApplicationController
|
|
365
|
+
def index
|
|
366
|
+
render json: @articles # Keep V1 unchanged
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
module V2
|
|
372
|
+
class ArticlesController < ApplicationController
|
|
373
|
+
def index
|
|
374
|
+
render json: {
|
|
375
|
+
articles: @articles,
|
|
376
|
+
total: @articles.count
|
|
377
|
+
}
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
</good-example>
|
|
385
|
+
|
|
386
|
+
**Why bad:** Breaking changes without versioning break existing API clients. Always version when changing response structure or behavior.
|
|
387
|
+
</antipattern>
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## Authentication & Authorization
|
|
392
|
+
|
|
393
|
+
<pattern name="token-authentication">
|
|
394
|
+
<description>Token-based authentication for stateless APIs</description>
|
|
395
|
+
|
|
396
|
+
**User Model:**
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
# app/models/user.rb
|
|
400
|
+
class User < ApplicationRecord
|
|
401
|
+
has_secure_password
|
|
402
|
+
has_secure_token :api_token
|
|
403
|
+
|
|
404
|
+
# Regenerate token on password change
|
|
405
|
+
after_update :regenerate_api_token, if: :saved_change_to_password_digest?
|
|
406
|
+
|
|
407
|
+
private
|
|
408
|
+
|
|
409
|
+
def regenerate_api_token
|
|
410
|
+
regenerate_api_token
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
**Authentication Controller:**
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
# app/controllers/api/v1/authentication_controller.rb
|
|
419
|
+
module Api
|
|
420
|
+
module V1
|
|
421
|
+
class AuthenticationController < ApplicationController
|
|
422
|
+
skip_before_action :authenticate, only: [:create]
|
|
423
|
+
|
|
424
|
+
# POST /api/v1/auth
|
|
425
|
+
def create
|
|
426
|
+
user = User.find_by(email: params[:email])
|
|
427
|
+
|
|
428
|
+
if user&.authenticate(params[:password])
|
|
429
|
+
render json: {
|
|
430
|
+
token: user.api_token,
|
|
431
|
+
user: {
|
|
432
|
+
id: user.id,
|
|
433
|
+
email: user.email,
|
|
434
|
+
name: user.name
|
|
435
|
+
}
|
|
436
|
+
}, status: :ok
|
|
437
|
+
else
|
|
438
|
+
render json: { error: 'Invalid email or password' }, status: :unauthorized
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# DELETE /api/v1/auth
|
|
443
|
+
def destroy
|
|
444
|
+
current_user.regenerate_api_token
|
|
445
|
+
head :no_content
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
**Using Token in Requests:**
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
# Client sends token in Authorization header
|
|
456
|
+
curl -H "Authorization: Token YOUR_API_TOKEN" \
|
|
457
|
+
https://api.example.com/api/v1/articles
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
**Why:** Token authentication is stateless (no sessions), works across domains, and is suitable for mobile/SPA clients.
|
|
461
|
+
</pattern>
|
|
462
|
+
|
|
463
|
+
<pattern name="jwt-authentication">
|
|
464
|
+
<description>JWT (JSON Web Token) authentication for APIs</description>
|
|
465
|
+
|
|
466
|
+
**Setup:**
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
# Gemfile
|
|
470
|
+
gem 'jwt'
|
|
471
|
+
|
|
472
|
+
# lib/json_web_token.rb
|
|
473
|
+
class JsonWebToken
|
|
474
|
+
SECRET_KEY = Rails.application.credentials.secret_key_base
|
|
475
|
+
|
|
476
|
+
def self.encode(payload, exp = 24.hours.from_now)
|
|
477
|
+
payload[:exp] = exp.to_i
|
|
478
|
+
JWT.encode(payload, SECRET_KEY)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def self.decode(token)
|
|
482
|
+
body = JWT.decode(token, SECRET_KEY)[0]
|
|
483
|
+
HashWithIndifferentAccess.new(body)
|
|
484
|
+
rescue JWT::DecodeError, JWT::ExpiredSignature
|
|
485
|
+
nil
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
**Application Controller:**
|
|
491
|
+
|
|
492
|
+
```ruby
|
|
493
|
+
# app/controllers/application_controller.rb
|
|
494
|
+
class ApplicationController < ActionController::API
|
|
495
|
+
before_action :authenticate_request
|
|
496
|
+
|
|
497
|
+
private
|
|
498
|
+
|
|
499
|
+
def authenticate_request
|
|
500
|
+
header = request.headers['Authorization']
|
|
501
|
+
token = header.split(' ').last if header
|
|
502
|
+
decoded = JsonWebToken.decode(token)
|
|
503
|
+
|
|
504
|
+
if decoded
|
|
505
|
+
@current_user = User.find(decoded[:user_id])
|
|
506
|
+
else
|
|
507
|
+
render json: { error: 'Unauthorized' }, status: :unauthorized
|
|
508
|
+
end
|
|
509
|
+
rescue ActiveRecord::RecordNotFound
|
|
510
|
+
render json: { error: 'Unauthorized' }, status: :unauthorized
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
attr_reader :current_user
|
|
514
|
+
end
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
**Authentication Endpoint:**
|
|
518
|
+
|
|
519
|
+
```ruby
|
|
520
|
+
# app/controllers/api/v1/authentication_controller.rb
|
|
521
|
+
module Api
|
|
522
|
+
module V1
|
|
523
|
+
class AuthenticationController < ApplicationController
|
|
524
|
+
skip_before_action :authenticate_request, only: [:create]
|
|
525
|
+
|
|
526
|
+
def create
|
|
527
|
+
user = User.find_by(email: params[:email])
|
|
528
|
+
|
|
529
|
+
if user&.authenticate(params[:password])
|
|
530
|
+
token = JsonWebToken.encode(user_id: user.id)
|
|
531
|
+
render json: { token: token, user: user }, status: :ok
|
|
532
|
+
else
|
|
533
|
+
render json: { error: 'Invalid credentials' }, status: :unauthorized
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
**Why:** JWT is self-contained, stateless, and can include claims (user_id, roles, expiration). Widely supported by API clients.
|
|
542
|
+
</pattern>
|
|
543
|
+
|
|
544
|
+
---
|
|
545
|
+
|
|
546
|
+
## Pagination, Filtering & Sorting
|
|
547
|
+
|
|
548
|
+
<pattern name="pagination">
|
|
549
|
+
<description>Paginate API responses with Kaminari or Pagy</description>
|
|
550
|
+
|
|
551
|
+
**With Kaminari:**
|
|
552
|
+
|
|
553
|
+
```ruby
|
|
554
|
+
# Gemfile
|
|
555
|
+
gem 'kaminari'
|
|
556
|
+
|
|
557
|
+
# app/controllers/api/v1/articles_controller.rb
|
|
558
|
+
def index
|
|
559
|
+
page = params[:page] || 1
|
|
560
|
+
per_page = params[:per_page] || 20
|
|
561
|
+
|
|
562
|
+
@articles = Article.page(page).per(per_page)
|
|
563
|
+
|
|
564
|
+
render json: {
|
|
565
|
+
data: @articles,
|
|
566
|
+
meta: {
|
|
567
|
+
current_page: @articles.current_page,
|
|
568
|
+
next_page: @articles.next_page,
|
|
569
|
+
prev_page: @articles.prev_page,
|
|
570
|
+
total_pages: @articles.total_pages,
|
|
571
|
+
total_count: @articles.total_count
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
end
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**With Pagy (Faster):**
|
|
578
|
+
|
|
579
|
+
```ruby
|
|
580
|
+
# Gemfile
|
|
581
|
+
gem 'pagy'
|
|
582
|
+
|
|
583
|
+
# app/controllers/application_controller.rb
|
|
584
|
+
include Pagy::Backend
|
|
585
|
+
|
|
586
|
+
# app/controllers/api/v1/articles_controller.rb
|
|
587
|
+
def index
|
|
588
|
+
pagy, articles = pagy(Article.all, items: params[:per_page] || 20)
|
|
589
|
+
|
|
590
|
+
render json: {
|
|
591
|
+
data: articles,
|
|
592
|
+
meta: {
|
|
593
|
+
current_page: pagy.page,
|
|
594
|
+
total_pages: pagy.pages,
|
|
595
|
+
total_count: pagy.count,
|
|
596
|
+
per_page: pagy.items
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
end
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
**Why:** Pagination prevents loading large datasets into memory. Include metadata so clients know how to fetch more pages.
|
|
603
|
+
</pattern>
|
|
604
|
+
|
|
605
|
+
<pattern name="filtering-and-sorting">
|
|
606
|
+
<description>Allow clients to filter and sort resources</description>
|
|
607
|
+
|
|
608
|
+
```ruby
|
|
609
|
+
# app/controllers/api/v1/articles_controller.rb
|
|
610
|
+
def index
|
|
611
|
+
@articles = Article.all
|
|
612
|
+
|
|
613
|
+
# Filtering
|
|
614
|
+
@articles = @articles.where(status: params[:status]) if params[:status].present?
|
|
615
|
+
@articles = @articles.where(category: params[:category]) if params[:category].present?
|
|
616
|
+
@articles = @articles.where('created_at >= ?', params[:from_date]) if params[:from_date].present?
|
|
617
|
+
|
|
618
|
+
# Searching
|
|
619
|
+
@articles = @articles.where('title ILIKE ?', "%#{params[:q]}%") if params[:q].present?
|
|
620
|
+
|
|
621
|
+
# Sorting
|
|
622
|
+
sort_column = params[:sort_by] || 'created_at'
|
|
623
|
+
sort_direction = params[:order] || 'desc'
|
|
624
|
+
@articles = @articles.order("#{sort_column} #{sort_direction}")
|
|
625
|
+
|
|
626
|
+
# Pagination
|
|
627
|
+
@articles = @articles.page(params[:page]).per(params[:per_page] || 20)
|
|
628
|
+
|
|
629
|
+
render json: {
|
|
630
|
+
data: @articles,
|
|
631
|
+
meta: pagination_meta(@articles)
|
|
632
|
+
}
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
private
|
|
636
|
+
|
|
637
|
+
def pagination_meta(collection)
|
|
638
|
+
{
|
|
639
|
+
current_page: collection.current_page,
|
|
640
|
+
total_pages: collection.total_pages,
|
|
641
|
+
total_count: collection.total_count
|
|
642
|
+
}
|
|
643
|
+
end
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
**Example Requests:**
|
|
647
|
+
|
|
648
|
+
```bash
|
|
649
|
+
# Filter by status
|
|
650
|
+
GET /api/v1/articles?status=published
|
|
651
|
+
|
|
652
|
+
# Search by title
|
|
653
|
+
GET /api/v1/articles?q=rails
|
|
654
|
+
|
|
655
|
+
# Sort by created_at descending
|
|
656
|
+
GET /api/v1/articles?sort_by=created_at&order=desc
|
|
657
|
+
|
|
658
|
+
# Combine filters, search, sort, and pagination
|
|
659
|
+
GET /api/v1/articles?status=published&q=rails&sort_by=title&order=asc&page=2&per_page=50
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
**Why:** Flexible filtering and sorting let clients fetch exactly what they need without loading unnecessary data.
|
|
663
|
+
</pattern>
|
|
664
|
+
|
|
665
|
+
---
|
|
666
|
+
|
|
667
|
+
## CORS Configuration
|
|
668
|
+
|
|
669
|
+
<pattern name="cors-setup">
|
|
670
|
+
<description>Configure CORS to allow cross-origin API requests</description>
|
|
671
|
+
|
|
672
|
+
**Setup:**
|
|
673
|
+
|
|
674
|
+
```ruby
|
|
675
|
+
# Gemfile
|
|
676
|
+
gem 'rack-cors'
|
|
677
|
+
|
|
678
|
+
# config/initializers/cors.rb
|
|
679
|
+
Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
|
680
|
+
allow do
|
|
681
|
+
origins 'example.com', 'localhost:3000' # Whitelist specific origins
|
|
682
|
+
|
|
683
|
+
resource '/api/*',
|
|
684
|
+
headers: :any,
|
|
685
|
+
methods: [:get, :post, :put, :patch, :delete, :options, :head],
|
|
686
|
+
credentials: true,
|
|
687
|
+
max_age: 86400 # Cache preflight for 24 hours
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
**Development (Allow All Origins):**
|
|
693
|
+
|
|
694
|
+
```ruby
|
|
695
|
+
# config/initializers/cors.rb
|
|
696
|
+
Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
|
697
|
+
allow do
|
|
698
|
+
if Rails.env.development?
|
|
699
|
+
origins '*' # Allow all in development
|
|
700
|
+
else
|
|
701
|
+
origins ENV['ALLOWED_ORIGINS']&.split(',') || 'example.com'
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
resource '/api/*',
|
|
705
|
+
headers: :any,
|
|
706
|
+
methods: [:get, :post, :put, :patch, :delete, :options, :head]
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
**Why:** CORS is required when frontend (SPA, mobile app) and API are on different domains. Whitelist specific origins in production for security.
|
|
712
|
+
</pattern>
|
|
713
|
+
|
|
714
|
+
---
|
|
715
|
+
|
|
716
|
+
## Rate Limiting
|
|
717
|
+
|
|
718
|
+
<pattern name="rate-limiting">
|
|
719
|
+
<description>Implement rate limiting to prevent API abuse</description>
|
|
720
|
+
|
|
721
|
+
**With Rack::Attack:**
|
|
722
|
+
|
|
723
|
+
```ruby
|
|
724
|
+
# Gemfile
|
|
725
|
+
gem 'rack-attack'
|
|
726
|
+
|
|
727
|
+
# config/initializers/rack_attack.rb
|
|
728
|
+
class Rack::Attack
|
|
729
|
+
# Throttle all requests by IP (60 requests per minute)
|
|
730
|
+
throttle('req/ip', limit: 60, period: 1.minute) do |req|
|
|
731
|
+
req.ip if req.path.start_with?('/api/')
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# Throttle POST requests by IP (10 per minute)
|
|
735
|
+
throttle('req/ip/post', limit: 10, period: 1.minute) do |req|
|
|
736
|
+
req.ip if req.path.start_with?('/api/') && req.post?
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# Throttle authenticated requests by user token
|
|
740
|
+
throttle('req/token', limit: 100, period: 1.minute) do |req|
|
|
741
|
+
if req.path.start_with?('/api/')
|
|
742
|
+
token = req.env['HTTP_AUTHORIZATION']&.split(' ')&.last
|
|
743
|
+
User.find_by(api_token: token)&.id if token
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Custom response for throttled requests
|
|
748
|
+
self.throttled_responder = lambda do |env|
|
|
749
|
+
[
|
|
750
|
+
429,
|
|
751
|
+
{ 'Content-Type' => 'application/json' },
|
|
752
|
+
[{ error: 'Rate limit exceeded. Try again later.' }.to_json]
|
|
753
|
+
]
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# config/application.rb
|
|
758
|
+
config.middleware.use Rack::Attack
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
**Why:** Rate limiting prevents abuse, protects server resources, and ensures fair usage across all API clients.
|
|
762
|
+
</pattern>
|
|
763
|
+
|
|
764
|
+
---
|
|
765
|
+
|
|
766
|
+
## Error Handling
|
|
767
|
+
|
|
768
|
+
<pattern name="consistent-error-responses">
|
|
769
|
+
<description>Standardized error response format</description>
|
|
770
|
+
|
|
771
|
+
```ruby
|
|
772
|
+
# app/controllers/application_controller.rb
|
|
773
|
+
class ApplicationController < ActionController::API
|
|
774
|
+
rescue_from StandardError, with: :internal_server_error
|
|
775
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
776
|
+
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
|
|
777
|
+
rescue_from ActionController::ParameterMissing, with: :bad_request
|
|
778
|
+
rescue_from Pundit::NotAuthorizedError, with: :forbidden
|
|
779
|
+
|
|
780
|
+
private
|
|
781
|
+
|
|
782
|
+
def not_found(exception)
|
|
783
|
+
render json: error_response(
|
|
784
|
+
'Resource not found',
|
|
785
|
+
exception.message
|
|
786
|
+
), status: :not_found
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def unprocessable_entity(exception)
|
|
790
|
+
render json: error_response(
|
|
791
|
+
'Validation failed',
|
|
792
|
+
exception.record.errors.full_messages
|
|
793
|
+
), status: :unprocessable_entity
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def bad_request(exception)
|
|
797
|
+
render json: error_response(
|
|
798
|
+
'Bad request',
|
|
799
|
+
exception.message
|
|
800
|
+
), status: :bad_request
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def forbidden(exception)
|
|
804
|
+
render json: error_response(
|
|
805
|
+
'Forbidden',
|
|
806
|
+
'You are not authorized to perform this action'
|
|
807
|
+
), status: :forbidden
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def internal_server_error(exception)
|
|
811
|
+
# Log error for debugging
|
|
812
|
+
Rails.logger.error(exception.message)
|
|
813
|
+
Rails.logger.error(exception.backtrace.join("\n"))
|
|
814
|
+
|
|
815
|
+
render json: error_response(
|
|
816
|
+
'Internal server error',
|
|
817
|
+
Rails.env.production? ? 'Something went wrong' : exception.message
|
|
818
|
+
), status: :internal_server_error
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def error_response(message, details = nil)
|
|
822
|
+
response = { error: message }
|
|
823
|
+
response[:details] = details if details.present?
|
|
824
|
+
response
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
**Example Error Responses:**
|
|
830
|
+
|
|
831
|
+
```json
|
|
832
|
+
// 404 Not Found
|
|
833
|
+
{
|
|
834
|
+
"error": "Resource not found",
|
|
835
|
+
"details": "Couldn't find Article with 'id'=999"
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// 422 Unprocessable Entity
|
|
839
|
+
{
|
|
840
|
+
"error": "Validation failed",
|
|
841
|
+
"details": [
|
|
842
|
+
"Title can't be blank",
|
|
843
|
+
"Body is too short (minimum is 10 characters)"
|
|
844
|
+
]
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// 400 Bad Request
|
|
848
|
+
{
|
|
849
|
+
"error": "Bad request",
|
|
850
|
+
"details": "param is missing or the value is empty: article"
|
|
851
|
+
}
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
**Why:** Consistent error format makes it easy for clients to parse and display errors. Include details for debugging without exposing sensitive info.
|
|
855
|
+
</pattern>
|
|
856
|
+
|
|
857
|
+
---
|
|
858
|
+
|
|
859
|
+
## Testing API Endpoints
|
|
860
|
+
|
|
861
|
+
<pattern name="request-specs">
|
|
862
|
+
<description>Test API endpoints with RSpec request specs</description>
|
|
863
|
+
|
|
864
|
+
```ruby
|
|
865
|
+
# spec/requests/api/v1/articles_spec.rb
|
|
866
|
+
require 'rails_helper'
|
|
867
|
+
|
|
868
|
+
RSpec.describe 'Api::V1::Articles', type: :request do
|
|
869
|
+
let(:user) { create(:user) }
|
|
870
|
+
let(:headers) { { 'Authorization' => "Token #{user.api_token}" } }
|
|
871
|
+
|
|
872
|
+
describe 'GET /api/v1/articles' do
|
|
873
|
+
let!(:articles) { create_list(:article, 3, :published) }
|
|
874
|
+
|
|
875
|
+
it 'returns all published articles' do
|
|
876
|
+
get '/api/v1/articles', headers: headers
|
|
877
|
+
|
|
878
|
+
expect(response).to have_http_status(:ok)
|
|
879
|
+
expect(json_response['data'].size).to eq(3)
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
it 'filters by status' do
|
|
883
|
+
draft = create(:article, status: :draft)
|
|
884
|
+
|
|
885
|
+
get '/api/v1/articles', params: { status: 'draft' }, headers: headers
|
|
886
|
+
|
|
887
|
+
expect(response).to have_http_status(:ok)
|
|
888
|
+
expect(json_response['data'].size).to eq(1)
|
|
889
|
+
expect(json_response['data'].first['id']).to eq(draft.id)
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
it 'paginates results' do
|
|
893
|
+
create_list(:article, 25)
|
|
894
|
+
|
|
895
|
+
get '/api/v1/articles', params: { page: 2, per_page: 10 }, headers: headers
|
|
896
|
+
|
|
897
|
+
expect(response).to have_http_status(:ok)
|
|
898
|
+
expect(json_response['data'].size).to eq(10)
|
|
899
|
+
expect(json_response['meta']['current_page']).to eq(2)
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
describe 'POST /api/v1/articles' do
|
|
904
|
+
let(:valid_attributes) { { article: { title: 'Test', body: 'Content' } } }
|
|
905
|
+
|
|
906
|
+
it 'creates a new article' do
|
|
907
|
+
expect {
|
|
908
|
+
post '/api/v1/articles', params: valid_attributes, headers: headers
|
|
909
|
+
}.to change(Article, :count).by(1)
|
|
910
|
+
|
|
911
|
+
expect(response).to have_http_status(:created)
|
|
912
|
+
expect(json_response['title']).to eq('Test')
|
|
913
|
+
expect(response.location).to be_present
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
it 'returns errors for invalid data' do
|
|
917
|
+
post '/api/v1/articles', params: { article: { title: '' } }, headers: headers
|
|
918
|
+
|
|
919
|
+
expect(response).to have_http_status(:unprocessable_entity)
|
|
920
|
+
expect(json_response['error']).to eq('Failed to create article')
|
|
921
|
+
expect(json_response['details']).to include("Title can't be blank")
|
|
922
|
+
end
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
describe 'DELETE /api/v1/articles/:id' do
|
|
926
|
+
let!(:article) { create(:article) }
|
|
927
|
+
|
|
928
|
+
it 'deletes the article' do
|
|
929
|
+
expect {
|
|
930
|
+
delete "/api/v1/articles/#{article.id}", headers: headers
|
|
931
|
+
}.to change(Article, :count).by(-1)
|
|
932
|
+
|
|
933
|
+
expect(response).to have_http_status(:no_content)
|
|
934
|
+
expect(response.body).to be_empty
|
|
935
|
+
end
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
describe 'authentication' do
|
|
939
|
+
it 'returns 401 without token' do
|
|
940
|
+
get '/api/v1/articles'
|
|
941
|
+
|
|
942
|
+
expect(response).to have_http_status(:unauthorized)
|
|
943
|
+
expect(json_response['error']).to eq('Unauthorized')
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
it 'returns 401 with invalid token' do
|
|
947
|
+
get '/api/v1/articles', headers: { 'Authorization' => 'Token invalid' }
|
|
948
|
+
|
|
949
|
+
expect(response).to have_http_status(:unauthorized)
|
|
950
|
+
end
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
private
|
|
954
|
+
|
|
955
|
+
def json_response
|
|
956
|
+
JSON.parse(response.body)
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
**Why:** Request specs test the full HTTP request/response cycle including routing, authentication, and JSON parsing. More realistic than controller specs.
|
|
962
|
+
</pattern>
|
|
963
|
+
|
|
964
|
+
---
|
|
965
|
+
|
|
966
|
+
<testing>
|
|
967
|
+
|
|
968
|
+
```ruby
|
|
969
|
+
# spec/support/request_helpers.rb
|
|
970
|
+
module RequestHelpers
|
|
971
|
+
def json_response
|
|
972
|
+
JSON.parse(response.body)
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
def auth_headers(user)
|
|
976
|
+
{ 'Authorization' => "Token #{user.api_token}" }
|
|
977
|
+
end
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
RSpec.configure do |config|
|
|
981
|
+
config.include RequestHelpers, type: :request
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# spec/requests/api/v1/authentication_spec.rb
|
|
985
|
+
RSpec.describe 'Api::V1::Authentication', type: :request do
|
|
986
|
+
describe 'POST /api/v1/auth' do
|
|
987
|
+
let(:user) { create(:user, email: 'test@example.com', password: 'password') }
|
|
988
|
+
|
|
989
|
+
it 'returns token with valid credentials' do
|
|
990
|
+
post '/api/v1/auth', params: { email: 'test@example.com', password: 'password' }
|
|
991
|
+
|
|
992
|
+
expect(response).to have_http_status(:ok)
|
|
993
|
+
expect(json_response['token']).to be_present
|
|
994
|
+
expect(json_response['user']['email']).to eq('test@example.com')
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
it 'returns error with invalid credentials' do
|
|
998
|
+
post '/api/v1/auth', params: { email: 'test@example.com', password: 'wrong' }
|
|
999
|
+
|
|
1000
|
+
expect(response).to have_http_status(:unauthorized)
|
|
1001
|
+
expect(json_response['error']).to eq('Invalid email or password')
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
end
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
</testing>
|
|
1008
|
+
|
|
1009
|
+
---
|
|
1010
|
+
|
|
1011
|
+
<related-skills>
|
|
1012
|
+
- rails-ai:models - Model patterns for API resources
|
|
1013
|
+
- rails-ai:serializers - JSON serialization (ActiveModelSerializers, Blueprinter)
|
|
1014
|
+
- rails-ai:testing - Testing patterns for API endpoints
|
|
1015
|
+
- rails-ai:auth-with-devise - Token-based authentication with Devise
|
|
1016
|
+
- rails-ai:jobs - Background processing for async API operations
|
|
1017
|
+
</related-skills>
|
|
1018
|
+
|
|
1019
|
+
<resources>
|
|
1020
|
+
|
|
1021
|
+
**Official Documentation:**
|
|
1022
|
+
- [Rails Guides - API-Only Applications](https://guides.rubyonrails.org/api_app.html)
|
|
1023
|
+
- [Rails API Documentation](https://api.rubyonrails.org/)
|
|
1024
|
+
|
|
1025
|
+
**Gems & Libraries:**
|
|
1026
|
+
- [jwt](https://github.com/jwt/ruby-jwt) - JSON Web Token implementation
|
|
1027
|
+
- [rack-cors](https://github.com/cyu/rack-cors) - CORS middleware
|
|
1028
|
+
- [rack-attack](https://github.com/rack/rack-attack) - Rate limiting and throttling
|
|
1029
|
+
- [kaminari](https://github.com/kaminari/kaminari) - Pagination
|
|
1030
|
+
- [pagy](https://github.com/ddnexus/pagy) - Fast pagination
|
|
1031
|
+
- [pundit](https://github.com/varvet/pundit) - Authorization
|
|
1032
|
+
|
|
1033
|
+
**API Documentation:**
|
|
1034
|
+
- [rswag](https://github.com/rswag/rswag) - OpenAPI/Swagger docs for Rails APIs
|
|
1035
|
+
- [apipie-rails](https://github.com/Apipie/apipie-rails) - API documentation tool
|
|
1036
|
+
|
|
1037
|
+
**Best Practices:**
|
|
1038
|
+
- [REST API Tutorial](https://restfulapi.net/)
|
|
1039
|
+
- [HTTP Status Codes](https://httpstatuses.com/)
|
|
1040
|
+
|
|
1041
|
+
</resources>
|