easy-peasy-api 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5863a5ef0b82d5420acdf6a93d9a085bce84b0b4c3ce72b389e1a2f3460ae5c9
4
+ data.tar.gz: d645b973b0dd6d79f366361a9cb52a1089981c275dafa540396b2a9308d44f34
5
+ SHA512:
6
+ metadata.gz: 1ffa616b4cadc9b82043e156ffe6c16b9e32bd9cdc25a131be8f1dafba72f5e17cb2fcc36fea88dcbd0008efdcd2a8480bd9e801a0b9f493f637baeb4fc624e7
7
+ data.tar.gz: 48487fd1ce5ef02a6b6cba144602baebad61b6bef0a9eadd57695d3f9338f1385fc0661589e632dbb2527e60154438c2df2eeb84f0d1e9acd6a4aa292bb46301
data/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # easy-peasy-api
2
+
3
+ Filesystem-based API routing for Rails. Drop controller files into a directory and they become API endpoints automatically. No `routes.rb` entries needed.
4
+
5
+ ## Install
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem "easy-peasy-api", require: "easy_peasy_api"
10
+ ```
11
+
12
+ ```ruby
13
+ # config/initializers/easy_peasy_api.rb
14
+ EasyPeasyApi.configure do |config|
15
+ config.path = "/api/v1"
16
+ end
17
+ ```
18
+
19
+ That's it. Every controller under `app/controllers/api/v1/` is now a live endpoint.
20
+
21
+ ## How it works
22
+
23
+ The URL maps directly to the filesystem. The HTTP method determines the action.
24
+
25
+ ```
26
+ URL Controller File Action
27
+ ───────────────────────────────────── ───────────────────────────────────────────────────────── ──────
28
+ GET /api/v1/customers app/controllers/api/v1/customers_controller.rb #index
29
+ POST /api/v1/customers app/controllers/api/v1/customers_controller.rb #create
30
+ GET /api/v1/customers/42 app/controllers/api/v1/customers_controller.rb #show
31
+ PUT /api/v1/customers/42 app/controllers/api/v1/customers_controller.rb #update
32
+ DELETE /api/v1/customers/42 app/controllers/api/v1/customers_controller.rb #destroy
33
+ ```
34
+
35
+ Nest directories for nested resources:
36
+
37
+ ```
38
+ GET /api/v1/customers/uncontacted app/controllers/api/v1/customers/uncontacted_controller.rb #index
39
+ GET /api/v1/customers/uncontacted/7 app/controllers/api/v1/customers/uncontacted_controller.rb #show
40
+ ```
41
+
42
+ Dynamic segments in the middle just work:
43
+
44
+ ```
45
+ GET /api/v1/customers/42/notes app/controllers/api/v1/customers/notes_controller.rb #index
46
+ GET /api/v1/customers/42/notes/99 app/controllers/api/v1/customers/notes_controller.rb #show
47
+ ```
48
+
49
+ The `42` becomes `params[:customer_id]` (singularized parent directory + `_id`). The `99` becomes `params[:id]`.
50
+
51
+ ## Examples
52
+
53
+ ### Basic controller
54
+
55
+ ```ruby
56
+ # app/controllers/api/v1/customers_controller.rb
57
+ module Api::V1
58
+ class CustomersController < ActionController::API
59
+ def index
60
+ render json: Customer.all
61
+ end
62
+
63
+ def show
64
+ render json: Customer.find(params[:id])
65
+ end
66
+
67
+ def create
68
+ customer = Customer.create!(customer_params)
69
+ render json: customer, status: :created
70
+ end
71
+
72
+ def update
73
+ customer = Customer.find(params[:id])
74
+ customer.update!(customer_params)
75
+ render json: customer
76
+ end
77
+
78
+ def destroy
79
+ Customer.find(params[:id]).destroy!
80
+ head :no_content
81
+ end
82
+
83
+ private
84
+
85
+ def customer_params
86
+ params.permit(:name, :email)
87
+ end
88
+ end
89
+ end
90
+ ```
91
+
92
+ ```
93
+ curl localhost:3000/api/v1/customers
94
+ curl localhost:3000/api/v1/customers/42
95
+ curl -X POST localhost:3000/api/v1/customers -d '{"name":"Acme"}' -H 'Content-Type: application/json'
96
+ ```
97
+
98
+ ### Nested resource with dynamic parent ID
99
+
100
+ ```ruby
101
+ # app/controllers/api/v1/customers/notes_controller.rb
102
+ module Api::V1::Customers
103
+ class NotesController < ActionController::API
104
+ def index
105
+ customer = Customer.find(params[:customer_id])
106
+ render json: customer.notes
107
+ end
108
+
109
+ def show
110
+ note = Note.find(params[:id])
111
+ render json: note
112
+ end
113
+ end
114
+ end
115
+ ```
116
+
117
+ ```
118
+ curl localhost:3000/api/v1/customers/42/notes # params[:customer_id] = "42"
119
+ curl localhost:3000/api/v1/customers/42/notes/99 # params[:customer_id] = "42", params[:id] = "99"
120
+ ```
121
+
122
+ ### Deeper nesting
123
+
124
+ ```ruby
125
+ # app/controllers/api/v1/customers/uncontacted/notes_controller.rb
126
+ module Api::V1::Customers::Uncontacted
127
+ class NotesController < ActionController::API
128
+ def index
129
+ render json: Note.where(uncontacted_id: params[:uncontacted_id])
130
+ end
131
+
132
+ def show
133
+ render json: Note.find(params[:id])
134
+ end
135
+ end
136
+ end
137
+ ```
138
+
139
+ ```
140
+ curl localhost:3000/api/v1/customers/uncontacted/55/notes # params[:uncontacted_id] = "55"
141
+ curl localhost:3000/api/v1/customers/uncontacted/55/notes/12 # params[:uncontacted_id] = "55", params[:id] = "12"
142
+ ```
143
+
144
+ ### Renaming params with `set_params`
145
+
146
+ Dynamic segments are auto-named after the parent directory (singularized + `_id`). Sometimes that default name doesn't match your domain. Inherit from `EasyPeasyApi::ApplicationController` and call `set_params` in the action to rename them:
147
+
148
+ ```ruby
149
+ # app/controllers/api/v1/customers/notes_controller.rb
150
+ #
151
+ # URL: /api/v1/customers/42/notes/99
152
+ # Default params: customer_id=42, id=99
153
+ # After set_params: account_id=42, id=99
154
+ module Api::V1::Customers
155
+ class NotesController < EasyPeasyApi::ApplicationController
156
+ def index
157
+ set_params :account_id
158
+ render json: Note.where(account_id: params[:account_id])
159
+ end
160
+
161
+ def show
162
+ set_params :account_id, :id
163
+ render json: Note.find_by(account_id: params[:account_id], id: params[:id])
164
+ end
165
+ end
166
+ end
167
+ ```
168
+
169
+ ```
170
+ curl localhost:3000/api/v1/customers/42/notes
171
+ # Without set_params: params[:customer_id] = "42"
172
+ # With set_params: params[:account_id] = "42"
173
+ ```
174
+
175
+ `set_params` maps names to dynamic segment values in the order they appear in the URL. Call it in the action, or in a `before_action`. It rebuilds params each time, so you can call it as many times as you need.
176
+
177
+ ## Param naming rules
178
+
179
+ | URL pattern | Default param name | Rule |
180
+ |---|---|---|
181
+ | `/customers/42` | `params[:id]` | Trailing segment after a controller |
182
+ | `/customers/42/notes` | `params[:customer_id]` | Mid-path segment, named from parent dir (singularized + `_id`) |
183
+ | `/customers/42/notes/99` | `params[:customer_id]`, `params[:id]` | Both rules combined |
184
+
185
+ ## Route priority
186
+
187
+ When a URL segment could match either a nested controller or a dynamic `:id`, the more specific match (nested controller) wins:
188
+
189
+ ```
190
+ app/controllers/api/v1/customers_controller.rb
191
+ app/controllers/api/v1/customers/uncontacted_controller.rb
192
+ ```
193
+
194
+ ```
195
+ GET /api/v1/customers/uncontacted -> UncontactedController#index (not CustomersController#show)
196
+ GET /api/v1/customers/42 -> CustomersController#show (no matching nested controller)
197
+ ```
198
+
199
+ ## Action mapping
200
+
201
+ | HTTP method | With `:id` | Without `:id` |
202
+ |---|---|---|
203
+ | GET | `show` | `index` |
204
+ | POST | -- | `create` |
205
+ | PUT / PATCH | `update` | -- |
206
+ | DELETE | `destroy` | -- |
@@ -0,0 +1,41 @@
1
+ module EasyPeasyApi
2
+ class ApplicationController < ActionController::API
3
+ before_action :set_default_params
4
+
5
+ private
6
+
7
+ # Called automatically. Applies the middleware's default param names.
8
+ def set_default_params
9
+ default_names = request.env['easy_peasy_api.default_param_names']
10
+ values = request.env['easy_peasy_api.dynamic_values']
11
+ return unless default_names && values
12
+
13
+ set_params(*default_names)
14
+ end
15
+
16
+ # Rebuild path_parameters with the given names mapped to the ordered
17
+ # dynamic segment values the middleware extracted from the URL.
18
+ #
19
+ # Call this inside any action (or a before_action) to rename params:
20
+ #
21
+ # def show
22
+ # set_params :customer_id, :id
23
+ # end
24
+ #
25
+ def set_params(*names)
26
+ values = request.env['easy_peasy_api.dynamic_values']
27
+ return unless values
28
+
29
+ path_params = {
30
+ controller: request.path_parameters[:controller],
31
+ action: request.path_parameters[:action]
32
+ }
33
+
34
+ names.each_with_index do |name, i|
35
+ path_params[name.to_sym] = values[i] if i < values.length
36
+ end
37
+
38
+ request.path_parameters = path_params
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ module EasyPeasyApi
2
+ class Configuration
3
+ attr_accessor :path
4
+
5
+ def initialize
6
+ @path = nil
7
+ end
8
+
9
+ # Returns the path with leading slash, no trailing slash
10
+ def normalized_path
11
+ return nil unless @path
12
+
13
+ p = @path.to_s
14
+ p = "/#{p}" unless p.start_with?('/')
15
+ p.chomp('/')
16
+ end
17
+
18
+ # Converts the configured path to a module prefix
19
+ # e.g. "/api/v1" => "Api::V1"
20
+ def controller_module_prefix
21
+ return nil unless normalized_path
22
+
23
+ normalized_path.delete_prefix('/').split('/').map { |s| s.camelize }.join('::')
24
+ end
25
+
26
+ # Returns the filesystem directory under app/controllers for this path
27
+ # e.g. "/api/v1" => "api/v1"
28
+ def controller_directory
29
+ return nil unless normalized_path
30
+
31
+ normalized_path.delete_prefix('/')
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,151 @@
1
+ require 'action_dispatch'
2
+ require 'active_support/inflector'
3
+
4
+ module EasyPeasyApi
5
+ class Middleware
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ config = EasyPeasyApi.configuration
12
+ prefix = config&.normalized_path
13
+
14
+ return @app.call(env) unless prefix
15
+
16
+ request_path = env['PATH_INFO'].to_s
17
+
18
+ return @app.call(env) unless path_matches?(request_path, prefix)
19
+
20
+ relative = request_path.delete_prefix(prefix).delete_prefix('/')
21
+ return @app.call(env) if relative.empty?
22
+
23
+ segments = relative.split('/').reject(&:empty?)
24
+ return @app.call(env) if segments.empty?
25
+
26
+ resolution = resolve(segments, config)
27
+ return not_found unless resolution
28
+
29
+ controller_class, dynamic_pairs = resolution
30
+
31
+ # dynamic_pairs is an ordered array of [default_name, value]
32
+ # e.g. [[:uncontacted_id, "234234"], [:id, "999"]]
33
+ has_id = dynamic_pairs.any? { |name, _| name == :id }
34
+
35
+ request_method = env['REQUEST_METHOD']
36
+ action = determine_action(request_method, has_id)
37
+ return method_not_allowed unless action
38
+
39
+ dispatch(controller_class, action, dynamic_pairs, env)
40
+ end
41
+
42
+ private
43
+
44
+ def path_matches?(request_path, prefix)
45
+ request_path == prefix || request_path.start_with?("#{prefix}/")
46
+ end
47
+
48
+ def resolve(segments, config)
49
+ controllers_root = Rails.root.join('app', 'controllers', config.controller_directory).to_s
50
+ mod_prefix = config.controller_module_prefix
51
+
52
+ result = walk(segments, controllers_root, [], [], nil)
53
+ return nil unless result
54
+
55
+ controller_segments, dynamic_pairs = result
56
+ klass = constantize_controller(mod_prefix, controller_segments)
57
+ return nil unless klass
58
+
59
+ [klass, dynamic_pairs]
60
+ end
61
+
62
+ # Recursive filesystem walk.
63
+ #
64
+ # Returns [controller_segments, dynamic_pairs] where dynamic_pairs is
65
+ # an ordered array of [default_param_name, value].
66
+ def walk(segments, current_dir, controller_segments, dynamic_pairs, last_dir_name)
67
+ return nil if segments.empty?
68
+
69
+ segment = segments.first
70
+ rest = segments[1..]
71
+
72
+ controller_file = File.join(current_dir, "#{segment}_controller.rb")
73
+ has_controller = File.exist?(controller_file)
74
+ subdir = File.join(current_dir, segment)
75
+ has_directory = File.directory?(subdir)
76
+
77
+ # 1. Try descending into a subdirectory first (most specific match wins)
78
+ if has_directory
79
+ result = walk(rest, subdir, controller_segments + [segment], dynamic_pairs, segment)
80
+ return result if result
81
+ end
82
+
83
+ # 2. Try matching a controller file
84
+ if has_controller
85
+ found_segments = controller_segments + [segment]
86
+
87
+ if rest.empty?
88
+ return [found_segments, dynamic_pairs]
89
+ elsif rest.length == 1
90
+ return [found_segments, dynamic_pairs + [[:id, rest.first]]]
91
+ end
92
+ end
93
+
94
+ # 3. No directory or controller match — dynamic param
95
+ if last_dir_name
96
+ param_name = :"#{last_dir_name.singularize}_id"
97
+ return walk(rest, current_dir, controller_segments, dynamic_pairs + [[param_name, segment]], last_dir_name)
98
+ end
99
+
100
+ nil
101
+ end
102
+
103
+ def constantize_controller(mod_prefix, segments)
104
+ class_name = segments.map { |s| s.camelize }.join('::')
105
+ full_name = "#{mod_prefix}::#{class_name}Controller"
106
+ full_name.constantize
107
+ rescue NameError
108
+ nil
109
+ end
110
+
111
+ def dispatch(controller_class, action, dynamic_pairs, env)
112
+ default_names = dynamic_pairs.map(&:first)
113
+ values = dynamic_pairs.map(&:last)
114
+
115
+ # Store ordered data for set_params
116
+ env['easy_peasy_api.dynamic_values'] = values
117
+ env['easy_peasy_api.default_param_names'] = default_names
118
+
119
+ # Set path_parameters with default names so it works out of the box
120
+ path_params = { controller: controller_class.controller_path, action: action.to_s }
121
+ dynamic_pairs.each { |name, value| path_params[name] = value }
122
+
123
+ env['action_dispatch.request.path_parameters'] = path_params
124
+
125
+ controller_class.action(action).call(env)
126
+ end
127
+
128
+ def determine_action(method, has_id)
129
+ if has_id
130
+ case method
131
+ when 'GET' then :show
132
+ when 'PUT', 'PATCH' then :update
133
+ when 'DELETE' then :destroy
134
+ end
135
+ else
136
+ case method
137
+ when 'GET' then :index
138
+ when 'POST' then :create
139
+ end
140
+ end
141
+ end
142
+
143
+ def not_found
144
+ [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
145
+ end
146
+
147
+ def method_not_allowed
148
+ [405, { 'content-type' => 'application/json' }, ['{"error":"Method Not Allowed"}']]
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,9 @@
1
+ require 'rails/railtie'
2
+
3
+ module EasyPeasyApi
4
+ class Railtie < Rails::Railtie
5
+ initializer 'easy_peasy_api.middleware' do |app|
6
+ app.middleware.use EasyPeasyApi::Middleware
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ module EasyPeasyApi
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,3 @@
1
+ module EasyPeasyApi
2
+ VERSION = "<%= version %>"
3
+ end
@@ -0,0 +1,22 @@
1
+ require 'easy_peasy_api/version'
2
+ require 'easy_peasy_api/configuration'
3
+ require 'easy_peasy_api/middleware'
4
+ require 'easy_peasy_api/railtie' if defined?(Rails::Railtie)
5
+
6
+ module EasyPeasyApi
7
+ class << self
8
+ def configuration
9
+ @configuration ||= Configuration.new
10
+ end
11
+
12
+ def configure
13
+ yield(configuration)
14
+ end
15
+
16
+ def reset_configuration!
17
+ @configuration = Configuration.new
18
+ end
19
+ end
20
+ end
21
+
22
+ require 'easy_peasy_api/application_controller' if defined?(ActionController::API)
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: easy-peasy-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionpack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rack-test
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rails
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '7.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '7.0'
82
+ description: A Rails Railtie that dynamically routes API requests to controllers based
83
+ on the filesystem structure.
84
+ executables: []
85
+ extensions: []
86
+ extra_rdoc_files: []
87
+ files:
88
+ - README.md
89
+ - lib/easy_peasy_api.rb
90
+ - lib/easy_peasy_api/application_controller.rb
91
+ - lib/easy_peasy_api/configuration.rb
92
+ - lib/easy_peasy_api/middleware.rb
93
+ - lib/easy_peasy_api/railtie.rb
94
+ - lib/easy_peasy_api/version.rb
95
+ - lib/easy_peasy_api/version.rb.erb
96
+ licenses:
97
+ - MIT
98
+ metadata: {}
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '3.0'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 3.7.2
114
+ specification_version: 4
115
+ summary: Filesystem-based API routing for Rails
116
+ test_files: []