rails_ninja 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.
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ class EndpointGroup < ActionController::Metal
5
+ abstract!
6
+
7
+ def params
8
+ @params ||= begin
9
+ raw = request.path_parameters.merge(request.query_parameters).merge(request.request_parameters)
10
+ raw.deep_symbolize_keys
11
+ end
12
+ end
13
+
14
+ # --- Class-level DSL ---
15
+
16
+ class << self
17
+ # HTTP verb decorators
18
+ %i[get post put patch delete].each do |verb|
19
+ define_method(verb) do |path, **options|
20
+ self._pending_route = { verb: verb, path: path, **options }
21
+ end
22
+ end
23
+
24
+ # Schema definition DSL
25
+ def schema(name, &block)
26
+ klass = Class.new(Schema::Base)
27
+ klass.class_eval(&block)
28
+
29
+ # Register as a constant on this class so it's accessible by name
30
+ const_set(name, klass)
31
+ _schemas[name] = klass
32
+ klass
33
+ end
34
+
35
+ def ninja_headers(*args)
36
+ if args.any?
37
+ @_headers = args.flatten
38
+ else
39
+ @_headers
40
+ end
41
+ end
42
+
43
+ def _headers
44
+ @_headers || []
45
+ end
46
+
47
+ def before_action(method_name = nil, &block)
48
+ _before_actions << (method_name || block)
49
+ end
50
+
51
+ def _before_actions
52
+ @_before_actions ||= []
53
+ end
54
+
55
+ def tags(*args)
56
+ if args.any?
57
+ @_tags = args.flatten
58
+ else
59
+ @_tags
60
+ end
61
+ end
62
+
63
+ def _tags
64
+ @_tags
65
+ end
66
+
67
+ # Pull in endpoints from an Endpoint class
68
+ def include_endpoint(endpoint_class)
69
+ endpoint_class._endpoints.each do |op|
70
+ # Dup the operation so we don't mutate the Endpoint's own copy
71
+ local_op = op.dup
72
+ local_op.with_tags(_tags) if _tags
73
+
74
+ # Generate a unique dispatch name to avoid collisions when multiple endpoints share handler names
75
+ original_handler = local_op.display_handler
76
+ dispatch_name = _unique_handler_name(endpoint_class, original_handler)
77
+ local_op.with_handler(dispatch_name)
78
+
79
+ _endpoints << local_op
80
+
81
+ define_method(dispatch_name) do
82
+ endpoint_instance = @_ninja_handler_instance
83
+ result = endpoint_instance.public_send(original_handler)
84
+ if endpoint_instance.performed?
85
+ self.status = endpoint_instance.status
86
+ self.content_type = endpoint_instance.content_type
87
+ self.response_body = endpoint_instance.response_body
88
+ else
89
+ result
90
+ end
91
+ result
92
+ end
93
+ end
94
+ end
95
+
96
+ # Mount an EndpointGroup (or a single Endpoint) under a path prefix
97
+ def mount(group_class, prefix: "/")
98
+ if group_class.is_a?(Class) && group_class <= RailsNinja::API
99
+ raise Error,
100
+ "#{group_class.name || group_class.inspect} is a RailsNinja::API and cannot be mounted: " \
101
+ "an API is a standalone document (one API class per openapi.json). " \
102
+ "Group its endpoints in a RailsNinja::EndpointGroup and mount that instead."
103
+ end
104
+
105
+ unless group_class.is_a?(Class) && group_class <= RailsNinja::EndpointGroup
106
+ raise Error,
107
+ "mount expects a RailsNinja::EndpointGroup (or RailsNinja::Endpoint) class, got #{group_class.inspect}"
108
+ end
109
+
110
+ _mounted_groups << { group_class: group_class, prefix: prefix }
111
+
112
+ # Define dispatch methods for all endpoints in the mounted group tree
113
+ group_class._all_endpoints.each do |op|
114
+ original_handler = op.display_handler
115
+ mounted_class = op.api_class
116
+ dispatch_name = _unique_handler_name(mounted_class, original_handler)
117
+
118
+ define_method(dispatch_name) do
119
+ endpoint_instance = @_ninja_handler_instance
120
+ result = endpoint_instance.public_send(original_handler)
121
+ if endpoint_instance.performed?
122
+ self.status = endpoint_instance.status
123
+ self.content_type = endpoint_instance.content_type
124
+ self.response_body = endpoint_instance.response_body
125
+ else
126
+ result
127
+ end
128
+ result
129
+ end
130
+ end
131
+ end
132
+
133
+ def _unique_handler_name(source_class, handler)
134
+ # Class names are unique constants, so they disambiguate deterministically
135
+ # across processes (object_id changes per boot, polluting logs and routes).
136
+ # Anonymous classes (e.g. Class.new in tests) have no name, so fall back.
137
+ prefix = source_class.name&.underscore&.tr("/", "_") || "__ninja_#{source_class.object_id}"
138
+ :"#{prefix}__#{handler}"
139
+ end
140
+
141
+ # Storage
142
+ def _endpoints
143
+ @_endpoints ||= []
144
+ end
145
+
146
+ def _schemas
147
+ @_schemas ||= {}
148
+ end
149
+
150
+ def _mounted_groups
151
+ @_mounted_groups ||= []
152
+ end
153
+
154
+ def _pending_route
155
+ @_pending_route
156
+ end
157
+
158
+ def _pending_route=(route)
159
+ @_pending_route = route
160
+ end
161
+
162
+ # The decorator magic: when a method is defined after a verb call,
163
+ # pair them together as an endpoint
164
+ def method_added(method_name)
165
+ super
166
+ return if @_inside_method_added
167
+ return unless _pending_route
168
+
169
+ @_inside_method_added = true
170
+
171
+ pending = _pending_route
172
+ self._pending_route = nil
173
+
174
+ # Extra paths that route to the same handler but are flagged deprecated
175
+ # in the OpenAPI spec. Accepts a single path or an array of paths.
176
+ deprecated_paths = Array(pending.delete(:deprecated_paths))
177
+
178
+ route_def = pending.merge(handler: method_name, api_class: self)
179
+ _endpoints << Operation.new(**route_def)
180
+
181
+ deprecated_paths.each do |deprecated_path|
182
+ _endpoints << Operation.new(**route_def, path: deprecated_path, deprecated: true)
183
+ end
184
+
185
+ @_inside_method_added = false
186
+ end
187
+
188
+ def inherited(subclass)
189
+ super
190
+ subclass.instance_variable_set(:@_endpoints, [])
191
+ subclass.instance_variable_set(:@_schemas, (_schemas || {}).dup)
192
+ subclass.instance_variable_set(:@_mounted_groups, [])
193
+ subclass.instance_variable_set(:@_before_actions, (_before_actions || []).dup)
194
+ end
195
+
196
+ def _all_endpoints
197
+ endpoints = _endpoints.dup
198
+ _mounted_groups.each do |mounted|
199
+ endpoints.concat(mounted[:group_class]._all_endpoints)
200
+ end
201
+ endpoints
202
+ end
203
+ end
204
+
205
+ private
206
+
207
+ def head(status_code)
208
+ self.status = status_code
209
+ self.content_type = "application/json"
210
+ self.response_body = [MultiJson.dump(nil)]
211
+ end
212
+
213
+ def render_json(body, status: 200)
214
+ self.status = status
215
+ self.content_type = "application/json"
216
+ self.response_body = [MultiJson.dump(body)]
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ class Error < StandardError; end
5
+
6
+ class ValidationError < Error
7
+ attr_reader :errors
8
+
9
+ def initialize(errors)
10
+ @errors = errors
11
+ super("Validation failed: #{errors.join(', ')}")
12
+ end
13
+ end
14
+
15
+ class NotFoundError < Error
16
+ def initialize(msg = "Not Found")
17
+ super
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module OpenAPI
5
+ class Generator
6
+ def initialize(api_class)
7
+ @api_class = api_class
8
+ end
9
+
10
+ def to_hash
11
+ @schema_names = build_schema_names
12
+
13
+ components = { schemas: build_schemas }
14
+ if @api_class._openapi_security_schemes.any?
15
+ components[:securitySchemes] = @api_class._openapi_security_schemes
16
+ end
17
+
18
+ spec = {
19
+ openapi: "3.2.0",
20
+ info: {
21
+ title: @api_class._title || @api_class.name || "API",
22
+ version: @api_class._version || "0.1.0",
23
+ },
24
+ paths: build_paths,
25
+ components: components,
26
+ }
27
+ spec[:servers] = [{ url: @api_class._server }] if @api_class._server
28
+ if @api_class._openapi_security.any?
29
+ spec[:security] = @api_class._openapi_security.map { |name| { name => [] } }
30
+ end
31
+ spec
32
+ end
33
+
34
+ def to_json(*_args)
35
+ MultiJson.dump(to_hash)
36
+ end
37
+
38
+ private
39
+
40
+ def collect_routes(group_class = @api_class, prefix = "/")
41
+ routes = []
42
+
43
+ group_class._endpoints.each do |endpoint|
44
+ full_path = normalize_path("#{prefix}/#{endpoint.path}")
45
+ routes << { endpoint: endpoint, full_path: full_path }
46
+ end
47
+
48
+ group_class._mounted_groups.each do |mounted|
49
+ sub_prefix = normalize_path("#{prefix}/#{mounted[:prefix]}")
50
+ routes.concat(collect_routes(mounted[:group_class], sub_prefix))
51
+ end
52
+
53
+ routes
54
+ end
55
+
56
+ def normalize_path(path)
57
+ "/" + path.squeeze("/").gsub(%r{^/|/$}, "")
58
+ end
59
+
60
+ def build_paths
61
+ routes = collect_routes
62
+ grouped = routes.group_by { |r| r[:full_path] }
63
+
64
+ grouped.each_with_object({}) do |(path, path_routes), paths|
65
+ openapi_path = path.gsub(/:(\w+)/, '{\1}')
66
+ paths[openapi_path] = {}
67
+
68
+ path_routes.each do |route|
69
+ endpoint = route[:endpoint]
70
+ verb = endpoint.verb.to_s.downcase
71
+ body_verb = %w[get delete head].exclude?(verb)
72
+
73
+ operation = { summary: endpoint.summary }
74
+ operation[:tags] = endpoint.tags if endpoint.tags&.any?
75
+ operation[:responses] = build_responses(endpoint)
76
+ # Deprecated aliases share a handler with the primary endpoint; only the
77
+ # current path carries the operationId to keep it unique across the spec.
78
+ operation[:operationId] = build_operation_id(endpoint) unless endpoint.deprecated?
79
+ operation[:deprecated] = true if endpoint.deprecated?
80
+
81
+ if endpoint.request_schema && body_verb
82
+ operation[:requestBody] = build_request_body(endpoint.request_schema)
83
+ end
84
+
85
+ params = extract_path_params(path) + extract_header_params(endpoint)
86
+ params += extract_query_params(endpoint) unless body_verb
87
+ operation[:parameters] = params if params.any?
88
+
89
+ paths[openapi_path][verb] = operation
90
+ end
91
+ end
92
+ end
93
+
94
+ def build_responses(endpoint)
95
+ return { "200" => { description: "Successful response" } } if endpoint.responses_map.empty?
96
+
97
+ endpoint.responses_map.each_with_object({}) do |(status, schema), out|
98
+ schema_node = if schema.is_a?(Array)
99
+ { type: "array", items: schema_ref(schema.first) }
100
+ else
101
+ schema_ref(schema)
102
+ end
103
+
104
+ out[status.to_s] = {
105
+ description: response_description_for(status),
106
+ content: { "application/json" => { schema: schema_node } },
107
+ }
108
+ end
109
+ end
110
+
111
+ def response_description_for(status)
112
+ case status.to_i
113
+ when 200..299 then "Successful response"
114
+ when 400 then "Bad request"
115
+ when 401 then "Unauthorized"
116
+ when 403 then "Forbidden"
117
+ when 404 then "Not found"
118
+ when 409 then "Conflict"
119
+ when 422 then "Unprocessable entity"
120
+ when 429 then "Too many requests"
121
+ when 500..599 then "Server error"
122
+ else "Response"
123
+ end
124
+ end
125
+
126
+ def build_request_body(schema)
127
+ {
128
+ required: true,
129
+ content: {
130
+ "application/json" => {
131
+ schema: schema_ref(schema),
132
+ },
133
+ },
134
+ }
135
+ end
136
+
137
+ def schema_ref(type)
138
+ if type.is_a?(Schema::OneOf)
139
+ result = { "oneOf" => type.variants.map { |v| schema_ref(v) } }
140
+ if type.discriminator
141
+ result["discriminator"] = {
142
+ "propertyName" => type.discriminator.to_s,
143
+ "mapping" => discriminator_mapping(type),
144
+ }
145
+ end
146
+ result
147
+ elsif type.is_a?(Array)
148
+ { type: "array", items: schema_ref(type.first) }
149
+ elsif type <= Schema::Base
150
+ { "$ref" => "#/components/schemas/#{schema_name(type)}" }
151
+ else
152
+ SchemaRef.primitive_type(type)
153
+ end
154
+ end
155
+
156
+ def discriminator_mapping(one_of)
157
+ one_of.variants.each_with_object({}) do |variant, mapping|
158
+ field = variant._fields[one_of.discriminator]
159
+ next unless field&.default
160
+
161
+ mapping[field.default.to_s] = "#/components/schemas/#{schema_name(variant)}"
162
+ end
163
+ end
164
+
165
+ def extract_path_params(path)
166
+ path.scan(/:(\w+)/).flatten.map do |param|
167
+ { name: param, in: "path", required: true, schema: { type: "string" } }
168
+ end
169
+ end
170
+
171
+ def extract_query_params(endpoint)
172
+ return [] unless endpoint.request_schema
173
+
174
+ endpoint.request_schema._fields.map do |name, field|
175
+ { name: name.to_s, in: "query", required: field.required, schema: schema_ref(field.type) }
176
+ end
177
+ end
178
+
179
+ def extract_header_params(endpoint)
180
+ return [] unless endpoint.header_params&.any?
181
+
182
+ endpoint.header_params.map do |h|
183
+ { name: h[:name], in: "header", required: h[:required], schema: h[:schema] }
184
+ end
185
+ end
186
+
187
+ def build_operation_id(endpoint)
188
+ tag = endpoint.tags&.first
189
+ handler = endpoint.display_handler.to_s
190
+
191
+ if tag
192
+ prefix = tag
193
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
194
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
195
+ .downcase
196
+ "#{prefix}_#{handler}"
197
+ else
198
+ handler
199
+ end
200
+ end
201
+
202
+ def schema_name(schema_class)
203
+ @schema_names[schema_class] || schema_class.name || schema_class.object_id.to_s
204
+ end
205
+
206
+ def build_schema_names
207
+ names = {}
208
+
209
+ collect_schema_sources.each do |api_class|
210
+ api_class._schemas.each do |short_name, schema_class|
211
+ name = short_name.to_s
212
+
213
+ next if names.key?(schema_class)
214
+
215
+ existing = names.values.find { |n| n == name }
216
+ if existing
217
+ raise Error, "Schema name clash: '#{name}' is defined in multiple API classes"
218
+ end
219
+
220
+ names[schema_class] = name
221
+ end
222
+ end
223
+
224
+ names
225
+ end
226
+
227
+ def build_schemas
228
+ collect_schemas.to_h do |schema_class|
229
+ [schema_name(schema_class), json_schema_for(schema_class)]
230
+ end
231
+ end
232
+
233
+ def json_schema_for(schema_class)
234
+ properties = {}
235
+ required = []
236
+
237
+ schema_class._fields.each do |name, field|
238
+ property = schema_ref(field.type)
239
+ property[:title] = schema_title(name)
240
+ property[:enum] = field.enum if field.enum
241
+ properties[name.to_s] = property
242
+ required << name.to_s if field.required
243
+ end
244
+
245
+ result = { type: "object", title: schema_name(schema_class), properties: properties }
246
+ result[:required] = required if required.any?
247
+ result
248
+ end
249
+
250
+ def schema_title(name)
251
+ name.to_s.split("_").map(&:capitalize).join(" ")
252
+ end
253
+
254
+ def collect_schemas
255
+ schemas = Set.new
256
+
257
+ collect_routes.each do |route|
258
+ endpoint = route[:endpoint]
259
+ walk_schema_tree(endpoint.request_schema, schemas) if endpoint.request_schema
260
+ endpoint.responses_map.each_value { |schema| walk_schema_tree(schema, schemas) }
261
+ end
262
+
263
+ schemas
264
+ end
265
+
266
+ def walk_schema_tree(type, schemas)
267
+ if type.is_a?(Schema::OneOf)
268
+ type.variants.each { |v| walk_schema_tree(v, schemas) }
269
+ elsif type.is_a?(Array)
270
+ walk_schema_tree(type.first, schemas)
271
+ elsif type.is_a?(Class) && type <= Schema::Base
272
+ return if schemas.include?(type)
273
+
274
+ schemas << type
275
+ type._fields.each_value { |field| walk_schema_tree(field.type, schemas) }
276
+ end
277
+ end
278
+
279
+ def collect_schema_sources
280
+ sources = Set.new
281
+
282
+ collect_routes.each do |route|
283
+ sources << route[:endpoint].api_class
284
+ end
285
+
286
+ sources
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module OpenAPI
5
+ module SchemaRef
6
+ module_function
7
+
8
+ def to_json_schema(type)
9
+ if type.is_a?(Array)
10
+ inner = type.first
11
+ {
12
+ type: "array",
13
+ items: to_json_schema(inner),
14
+ }
15
+ elsif type <= Schema::Base
16
+ { "$ref" => "#/components/schemas/#{type.name || type.object_id}" }
17
+ else
18
+ primitive_type(type)
19
+ end
20
+ end
21
+
22
+ def schema_to_json_schema(schema_class)
23
+ properties = {}
24
+ required = []
25
+
26
+ schema_class._fields.each do |name, field|
27
+ properties[name.to_s] = to_json_schema(field.type)
28
+ required << name.to_s if field.required
29
+ end
30
+
31
+ result = { type: "object", properties: properties }
32
+ result[:required] = required if required.any?
33
+ result
34
+ end
35
+
36
+ def primitive_type(type)
37
+ return type.openapi_schema if type.is_a?(Class) && type <= Types::BaseScalar
38
+
39
+ raise Error, "Unsupported schema type: #{type.inspect}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ class Operation
5
+ attr_reader :verb, :path, :handler, :display_handler, :api_class,
6
+ :request_schema, :responses_map, :tags, :summary, :header_params
7
+
8
+ def initialize(verb:, path:, handler:, api_class:, request: nil, response: nil, responses: nil,
9
+ tags: nil, summary: nil, headers: nil, deprecated: false)
10
+ @verb = verb
11
+ @path = path
12
+ @handler = handler
13
+ @display_handler = handler
14
+ @api_class = api_class
15
+ @request_schema = request
16
+ @responses_map = responses || (response ? { 200 => response } : {})
17
+ @tags = tags || api_class._tags || []
18
+ @summary = summary || handler.to_s.tr("_", " ").capitalize
19
+ @header_params = merge_headers(api_class._headers, headers)
20
+ @deprecated = deprecated
21
+ end
22
+
23
+ def deprecated?
24
+ @deprecated
25
+ end
26
+
27
+ def response_schema
28
+ responses_map[200]
29
+ end
30
+
31
+ def with_handler(new_handler)
32
+ @handler = new_handler
33
+ self
34
+ end
35
+
36
+ def with_tags(new_tags)
37
+ @tags = new_tags
38
+ self
39
+ end
40
+
41
+ def response_is_array?
42
+ response_schema.is_a?(Array)
43
+ end
44
+
45
+ def response_schema_class
46
+ response_is_array? ? response_schema.first : response_schema
47
+ end
48
+
49
+ private
50
+
51
+ def merge_headers(class_headers, endpoint_headers)
52
+ parsed_class = parse_headers(class_headers)
53
+ parsed_endpoint = parse_headers(endpoint_headers)
54
+
55
+ # Endpoint-level headers override class-level headers with the same name
56
+ merged = parsed_class.reject { |ch| parsed_endpoint.any? { |eh| eh[:name] == ch[:name] } }
57
+ merged + parsed_endpoint
58
+ end
59
+
60
+ def parse_headers(headers)
61
+ return [] if headers.nil?
62
+
63
+ Array(headers).filter_map do |h|
64
+ if h.is_a?(String)
65
+ { name: h, required: true, schema: { type: "string" } }
66
+ elsif h.is_a?(Hash)
67
+ { name: h[:name], required: h.fetch(:required, true), schema: { type: h.fetch(:type, "string") } }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "rails_ninja.add_autoload_paths" do |app|
6
+ api_path = Rails.root.join("app/api")
7
+ if api_path.exist?
8
+ app.config.autoload_paths << api_path.to_s
9
+ app.config.eager_load_paths << api_path.to_s
10
+ end
11
+ end
12
+
13
+ rake_tasks do
14
+ load File.expand_path("tasks.rake", __dir__)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Response
5
+ module_function
6
+
7
+ def json(body, status: 200)
8
+ [status, { "content-type" => "application/json" }, [MultiJson.dump(body)]]
9
+ end
10
+
11
+ def html(body, status: 200)
12
+ [status, { "content-type" => "text/html" }, [body]]
13
+ end
14
+
15
+ def error(message, status:)
16
+ json({ error: message }, status: status)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Schema
5
+ Field = Struct.new(:name, :type, :required, :default, :enum, keyword_init: true) do
6
+ def initialize(name:, type:, required: true, default: nil, enum: nil)
7
+ super
8
+ end
9
+ end
10
+ end
11
+ end