rapitapir 0.1.1 → 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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -7
  3. data/.rubocop_todo.yml +83 -0
  4. data/README.md +1319 -235
  5. data/RUBY_WEEKLY_LAUNCH_POST.md +219 -0
  6. data/docs/RAILS_INTEGRATION_IMPLEMENTATION.md +209 -0
  7. data/docs/SINATRA_EXTENSION.md +399 -348
  8. data/docs/STRICT_VALIDATION.md +229 -0
  9. data/docs/VALIDATION_IMPROVEMENTS.md +218 -0
  10. data/docs/ai-integration-plan.md +112 -0
  11. data/docs/auto-derivation.md +505 -92
  12. data/docs/endpoint-definition.md +536 -129
  13. data/docs/n8n-integration.md +212 -0
  14. data/docs/observability.md +810 -500
  15. data/docs/using-mcp.md +93 -0
  16. data/examples/ai/knowledge_base_rag.rb +83 -0
  17. data/examples/ai/user_management_mcp.rb +92 -0
  18. data/examples/ai/user_validation_llm.rb +187 -0
  19. data/examples/rails/RAILS_8_GUIDE.md +165 -0
  20. data/examples/rails/RAILS_LOADING_FIX.rb +35 -0
  21. data/examples/rails/README.md +497 -0
  22. data/examples/rails/comprehensive_test.rb +91 -0
  23. data/examples/rails/config/routes.rb +48 -0
  24. data/examples/rails/debug_controller.rb +63 -0
  25. data/examples/rails/detailed_test.rb +46 -0
  26. data/examples/rails/enhanced_users_controller.rb +278 -0
  27. data/examples/rails/final_server_test.rb +50 -0
  28. data/examples/rails/hello_world_app.rb +116 -0
  29. data/examples/rails/hello_world_controller.rb +186 -0
  30. data/examples/rails/hello_world_routes.rb +28 -0
  31. data/examples/rails/rails8_minimal_demo.rb +132 -0
  32. data/examples/rails/rails8_simple_demo.rb +140 -0
  33. data/examples/rails/rails8_working_demo.rb +255 -0
  34. data/examples/rails/real_world_blog_api.rb +510 -0
  35. data/examples/rails/server_test.rb +46 -0
  36. data/examples/rails/test_direct_processing.rb +41 -0
  37. data/examples/rails/test_hello_world.rb +80 -0
  38. data/examples/rails/test_rails_integration.rb +54 -0
  39. data/examples/rails/traditional_app/Gemfile +37 -0
  40. data/examples/rails/traditional_app/README.md +265 -0
  41. data/examples/rails/traditional_app/app/controllers/api/v1/posts_controller.rb +254 -0
  42. data/examples/rails/traditional_app/app/controllers/api/v1/users_controller.rb +220 -0
  43. data/examples/rails/traditional_app/app/controllers/application_controller.rb +86 -0
  44. data/examples/rails/traditional_app/app/controllers/application_controller_simplified.rb +87 -0
  45. data/examples/rails/traditional_app/app/controllers/documentation_controller.rb +149 -0
  46. data/examples/rails/traditional_app/app/controllers/health_controller.rb +42 -0
  47. data/examples/rails/traditional_app/config/routes.rb +25 -0
  48. data/examples/rails/traditional_app/config/routes_best_practice.rb +25 -0
  49. data/examples/rails/traditional_app/config/routes_simplified.rb +36 -0
  50. data/examples/rails/traditional_app_runnable.rb +406 -0
  51. data/examples/rails/users_controller.rb +4 -1
  52. data/examples/serverless/Gemfile +43 -0
  53. data/examples/serverless/QUICKSTART.md +331 -0
  54. data/examples/serverless/README.md +520 -0
  55. data/examples/serverless/aws_lambda_example.rb +307 -0
  56. data/examples/serverless/aws_sam_template.yaml +215 -0
  57. data/examples/serverless/azure_functions_example.rb +407 -0
  58. data/examples/serverless/deploy.rb +204 -0
  59. data/examples/serverless/gcp_cloud_functions_example.rb +367 -0
  60. data/examples/serverless/gcp_function.yaml +23 -0
  61. data/examples/serverless/host.json +24 -0
  62. data/examples/serverless/package.json +32 -0
  63. data/examples/serverless/spec/aws_lambda_spec.rb +196 -0
  64. data/examples/serverless/spec/spec_helper.rb +89 -0
  65. data/examples/serverless/vercel.json +31 -0
  66. data/examples/serverless/vercel_example.rb +404 -0
  67. data/examples/strict_validation_examples.rb +104 -0
  68. data/examples/validation_error_examples.rb +173 -0
  69. data/lib/rapitapir/ai/llm_instruction.rb +456 -0
  70. data/lib/rapitapir/ai/mcp.rb +134 -0
  71. data/lib/rapitapir/ai/rag.rb +287 -0
  72. data/lib/rapitapir/ai/rag_middleware.rb +147 -0
  73. data/lib/rapitapir/auth/oauth2.rb +43 -57
  74. data/lib/rapitapir/cli/command.rb +362 -2
  75. data/lib/rapitapir/cli/mcp_export.rb +18 -0
  76. data/lib/rapitapir/cli/validator.rb +2 -6
  77. data/lib/rapitapir/core/endpoint.rb +59 -6
  78. data/lib/rapitapir/core/enhanced_endpoint.rb +2 -6
  79. data/lib/rapitapir/dsl/fluent_endpoint_builder.rb +53 -0
  80. data/lib/rapitapir/endpoint_registry.rb +47 -0
  81. data/lib/rapitapir/observability/health_check.rb +4 -4
  82. data/lib/rapitapir/observability/logging.rb +10 -10
  83. data/lib/rapitapir/schema.rb +2 -2
  84. data/lib/rapitapir/server/rack_adapter.rb +1 -3
  85. data/lib/rapitapir/server/rails/configuration.rb +77 -0
  86. data/lib/rapitapir/server/rails/controller_base.rb +185 -0
  87. data/lib/rapitapir/server/rails/documentation_helpers.rb +76 -0
  88. data/lib/rapitapir/server/rails/resource_builder.rb +181 -0
  89. data/lib/rapitapir/server/rails/routes.rb +114 -0
  90. data/lib/rapitapir/server/rails_adapter.rb +10 -3
  91. data/lib/rapitapir/server/rails_adapter_class.rb +1 -3
  92. data/lib/rapitapir/server/rails_controller.rb +1 -3
  93. data/lib/rapitapir/server/rails_integration.rb +67 -0
  94. data/lib/rapitapir/server/rails_response_handler.rb +16 -3
  95. data/lib/rapitapir/server/sinatra_adapter.rb +29 -5
  96. data/lib/rapitapir/server/sinatra_integration.rb +4 -4
  97. data/lib/rapitapir/sinatra/extension.rb +2 -2
  98. data/lib/rapitapir/sinatra/oauth2_helpers.rb +34 -40
  99. data/lib/rapitapir/types/array.rb +4 -0
  100. data/lib/rapitapir/types/auto_derivation.rb +4 -18
  101. data/lib/rapitapir/types/datetime.rb +1 -3
  102. data/lib/rapitapir/types/float.rb +2 -6
  103. data/lib/rapitapir/types/hash.rb +40 -2
  104. data/lib/rapitapir/types/integer.rb +4 -12
  105. data/lib/rapitapir/types/object.rb +6 -2
  106. data/lib/rapitapir/types.rb +6 -2
  107. data/lib/rapitapir/version.rb +1 -1
  108. data/lib/rapitapir.rb +5 -3
  109. data/rapitapir.gemspec +7 -5
  110. metadata +116 -16
@@ -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