mortymer 0.0.2

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,190 @@
1
+ # Quick Start
2
+
3
+ This guide will help you get started with Mortymer in just a few minutes.
4
+ We'll create a simple API endpoint that demonstrates the core features of Mortymer
5
+ by integrating it in a Rails API, let's call it Rails and Mortymer.
6
+
7
+ ## Basic Setup
8
+
9
+ First, let's create a new Rails application (skip this if you already have one):
10
+
11
+ ```bash
12
+ rails new my_api --api
13
+ cd my_api
14
+ ```
15
+
16
+ ## Installation
17
+
18
+ Add Mortymer to your Gemfile:
19
+
20
+ ```ruby
21
+ gem 'morty'
22
+ ```
23
+
24
+ Then install it:
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ Mortymer requires that every class is autoloaded for it to work. This is deafault rails
31
+ behavior when running in production, but it is disabled by default on development, so,
32
+ we first need to enable eager load
33
+
34
+ ```ruby
35
+ # config/environments/development.rb
36
+ Rails.application.configure do
37
+ # Settings specified here will take precedence over those in config/application.rb.
38
+
39
+ config.eager_load = true # This line is false by default, change it to true
40
+
41
+ # other configs hers ...
42
+ end
43
+ ```
44
+
45
+ In rails environments, Mortymer can automatically create the routes for you, it is not necessary
46
+ that you register them one by one.
47
+
48
+ ```ruby
49
+ # config/routes.rb
50
+ Rails.application.routes.draw do
51
+ Mortymer::Rails::Routes.new(self).mount_controllers
52
+ end
53
+ ```
54
+
55
+ ## Creating Your First API
56
+
57
+ Let's build a simple book API to demonstrate Mortymer's key features.
58
+
59
+ ### 1. Define Your Type
60
+
61
+ Create a Book type that defines the structure of your data:
62
+
63
+ ```ruby
64
+ # app/types/book.rb
65
+ class Book < Mortymer::Model
66
+ attribute :id, Integer
67
+ attribute :title, String
68
+ attribute :author, String
69
+ attribute :published_year, Integer
70
+ end
71
+ ```
72
+
73
+ ### 2. Create a Service
74
+
75
+ Create a service to handle business logic:
76
+
77
+ ```ruby
78
+ # app/services/book_service.rb
79
+ class BookService
80
+ def initialize
81
+ @books = []
82
+ end
83
+
84
+ def create_book(title:, author:, published_year:)
85
+ book = Book.new(
86
+ id: next_id,
87
+ title: title,
88
+ author: author,
89
+ published_year: published_year
90
+ )
91
+ @books << book
92
+ book
93
+ end
94
+
95
+ def list_books
96
+ @books
97
+ end
98
+
99
+ private
100
+
101
+ def next_id
102
+ @books.length + 1
103
+ end
104
+ end
105
+ ```
106
+
107
+ ### 3. Create a Controller
108
+
109
+ Set up your API controller with Mortymer's features:
110
+
111
+ ```ruby
112
+ # app/controllers/api/books_controller.rb
113
+ module Api
114
+ class BooksController < ApplicationController
115
+ inject BookService, as: :books
116
+
117
+ class Empty < Mortymer::Model
118
+ end
119
+
120
+ class ListAllBooksOutput < Mortymer::Model
121
+ attribute :books, Array.of(Book)
122
+ end
123
+
124
+ class CreateBookInput < Mortymer::Model
125
+ attribute :title, String
126
+ attribute :author, String
127
+ attribute :published_year, Coercible::Integer
128
+ end
129
+
130
+ # GET /api/books
131
+ get input: Empty, output: ListAllBooksOutput
132
+ def list_all_books(_params)
133
+ ListAllBooksOutput.new(books: @books.list_books)
134
+ end
135
+
136
+ # POST /api/books
137
+ post input: CreateBookInput, output: Book
138
+ def create_book(params)
139
+ @books.create_book(
140
+ title: params.title,
141
+ author: params.author,
142
+ published_year: params.published_year
143
+ )
144
+ end
145
+ end
146
+ end
147
+ ```
148
+
149
+ ## Testing Your API
150
+
151
+ Start your Rails server:
152
+
153
+ ```bash
154
+ rails server
155
+ ```
156
+
157
+ Create a new book:
158
+
159
+ ```bash
160
+ curl -X POST http://localhost:3000/api/books \
161
+ -H "Content-Type: application/json" \
162
+ -d '{
163
+ "title": "The Ruby Way",
164
+ "author": "Hal Fulton",
165
+ "published_year": 2015
166
+ }'
167
+ ```
168
+
169
+ List all books:
170
+
171
+ ```bash
172
+ curl http://localhost:3000/api/books
173
+ ```
174
+
175
+ ## Key Features Demonstrated
176
+
177
+ This simple example showcases several of Mortymer's powerful features:
178
+
179
+ - ✨ **Type System**: Strong typing with `Mortymer::Models` which are powered by `Dry::Struct`
180
+ - 🔌 **Dependency Injection**: Clean service injection with `inject :book_service`
181
+ - ✅ **Parameter Validation**: Built-in request validation in controllers
182
+
183
+ ## Next Steps
184
+
185
+ Now that you have a basic understanding of Mortymer, you might want to explore:
186
+
187
+ - [Type System](./type-system.md) - Learn more about Mortymer's type system
188
+ - [Dependency Injection](./dependency-injection.md) - Deep dive into DI patterns
189
+ - [Controllers](./controllers.md) - Advanced controller features
190
+ - [API Metadata](./api-metadata.md) - API documentation capabilities
data/docs/index.md ADDED
@@ -0,0 +1,26 @@
1
+ ---
2
+ home: true
3
+ layout: home
4
+
5
+ hero:
6
+ name: "Morty"
7
+ text: "Modern Ruby API Development"
8
+ tagline: Build type-safe, documented, and maintainable APIs using the ruby ecosystem you love.
9
+ actions:
10
+ - theme: brand
11
+ text: Get Started
12
+ link: /guide/introduction
13
+ - theme: alt
14
+ text: View on GitHub
15
+ link: https://github.com/yourusername/morty
16
+
17
+ features:
18
+ - title: Type Safety
19
+ details: Built-in type validation and schema generation using dry-types
20
+ - title: OpenAPI Documentation
21
+ details: Automatic OpenAPI (Swagger) documentation generation from your code
22
+ - title: Rails Integration
23
+ details: Seamless integration with Ruby on Rails for robust API development
24
+ - title: Dependency Injection
25
+ details: Clean and testable code with built-in dependency injection support
26
+ ---
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dry_swagger"
4
+ require_relative "endpoint_registry"
5
+ require_relative "endpoint"
6
+ require_relative "utils/string_transformations"
7
+
8
+ module Mortymer
9
+ # Include this module in your classes to register
10
+ # and configure your classes as API endpoints.
11
+ module ApiMetadata
12
+ def self.included(base)
13
+ base.extend(ClassMethods)
14
+ end
15
+
16
+ # The DSL
17
+ module ClassMethods
18
+ def get(input:, output:, path: nil)
19
+ register_endpoint(:get, input, output, path)
20
+ end
21
+
22
+ def post(input:, output:, path: nil)
23
+ register_endpoint(:post, input, output, path)
24
+ end
25
+
26
+ def put(input:, output:, path: nil)
27
+ register_endpoint(:put, input, output, path)
28
+ end
29
+
30
+ def delete(input:, output:, path: nil)
31
+ register_endpoint(:delete, input, output, path)
32
+ end
33
+
34
+ private
35
+
36
+ def method_added(method_name)
37
+ super
38
+ return unless @__endpoint_signature__
39
+
40
+ EndpointRegistry.registry << Endpoint.new(**@__endpoint_signature__.merge(
41
+ name: reflect_method_name(method_name), action: method_name
42
+ ))
43
+ input_class = @__endpoint_signature__[:input_class]
44
+ @__endpoint_signature__ = nil
45
+ return unless defined?(::Rails) && ::Rails.application.config.morty.wrap_methods
46
+
47
+ rails_wrap_method_with_no_params_call(method_name, input_class)
48
+ end
49
+
50
+ def rails_wrap_method_with_no_params_call(method_name, input_class)
51
+ original_method = instance_method(method_name)
52
+ define_method(method_name) do
53
+ input = input_class.new(params.to_unsafe_h.to_h.deep_transform_keys(&:to_sym))
54
+ output = original_method.bind_call(self, input)
55
+ render json: output.to_h, status: :ok
56
+ end
57
+ end
58
+
59
+ def register_endpoint(http_method, input_class, output_class, path)
60
+ @__endpoint_signature__ =
61
+ {
62
+ http_method: http_method,
63
+ input_class: input_class,
64
+ output_class: output_class,
65
+ path: path,
66
+ controller_class: self
67
+ }
68
+ end
69
+
70
+ def reflect_method_name(method_name)
71
+ if %i[call execute].include?(method_name)
72
+ name
73
+ else
74
+ "#{name}##{method_name}"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mortymer
4
+ # Base container for dependency injection
5
+ class Container
6
+ class Error < StandardError; end
7
+ class DependencyError < Error; end
8
+ class NotFoundError < Error; end
9
+
10
+ class << self
11
+ # Initialize the container storage
12
+ def registry
13
+ @registry ||= {}
14
+ end
15
+
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
22
+ end
23
+
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}"
34
+ end
35
+
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]
40
+
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
51
+
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
57
+
58
+ raise NotFoundError, "No implementation found for #{key}" unless implementation
59
+
60
+ # Add current constant to resolution stack
61
+ resolution_stack.push(key)
62
+
63
+ result = resolve_implementation(implementation, key, resolution_stack)
64
+
65
+ resolution_stack.pop
66
+ result
67
+ end
68
+
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
82
+ end
83
+ end
84
+
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
94
+ end
95
+
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
101
+
102
+ instance = klass.new
103
+ deps.each do |dep|
104
+ instance.instance_variable_set("@#{dep[:var_name]}", dep[:value])
105
+ end
106
+ instance
107
+ end
108
+
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("_"))
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "utils/string_transformations"
4
+
5
+ module Mortymer
6
+ # A simple dsl to declare class dependencies for injection
7
+ module DependenciesDsl
8
+ def self.included(base)
9
+ base.extend(ClassMethods)
10
+ base.include(InstanceMethods)
11
+ base.prepend(
12
+ Module.new do
13
+ def initialize(*args, **kwargs)
14
+ send(:inject_dependencies, kwargs)
15
+ super(*args, **kwargs)
16
+ end
17
+ end
18
+ )
19
+ end
20
+
21
+ # Included module class methods
22
+ module ClassMethods
23
+ # Store dependencies for the class
24
+ def dependencies
25
+ @dependencies ||= []
26
+ end
27
+
28
+ # Declare a dependency
29
+ # @param constant [Class] The constant to inject
30
+ # @param as: [Symbol] The instance variable name
31
+ def inject(constant, as: nil)
32
+ var_name = (as || infer_var_name(constant)).to_s
33
+ dependencies << { constant: constant, var_name: var_name }
34
+ end
35
+
36
+ private
37
+
38
+ # Infer the instance variable name from the constant
39
+ def infer_var_name(constant)
40
+ Utils::StringTransformations.underscore(constant.name.split("::").last)
41
+ end
42
+ end
43
+
44
+ # Included module instance methods
45
+ module InstanceMethods
46
+ private
47
+
48
+ # Inject all declared dependencies
49
+ # @param overrides [Hash] Optional dependency overrides for named dependencies
50
+ def inject_dependencies(overrides = {})
51
+ self.class.dependencies.each do |dep|
52
+ value = if overrides.key?(dep[:var_name].to_sym)
53
+ overrides[dep[:var_name].to_sym]
54
+ else
55
+ Container.resolve_constant(dep[:constant])
56
+ end
57
+
58
+ instance_variable_set("@#{dep[:var_name]}", value)
59
+ rescue Container::NotFoundError => e
60
+ raise Container::DependencyError, "Failed to inject dependency #{dep[:constant]}: #{e.message}"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry_struct_parser/struct_schema_parser"
4
+ require "dry_validation_parser/validation_schema_parser"
5
+ require "dry/swagger/documentation_generator"
6
+ require "dry/swagger/errors/missing_hash_schema_error"
7
+ require "dry/swagger/errors/missing_type_error"
8
+ require "dry/swagger/config/configuration"
9
+ require "dry/swagger/config/swagger_configuration"
10
+ require "dry/swagger/railtie" if defined?(Rails)
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mortymer
4
+ # Represents an endpoint in a given system
5
+ class Endpoint
6
+ attr_reader :http_method, :path, :input_class, :output_class, :controller_class, :action, :name
7
+
8
+ def initialize(opts = {})
9
+ @http_method = opts[:http_method]
10
+ @input_class = opts[:input_class]
11
+ @output_class = opts[:output_class]
12
+ @name = opts[:name]
13
+ @path = opts[:path] || infer_path_from_class
14
+ @controller_class = opts[:controller_class]
15
+ @action = opts[:action]
16
+ end
17
+
18
+ def routeable?
19
+ [@input_class, @http_method, @output_class, @path].none?(&:nil?)
20
+ end
21
+
22
+ def api_name
23
+ Utils::StringTransformations.underscore(name.gsub("::", "/")).gsub(/_endpoint$/, "").split("#").first
24
+ end
25
+
26
+ def controller_name
27
+ Utils::StringTransformations.underscore(@name.split("#").first.split("::").join("/"))
28
+ end
29
+
30
+ def infer_path_from_class
31
+ # Remove 'Endpoint' suffix if present and convert to path
32
+ "/" + @name.split("#").first.split("::").map do |s|
33
+ Utils::StringTransformations.underscore(s).gsub(/_endpoint$/, "").gsub(/_controller$/, "")
34
+ end.join("/")
35
+ end
36
+
37
+ def generate_openapi_schema # rubocop:disable Metrics/MethodLength
38
+ return unless defined?(@input_class) && defined?(@output_class)
39
+
40
+ input_schema = Dry::Swagger::DocumentationGenerator.new.from_struct(@input_class)
41
+ responses = {
42
+ "200" => {
43
+ description: "Successful response",
44
+ content: {
45
+ "application/json" => {
46
+ schema: class_ref(@output_class)
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ # Add 422 response if there are required properties or non-string types that need coercion
53
+ if validations?(input_schema)
54
+ responses["422"] = {
55
+ description: "Validation Failed - Invalid parameters or coercion error",
56
+ content: {
57
+ "application/json": {
58
+ schema: error422_ref
59
+ }
60
+ }
61
+ }
62
+ end
63
+
64
+ {
65
+ path.to_s => {
66
+ http_method.to_s => {
67
+ operation_id: operation_id,
68
+ parameters: generate_parameters,
69
+ requestBody: generate_request_body,
70
+ responses: responses
71
+ }
72
+ }
73
+ }
74
+ end
75
+
76
+ private
77
+
78
+ def validations?(schema)
79
+ return false unless schema
80
+
81
+ # Check if there are any required properties
82
+ has_required = schema[:required]&.any?
83
+
84
+ # Check if there are any properties that need type coercion (non-string types)
85
+ has_coercions = schema[:properties]&.any? do |_, property|
86
+ type = property[:type]
87
+ type && type != "string" # Any non-string type will need coercion
88
+ end
89
+
90
+ has_required || has_coercions
91
+ end
92
+
93
+ def generate_parameters
94
+ return [] unless @input_class && %i[get delete].include?(@http_method)
95
+
96
+ schema = Dry::Swagger::DocumentationGenerator.new.from_struct(@input_class)
97
+ schema[:properties]&.map do |name, property|
98
+ {
99
+ name: name,
100
+ in: "query",
101
+ schema: property,
102
+ required: schema[:required]&.include?(name)
103
+ }
104
+ end || []
105
+ end
106
+
107
+ def generate_request_body
108
+ return unless @input_class && %i[post put].include?(@http_method)
109
+
110
+ {
111
+ required: true,
112
+ content: {
113
+ "application/json" => {
114
+ schema: class_ref(@input_class)
115
+ }
116
+ }
117
+ }
118
+ end
119
+
120
+ def class_ref(klass)
121
+ { "$ref": "#/components/schemas/#{klass.name.split("::").last}" }
122
+ end
123
+
124
+ def error422_ref
125
+ { "$ref": "#/components/schemas/Error422" }
126
+ end
127
+
128
+ def operation_id
129
+ names_map = {
130
+ post: :create,
131
+ put: :update,
132
+ delete: :destroy,
133
+ get: :get
134
+ }
135
+
136
+ "#{names_map[@http_method]}_#{controller_name.split("/").last.gsub(/_controller$/, "")}"
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mortymer
4
+ # A global registry of all defined endpoints
5
+ class EndpointRegistry
6
+ class << self
7
+ def registry
8
+ @registry ||= []
9
+ end
10
+
11
+ def get_matcher(path, method)
12
+ @registry.find { |e| e.path.to_s == path.to_s && e.http_method.to_s == method.to_s }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mortymer
4
+ # A base model for defining schemas
5
+ class Model < Dry::Struct
6
+ include Dry.Types()
7
+ end
8
+ end