mortymer 0.0.3 → 0.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcf84fa79c18ef656fba4ad835ac55f8460f433e06df66c9aba4e6a751ec4b38
4
- data.tar.gz: f76536d61133af4fd8e8a3188d5adab741deb7ec8c38ef0122c6e637cdeced83
3
+ metadata.gz: 7a6f89fe53efef1f0307aa8a123827de3179bb00b9bde0dea02900773f3d346f
4
+ data.tar.gz: 439f37ea7091f81186fccc64746fa6ba7392dd06cd46fd24f01da917a4b643cd
5
5
  SHA512:
6
- metadata.gz: 6897a3f6ce20a442a5935ad86bd0dafbd3957b3ca60bf9ca5c7af6ea03f4c2b06417aad039ae19524cc83c9eff2d7e37a3faada6812d96131b2f890e2f976c14
7
- data.tar.gz: d20763a7a82323e5c3511bf67db1573a77d464cd497e67db11b5422cf5a3ca9afca6f06cf70e085d3c46af5c361632e2802cb984851a5bdb1383be9792db5dcc
6
+ metadata.gz: 45790ce281ff50fe565f3ae62fdac1d8727cbe84bdc5fa29e17925ec8dcf795eeae9ebaf5055346e88e4dd590c8dca1d5fd8fb1a6711f92eda594413abad9dcf
7
+ data.tar.gz: 7dc98a3c699f4573cbf826ed7f37546d02ec5eb5434954f78066f2436af792f4f8f109be48b7d6502e980361bdf9c68e30aa8ab2c746087ffc2b4358a1a3bd6b
@@ -25,7 +25,7 @@ features, I strongly believe that if you are able to define the shape of your en
25
25
  you should be able to use a fully Parsed object from your params, much like FastAPI's approach.
26
26
  Im tire of beign jelous of FastAPI and want to use Ruby in a similar way, that's when Mortymer was born.
27
27
 
28
- The name Mortymer comes from the popular series Rick and Mortymer, where Mortymer is like an assistant, a
29
- faithful companion. And that is what this gem aims to be, not a replacement for any existent framework,
28
+ The name Mortymer comes from the popular series Rick and Morty, where Morty is like an assistant, a
29
+ faithful companion (Mortymer itself is the pet of a heroe of Dota2). And that is what this gem aims to be, not a replacement for any existent framework,
30
30
  but a companion that allows the real heroes (Rails, Sinatra, Grape, etc) shine with their cool features,
31
31
  and not get bored with the API handling stuff.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mortymer
4
+ class << self
5
+ def configure
6
+ yield(config)
7
+ end
8
+
9
+ def container
10
+ yield(config.container) if block_given?
11
+ config.container
12
+ end
13
+
14
+ def config
15
+ @config ||= Configuration.new
16
+ end
17
+ end
18
+
19
+ # Global configuration for Mortymer
20
+ class Configuration
21
+ attr_accessor :container, :serve_swagger, :swagger_title, :swagger_path, :swagger_root, :api_version,
22
+ :api_description
23
+
24
+ def initialize
25
+ @container = Mortymer::Container.new
26
+ @serve_swagger = true
27
+ @swagger_title = "Rick & Rails API"
28
+ @swagger_path = "/api-docs/openapi.json"
29
+ @swagger_root = "/api-docs"
30
+ @api_description = "An awsome API developed with MORTYMER"
31
+ @api_version = "v1"
32
+ end
33
+ end
34
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent"
4
+
3
5
  module Mortymer
4
6
  # Base container for dependency injection
5
7
  class Container
@@ -8,115 +10,148 @@ module Mortymer
8
10
  class NotFoundError < Error; end
9
11
 
10
12
  class << self
13
+ # Get the global container instance
14
+ def instance
15
+ @instance ||= new
16
+ end
17
+
11
18
  # Initialize the container storage
12
19
  def registry
13
- @registry ||= {}
20
+ instance.registry
14
21
  end
15
22
 
16
- # Register a constant with its implementation
17
- # @param constant [Class] The constant to register
18
- # @param implementation [Object] The implementation to register
19
- def register_constant(constant, implementation = nil, &block)
20
- key = constant_to_key(constant)
21
- registry[key] = block || implementation
23
+ # Create a new container with a copy of the current registry
24
+ def duplicate
25
+ new(registry.dup)
22
26
  end
23
27
 
24
- # Resolve a constant to its implementation
25
- # @param constant [Class] The constant to resolve
26
- # @param resolution_stack [Array] Stack of constants being resolved to detect cycles
27
- # @return [Object] The resolved implementation
28
- def resolve_constant(constant, resolution_stack = []) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
29
- key = constant_to_key(constant)
30
-
31
- # Check for circular dependencies
32
- if resolution_stack.include?(key)
33
- raise DependencyError, "Circular dependency detected: #{resolution_stack.join(" -> ")} -> #{key}"
28
+ # Delegate instance methods to the singleton instance
29
+ def method_missing(method_name, *args, &block)
30
+ if instance.respond_to?(method_name)
31
+ instance.public_send(method_name, *args, &block)
32
+ else
33
+ super
34
34
  end
35
+ end
35
36
 
36
- # Return cached instance if available
37
- return registry[key] if registry[key] && !registry[key].is_a?(Class) && !registry[key].is_a?(Proc)
38
-
39
- implementation = registry[key]
37
+ def respond_to_missing?(method_name, include_private = false)
38
+ instance.respond_to?(method_name, include_private) || super
39
+ end
40
+ end
40
41
 
41
- # If not registered, try to resolve the constant from string/symbol
42
- if !implementation && (constant.is_a?(String) || constant.is_a?(Symbol))
43
- begin
44
- const_name = constant.to_s
45
- implementation = Object.const_get(const_name)
46
- registry[key] = implementation
47
- rescue NameError
48
- raise NotFoundError, "No implementation found for #{key}"
49
- end
50
- end
42
+ attr_reader :registry
51
43
 
52
- # If not registered, try to auto-resolve the constant if it's a class
53
- if !implementation && constant.is_a?(Class)
54
- implementation = constant
55
- registry[key] = implementation
56
- end
44
+ def initialize(initial_registry = Concurrent::Hash.new)
45
+ @registry = initial_registry
46
+ end
57
47
 
58
- raise NotFoundError, "No implementation found for #{key}" unless implementation
48
+ def duplicate
49
+ self.class.new(registry.dup)
50
+ end
59
51
 
60
- # Add current constant to resolution stack
61
- resolution_stack.push(key)
52
+ # Register a constant with its implementation
53
+ # @param constant [Class] The constant to register
54
+ # @param implementation [Object] The implementation to register
55
+ def register_constant(constant, implementation = nil, &block)
56
+ key = constant_to_key(constant)
57
+ registry[key] = block || implementation
58
+ end
62
59
 
63
- result = resolve_implementation(implementation, key, resolution_stack)
60
+ # Resolve a constant to its implementation
61
+ # @param constant [Class] The constant to resolve
62
+ # @param resolution_stack [Array] Stack of constants being resolved to detect cycles
63
+ # @return [Object] The resolved implementation
64
+ def resolve_constant(constant, resolution_stack = []) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
65
+ key = constant_to_key(constant)
64
66
 
65
- resolution_stack.pop
66
- result
67
+ # Check for circular dependencies
68
+ if resolution_stack.include?(key)
69
+ raise DependencyError, "Circular dependency detected: #{resolution_stack.join(" -> ")} -> #{key}"
67
70
  end
68
71
 
69
- private
70
-
71
- # Resolve the actual implementation
72
- def resolve_implementation(implementation, key, resolution_stack)
73
- case implementation
74
- when Proc
75
- result = instance_exec(&implementation)
76
- registry[key] = result
77
- result
78
- when Class
79
- resolve_class(implementation, key, resolution_stack)
80
- else
81
- implementation
72
+ # Return cached instance if available
73
+ return registry[key] if registry[key] && !registry[key].is_a?(Class) && !registry[key].is_a?(Proc)
74
+
75
+ implementation = registry[key]
76
+
77
+ # If not registered, try to resolve the constant from string/symbol
78
+ if !implementation && (constant.is_a?(String) || constant.is_a?(Symbol))
79
+ begin
80
+ const_name = constant.to_s
81
+ implementation = Object.const_get(const_name)
82
+ registry[key] = implementation
83
+ rescue NameError
84
+ raise NotFoundError, "No implementation found for #{key}"
82
85
  end
83
86
  end
84
87
 
85
- # Resolve a class implementation with its dependencies
86
- def resolve_class(klass, key, resolution_stack)
87
- instance = if klass.respond_to?(:dependencies)
88
- inject_dependencies(klass, resolution_stack)
89
- else
90
- klass.new
91
- end
92
- registry[key] = instance
93
- instance
88
+ # If not registered, try to auto-resolve the constant if it's a class
89
+ if !implementation && constant.is_a?(Class)
90
+ implementation = constant
91
+ registry[key] = implementation
94
92
  end
95
93
 
96
- # Inject dependencies into a new instance
97
- def inject_dependencies(klass, resolution_stack)
98
- deps = klass.dependencies.map do |dep|
99
- { var_name: dep[:var_name], value: resolve_constant(dep[:constant], resolution_stack.dup) }
100
- end
94
+ raise NotFoundError, "No implementation found for #{key}" unless implementation
101
95
 
102
- instance = klass.new
103
- deps.each do |dep|
104
- instance.instance_variable_set("@#{dep[:var_name]}", dep[:value])
105
- end
106
- instance
96
+ # Add current constant to resolution stack
97
+ resolution_stack.push(key)
98
+
99
+ result = resolve_implementation(implementation, key, resolution_stack)
100
+
101
+ resolution_stack.pop
102
+ result
103
+ end
104
+
105
+ private
106
+
107
+ # Resolve the actual implementation
108
+ def resolve_implementation(implementation, key, resolution_stack)
109
+ case implementation
110
+ when Proc
111
+ result = instance_exec(&implementation)
112
+ registry[key] = result
113
+ result
114
+ when Class
115
+ resolve_class(implementation, key, resolution_stack)
116
+ else
117
+ implementation
118
+ end
119
+ end
120
+
121
+ # Resolve a class implementation with its dependencies
122
+ def resolve_class(klass, key, resolution_stack)
123
+ instance = if klass.respond_to?(:dependencies)
124
+ inject_dependencies(klass, resolution_stack)
125
+ else
126
+ klass.new
127
+ end
128
+ registry[key] = instance
129
+ instance
130
+ end
131
+
132
+ # Inject dependencies into a new instance
133
+ def inject_dependencies(klass, resolution_stack)
134
+ deps = klass.dependencies.map do |dep|
135
+ { var_name: dep[:var_name], value: resolve_constant(dep[:constant], resolution_stack.dup) }
107
136
  end
108
137
 
109
- # Convert a constant to a container registration key
110
- # @param constant [Class] The constant to convert
111
- # @return [String] The container registration key
112
- def constant_to_key(constant)
113
- key = if constant.is_a?(String) || constant.is_a?(Symbol)
114
- constant.to_s
115
- else
116
- constant.name
117
- end
118
- Utils::StringTransformations.underscore(key.split("::").join("_"))
138
+ instance = klass.new
139
+ deps.each do |dep|
140
+ instance.instance_variable_set("@#{dep[:var_name]}", dep[:value])
119
141
  end
142
+ instance
143
+ end
144
+
145
+ # Convert a constant to a container registration key
146
+ # @param constant [Class] The constant to convert
147
+ # @return [String] The container registration key
148
+ def constant_to_key(constant)
149
+ key = if constant.is_a?(String) || constant.is_a?(Symbol)
150
+ constant.to_s
151
+ else
152
+ constant.name
153
+ end
154
+ Utils::StringTransformations.underscore(key.split("::").join("_"))
120
155
  end
121
156
  end
122
157
  end
@@ -47,12 +47,14 @@ module Mortymer
47
47
 
48
48
  # Inject all declared dependencies
49
49
  # @param overrides [Hash] Optional dependency overrides for named dependencies
50
- def inject_dependencies(overrides = {})
50
+ # @param container [Mortymer::Container] Optional container to use for dependency resolution
51
+ def inject_dependencies(overrides = {}, container = nil)
52
+ container ||= Mortymer.config&.container || Container.new
51
53
  self.class.dependencies.each do |dep|
52
54
  value = if overrides.key?(dep[:var_name].to_sym)
53
55
  overrides[dep[:var_name].to_sym]
54
56
  else
55
- Container.resolve_constant(dep[:constant])
57
+ container.resolve_constant(dep[:constant])
56
58
  end
57
59
 
58
60
  instance_variable_set("@#{dep[:var_name]}", value)
@@ -1,6 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry_struct_parser/struct_schema_parser"
4
+
5
+ # Need to monkey patch the nominal visitor for dry struct parser
6
+ # as it currently does nothing with this field
7
+ module DryStructParser
8
+ class StructSchemaParser
9
+ def visit_constructor(node, opts)
10
+ # Handle coercible types which have the form:
11
+ # [:constructor, [[:nominal, [Type, {}]], [:method, Kernel, :Type]]]
12
+ if node[0].is_a?(Array) && node[0][0] == :nominal
13
+ required = opts.fetch(:required, true)
14
+ nullable = opts.fetch(:nullable, false)
15
+ type = node[0][1][0] # Get the type from nominal node
16
+ if PREDICATE_TYPES[type.name.to_sym]
17
+ definition = {
18
+ type: PREDICATE_TYPES[type.name.to_sym],
19
+ required: required,
20
+ nullable: nullable
21
+ }
22
+ definition[:array] = opts[:array] if opts[:array]
23
+ keys[opts[:key]] = definition
24
+ return
25
+ end
26
+ end
27
+
28
+ # Fall back to regular visit for other cases
29
+ visit(node[0], opts)
30
+ end
31
+ end
32
+ end
33
+
4
34
  require "dry_validation_parser/validation_schema_parser"
5
35
  require "dry/swagger/documentation_generator"
6
36
  require "dry/swagger/errors/missing_hash_schema_error"
@@ -93,7 +93,11 @@ module Mortymer
93
93
  def generate_parameters
94
94
  return [] unless @input_class && %i[get delete].include?(@http_method)
95
95
 
96
- schema = Dry::Swagger::DocumentationGenerator.new.from_struct(@input_class)
96
+ schema = if @input_class.respond_to?(:json_schema)
97
+ @input_class.json_schema
98
+ else
99
+ Dry::Swagger::DocumentationGenerator.new.from_struct(@input_class)
100
+ end
97
101
  schema[:properties]&.map do |name, property|
98
102
  {
99
103
  name: name,
@@ -5,11 +5,21 @@ module Mortymer
5
5
  class EndpointRegistry
6
6
  class << self
7
7
  def registry
8
- @registry ||= []
8
+ if defined?(Rails.application) && Rails.application.config.cache_classes == false
9
+ Rails.application.reloader.wrap do
10
+ @registry ||= []
11
+ end
12
+ else
13
+ @registry ||= []
14
+ end
9
15
  end
10
16
 
11
17
  def get_matcher(path, method)
12
- @registry.find { |e| e.path.to_s == path.to_s && e.http_method.to_s == method.to_s }
18
+ registry.find { |e| e.path.to_s == path.to_s && e.http_method.to_s == method.to_s }
19
+ end
20
+
21
+ def clear
22
+ @registry = nil
13
23
  end
14
24
  end
15
25
  end
@@ -4,12 +4,19 @@ module Mortymer
4
4
  module Rails
5
5
  # A configuration api for Rails & Morty integration
6
6
  class Configuration
7
- attr_accessor :base_controller_class, :error_handler, :wrap_methods
7
+ attr_accessor :base_controller_class, :error_handler, :wrap_methods, :container, :api_title, :api_version,
8
+ :api_description
8
9
 
9
10
  def initialize
10
11
  @base_controller_class = "ApplicationController"
11
12
  @error_handler = ->(error) { raise error }
12
13
  @wrap_methods = true
14
+ @container = Mortymer::Container.new
15
+
16
+ # API documentation defaults
17
+ @api_title = "Rick on Rails API"
18
+ @api_version = "v1"
19
+ @api_description = ""
13
20
  end
14
21
  end
15
22
  end
@@ -10,13 +10,13 @@ module Mortymer
10
10
  end
11
11
 
12
12
  def mount_morty_endpoints
13
- Morty::EndpointRegistry.registry.each do |endpoint|
13
+ Mortymer::EndpointRegistry.registry.each do |endpoint|
14
14
  mount_endpoint(endpoint)
15
15
  end
16
16
  end
17
17
 
18
18
  def mount_controllers
19
- Morty::EndpointRegistry.registry.each do |endpoint|
19
+ Mortymer::EndpointRegistry.registry.each do |endpoint|
20
20
  mount_controller(endpoint)
21
21
  end
22
22
  end
@@ -40,21 +40,22 @@ module Mortymer
40
40
  path = endpoint.path || endpoint.infer_path_from_class
41
41
 
42
42
  # Store the endpoint class in the request env for the wrapper to access
43
- defaults = { controller: "morty/rails/endpoint_wrapper", action: "handle" }
43
+ defaults = { controller: "mortymer/rails/endpoint_wrapper", action: "handle" }
44
44
  constraints = lambda do |request|
45
- request.env["morty.endpoint_class"] = endpoint
45
+ request.env["mortymer.endpoint_class"] = endpoint
46
46
  true
47
47
  end
48
48
 
49
49
  case endpoint.http_method
50
50
  when :get
51
- @drawer.get path, to: "morty/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
51
+ @drawer.get path, to: "mortymer/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
52
52
  when :post
53
- @drawer.post path, to: "morty/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
53
+ @drawer.post path, to: "mortymer/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
54
54
  when :put
55
- @drawer.put path, to: "morty/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
55
+ @drawer.put path, to: "mortymer/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
56
56
  when :delete
57
- @drawer.delete path, to: "morty/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
57
+ @drawer.delete path, to: "mortymer/rails/endpoint_wrapper#handle", defaults: defaults,
58
+ constraints: constraints
58
59
  end
59
60
  end
60
61
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "morty/rails/configuration"
4
- require "morty/rails/endpoint_wrapper_controller"
5
- require "morty/rails/routes"
3
+ require "mortymer/rails/configuration"
4
+ require "mortymer/rails/endpoint_wrapper_controller"
5
+ require "mortymer/rails/routes"
6
6
 
7
7
  module Mortymer
8
8
  module Rails
@@ -3,12 +3,39 @@
3
3
  require_relative "rails/routes"
4
4
  require_relative "rails/configuration"
5
5
  require_relative "rails/endpoint_wrapper_controller"
6
+ require_relative "openapi_generator"
6
7
 
7
8
  module Mortymer
8
- class Railtie < ::Rails::Railtie
9
+ class Railtie < ::Rails::Railtie # rubocop:disable Style/Documentation
9
10
  config.morty = Mortymer::Rails::Configuration.new
10
11
 
11
12
  initializer "mortymer.initialize" do |app|
13
+ # Clear registry on reload in development
14
+ if ::Rails.application.config.cache_classes == false
15
+ app.reloader.before_class_unload do
16
+ Mortymer::EndpointRegistry.clear
17
+ end
18
+
19
+ app.reloader.to_prepare do
20
+ # Routes will be remounted as classes are autoloaded
21
+ ::Rails.application.eager_load!
22
+
23
+ # Regenerate OpenAPI spec
24
+ generator = Mortymer::OpenapiGenerator.new(
25
+ registry: Mortymer::EndpointRegistry.registry,
26
+ title: Mortymer.config.swagger_title,
27
+ version: Mortymer.config.api_version,
28
+ description: Mortymer.config.api_description
29
+ )
30
+
31
+ # Save OpenAPI spec to public directory
32
+ spec_dir = ::Rails.root.join("api-docs")
33
+ FileUtils.mkdir_p(spec_dir)
34
+ File.write(spec_dir.join("openapi.json"), JSON.pretty_generate(generator.generate))
35
+
36
+ ::Rails.application.reload_routes!
37
+ end
38
+ end
12
39
  end
13
40
  end
14
41
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mortymer
4
- VERSION = "0.0.3"
4
+ VERSION = "0.0.5"
5
5
  end
data/lib/mortymer.rb CHANGED
@@ -2,6 +2,7 @@
2
2
  # typed: true
3
3
 
4
4
  require "dry/struct"
5
+ require "mortymer/configuration"
5
6
  require "mortymer/model"
6
7
  require "mortymer/endpoint"
7
8
  require "mortymer/dry_swagger"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mortymer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Gonzalez
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-03-04 00:00:00.000000000 Z
11
+ date: 2025-03-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-monads
@@ -123,6 +123,7 @@ files:
123
123
  - docs/index.md
124
124
  - lib/mortymer.rb
125
125
  - lib/mortymer/api_metadata.rb
126
+ - lib/mortymer/configuration.rb
126
127
  - lib/mortymer/container.rb
127
128
  - lib/mortymer/dependencies_dsl.rb
128
129
  - lib/mortymer/dry_swagger.rb