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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Schema
5
+ class OneOf
6
+ attr_reader :variants, :discriminator
7
+
8
+ def initialize(variants, discriminator: nil)
9
+ @variants = variants
10
+ @discriminator = discriminator
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Schema
5
+ class ParameterDecoder
6
+ INTEGER_PATTERN = /\A-?(?:0|[1-9]\d*)\z/
7
+ NUMBER_PATTERN = /\A-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?\z/
8
+
9
+ attr_reader :schema_class, :data
10
+
11
+ def initialize(schema_class, data)
12
+ @schema_class = schema_class
13
+ @data = data || {}
14
+ end
15
+
16
+ def call
17
+ schema_class._fields.each_with_object({}) do |(name, field), decoded|
18
+ next unless data.key?(name) || data.key?(name.to_s)
19
+
20
+ value = data.key?(name) ? data[name] : data[name.to_s]
21
+ decoded[name] = decode_value(value, field.type)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def decode_value(value, type)
28
+ if type.is_a?(Array)
29
+ value.is_a?(Array) ? value.map { |item| decode_value(item, type.first) } : value
30
+ elsif type.is_a?(Class) && type <= Schema::Base
31
+ value.is_a?(Hash) ? self.class.new(type, value).call : value
32
+ elsif type.is_a?(Class) && type <= Types::BaseScalar
33
+ decode_scalar(value, type)
34
+ else
35
+ value
36
+ end
37
+ end
38
+
39
+ def decode_scalar(value, type)
40
+ return value unless value.is_a?(::String)
41
+
42
+ case type.openapi_schema[:type]
43
+ when "integer" then decode_integer(value)
44
+ when "number" then decode_number(value)
45
+ when "boolean" then decode_boolean(value)
46
+ else value
47
+ end
48
+ end
49
+
50
+ def decode_integer(value)
51
+ INTEGER_PATTERN.match?(value) ? Kernel.Integer(value, 10) : value
52
+ end
53
+
54
+ def decode_number(value)
55
+ NUMBER_PATTERN.match?(value) ? Kernel.Float(value) : value
56
+ end
57
+
58
+ def decode_boolean(value)
59
+ return true if value == "true"
60
+ return false if value == "false"
61
+
62
+ value
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Schema
5
+ class Serializer
6
+ attr_reader :schema_class, :object
7
+
8
+ def initialize(schema_class, object)
9
+ @schema_class = schema_class
10
+ @object = object
11
+ end
12
+
13
+ def call
14
+ result = {}
15
+
16
+ schema_class._fields.each do |name, field|
17
+ value = read_attribute(object, name)
18
+ result[name] = serialize_value(value, field.type)
19
+ end
20
+
21
+ result
22
+ end
23
+
24
+ private
25
+
26
+ def serialize_value(value, type)
27
+ return nil if value.nil?
28
+
29
+ if type.is_a?(OneOf)
30
+ variant = pick_variant(type, value)
31
+ variant ? Serializer.new(variant, value).call : value
32
+ elsif array_type?(type)
33
+ value.map { |item| serialize_value(item, type.first) }
34
+ elsif schema_type?(type)
35
+ Serializer.new(type, value).call
36
+ elsif scalar_type?(type)
37
+ value
38
+ else
39
+ raise Error, "Unsupported schema type: #{type.inspect}"
40
+ end
41
+ end
42
+
43
+ def pick_variant(one_of, value)
44
+ return nil unless value.is_a?(Hash)
45
+
46
+ one_of.variants.find do |variant|
47
+ variant._fields.each_value.all? do |field|
48
+ !field.required || value.key?(field.name) || value.key?(field.name.to_s)
49
+ end
50
+ end
51
+ end
52
+
53
+ def read_attribute(obj, name)
54
+ if obj.is_a?(Hash)
55
+ obj.key?(name) ? obj[name] : obj[name.to_s]
56
+ elsif obj.respond_to?(name)
57
+ obj.public_send(name)
58
+ elsif obj.respond_to?(:[])
59
+ obj[name]
60
+ end
61
+ end
62
+
63
+ def array_type?(type)
64
+ type.is_a?(Array)
65
+ end
66
+
67
+ def schema_type?(type)
68
+ type.is_a?(Class) && type <= Schema::Base
69
+ end
70
+
71
+ def scalar_type?(type)
72
+ type.is_a?(Class) && type <= Types::BaseScalar
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Schema
5
+ class Validator
6
+ attr_reader :schema_class, :data
7
+
8
+ def initialize(schema_class, data)
9
+ @schema_class = schema_class
10
+ @data = data || {}
11
+ end
12
+
13
+ def call
14
+ errors = []
15
+ validated = {}
16
+
17
+ schema_class._fields.each do |name, field|
18
+ value = data.key?(name) ? data[name] : data[name.to_s]
19
+
20
+ if value.nil?
21
+ if field.required && field.default.nil?
22
+ errors << "#{name} is required"
23
+ next
24
+ end
25
+ value = field.default
26
+ end
27
+
28
+ next if value.nil?
29
+
30
+ result, validation_errors = validate_value(value, field.type)
31
+ if validation_errors.any?
32
+ errors.concat(validation_errors.map { |e| "#{name}#{e}" })
33
+ else
34
+ validated[name] = result
35
+ end
36
+ end
37
+
38
+ [validated, errors]
39
+ end
40
+
41
+ private
42
+
43
+ def validate_value(value, type)
44
+ if array_type?(type)
45
+ validate_array(value, type)
46
+ elsif schema_type?(type)
47
+ validate_schema(value, type)
48
+ elsif scalar_type?(type)
49
+ validate_scalar(value, type)
50
+ else
51
+ raise Error, "Unsupported schema type: #{type.inspect}"
52
+ end
53
+ end
54
+
55
+ def validate_array(value, type)
56
+ return [nil, [": Expected #{format_type(type)}, got #{value.class}"]] unless value.is_a?(Array)
57
+
58
+ validated = []
59
+ errors = []
60
+
61
+ value.each_with_index do |item, index|
62
+ result, item_errors = validate_value(item, type.first)
63
+ if item_errors.any?
64
+ errors.concat(item_errors.map { |e| "[#{index}]#{e}" })
65
+ else
66
+ validated << result
67
+ end
68
+ end
69
+
70
+ [validated, errors]
71
+ end
72
+
73
+ def validate_scalar(value, type)
74
+ return [value, []] if type.valid?(value)
75
+
76
+ [nil, [": Expected #{type.type_name}, got #{value.class}"]]
77
+ end
78
+
79
+ def validate_schema(value, type)
80
+ result, errors = type.validate(value)
81
+ [result, errors.map { |err| ".#{err}" }]
82
+ end
83
+
84
+ def array_type?(type)
85
+ type.is_a?(Array)
86
+ end
87
+
88
+ def schema_type?(type)
89
+ type.is_a?(Class) && type <= Schema::Base
90
+ end
91
+
92
+ def scalar_type?(type)
93
+ type.is_a?(Class) && type <= Types::BaseScalar
94
+ end
95
+
96
+ def format_type(type)
97
+ return "[#{format_type(type.first)}]" if array_type?(type)
98
+ return type.type_name if scalar_type?(type)
99
+
100
+ type.name || type.to_s
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Schema
5
+ class Base
6
+ class << self
7
+ def field(name, type, required: true, default: nil, enum: nil)
8
+ validate_type!(type)
9
+ _fields[name.to_sym] = Field.new(
10
+ name: name.to_sym,
11
+ type: type,
12
+ required: required,
13
+ default: default,
14
+ enum: enum
15
+ )
16
+ end
17
+
18
+ def one_of(*variants, discriminator: nil)
19
+ OneOf.new(variants, discriminator: discriminator)
20
+ end
21
+
22
+ def _fields
23
+ @_fields ||= {}
24
+ end
25
+
26
+ def inherited(subclass)
27
+ super
28
+ subclass.instance_variable_set(:@_fields, _fields.dup)
29
+ end
30
+
31
+ def validate(data)
32
+ Validator.new(self, data).call
33
+ end
34
+
35
+ def serialize(object)
36
+ Serializer.new(self, object).call
37
+ end
38
+
39
+ def serialize_many(collection)
40
+ collection.map { |obj| serialize(obj) }
41
+ end
42
+
43
+ private
44
+
45
+ def validate_type!(type)
46
+ if type.is_a?(Array)
47
+ raise Error, "List types must contain exactly one type" unless type.one?
48
+
49
+ validate_type!(type.first)
50
+ elsif type.is_a?(OneOf)
51
+ type.variants.each { |variant| validate_type!(variant) }
52
+ elsif type.is_a?(Class) && (type <= Base || type <= Types::BaseScalar)
53
+ return
54
+ else
55
+ raise Error, "Unsupported schema type: #{type.inspect}"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Swagger
5
+ module UI
6
+ module_function
7
+
8
+ def html(spec_url: "./openapi.json")
9
+ <<~HTML
10
+ <!DOCTYPE html>
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="UTF-8">
14
+ <title>API Documentation</title>
15
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
16
+ <style>
17
+ body { margin: 0; padding: 0; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <div id="swagger-ui"></div>
22
+ <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
23
+ <script>
24
+ SwaggerUIBundle({
25
+ url: "#{spec_url}",
26
+ dom_id: '#swagger-ui',
27
+ presets: [SwaggerUIBundle.presets.apis],
28
+ layout: "BaseLayout"
29
+ })
30
+ </script>
31
+ </body>
32
+ </html>
33
+ HTML
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :rails_ninja do
4
+ namespace :openapi do
5
+ desc "Generate OpenAPI JSON spec files for all registered APIs"
6
+ task generate: :environment do
7
+ output = ENV.fetch("OUTPUT", "public/openapi")
8
+ RailsNinja.generate_openapi(output: output)
9
+ puts "Generated OpenAPI specs in #{output}/"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Types
5
+ class BaseScalar
6
+ class << self
7
+ def valid?(value)
8
+ ruby_classes.any? { |ruby_class| value.is_a?(ruby_class) }
9
+ end
10
+
11
+ def openapi_schema
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def type_name
16
+ name.split("::").last
17
+ end
18
+
19
+ def ruby_classes
20
+ raise NotImplementedError
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Types
5
+ class Boolean < BaseScalar
6
+ class << self
7
+ def ruby_classes
8
+ [TrueClass, FalseClass]
9
+ end
10
+
11
+ def openapi_schema
12
+ { type: "boolean" }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Types
5
+ class Float < BaseScalar
6
+ class << self
7
+ def ruby_classes
8
+ [::Float]
9
+ end
10
+
11
+ def openapi_schema
12
+ { type: "number" }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Types
5
+ class Int < BaseScalar
6
+ class << self
7
+ def ruby_classes
8
+ [::Integer]
9
+ end
10
+
11
+ def openapi_schema
12
+ { type: "integer" }
13
+ end
14
+
15
+ def type_name
16
+ "Integer"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ module Types
5
+ class String < BaseScalar
6
+ class << self
7
+ def ruby_classes
8
+ [::String]
9
+ end
10
+
11
+ def openapi_schema
12
+ { type: "string" }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller/metal"
4
+ require "active_support/core_ext/enumerable"
5
+ require "rack"
6
+ require "multi_json"
7
+
8
+ require_relative "rails_ninja/version"
9
+ require_relative "rails_ninja/errors"
10
+ require_relative "rails_ninja/types/base_scalar"
11
+ require_relative "rails_ninja/types/boolean"
12
+ require_relative "rails_ninja/types/float"
13
+ require_relative "rails_ninja/types/int"
14
+ require_relative "rails_ninja/types/string"
15
+ require_relative "rails_ninja/schema/field"
16
+ require_relative "rails_ninja/schema/one_of"
17
+ require_relative "rails_ninja/schema/parameter_decoder"
18
+ require_relative "rails_ninja/schema/validator"
19
+ require_relative "rails_ninja/schema/serializer"
20
+ require_relative "rails_ninja/schema"
21
+ require_relative "rails_ninja/operation"
22
+ require_relative "rails_ninja/response"
23
+ require_relative "rails_ninja/openapi/schema_ref"
24
+ require_relative "rails_ninja/openapi/generator"
25
+ require_relative "rails_ninja/swagger/ui"
26
+ require_relative "rails_ninja/endpoint_group"
27
+ require_relative "rails_ninja/api"
28
+ require_relative "rails_ninja/endpoint"
29
+
30
+ require_relative "rails_ninja/railtie" if defined?(Rails::Railtie)
31
+
32
+ module RailsNinja
33
+ def self.registered_apis
34
+ @registered_apis ||= []
35
+ end
36
+
37
+ def self.generate_openapi(output: "public/openapi")
38
+ require "fileutils"
39
+ FileUtils.mkdir_p(output)
40
+
41
+ registered_apis.each do |api_class|
42
+ generator = OpenAPI::Generator.new(api_class)
43
+
44
+ filename = api_class.name.split("::").last
45
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
46
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
47
+ .downcase + ".json"
48
+
49
+ File.write(File.join(output, filename), generator.to_json)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rails_ninja/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rails_ninja"
7
+ spec.version = RailsNinja::VERSION
8
+ spec.authors = ["Benjamin Urrutia"]
9
+ spec.email = ["benja@fintual.com"]
10
+ spec.summary = "Django Ninja-inspired API framework for Ruby/Rails"
11
+ spec.description = "Define API endpoints with a decorator-like DSL, schema validation, and automatic OpenAPI spec generation."
12
+ spec.homepage = "https://github.com/fintual-oss/rails-ninja"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.0"
15
+
16
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
20
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/releases"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ spec.files = Dir.chdir(__dir__) do
24
+ Dir["lib/**/*"].select { |path| File.file?(path) } +
25
+ %w[LICENSE README.md rails_ninja.gemspec]
26
+ end
27
+ spec.require_paths = ["lib"]
28
+
29
+ spec.add_dependency "actionpack", ">= 7.0"
30
+ spec.add_dependency "activesupport", ">= 7.0"
31
+ spec.add_dependency "multi_json", "~> 1.15"
32
+ spec.add_dependency "rack", ">= 2.0"
33
+
34
+ spec.add_development_dependency "minitest", ">= 5.0"
35
+ spec.add_development_dependency "rack-test", ">= 2.0"
36
+ spec.add_development_dependency "rake", ">= 13.0"
37
+ end