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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +276 -0
- data/lib/rails_ninja/api.rb +388 -0
- data/lib/rails_ninja/endpoint.rb +6 -0
- data/lib/rails_ninja/endpoint_group.rb +219 -0
- data/lib/rails_ninja/errors.rb +20 -0
- data/lib/rails_ninja/openapi/generator.rb +290 -0
- data/lib/rails_ninja/openapi/schema_ref.rb +43 -0
- data/lib/rails_ninja/operation.rb +72 -0
- data/lib/rails_ninja/railtie.rb +17 -0
- data/lib/rails_ninja/response.rb +19 -0
- data/lib/rails_ninja/schema/field.rb +11 -0
- data/lib/rails_ninja/schema/one_of.rb +14 -0
- data/lib/rails_ninja/schema/parameter_decoder.rb +66 -0
- data/lib/rails_ninja/schema/serializer.rb +76 -0
- data/lib/rails_ninja/schema/validator.rb +104 -0
- data/lib/rails_ninja/schema.rb +61 -0
- data/lib/rails_ninja/swagger/ui.rb +37 -0
- data/lib/rails_ninja/tasks.rake +12 -0
- data/lib/rails_ninja/types/base_scalar.rb +25 -0
- data/lib/rails_ninja/types/boolean.rb +17 -0
- data/lib/rails_ninja/types/float.rb +17 -0
- data/lib/rails_ninja/types/int.rb +21 -0
- data/lib/rails_ninja/types/string.rb +17 -0
- data/lib/rails_ninja/version.rb +5 -0
- data/lib/rails_ninja.rb +52 -0
- data/rails_ninja.gemspec +37 -0
- metadata +172 -0
|
@@ -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
|