mortymer 0.0.4 → 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 +4 -4
- data/lib/mortymer/configuration.rb +34 -0
- data/lib/mortymer/container.rb +119 -84
- data/lib/mortymer/dependencies_dsl.rb +4 -2
- data/lib/mortymer/dry_swagger.rb +30 -0
- data/lib/mortymer/endpoint.rb +5 -1
- data/lib/mortymer/endpoint_registry.rb +12 -2
- data/lib/mortymer/rails/configuration.rb +8 -1
- data/lib/mortymer/rails/routes.rb +9 -8
- data/lib/mortymer/railtie.rb +28 -1
- data/lib/mortymer/version.rb +1 -1
- data/lib/mortymer.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7a6f89fe53efef1f0307aa8a123827de3179bb00b9bde0dea02900773f3d346f
|
4
|
+
data.tar.gz: 439f37ea7091f81186fccc64746fa6ba7392dd06cd46fd24f01da917a4b643cd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 45790ce281ff50fe565f3ae62fdac1d8727cbe84bdc5fa29e17925ec8dcf795eeae9ebaf5055346e88e4dd590c8dca1d5fd8fb1a6711f92eda594413abad9dcf
|
7
|
+
data.tar.gz: 7dc98a3c699f4573cbf826ed7f37546d02ec5eb5434954f78066f2436af792f4f8f109be48b7d6502e980361bdf9c68e30aa8ab2c746087ffc2b4358a1a3bd6b
|
@@ -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
|
data/lib/mortymer/container.rb
CHANGED
@@ -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
|
-
|
20
|
+
instance.registry
|
14
21
|
end
|
15
22
|
|
16
|
-
#
|
17
|
-
|
18
|
-
|
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
|
-
#
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
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
|
-
|
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
|
-
|
53
|
-
|
54
|
-
|
55
|
-
registry[key] = implementation
|
56
|
-
end
|
44
|
+
def initialize(initial_registry = Concurrent::Hash.new)
|
45
|
+
@registry = initial_registry
|
46
|
+
end
|
57
47
|
|
58
|
-
|
48
|
+
def duplicate
|
49
|
+
self.class.new(registry.dup)
|
50
|
+
end
|
59
51
|
|
60
|
-
|
61
|
-
|
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
|
-
|
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
|
-
|
66
|
-
|
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
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
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
|
-
#
|
86
|
-
|
87
|
-
|
88
|
-
|
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
|
-
|
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
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
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
|
-
|
110
|
-
|
111
|
-
|
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
|
-
|
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
|
-
|
57
|
+
container.resolve_constant(dep[:constant])
|
56
58
|
end
|
57
59
|
|
58
60
|
instance_variable_set("@#{dep[:var_name]}", value)
|
data/lib/mortymer/dry_swagger.rb
CHANGED
@@ -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"
|
data/lib/mortymer/endpoint.rb
CHANGED
@@ -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 =
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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: "
|
43
|
+
defaults = { controller: "mortymer/rails/endpoint_wrapper", action: "handle" }
|
44
44
|
constraints = lambda do |request|
|
45
|
-
request.env["
|
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: "
|
51
|
+
@drawer.get path, to: "mortymer/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
|
52
52
|
when :post
|
53
|
-
@drawer.post path, to: "
|
53
|
+
@drawer.post path, to: "mortymer/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
|
54
54
|
when :put
|
55
|
-
@drawer.put path, to: "
|
55
|
+
@drawer.put path, to: "mortymer/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
|
56
56
|
when :delete
|
57
|
-
@drawer.delete path, to: "
|
57
|
+
@drawer.delete path, to: "mortymer/rails/endpoint_wrapper#handle", defaults: defaults,
|
58
|
+
constraints: constraints
|
58
59
|
end
|
59
60
|
end
|
60
61
|
end
|
data/lib/mortymer/railtie.rb
CHANGED
@@ -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
|
data/lib/mortymer/version.rb
CHANGED
data/lib/mortymer.rb
CHANGED
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.
|
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-
|
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
|