rapitapir 0.1.2 → 2.0.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/.rubocop.yml +7 -7
- data/.rubocop_todo.yml +83 -0
- data/README.md +1319 -235
- data/RUBY_WEEKLY_LAUNCH_POST.md +219 -0
- data/docs/RAILS_INTEGRATION_IMPLEMENTATION.md +209 -0
- data/docs/SINATRA_EXTENSION.md +399 -348
- data/docs/STRICT_VALIDATION.md +229 -0
- data/docs/VALIDATION_IMPROVEMENTS.md +218 -0
- data/docs/ai-integration-plan.md +112 -0
- data/docs/auto-derivation.md +505 -92
- data/docs/endpoint-definition.md +536 -129
- data/docs/n8n-integration.md +212 -0
- data/docs/observability.md +810 -500
- data/docs/using-mcp.md +93 -0
- data/examples/ai/knowledge_base_rag.rb +83 -0
- data/examples/ai/user_management_mcp.rb +92 -0
- data/examples/ai/user_validation_llm.rb +187 -0
- data/examples/rails/RAILS_8_GUIDE.md +165 -0
- data/examples/rails/RAILS_LOADING_FIX.rb +35 -0
- data/examples/rails/README.md +497 -0
- data/examples/rails/comprehensive_test.rb +91 -0
- data/examples/rails/config/routes.rb +48 -0
- data/examples/rails/debug_controller.rb +63 -0
- data/examples/rails/detailed_test.rb +46 -0
- data/examples/rails/enhanced_users_controller.rb +278 -0
- data/examples/rails/final_server_test.rb +50 -0
- data/examples/rails/hello_world_app.rb +116 -0
- data/examples/rails/hello_world_controller.rb +186 -0
- data/examples/rails/hello_world_routes.rb +28 -0
- data/examples/rails/rails8_minimal_demo.rb +132 -0
- data/examples/rails/rails8_simple_demo.rb +140 -0
- data/examples/rails/rails8_working_demo.rb +255 -0
- data/examples/rails/real_world_blog_api.rb +510 -0
- data/examples/rails/server_test.rb +46 -0
- data/examples/rails/test_direct_processing.rb +41 -0
- data/examples/rails/test_hello_world.rb +80 -0
- data/examples/rails/test_rails_integration.rb +54 -0
- data/examples/rails/traditional_app/Gemfile +37 -0
- data/examples/rails/traditional_app/README.md +265 -0
- data/examples/rails/traditional_app/app/controllers/api/v1/posts_controller.rb +254 -0
- data/examples/rails/traditional_app/app/controllers/api/v1/users_controller.rb +220 -0
- data/examples/rails/traditional_app/app/controllers/application_controller.rb +86 -0
- data/examples/rails/traditional_app/app/controllers/application_controller_simplified.rb +87 -0
- data/examples/rails/traditional_app/app/controllers/documentation_controller.rb +149 -0
- data/examples/rails/traditional_app/app/controllers/health_controller.rb +42 -0
- data/examples/rails/traditional_app/config/routes.rb +25 -0
- data/examples/rails/traditional_app/config/routes_best_practice.rb +25 -0
- data/examples/rails/traditional_app/config/routes_simplified.rb +36 -0
- data/examples/rails/traditional_app_runnable.rb +406 -0
- data/examples/rails/users_controller.rb +4 -1
- data/examples/serverless/Gemfile +43 -0
- data/examples/serverless/QUICKSTART.md +331 -0
- data/examples/serverless/README.md +520 -0
- data/examples/serverless/aws_lambda_example.rb +307 -0
- data/examples/serverless/aws_sam_template.yaml +215 -0
- data/examples/serverless/azure_functions_example.rb +407 -0
- data/examples/serverless/deploy.rb +204 -0
- data/examples/serverless/gcp_cloud_functions_example.rb +367 -0
- data/examples/serverless/gcp_function.yaml +23 -0
- data/examples/serverless/host.json +24 -0
- data/examples/serverless/package.json +32 -0
- data/examples/serverless/spec/aws_lambda_spec.rb +196 -0
- data/examples/serverless/spec/spec_helper.rb +89 -0
- data/examples/serverless/vercel.json +31 -0
- data/examples/serverless/vercel_example.rb +404 -0
- data/examples/strict_validation_examples.rb +104 -0
- data/examples/validation_error_examples.rb +173 -0
- data/lib/rapitapir/ai/llm_instruction.rb +456 -0
- data/lib/rapitapir/ai/mcp.rb +134 -0
- data/lib/rapitapir/ai/rag.rb +287 -0
- data/lib/rapitapir/ai/rag_middleware.rb +147 -0
- data/lib/rapitapir/auth/oauth2.rb +43 -57
- data/lib/rapitapir/cli/command.rb +362 -2
- data/lib/rapitapir/cli/mcp_export.rb +18 -0
- data/lib/rapitapir/cli/validator.rb +2 -6
- data/lib/rapitapir/core/endpoint.rb +59 -6
- data/lib/rapitapir/core/enhanced_endpoint.rb +2 -6
- data/lib/rapitapir/dsl/fluent_endpoint_builder.rb +53 -0
- data/lib/rapitapir/endpoint_registry.rb +47 -0
- data/lib/rapitapir/observability/health_check.rb +4 -4
- data/lib/rapitapir/observability/logging.rb +10 -10
- data/lib/rapitapir/schema.rb +2 -2
- data/lib/rapitapir/server/rack_adapter.rb +1 -3
- data/lib/rapitapir/server/rails/configuration.rb +77 -0
- data/lib/rapitapir/server/rails/controller_base.rb +185 -0
- data/lib/rapitapir/server/rails/documentation_helpers.rb +76 -0
- data/lib/rapitapir/server/rails/resource_builder.rb +181 -0
- data/lib/rapitapir/server/rails/routes.rb +114 -0
- data/lib/rapitapir/server/rails_adapter.rb +10 -3
- data/lib/rapitapir/server/rails_adapter_class.rb +1 -3
- data/lib/rapitapir/server/rails_controller.rb +1 -3
- data/lib/rapitapir/server/rails_integration.rb +67 -0
- data/lib/rapitapir/server/rails_response_handler.rb +16 -3
- data/lib/rapitapir/server/sinatra_adapter.rb +29 -5
- data/lib/rapitapir/server/sinatra_integration.rb +4 -4
- data/lib/rapitapir/sinatra/extension.rb +2 -2
- data/lib/rapitapir/sinatra/oauth2_helpers.rb +34 -40
- data/lib/rapitapir/types/array.rb +4 -0
- data/lib/rapitapir/types/auto_derivation.rb +4 -18
- data/lib/rapitapir/types/datetime.rb +1 -3
- data/lib/rapitapir/types/float.rb +2 -6
- data/lib/rapitapir/types/hash.rb +40 -2
- data/lib/rapitapir/types/integer.rb +4 -12
- data/lib/rapitapir/types/object.rb +6 -2
- data/lib/rapitapir/types.rb +6 -2
- data/lib/rapitapir/version.rb +1 -1
- data/lib/rapitapir.rb +5 -3
- metadata +74 -2
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Test script to verify Rails integration loads properly with correct order
|
4
|
+
|
5
|
+
# First, simulate Rails being loaded (this is what Rails apps do)
|
6
|
+
require 'bundler/inline'
|
7
|
+
|
8
|
+
gemfile do
|
9
|
+
source 'https://rubygems.org'
|
10
|
+
gem 'rails', '~> 8.0'
|
11
|
+
gem 'activesupport'
|
12
|
+
end
|
13
|
+
|
14
|
+
# Load Rails and ActiveSupport first (this is crucial!)
|
15
|
+
require 'rails/all'
|
16
|
+
|
17
|
+
# NOW load RapiTapir (this is the correct order)
|
18
|
+
require_relative '../../lib/rapitapir'
|
19
|
+
|
20
|
+
puts "Testing RapiTapir Rails integration loading..."
|
21
|
+
|
22
|
+
begin
|
23
|
+
# Try to access the ControllerBase class
|
24
|
+
controller_base = RapiTapir::Server::Rails::ControllerBase
|
25
|
+
puts "✅ RapiTapir::Server::Rails::ControllerBase loaded successfully"
|
26
|
+
|
27
|
+
# Test that it's a class
|
28
|
+
if controller_base.is_a?(Class)
|
29
|
+
puts "✅ ControllerBase is a proper class"
|
30
|
+
else
|
31
|
+
puts "❌ ControllerBase is not a class: #{controller_base.class}"
|
32
|
+
end
|
33
|
+
|
34
|
+
# Test other components
|
35
|
+
config = RapiTapir::Server::Rails::Configuration
|
36
|
+
puts "✅ RapiTapir::Server::Rails::Configuration loaded"
|
37
|
+
|
38
|
+
routes = RapiTapir::Server::Rails::Routes
|
39
|
+
puts "✅ RapiTapir::Server::Rails::Routes loaded"
|
40
|
+
|
41
|
+
puts "\n🎉 All Rails integration components loaded successfully!"
|
42
|
+
|
43
|
+
rescue NameError => e
|
44
|
+
puts "❌ Error loading Rails integration: #{e.message}"
|
45
|
+
puts " This means there's still an issue with the loading order"
|
46
|
+
exit 1
|
47
|
+
rescue => e
|
48
|
+
puts "❌ Unexpected error: #{e.message}"
|
49
|
+
puts " #{e.backtrace.first(3).join("\n ")}"
|
50
|
+
exit 1
|
51
|
+
end
|
52
|
+
|
53
|
+
puts "\n✅ Rails integration test passed!"
|
54
|
+
puts "✅ The correct loading order is: Rails first, then RapiTapir"
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Gemfile for a traditional Rails app with RapiTapir
|
4
|
+
source 'https://rubygems.org'
|
5
|
+
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
6
|
+
|
7
|
+
ruby '3.2.0'
|
8
|
+
|
9
|
+
# Rails framework
|
10
|
+
gem 'rails', '~> 8.0.0'
|
11
|
+
|
12
|
+
# Database
|
13
|
+
gem 'sqlite3', '~> 1.4'
|
14
|
+
|
15
|
+
# Web server
|
16
|
+
gem 'puma', '~> 6.0'
|
17
|
+
|
18
|
+
# Reduces boot times through caching; required in config/boot.rb
|
19
|
+
gem 'bootsnap', '>= 1.4.4', require: false
|
20
|
+
|
21
|
+
# RapiTapir for API definitions
|
22
|
+
# In a real app, you'd add this to your Gemfile:
|
23
|
+
# gem 'rapitapir', '~> 1.0'
|
24
|
+
|
25
|
+
# For this example, we'll use a local path
|
26
|
+
gem 'rapitapir', path: '../../../'
|
27
|
+
|
28
|
+
group :development, :test do
|
29
|
+
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
|
30
|
+
gem 'rspec-rails'
|
31
|
+
gem 'factory_bot_rails'
|
32
|
+
end
|
33
|
+
|
34
|
+
group :development do
|
35
|
+
gem 'listen', '~> 3.3'
|
36
|
+
gem 'spring'
|
37
|
+
end
|
@@ -0,0 +1,265 @@
|
|
1
|
+
# Traditional Rails Application with RapiTapir
|
2
|
+
|
3
|
+
This example demonstrates how to structure a **real Rails application** using RapiTapir following Rails conventions and best practices.
|
4
|
+
|
5
|
+
## 📁 Clean Structure
|
6
|
+
|
7
|
+
```
|
8
|
+
traditional_app/
|
9
|
+
├── Gemfile # Dependencies
|
10
|
+
├── README.md # This file
|
11
|
+
├── app/
|
12
|
+
│ └── controllers/
|
13
|
+
│ ├── application_controller.rb # Base controller with health check
|
14
|
+
│ └── api/
|
15
|
+
│ └── v1/
|
16
|
+
│ ├── users_controller.rb # User API endpoints
|
17
|
+
│ └── posts_controller.rb # Post API endpoints
|
18
|
+
└── config/
|
19
|
+
└── routes.rb # Clean routing with rapitapir_routes_for
|
20
|
+
```
|
21
|
+
|
22
|
+
## 🎯 Key Features
|
23
|
+
|
24
|
+
### ✅ **Simplified Architecture**
|
25
|
+
- **No separate health controller** - health check is an endpoint in ApplicationController
|
26
|
+
- **No separate documentation controller** - uses RapiTapir's built-in `DocumentationHelpers`
|
27
|
+
- **Auto-generated routes** - `rapitapir_routes_for` creates routes from endpoint definitions
|
28
|
+
- **Auto-generated docs** - `development_defaults!` enables `/docs` and `/openapi.json`
|
29
|
+
|
30
|
+
### ✅ **Rails Best Practices**
|
31
|
+
- **Namespaced APIs** - `/api/v1/` structure
|
32
|
+
- **Global error handling** - Defined once in ApplicationController
|
33
|
+
- **Standard Rails patterns** - Filters, rescue handlers, etc.
|
34
|
+
- **Environment-aware** - Documentation only in development
|
35
|
+
|
36
|
+
### ✅ **Production Ready**
|
37
|
+
- **Comprehensive error responses** - 401, 403, 404, 422, 500
|
38
|
+
- **Type safety** - All inputs/outputs defined and validated
|
39
|
+
- **Health monitoring** - Database and service status checks
|
40
|
+
- **API documentation** - Always up-to-date with code
|
41
|
+
|
42
|
+
## 🚀 Running the Application
|
43
|
+
|
44
|
+
### Option 1: Runnable Demo (Easiest)
|
45
|
+
|
46
|
+
For a quick demo, use the standalone runnable version:
|
47
|
+
|
48
|
+
```bash
|
49
|
+
# From the examples/rails directory
|
50
|
+
ruby traditional_app_runnable.rb
|
51
|
+
|
52
|
+
# Visit the endpoints:
|
53
|
+
# - http://localhost:3000/docs (Swagger UI)
|
54
|
+
# - http://localhost:3000/health (Health check)
|
55
|
+
# - http://localhost:3000/api/v1/users (Users API)
|
56
|
+
# - http://localhost:3000/api/v1/posts (Posts API)
|
57
|
+
```
|
58
|
+
|
59
|
+
This single file demonstrates the complete traditional Rails app structure.
|
60
|
+
|
61
|
+
### Option 2: Full Rails Application
|
62
|
+
|
63
|
+
To create a complete Rails application using this structure:
|
64
|
+
|
65
|
+
#### 1. Create New Rails App
|
66
|
+
```bash
|
67
|
+
rails new my_api_app --api
|
68
|
+
cd my_api_app
|
69
|
+
```
|
70
|
+
|
71
|
+
#### 2. Add RapiTapir to Gemfile
|
72
|
+
```ruby
|
73
|
+
gem 'rapitapir', '~> 1.0'
|
74
|
+
```
|
75
|
+
|
76
|
+
#### 3. Copy the Controller Structure
|
77
|
+
Copy the controllers from this example:
|
78
|
+
- `app/controllers/application_controller.rb`
|
79
|
+
- `app/controllers/api/v1/users_controller.rb`
|
80
|
+
- `app/controllers/api/v1/posts_controller.rb`
|
81
|
+
|
82
|
+
#### 4. Update Routes
|
83
|
+
Copy the routes configuration from `config/routes.rb`
|
84
|
+
|
85
|
+
#### 5. Run Standard Rails Commands
|
86
|
+
```bash
|
87
|
+
bundle install
|
88
|
+
rails db:create
|
89
|
+
rails db:migrate
|
90
|
+
rails server
|
91
|
+
```
|
92
|
+
|
93
|
+
## 📋 Available Endpoints
|
94
|
+
|
95
|
+
### System
|
96
|
+
- `GET /health` - Health check with database and service status
|
97
|
+
|
98
|
+
### Users API (`/api/v1/users`)
|
99
|
+
- `GET /api/v1/users` - List users (with search, pagination, sorting)
|
100
|
+
- `GET /api/v1/users/:id` - Get specific user
|
101
|
+
- `POST /api/v1/users` - Create new user
|
102
|
+
- `PUT /api/v1/users/:id` - Update user
|
103
|
+
- `DELETE /api/v1/users/:id` - Delete user
|
104
|
+
- `GET /api/v1/users/:id/posts` - Get user's posts
|
105
|
+
|
106
|
+
### Posts API (`/api/v1/posts`)
|
107
|
+
- `GET /api/v1/posts` - List posts (with filtering)
|
108
|
+
- `GET /api/v1/posts/:id` - Get specific post
|
109
|
+
- `POST /api/v1/posts` - Create new post (requires auth)
|
110
|
+
- `PUT /api/v1/posts/:id` - Update post (requires auth)
|
111
|
+
- `DELETE /api/v1/posts/:id` - Delete post (requires auth)
|
112
|
+
- `PATCH /api/v1/posts/:id/publish` - Toggle publish status (requires auth)
|
113
|
+
|
114
|
+
## 🔧 Key Implementation Details
|
115
|
+
|
116
|
+
### ApplicationController Pattern
|
117
|
+
```ruby
|
118
|
+
class ApplicationController < RapiTapir::Server::Rails::ControllerBase
|
119
|
+
rapitapir do
|
120
|
+
development_defaults! if Rails.env.development?
|
121
|
+
|
122
|
+
# Global error responses
|
123
|
+
error_out(json_body(error: T.string), 404)
|
124
|
+
error_out(json_body(error: T.string, errors: T.array(T.string).optional), 422)
|
125
|
+
|
126
|
+
# Health check endpoint
|
127
|
+
GET('/health')
|
128
|
+
.out(json_body(status: T.string, timestamp: T.string, ...))
|
129
|
+
end
|
130
|
+
end
|
131
|
+
```
|
132
|
+
|
133
|
+
### Auto-Generated Routes
|
134
|
+
```ruby
|
135
|
+
# config/routes.rb
|
136
|
+
Rails.application.routes.draw do
|
137
|
+
rapitapir_routes_for ApplicationController # Generates /health
|
138
|
+
|
139
|
+
namespace :api do
|
140
|
+
namespace :v1 do
|
141
|
+
rapitapir_routes_for 'Api::V1::UsersController' # Auto-generates all user routes
|
142
|
+
rapitapir_routes_for 'Api::V1::PostsController' # Auto-generates all post routes
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
```
|
147
|
+
|
148
|
+
### Type-Safe Controllers
|
149
|
+
```ruby
|
150
|
+
class Api::V1::UsersController < ApplicationController
|
151
|
+
rapitapir do
|
152
|
+
GET('/api/v1/users')
|
153
|
+
.in(query(:page, T.integer.default(1)))
|
154
|
+
.in(query(:search, T.string.optional))
|
155
|
+
.out(json_body(users: T.array(user_type), pagination: pagination_type))
|
156
|
+
end
|
157
|
+
|
158
|
+
def list_users
|
159
|
+
# inputs[:page] and inputs[:search] are automatically validated
|
160
|
+
users = User.where(conditions).page(inputs[:page])
|
161
|
+
{ users: users.map(&method(:serialize_user)) }
|
162
|
+
end
|
163
|
+
end
|
164
|
+
```
|
165
|
+
|
166
|
+
## 🆚 Comparison with Standard Rails
|
167
|
+
|
168
|
+
### Before (Standard Rails)
|
169
|
+
```ruby
|
170
|
+
# Multiple controllers for health/docs
|
171
|
+
class HealthController < ApplicationController
|
172
|
+
def check
|
173
|
+
# Custom health check logic
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
class DocumentationController < ApplicationController
|
178
|
+
def swagger_ui
|
179
|
+
# Custom Swagger UI rendering
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Manual route definitions
|
184
|
+
Rails.application.routes.draw do
|
185
|
+
get '/health', to: 'health#check'
|
186
|
+
get '/docs', to: 'documentation#swagger_ui'
|
187
|
+
|
188
|
+
resources :users # Generic CRUD, no type safety
|
189
|
+
end
|
190
|
+
|
191
|
+
# Manual parameter handling
|
192
|
+
class UsersController < ApplicationController
|
193
|
+
def index
|
194
|
+
page = params[:page]&.to_i || 1 # Manual validation
|
195
|
+
users = User.page(page)
|
196
|
+
render json: { users: users } # No output type safety
|
197
|
+
end
|
198
|
+
end
|
199
|
+
```
|
200
|
+
|
201
|
+
### After (RapiTapir)
|
202
|
+
```ruby
|
203
|
+
# Single ApplicationController with health endpoint
|
204
|
+
class ApplicationController < RapiTapir::Server::Rails::ControllerBase
|
205
|
+
rapitapir do
|
206
|
+
development_defaults! # Auto docs
|
207
|
+
GET('/health').out(json_body(...)) # Type-safe health endpoint
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
# Auto-generated routes
|
212
|
+
Rails.application.routes.draw do
|
213
|
+
rapitapir_routes_for ApplicationController # Health + docs
|
214
|
+
rapitapir_routes_for 'Api::V1::UsersController' # All user routes
|
215
|
+
end
|
216
|
+
|
217
|
+
# Type-safe controllers
|
218
|
+
class Api::V1::UsersController < ApplicationController
|
219
|
+
rapitapir do
|
220
|
+
GET('/api/v1/users')
|
221
|
+
.in(query(:page, T.integer.default(1))) # Auto validation
|
222
|
+
.out(json_body(users: T.array(user_type))) # Type-safe output
|
223
|
+
end
|
224
|
+
|
225
|
+
def list_users
|
226
|
+
users = User.page(inputs[:page]) # inputs guaranteed valid
|
227
|
+
{ users: users.map(&method(:serialize_user)) } # Return data, not render
|
228
|
+
end
|
229
|
+
end
|
230
|
+
```
|
231
|
+
|
232
|
+
## 🏗️ Benefits for Real Rails Apps
|
233
|
+
|
234
|
+
1. **Fewer Files**: No separate health/docs controllers
|
235
|
+
2. **Less Boilerplate**: Auto-generated routes and validation
|
236
|
+
3. **Type Safety**: Input/output validation with clear error messages
|
237
|
+
4. **Always Up-to-date Docs**: Documentation reflects actual code
|
238
|
+
5. **Rails Ecosystem**: Works with existing gems, middleware, and patterns
|
239
|
+
6. **Testing**: Standard Rails testing patterns work perfectly
|
240
|
+
7. **Performance**: No overhead, just cleaner organization
|
241
|
+
|
242
|
+
## 🧪 Testing Example
|
243
|
+
|
244
|
+
```ruby
|
245
|
+
# spec/controllers/api/v1/users_controller_spec.rb
|
246
|
+
RSpec.describe Api::V1::UsersController, type: :controller do
|
247
|
+
describe 'GET #list_users' do
|
248
|
+
it 'validates page parameter' do
|
249
|
+
get :list_users, params: { page: "invalid" }
|
250
|
+
expect(response).to have_http_status(:unprocessable_entity)
|
251
|
+
end
|
252
|
+
|
253
|
+
it 'returns paginated users' do
|
254
|
+
create_list(:user, 15)
|
255
|
+
get :list_users, params: { page: 2 }
|
256
|
+
|
257
|
+
expect(response).to have_http_status(:ok)
|
258
|
+
expect(json_response[:users]).to be_an(Array)
|
259
|
+
expect(json_response[:pagination][:page]).to eq(2)
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
```
|
264
|
+
|
265
|
+
This example shows how RapiTapir makes Rails APIs cleaner, safer, and more maintainable while preserving all the Rails patterns you know and love! 🎉
|
@@ -0,0 +1,254 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Api::V1::PostsController < ApplicationController
|
4
|
+
rapitapir do
|
5
|
+
# Type definitions
|
6
|
+
post_type = T.hash(
|
7
|
+
id: T.integer,
|
8
|
+
title: T.string,
|
9
|
+
content: T.string,
|
10
|
+
excerpt: T.string,
|
11
|
+
published: T.boolean,
|
12
|
+
tags: T.array(T.string),
|
13
|
+
user: T.hash(
|
14
|
+
id: T.integer,
|
15
|
+
name: T.string,
|
16
|
+
email: T.string
|
17
|
+
),
|
18
|
+
comments_count: T.integer,
|
19
|
+
created_at: T.string,
|
20
|
+
updated_at: T.string
|
21
|
+
)
|
22
|
+
|
23
|
+
# List posts with filtering
|
24
|
+
GET('/api/v1/posts')
|
25
|
+
.in(query(:published, T.boolean.optional))
|
26
|
+
.in(query(:tag, T.string.optional))
|
27
|
+
.in(query(:user_id, T.integer.optional))
|
28
|
+
.in(query(:search, T.string.optional))
|
29
|
+
.in(query(:page, T.integer.default(1)))
|
30
|
+
.in(query(:per_page, T.integer.default(10)))
|
31
|
+
.out(json_body(
|
32
|
+
posts: T.array(post_type),
|
33
|
+
pagination: T.hash(
|
34
|
+
page: T.integer,
|
35
|
+
per_page: T.integer,
|
36
|
+
total: T.integer,
|
37
|
+
total_pages: T.integer
|
38
|
+
),
|
39
|
+
filters: T.hash(
|
40
|
+
published: T.boolean.optional,
|
41
|
+
tag: T.string.optional,
|
42
|
+
user_id: T.integer.optional,
|
43
|
+
search: T.string.optional
|
44
|
+
)
|
45
|
+
))
|
46
|
+
.summary("List posts")
|
47
|
+
.description("Get posts with filtering and pagination")
|
48
|
+
.tag("Posts")
|
49
|
+
|
50
|
+
# Get specific post
|
51
|
+
GET('/api/v1/posts/:id')
|
52
|
+
.in(path(:id, T.integer))
|
53
|
+
.in(query(:include_comments, T.boolean.default(false)))
|
54
|
+
.out(json_body(
|
55
|
+
post: post_type,
|
56
|
+
comments: T.array(T.hash(
|
57
|
+
id: T.integer,
|
58
|
+
content: T.string,
|
59
|
+
user: T.hash(id: T.integer, name: T.string),
|
60
|
+
created_at: T.string
|
61
|
+
)).optional
|
62
|
+
))
|
63
|
+
.summary("Get post")
|
64
|
+
.description("Get a specific post with optional comments")
|
65
|
+
.tag("Posts")
|
66
|
+
|
67
|
+
# Create post
|
68
|
+
POST('/api/v1/posts')
|
69
|
+
.in(json_body(
|
70
|
+
title: T.string,
|
71
|
+
content: T.string,
|
72
|
+
published: T.boolean.default(false),
|
73
|
+
tags: T.array(T.string).default([])
|
74
|
+
))
|
75
|
+
.in(header(:authorization, T.string))
|
76
|
+
.out(json_body(post: post_type), 201)
|
77
|
+
.summary("Create post")
|
78
|
+
.description("Create a new blog post")
|
79
|
+
.tag("Posts")
|
80
|
+
|
81
|
+
# Update post
|
82
|
+
PUT('/api/v1/posts/:id')
|
83
|
+
.in(path(:id, T.integer))
|
84
|
+
.in(json_body(
|
85
|
+
title: T.string.optional,
|
86
|
+
content: T.string.optional,
|
87
|
+
published: T.boolean.optional,
|
88
|
+
tags: T.array(T.string).optional
|
89
|
+
))
|
90
|
+
.in(header(:authorization, T.string))
|
91
|
+
.out(json_body(post: post_type))
|
92
|
+
.summary("Update post")
|
93
|
+
.description("Update an existing post")
|
94
|
+
.tag("Posts")
|
95
|
+
|
96
|
+
# Delete post
|
97
|
+
DELETE('/api/v1/posts/:id')
|
98
|
+
.in(path(:id, T.integer))
|
99
|
+
.in(header(:authorization, T.string))
|
100
|
+
.out(json_body(message: T.string))
|
101
|
+
.summary("Delete post")
|
102
|
+
.description("Delete a blog post")
|
103
|
+
.tag("Posts")
|
104
|
+
|
105
|
+
# Publish/unpublish post
|
106
|
+
PATCH('/api/v1/posts/:id/publish')
|
107
|
+
.in(path(:id, T.integer))
|
108
|
+
.in(json_body(published: T.boolean))
|
109
|
+
.in(header(:authorization, T.string))
|
110
|
+
.out(json_body(post: post_type))
|
111
|
+
.summary("Toggle post publication")
|
112
|
+
.description("Publish or unpublish a post")
|
113
|
+
.tag("Posts")
|
114
|
+
end
|
115
|
+
|
116
|
+
before_action :authenticate_user!, only: [:create_post, :update_post, :delete_post, :toggle_publish]
|
117
|
+
before_action :authorize_post_owner!, only: [:update_post, :delete_post, :toggle_publish]
|
118
|
+
|
119
|
+
def list_posts
|
120
|
+
posts_scope = Post.includes(:user, :comments)
|
121
|
+
|
122
|
+
# Apply filters
|
123
|
+
posts_scope = posts_scope.where(published: inputs[:published]) if inputs.key?(:published)
|
124
|
+
posts_scope = posts_scope.where(user_id: inputs[:user_id]) if inputs[:user_id]
|
125
|
+
posts_scope = posts_scope.joins(:tags).where(tags: { name: inputs[:tag] }) if inputs[:tag]
|
126
|
+
|
127
|
+
# Search
|
128
|
+
if inputs[:search].present?
|
129
|
+
search_term = "%#{inputs[:search]}%"
|
130
|
+
posts_scope = posts_scope.where(
|
131
|
+
"title ILIKE ? OR content ILIKE ?", search_term, search_term
|
132
|
+
)
|
133
|
+
end
|
134
|
+
|
135
|
+
# Pagination
|
136
|
+
page = inputs[:page]
|
137
|
+
per_page = inputs[:per_page]
|
138
|
+
posts = posts_scope.order(created_at: :desc).page(page).per(per_page)
|
139
|
+
|
140
|
+
{
|
141
|
+
posts: posts.map { |post| serialize_post(post) },
|
142
|
+
pagination: pagination_metadata(posts_scope, page, per_page),
|
143
|
+
filters: inputs.slice(:published, :tag, :user_id, :search).compact
|
144
|
+
}
|
145
|
+
end
|
146
|
+
|
147
|
+
def get_post
|
148
|
+
post = Post.includes(:user, :comments, :tags).find_by(id: inputs[:id])
|
149
|
+
return render_error("Post not found", 404) unless post
|
150
|
+
|
151
|
+
response = { post: serialize_post(post) }
|
152
|
+
|
153
|
+
if inputs[:include_comments]
|
154
|
+
response[:comments] = post.comments.includes(:user).map do |comment|
|
155
|
+
{
|
156
|
+
id: comment.id,
|
157
|
+
content: comment.content,
|
158
|
+
user: {
|
159
|
+
id: comment.user.id,
|
160
|
+
name: comment.user.name
|
161
|
+
},
|
162
|
+
created_at: comment.created_at.iso8601
|
163
|
+
}
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
response
|
168
|
+
end
|
169
|
+
|
170
|
+
def create_post
|
171
|
+
post = current_user.posts.build(post_params)
|
172
|
+
|
173
|
+
if post.save
|
174
|
+
add_tags_to_post(post, inputs[:tags]) if inputs[:tags]
|
175
|
+
render json: { post: serialize_post(post.reload) }, status: 201
|
176
|
+
else
|
177
|
+
render_error("Validation failed", 422, errors: post.errors.full_messages)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def update_post
|
182
|
+
if @post.update(post_params.compact)
|
183
|
+
add_tags_to_post(@post, inputs[:tags]) if inputs[:tags]
|
184
|
+
{ post: serialize_post(@post.reload) }
|
185
|
+
else
|
186
|
+
render_error("Validation failed", 422, errors: @post.errors.full_messages)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def delete_post
|
191
|
+
@post.destroy
|
192
|
+
{ message: "Post deleted successfully" }
|
193
|
+
end
|
194
|
+
|
195
|
+
def toggle_publish
|
196
|
+
if @post.update(published: inputs[:published])
|
197
|
+
{ post: serialize_post(@post) }
|
198
|
+
else
|
199
|
+
render_error("Failed to update post", 422, errors: @post.errors.full_messages)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
private
|
204
|
+
|
205
|
+
def authenticate_user!
|
206
|
+
token = inputs[:authorization]&.sub(/^Bearer /, '')
|
207
|
+
return render_error("Authorization required", 401) unless token
|
208
|
+
|
209
|
+
# In a real app, you'd validate the JWT token here
|
210
|
+
@current_user = User.find_by(auth_token: token)
|
211
|
+
return render_error("Invalid token", 401) unless @current_user
|
212
|
+
end
|
213
|
+
|
214
|
+
def current_user
|
215
|
+
@current_user
|
216
|
+
end
|
217
|
+
|
218
|
+
def authorize_post_owner!
|
219
|
+
@post = Post.find_by(id: inputs[:id])
|
220
|
+
return render_error("Post not found", 404) unless @post
|
221
|
+
return render_error("Forbidden", 403) unless @post.user == current_user
|
222
|
+
end
|
223
|
+
|
224
|
+
def post_params
|
225
|
+
inputs.slice(:title, :content, :published)
|
226
|
+
end
|
227
|
+
|
228
|
+
def add_tags_to_post(post, tag_names)
|
229
|
+
post.tags.clear
|
230
|
+
tag_names.each do |name|
|
231
|
+
tag = Tag.find_or_create_by(name: name.strip.downcase)
|
232
|
+
post.tags << tag unless post.tags.include?(tag)
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
def serialize_post(post)
|
237
|
+
{
|
238
|
+
id: post.id,
|
239
|
+
title: post.title,
|
240
|
+
content: post.content,
|
241
|
+
excerpt: post.content.truncate(200),
|
242
|
+
published: post.published,
|
243
|
+
tags: post.tags.pluck(:name),
|
244
|
+
user: {
|
245
|
+
id: post.user.id,
|
246
|
+
name: post.user.name,
|
247
|
+
email: post.user.email
|
248
|
+
},
|
249
|
+
comments_count: post.comments.count,
|
250
|
+
created_at: post.created_at.iso8601,
|
251
|
+
updated_at: post.updated_at.iso8601
|
252
|
+
}
|
253
|
+
end
|
254
|
+
end
|