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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Guardfile +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +167 -0
- data/Rakefile +12 -0
- data/config.ru +5 -0
- data/docs/.vitepress/config.mts +73 -0
- data/docs/.vitepress/theme/Layout.vue +21 -0
- data/docs/.vitepress/theme/index.ts +5 -0
- data/docs/.vitepress/theme/style.css +5 -0
- data/docs/advanced/dependency-injection.md +21 -0
- data/docs/advanced/openapi.md +16 -0
- data/docs/guide/api-metadata.md +142 -0
- data/docs/guide/introduction.md +31 -0
- data/docs/guide/models.md +16 -0
- data/docs/guide/quick-start.md +190 -0
- data/docs/index.md +26 -0
- data/lib/mortymer/api_metadata.rb +79 -0
- data/lib/mortymer/container.rb +122 -0
- data/lib/mortymer/dependencies_dsl.rb +65 -0
- data/lib/mortymer/dry_swagger.rb +10 -0
- data/lib/mortymer/endpoint.rb +139 -0
- data/lib/mortymer/endpoint_registry.rb +16 -0
- data/lib/mortymer/model.rb +8 -0
- data/lib/mortymer/openapi_generator.rb +76 -0
- data/lib/mortymer/rails/configuration.rb +16 -0
- data/lib/mortymer/rails/endpoint_wrapper_controller.rb +44 -0
- data/lib/mortymer/rails/routes.rb +62 -0
- data/lib/mortymer/rails.rb +19 -0
- data/lib/mortymer/railtie.rb +14 -0
- data/lib/mortymer/utils/string_transformations.rb +28 -0
- data/lib/mortymer/version.rb +5 -0
- data/lib/mortymer.rb +15 -0
- data/lib/mortymermer.rb +15 -0
- data/mortymer.gemspec +45 -0
- data/package-lock.json +2429 -0
- data/package.json +13 -0
- metadata +172 -0
@@ -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
|