mortymer 0.0.6 → 0.0.8
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/docs/assets/swagg.png +0 -0
- data/docs/guide/quick-start.md +19 -23
- data/lib/mortymer/api_metadata.rb +65 -13
- data/lib/mortymer/configuration.rb +3 -1
- data/lib/mortymer/contract.rb +34 -0
- data/lib/mortymer/dependencies_dsl.rb +1 -1
- data/lib/mortymer/dry_swagger.rb +16 -9
- data/lib/mortymer/endpoint.rb +86 -21
- data/lib/mortymer/generator.rb +115 -0
- data/lib/mortymer/model.rb +14 -0
- data/lib/mortymer/moldeable.rb +15 -0
- data/lib/mortymer/openapi_generator.rb +26 -9
- data/lib/mortymer/rails/routes.rb +1 -1
- data/lib/mortymer/railtie.rb +3 -1
- data/lib/mortymer/security_schemes.rb +85 -0
- data/lib/mortymer/types.rb +52 -0
- data/lib/mortymer/uploaded_file.rb +5 -0
- data/lib/mortymer/uploaded_files.rb +5 -0
- data/lib/mortymer/version.rb +1 -1
- data/lib/mortymer.rb +6 -0
- metadata +10 -3
- data/lib/mortymermer.rb +0 -15
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c13887be646cf5c591070d4d51c85e657c3509b11858d3d1cc7fa9650ec3c5ed
|
4
|
+
data.tar.gz: 0ca382da28037d53ab387f553854fc24047450b1b1d8589078f091130b15cbee
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5fbe4192b479624078104e07290a0e6052eb7b752a59d566ad4501c197f325c97080ec00605654f9ecdda34a16f466a71fb0ea8807a6da0e08ed0b1bee65c1fd
|
7
|
+
data.tar.gz: 7da23bf7783533a1ba3dbe56ec41a800c5e1473ab22b51bd9d1fc4ab595dae81b43f48767c145d1d429b13c601d225d1599bbaff31746b263bea68aa19be4b9c
|
Binary file
|
data/docs/guide/quick-start.md
CHANGED
@@ -27,21 +27,6 @@ Then install it:
|
|
27
27
|
bundle install
|
28
28
|
```
|
29
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
30
|
In rails environments, Mortymer can automatically create the routes for you, it is not necessary
|
46
31
|
that you register them one by one.
|
47
32
|
|
@@ -109,11 +94,10 @@ end
|
|
109
94
|
Set up your API controller with Mortymer's features:
|
110
95
|
|
111
96
|
```ruby
|
112
|
-
# app/controllers/
|
113
|
-
|
114
|
-
class BooksController < ApplicationController
|
115
|
-
include Mortymer::DependeciesDsl
|
97
|
+
# app/controllers/example_controller.rb
|
98
|
+
class ExampleController < ApplicationController
|
116
99
|
include Mortymer::ApiMetadata
|
100
|
+
include Mortymer::DependenciesDsl
|
117
101
|
|
118
102
|
inject BookService, as: :books
|
119
103
|
|
@@ -130,13 +114,13 @@ module Api
|
|
130
114
|
attribute :published_year, Coercible::Integer
|
131
115
|
end
|
132
116
|
|
133
|
-
|
134
|
-
|
117
|
+
tags :Books
|
118
|
+
|
119
|
+
get input: Empty, output: ListAllBooksOutput, path: "/examples/list_books"
|
135
120
|
def list_all_books(_params)
|
136
121
|
ListAllBooksOutput.new(books: @books.list_books)
|
137
122
|
end
|
138
123
|
|
139
|
-
# POST /api/books
|
140
124
|
post input: CreateBookInput, output: Book
|
141
125
|
def create_book(params)
|
142
126
|
@books.create_book(
|
@@ -145,7 +129,6 @@ module Api
|
|
145
129
|
published_year: params.published_year
|
146
130
|
)
|
147
131
|
end
|
148
|
-
end
|
149
132
|
end
|
150
133
|
```
|
151
134
|
|
@@ -182,3 +165,16 @@ This simple example showcases several of Mortymer's powerful features:
|
|
182
165
|
- ✨ **Type System**: Strong typing with `Mortymer::Models` which are powered by `Dry::Struct`
|
183
166
|
- 🔌 **Dependency Injection**: Clean service injection with `inject :book_service`
|
184
167
|
- ✅ **Parameter Validation**: Built-in request validation in controllers
|
168
|
+
|
169
|
+
## OpenAPI Documentation
|
170
|
+
|
171
|
+
Mortymer automatically generates OpenAPI (Swagger) documentation for your API endpoints. After setting up your application, you can access the Swagger UI at `/api-docs`:
|
172
|
+
|
173
|
+

|
174
|
+
|
175
|
+
This interactive documentation allows you to:
|
176
|
+
|
177
|
+
- Browse all available endpoints
|
178
|
+
- See request/response schemas
|
179
|
+
- Test API endpoints directly from the browser
|
180
|
+
- Download the OpenAPI specification
|
@@ -15,20 +15,56 @@ module Mortymer
|
|
15
15
|
|
16
16
|
# The DSL
|
17
17
|
module ClassMethods
|
18
|
-
def
|
19
|
-
|
18
|
+
def secured_with(security)
|
19
|
+
@__endpoint_security__ = security
|
20
20
|
end
|
21
21
|
|
22
|
-
def
|
23
|
-
|
22
|
+
def remove_security!
|
23
|
+
@__endpoint_security__ = nil
|
24
24
|
end
|
25
25
|
|
26
|
-
def
|
27
|
-
|
26
|
+
def tags(*tag_list)
|
27
|
+
@__endpoint_tags__ = tag_list
|
28
28
|
end
|
29
29
|
|
30
|
-
def
|
31
|
-
|
30
|
+
def __default_tag_for_endpoint__
|
31
|
+
# Assuming the endpoint is always defined inside a module
|
32
|
+
# the Tag would be the module of that endpoint. If no module, then
|
33
|
+
# we take the endpoint and remove any endpoint, controller suffix
|
34
|
+
[name.split("::").last(2).first] || [name.gsub(/Controller$/, "").gsub(/Endpoint$/, "")]
|
35
|
+
end
|
36
|
+
|
37
|
+
def get(input:, output:, path: nil, security: nil)
|
38
|
+
register_endpoint(:get, input, output, path, security || @__endpoint_security__)
|
39
|
+
end
|
40
|
+
|
41
|
+
def post(input:, output:, path: nil, security: nil)
|
42
|
+
register_endpoint(:post, input, output, path, security || @__endpoint_security__)
|
43
|
+
end
|
44
|
+
|
45
|
+
def put(input:, output:, path: nil, security: nil)
|
46
|
+
register_endpoint(:put, input, output, path, security || @__endpoint_security__)
|
47
|
+
end
|
48
|
+
|
49
|
+
def delete(input:, output:, path: nil, security: nil)
|
50
|
+
register_endpoint(:delete, input, output, path, security || @__endpoint_security__)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Register an exception handler for the next endpoint
|
54
|
+
# @param exception [Class] The exception class to handle
|
55
|
+
# @param status [Symbol, Integer] The HTTP status code to return
|
56
|
+
# @param output [Class] The output schema class for the error response
|
57
|
+
# @yield [exception] Optional block to transform the exception into a response
|
58
|
+
# @yieldparam exception [Exception] The caught exception
|
59
|
+
# @yieldreturn [Hash] The response body
|
60
|
+
def handles_exception(exception, status: 400, output: nil, &block)
|
61
|
+
@__endpoint_exception_handlers__ ||= []
|
62
|
+
@__endpoint_exception_handlers__ << {
|
63
|
+
exception: exception,
|
64
|
+
status: status,
|
65
|
+
output: output,
|
66
|
+
handler: block
|
67
|
+
}
|
32
68
|
end
|
33
69
|
|
34
70
|
private
|
@@ -41,30 +77,46 @@ module Mortymer
|
|
41
77
|
name: reflect_method_name(method_name), action: method_name
|
42
78
|
))
|
43
79
|
input_class = @__endpoint_signature__[:input_class]
|
80
|
+
handlers = @__endpoint_signature__[:exception_handlers]
|
44
81
|
@__endpoint_signature__ = nil
|
45
82
|
return unless defined?(::Rails) && ::Rails.application.config.morty.wrap_methods
|
46
83
|
|
47
|
-
rails_wrap_method_with_no_params_call(method_name, input_class)
|
84
|
+
rails_wrap_method_with_no_params_call(method_name, input_class, handlers)
|
48
85
|
end
|
49
86
|
|
50
|
-
def rails_wrap_method_with_no_params_call(method_name, input_class)
|
87
|
+
def rails_wrap_method_with_no_params_call(method_name, input_class, handlers)
|
51
88
|
original_method = instance_method(method_name)
|
52
89
|
define_method(method_name) do
|
53
|
-
input = input_class.
|
90
|
+
input = input_class.structify(params.to_unsafe_h.to_h.deep_transform_keys(&:to_sym))
|
54
91
|
output = original_method.bind_call(self, input)
|
55
92
|
render json: output.to_h, status: :ok
|
93
|
+
rescue StandardError => e
|
94
|
+
handler = handlers.find { |h| e.is_a?(h[:exception]) }
|
95
|
+
raise unless handler
|
96
|
+
|
97
|
+
response = if handler[:handler]
|
98
|
+
handler[:handler].call(e)
|
99
|
+
else
|
100
|
+
{ error: e.message || e.class.name.underscore }
|
101
|
+
end
|
102
|
+
|
103
|
+
render json: response, status: handler[:status]
|
56
104
|
end
|
57
105
|
end
|
58
106
|
|
59
|
-
def register_endpoint(http_method, input_class, output_class, path)
|
107
|
+
def register_endpoint(http_method, input_class, output_class, path, security)
|
60
108
|
@__endpoint_signature__ =
|
61
109
|
{
|
62
110
|
http_method: http_method,
|
63
111
|
input_class: input_class,
|
64
112
|
output_class: output_class,
|
65
113
|
path: path,
|
66
|
-
controller_class: self
|
114
|
+
controller_class: self,
|
115
|
+
security: security,
|
116
|
+
tags: @__endpoint_tags__ || __default_tag_for_endpoint__,
|
117
|
+
exception_handlers: [*(@__endpoint_exception_handlers__ || [])]
|
67
118
|
}
|
119
|
+
@__endpoint_exception_handlers__ = nil
|
68
120
|
end
|
69
121
|
|
70
122
|
def reflect_method_name(method_name)
|
@@ -19,7 +19,7 @@ module Mortymer
|
|
19
19
|
# Global configuration for Mortymer
|
20
20
|
class Configuration
|
21
21
|
attr_accessor :container, :serve_swagger, :swagger_title, :swagger_path, :swagger_root, :api_version,
|
22
|
-
:api_description
|
22
|
+
:api_description, :security_schemes, :api_prefix
|
23
23
|
|
24
24
|
def initialize
|
25
25
|
@container = Mortymer::Container.new
|
@@ -29,6 +29,8 @@ module Mortymer
|
|
29
29
|
@swagger_root = "/api-docs"
|
30
30
|
@api_description = "An awsome API developed with MORTYMER"
|
31
31
|
@api_version = "v1"
|
32
|
+
@security_schemes = {}
|
33
|
+
@api_prefix = "/api/v1"
|
32
34
|
end
|
33
35
|
end
|
34
36
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "moldeable"
|
4
|
+
require "dry/validation"
|
5
|
+
require "dry/validation/contract"
|
6
|
+
require_relative "generator"
|
7
|
+
|
8
|
+
module Mortymer
|
9
|
+
# A base model for defining schemas
|
10
|
+
class Contract < Dry::Validation::Contract
|
11
|
+
include Mortymer::Moldeable
|
12
|
+
|
13
|
+
# Exception raised when an error occours in a contract
|
14
|
+
class ContractError < StandardError
|
15
|
+
attr_reader :errors
|
16
|
+
|
17
|
+
def initialize(errors)
|
18
|
+
super
|
19
|
+
@errors = errors
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.json_schema
|
24
|
+
Generator.new.from_validation(self)
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.structify(params)
|
28
|
+
result = new.call(params)
|
29
|
+
raise ContractError.new(result.errors.to_h) unless result.errors.empty?
|
30
|
+
|
31
|
+
result.to_h
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -52,7 +52,7 @@ module Mortymer
|
|
52
52
|
container ||= Mortymer.config&.container || Container.new
|
53
53
|
self.class.dependencies.each do |dep|
|
54
54
|
value = if overrides.key?(dep[:var_name].to_sym)
|
55
|
-
overrides
|
55
|
+
overrides.delete(dep[:var_name].to_sym)
|
56
56
|
else
|
57
57
|
container.resolve_constant(dep[:constant])
|
58
58
|
end
|
data/lib/mortymer/dry_swagger.rb
CHANGED
@@ -1,15 +1,30 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
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)
|
4
11
|
|
5
12
|
# Need to monkey patch the nominal visitor for dry struct parser
|
6
13
|
# as it currently does nothing with this field
|
7
14
|
module DryStructParser
|
15
|
+
# Just to monkey patch it
|
8
16
|
class StructSchemaParser
|
9
|
-
def visit_constructor(node, opts)
|
17
|
+
def visit_constructor(node, opts) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
10
18
|
# Handle coercible types which have the form:
|
11
19
|
# [:constructor, [[:nominal, [Type, {}]], [:method, Kernel, :Type]]]
|
12
20
|
if node[0].is_a?(Array) && node[0][0] == :nominal
|
21
|
+
if node[0][1][0] == Mortymer::Types::RackFile
|
22
|
+
keys[opts[:key]] = node[0][1][1][:swagger].merge(
|
23
|
+
required: opts.fetch(:required, true),
|
24
|
+
nullable: opts.fetch(:nullable, false)
|
25
|
+
)
|
26
|
+
return
|
27
|
+
end
|
13
28
|
required = opts.fetch(:required, true)
|
14
29
|
nullable = opts.fetch(:nullable, false)
|
15
30
|
type = node[0][1][0] # Get the type from nominal node
|
@@ -30,11 +45,3 @@ module DryStructParser
|
|
30
45
|
end
|
31
46
|
end
|
32
47
|
end
|
33
|
-
|
34
|
-
require "dry_validation_parser/validation_schema_parser"
|
35
|
-
require "dry/swagger/documentation_generator"
|
36
|
-
require "dry/swagger/errors/missing_hash_schema_error"
|
37
|
-
require "dry/swagger/errors/missing_type_error"
|
38
|
-
require "dry/swagger/config/configuration"
|
39
|
-
require "dry/swagger/config/swagger_configuration"
|
40
|
-
require "dry/swagger/railtie" if defined?(Rails)
|
data/lib/mortymer/endpoint.rb
CHANGED
@@ -1,8 +1,10 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "generator"
|
4
|
+
|
3
5
|
module Mortymer
|
4
6
|
# Represents an endpoint in a given system
|
5
|
-
class Endpoint
|
7
|
+
class Endpoint # rubocop:disable Metrics/ClassLength
|
6
8
|
attr_reader :http_method, :path, :input_class, :output_class, :controller_class, :action, :name
|
7
9
|
|
8
10
|
def initialize(opts = {})
|
@@ -13,6 +15,9 @@ module Mortymer
|
|
13
15
|
@path = opts[:path] || infer_path_from_class
|
14
16
|
@controller_class = opts[:controller_class]
|
15
17
|
@action = opts[:action]
|
18
|
+
@security = opts[:security]
|
19
|
+
@tags = opts[:tags]
|
20
|
+
@exception_handlers = opts[:exception_handlers]
|
16
21
|
end
|
17
22
|
|
18
23
|
def routeable?
|
@@ -34,20 +39,11 @@ module Mortymer
|
|
34
39
|
end.join("/")
|
35
40
|
end
|
36
41
|
|
37
|
-
def generate_openapi_schema # rubocop:disable Metrics/MethodLength
|
42
|
+
def generate_openapi_schema # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
38
43
|
return unless defined?(@input_class) && defined?(@output_class)
|
39
44
|
|
40
|
-
input_schema =
|
41
|
-
responses =
|
42
|
-
"200" => {
|
43
|
-
description: "Successful response",
|
44
|
-
content: {
|
45
|
-
"application/json" => {
|
46
|
-
schema: class_ref(@output_class)
|
47
|
-
}
|
48
|
-
}
|
49
|
-
}
|
50
|
-
}
|
45
|
+
input_schema = @input_class.respond_to?(:json_schema) ? @input_class.json_schema : Generator.new.from_struct(@input_class)
|
46
|
+
responses = generate_responses
|
51
47
|
|
52
48
|
# Add 422 response if there are required properties or non-string types that need coercion
|
53
49
|
if validations?(input_schema)
|
@@ -61,20 +57,67 @@ module Mortymer
|
|
61
57
|
}
|
62
58
|
end
|
63
59
|
|
60
|
+
operation = {
|
61
|
+
operation_id: operation_id,
|
62
|
+
parameters: generate_parameters,
|
63
|
+
requestBody: generate_request_body,
|
64
|
+
responses: responses,
|
65
|
+
tags: @tags
|
66
|
+
}
|
67
|
+
|
68
|
+
operation[:security] = security if @security
|
64
69
|
{
|
65
70
|
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
|
-
}
|
71
|
+
http_method.to_s => operation
|
72
72
|
}
|
73
73
|
}
|
74
74
|
end
|
75
75
|
|
76
76
|
private
|
77
77
|
|
78
|
+
def generate_responses
|
79
|
+
responses = {
|
80
|
+
"200" => {
|
81
|
+
description: "Successful response",
|
82
|
+
content: {
|
83
|
+
"application/json" => {
|
84
|
+
schema: class_ref(@output_class)
|
85
|
+
}
|
86
|
+
}
|
87
|
+
}
|
88
|
+
}
|
89
|
+
|
90
|
+
@exception_handlers&.each do |handler|
|
91
|
+
status_code = if handler[:status].is_a?(Symbol)
|
92
|
+
Rack::Utils::SYMBOL_TO_STATUS_CODE[handler[:status]].to_s
|
93
|
+
else
|
94
|
+
handler[:status].to_s
|
95
|
+
end
|
96
|
+
|
97
|
+
responses[status_code] = {
|
98
|
+
description: handler[:exception].name,
|
99
|
+
content: {
|
100
|
+
"application/json" => {
|
101
|
+
schema: if handler[:output]
|
102
|
+
class_ref(handler[:output])
|
103
|
+
else
|
104
|
+
{
|
105
|
+
type: "object",
|
106
|
+
properties: {
|
107
|
+
error: {
|
108
|
+
type: "string"
|
109
|
+
}
|
110
|
+
}
|
111
|
+
}
|
112
|
+
end
|
113
|
+
}
|
114
|
+
}
|
115
|
+
}
|
116
|
+
end
|
117
|
+
|
118
|
+
responses
|
119
|
+
end
|
120
|
+
|
78
121
|
def validations?(schema)
|
79
122
|
return false unless schema
|
80
123
|
|
@@ -90,13 +133,23 @@ module Mortymer
|
|
90
133
|
has_required || has_coercions
|
91
134
|
end
|
92
135
|
|
136
|
+
def security
|
137
|
+
return [] if @security.nil?
|
138
|
+
|
139
|
+
return [{ @security => [] }] if @security.is_a?(Symbol)
|
140
|
+
|
141
|
+
return [@security.scheme] if @security.respond_to?(:scheme)
|
142
|
+
|
143
|
+
[@security]
|
144
|
+
end
|
145
|
+
|
93
146
|
def generate_parameters
|
94
147
|
return [] unless @input_class && %i[get delete].include?(@http_method)
|
95
148
|
|
96
149
|
schema = if @input_class.respond_to?(:json_schema)
|
97
150
|
@input_class.json_schema
|
98
151
|
else
|
99
|
-
|
152
|
+
Generator.new.from_struct(@input_class)
|
100
153
|
end
|
101
154
|
schema[:properties]&.map do |name, property|
|
102
155
|
{
|
@@ -111,10 +164,22 @@ module Mortymer
|
|
111
164
|
def generate_request_body
|
112
165
|
return unless @input_class && %i[post put].include?(@http_method)
|
113
166
|
|
167
|
+
schema = if @input_class.respond_to?(:json_schema)
|
168
|
+
@input_class.json_schema
|
169
|
+
else
|
170
|
+
Generator.new.from_struct(@input_class)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Check if any property is a File type
|
174
|
+
has_file = schema[:properties]&.any? do |_, property|
|
175
|
+
property[:format] == :binary
|
176
|
+
end
|
177
|
+
|
178
|
+
content_type = has_file ? "multipart/form-data" : "application/json"
|
114
179
|
{
|
115
180
|
required: true,
|
116
181
|
content: {
|
117
|
-
|
182
|
+
content_type => {
|
118
183
|
schema: class_ref(@input_class)
|
119
184
|
}
|
120
185
|
}
|
@@ -0,0 +1,115 @@
|
|
1
|
+
require "dry/swagger"
|
2
|
+
|
3
|
+
module Mortymer
|
4
|
+
class Generator < Dry::Swagger::DocumentationGenerator
|
5
|
+
SWAGGER_FIELD_TYPE_DEFINITIONS = {
|
6
|
+
"string" => { type: :string },
|
7
|
+
"integer" => { type: :integer },
|
8
|
+
"boolean" => { type: :boolean },
|
9
|
+
"float" => { type: :float },
|
10
|
+
"decimal" => { type: :string, format: :decimal },
|
11
|
+
"datetime" => { type: :string, format: :datetime },
|
12
|
+
"date" => { type: :string, format: :date },
|
13
|
+
"time" => { type: :string, format: :time },
|
14
|
+
"uuid" => { type: :string, format: :uuid },
|
15
|
+
"file" => { type: :string, format: :binary }
|
16
|
+
}.freeze
|
17
|
+
|
18
|
+
def from_struct(struct)
|
19
|
+
generate_documentation(::DryStructParser::StructSchemaParser.new.call(struct).keys)
|
20
|
+
end
|
21
|
+
|
22
|
+
def from_validation(validation)
|
23
|
+
generate_documentation(::DryValidationParser::ValidationSchemaParser.new.call(validation).keys)
|
24
|
+
end
|
25
|
+
|
26
|
+
def generate_documentation(fields)
|
27
|
+
documentation = { properties: {}, required: [] }
|
28
|
+
fields.each do |field_name, definition|
|
29
|
+
documentation[:properties][field_name] = generate_field_properties(definition)
|
30
|
+
if definition.is_a?(Hash)
|
31
|
+
documentation[:required] << field_name if definition.fetch(:required,
|
32
|
+
true) && @config.enable_required_validation
|
33
|
+
elsif definition[0].fetch(:required, true) && @config.enable_required_validation
|
34
|
+
documentation[:required] << field_name
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
{ type: :object, properties: documentation[:properties], required: documentation[:required] }
|
39
|
+
end
|
40
|
+
|
41
|
+
def generate_field_properties(definition)
|
42
|
+
return generate_for_sti_type(definition) if definition.is_a?(Array)
|
43
|
+
|
44
|
+
documentation = if definition[:type] == "array" || definition[:array]
|
45
|
+
generate_for_array(definition)
|
46
|
+
elsif definition[:type] == "hash"
|
47
|
+
generate_for_hash(definition)
|
48
|
+
else
|
49
|
+
generate_for_primitive_type(definition)
|
50
|
+
end
|
51
|
+
if @config.enable_nullable_validation
|
52
|
+
documentation.merge(@config.nullable_type => definition.fetch(:nullable, false))
|
53
|
+
else
|
54
|
+
documentation.merge(@config.nullable_type => true)
|
55
|
+
end
|
56
|
+
rescue KeyError
|
57
|
+
raise Errors::MissingTypeError
|
58
|
+
end
|
59
|
+
|
60
|
+
def generate_for_sti_type(definition)
|
61
|
+
properties = {}
|
62
|
+
|
63
|
+
definition.each_with_index do |_, index|
|
64
|
+
properties["definition_#{index + 1}"] = generate_field_properties(definition[index])
|
65
|
+
end
|
66
|
+
|
67
|
+
documentation = {
|
68
|
+
type: :object,
|
69
|
+
properties: properties,
|
70
|
+
example: "Dynamic Field. See Model Definitions"
|
71
|
+
}
|
72
|
+
|
73
|
+
if definition[0][:type] == "array"
|
74
|
+
definition.each { |it| it[:type] = "hash" }
|
75
|
+
documentation[:oneOf] = definition.map { |it| generate_field_properties(it) }
|
76
|
+
{ type: :array, items: documentation }
|
77
|
+
else
|
78
|
+
documentation[:oneOf] = definition.map { |it| generate_field_properties(it) }
|
79
|
+
documentation
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def generate_for_array(definition)
|
84
|
+
items = if array_of_primitive_type?(definition)
|
85
|
+
self.class::SWAGGER_FIELD_TYPE_DEFINITIONS.fetch(definition.fetch(:type))
|
86
|
+
else
|
87
|
+
generate_documentation(definition.fetch(:keys))
|
88
|
+
end
|
89
|
+
items = if @config.enable_nullable_validation
|
90
|
+
items.merge(@config.nullable_type => definition.fetch(:nullable, false))
|
91
|
+
else
|
92
|
+
items.merge(@config.nullable_type => true)
|
93
|
+
end
|
94
|
+
{ type: :array, items: items }
|
95
|
+
end
|
96
|
+
|
97
|
+
def generate_for_hash(definition)
|
98
|
+
raise Errors::MissingHashSchemaError unless definition[:keys]
|
99
|
+
|
100
|
+
generate_documentation(definition.fetch(:keys))
|
101
|
+
end
|
102
|
+
|
103
|
+
def generate_for_primitive_type(definition)
|
104
|
+
documentation = self.class::SWAGGER_FIELD_TYPE_DEFINITIONS.fetch(definition.fetch(:type))
|
105
|
+
documentation = documentation.merge(enum: definition.fetch(:enum)) if definition[:enum] && @config.enable_enums
|
106
|
+
documentation = documentation.merge(description: definition.fetch(:description)) if definition[:description] &&
|
107
|
+
@config.enable_descriptions
|
108
|
+
documentation
|
109
|
+
end
|
110
|
+
|
111
|
+
def array_of_primitive_type?(definition)
|
112
|
+
definition[:array] && definition.fetch(:type) != "array"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
data/lib/mortymer/model.rb
CHANGED
@@ -1,8 +1,22 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "dry/swagger/documentation_generator"
|
4
|
+
require_relative "moldeable"
|
5
|
+
require_relative "types"
|
6
|
+
require_relative "generator"
|
7
|
+
|
3
8
|
module Mortymer
|
4
9
|
# A base model for defining schemas
|
5
10
|
class Model < Dry::Struct
|
11
|
+
include Mortymer::Moldeable
|
6
12
|
include Dry.Types()
|
13
|
+
|
14
|
+
def self.json_schema
|
15
|
+
Generator.new.from_struct(self)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.structify(params)
|
19
|
+
new(params)
|
20
|
+
end
|
7
21
|
end
|
8
22
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Mortymer
|
4
|
+
# Interface for models to be able to be documentable
|
5
|
+
# and buildable from request inputs
|
6
|
+
module Moldeable
|
7
|
+
def self.json_schema
|
8
|
+
raise NotImplementedError
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.structify(params = {})
|
12
|
+
raise NotImplementedError
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -2,18 +2,21 @@
|
|
2
2
|
|
3
3
|
require_relative "endpoint_registry"
|
4
4
|
require_relative "utils/string_transformations"
|
5
|
+
require_relative "generator"
|
5
6
|
|
6
7
|
module Mortymer
|
7
8
|
# Generate an openapi doc based on the registered endpoints
|
8
9
|
class OpenapiGenerator
|
9
10
|
include Utils::StringTransformations
|
10
11
|
|
11
|
-
def initialize(prefix: "", title: "Rick on Rails API", version: "v1", description: "", registry: []
|
12
|
+
def initialize(prefix: "", title: "Rick on Rails API", version: "v1", description: "", registry: [], # rubocop:disable Metrics/ParameterLists
|
13
|
+
security_schemes: {})
|
12
14
|
@prefix = prefix
|
13
15
|
@title = title
|
14
16
|
@version = version
|
15
17
|
@description = description
|
16
18
|
@endpoints_registry = registry
|
19
|
+
@security_schemes = security_schemes
|
17
20
|
end
|
18
21
|
|
19
22
|
def generate
|
@@ -21,7 +24,10 @@ module Mortymer
|
|
21
24
|
openapi: "3.0.1",
|
22
25
|
info: { title: @title, version: @version, description: @description },
|
23
26
|
paths: generate_paths,
|
24
|
-
components: {
|
27
|
+
components: {
|
28
|
+
schemas: generate_schemas,
|
29
|
+
securitySchemes: @security_schemes
|
30
|
+
}
|
25
31
|
}
|
26
32
|
end
|
27
33
|
|
@@ -32,8 +38,11 @@ module Mortymer
|
|
32
38
|
next unless endpoint.routeable?
|
33
39
|
|
34
40
|
schema = endpoint.generate_openapi_schema || {}
|
35
|
-
schema
|
36
|
-
|
41
|
+
schema.each do |path, methods|
|
42
|
+
prefixed_path = @prefix + path
|
43
|
+
paths[prefixed_path] ||= {}
|
44
|
+
paths[prefixed_path].merge!(methods)
|
45
|
+
end
|
37
46
|
end || {}
|
38
47
|
end
|
39
48
|
|
@@ -62,13 +71,21 @@ module Mortymer
|
|
62
71
|
|
63
72
|
if endpoint.input_class && !schemas.key?(demodulize(endpoint.input_class.name))
|
64
73
|
schemas[demodulize(endpoint.input_class.name)] =
|
65
|
-
|
74
|
+
if endpoint.input_class.respond_to?(:json_schema)
|
75
|
+
endpoint.input_class.json_schema
|
76
|
+
else
|
77
|
+
Generator.new.from_struct(endpoint.input_class)
|
78
|
+
end
|
66
79
|
end
|
67
80
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
81
|
+
next unless endpoint.output_class && !schemas.key?(demodulize(endpoint.output_class.name))
|
82
|
+
|
83
|
+
schemas[demodulize(endpoint.output_class.name)] =
|
84
|
+
if endpoint.output_class.respond_to?(:json_schema)
|
85
|
+
endpoint.output_class.json_schema
|
86
|
+
else
|
87
|
+
Generator.new.from_struct(endpoint.output_class)
|
88
|
+
end
|
72
89
|
end
|
73
90
|
schemas
|
74
91
|
end
|
@@ -28,7 +28,7 @@ module Mortymer
|
|
28
28
|
|
29
29
|
@drawer.send(
|
30
30
|
endpoint.http_method,
|
31
|
-
endpoint.path,
|
31
|
+
"#{Mortymer.config.api_prefix}#{endpoint.path}",
|
32
32
|
to: "#{endpoint.controller_name.gsub(/_controller$/, "")}##{endpoint.action}",
|
33
33
|
as: "#{endpoint.http_method}_#{endpoint.api_name}"
|
34
34
|
)
|
data/lib/mortymer/railtie.rb
CHANGED
@@ -26,7 +26,9 @@ module Mortymer
|
|
26
26
|
registry: Mortymer::EndpointRegistry.registry,
|
27
27
|
title: Mortymer.config.swagger_title,
|
28
28
|
version: Mortymer.config.api_version,
|
29
|
-
description: Mortymer.config.api_description
|
29
|
+
description: Mortymer.config.api_description,
|
30
|
+
security_schemes: Mortymer.config.security_schemes,
|
31
|
+
prefix: Mortymer.config.api_prefix
|
30
32
|
)
|
31
33
|
|
32
34
|
# Save OpenAPI spec to public directory
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Mortymer
|
4
|
+
module SecuritySchemes
|
5
|
+
# Define the Bearer Auth security scheme
|
6
|
+
class Bearer
|
7
|
+
def self.scheme(scopes = [].freeze)
|
8
|
+
{ BearerAuth: scopes }
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.to_scheme
|
12
|
+
{
|
13
|
+
BearerAuth: {
|
14
|
+
type: :http,
|
15
|
+
scheme: :bearer
|
16
|
+
}
|
17
|
+
}
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Define the API Key security scheme
|
22
|
+
class ApiKey
|
23
|
+
def self.scheme(scopes = [].freeze)
|
24
|
+
{ ApiKeyAuth: scopes }
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.to_scheme(name: "X-API-Key", in: :header)
|
28
|
+
{
|
29
|
+
ApiKeyAuth: {
|
30
|
+
type: :apiKey,
|
31
|
+
name: name,
|
32
|
+
in: binding.local_variable_get(:in) # Use binding because 'in' is a reserved word
|
33
|
+
}
|
34
|
+
}
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Define the Basic Auth security scheme
|
39
|
+
class Basic
|
40
|
+
def self.scheme(scopes = [].freeze)
|
41
|
+
{ BasicAuth: scopes }
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.to_scheme
|
45
|
+
{
|
46
|
+
BasicAuth: {
|
47
|
+
type: :http,
|
48
|
+
scheme: :basic
|
49
|
+
}
|
50
|
+
}
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Define the OAuth2 security scheme
|
55
|
+
class OAuth2
|
56
|
+
def self.scheme(scopes = [].freeze)
|
57
|
+
{ OAuth2: scopes }
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.to_scheme(flows: {})
|
61
|
+
{
|
62
|
+
OAuth2: {
|
63
|
+
type: :oauth2,
|
64
|
+
flows: flows || {
|
65
|
+
implicit: {
|
66
|
+
authorizationUrl: "/oauth/authorize",
|
67
|
+
scopes: {
|
68
|
+
"read:api" => "Read access",
|
69
|
+
"write:api" => "Write access"
|
70
|
+
}
|
71
|
+
},
|
72
|
+
password: {
|
73
|
+
tokenUrl: "/oauth/token",
|
74
|
+
scopes: {
|
75
|
+
"read:api" => "Read access",
|
76
|
+
"write:api" => "Write access"
|
77
|
+
}
|
78
|
+
}
|
79
|
+
}
|
80
|
+
}
|
81
|
+
}
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-types"
|
4
|
+
|
5
|
+
module Mortymer
|
6
|
+
module Types
|
7
|
+
include Dry.Types()
|
8
|
+
|
9
|
+
class RackFile
|
10
|
+
attr_reader :tempfile, :original_filename, :content_type
|
11
|
+
|
12
|
+
def initialize(tempfile:, original_filename:, content_type:)
|
13
|
+
@tempfile = tempfile
|
14
|
+
@original_filename = original_filename
|
15
|
+
@content_type = content_type
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.new_from_rack_file(file)
|
19
|
+
return nil if file.nil?
|
20
|
+
|
21
|
+
new(
|
22
|
+
tempfile: file.respond_to?(:tempfile) ? file.tempfile : file[:tempfile],
|
23
|
+
original_filename: file.respond_to?(:original_filename) ? file.original_filename : file[:original_filename],
|
24
|
+
content_type: file.respond_to?(:content_type) ? file.content_type : file[:content_type]
|
25
|
+
)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Define a custom type for handling file uploads
|
30
|
+
UploadedFile = Types.Constructor(RackFile) do |value|
|
31
|
+
case value
|
32
|
+
when RackFile
|
33
|
+
value
|
34
|
+
when Hash
|
35
|
+
RackFile.new_from_rack_file(value)
|
36
|
+
when ActionDispatch::Http::UploadedFile
|
37
|
+
RackFile.new_from_rack_file(value)
|
38
|
+
when NilClass
|
39
|
+
nil
|
40
|
+
else
|
41
|
+
raise Dry::Types::CoercionError, "#{value.inspect} cannot be coerced to UploadedFile"
|
42
|
+
end
|
43
|
+
end.meta(
|
44
|
+
swagger: {
|
45
|
+
type: "file"
|
46
|
+
}
|
47
|
+
)
|
48
|
+
|
49
|
+
# Array of files
|
50
|
+
UploadedFiles = Types::Array.of(File)
|
51
|
+
end
|
52
|
+
end
|
data/lib/mortymer/version.rb
CHANGED
data/lib/mortymer.rb
CHANGED
@@ -2,8 +2,13 @@
|
|
2
2
|
# typed: true
|
3
3
|
|
4
4
|
require "dry/struct"
|
5
|
+
require "mortymer/types"
|
6
|
+
require "mortymer/uploaded_file"
|
7
|
+
require "mortymer/uploaded_files"
|
5
8
|
require "mortymer/configuration"
|
9
|
+
require "mortymer/moldeable"
|
6
10
|
require "mortymer/model"
|
11
|
+
require "mortymer/contract"
|
7
12
|
require "mortymer/endpoint"
|
8
13
|
require "mortymer/dry_swagger"
|
9
14
|
require "mortymer/endpoint_registry"
|
@@ -12,5 +17,6 @@ require "mortymer/api_metadata"
|
|
12
17
|
require "mortymer/openapi_generator"
|
13
18
|
require "mortymer/container"
|
14
19
|
require "mortymer/dependencies_dsl"
|
20
|
+
require "mortymer/security_schemes"
|
15
21
|
require "mortymer/rails" if defined?(Rails)
|
16
22
|
require "mortymer/railtie" if defined?(Rails::Railtie)
|
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.8
|
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-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: dry-monads
|
@@ -116,6 +116,7 @@ files:
|
|
116
116
|
- docs/.vitepress/theme/style.css
|
117
117
|
- docs/advanced/dependency-injection.md
|
118
118
|
- docs/advanced/openapi.md
|
119
|
+
- docs/assets/swagg.png
|
119
120
|
- docs/guide/api-metadata.md
|
120
121
|
- docs/guide/introduction.md
|
121
122
|
- docs/guide/models.md
|
@@ -125,20 +126,26 @@ files:
|
|
125
126
|
- lib/mortymer/api_metadata.rb
|
126
127
|
- lib/mortymer/configuration.rb
|
127
128
|
- lib/mortymer/container.rb
|
129
|
+
- lib/mortymer/contract.rb
|
128
130
|
- lib/mortymer/dependencies_dsl.rb
|
129
131
|
- lib/mortymer/dry_swagger.rb
|
130
132
|
- lib/mortymer/endpoint.rb
|
131
133
|
- lib/mortymer/endpoint_registry.rb
|
134
|
+
- lib/mortymer/generator.rb
|
132
135
|
- lib/mortymer/model.rb
|
136
|
+
- lib/mortymer/moldeable.rb
|
133
137
|
- lib/mortymer/openapi_generator.rb
|
134
138
|
- lib/mortymer/rails.rb
|
135
139
|
- lib/mortymer/rails/configuration.rb
|
136
140
|
- lib/mortymer/rails/endpoint_wrapper_controller.rb
|
137
141
|
- lib/mortymer/rails/routes.rb
|
138
142
|
- lib/mortymer/railtie.rb
|
143
|
+
- lib/mortymer/security_schemes.rb
|
144
|
+
- lib/mortymer/types.rb
|
145
|
+
- lib/mortymer/uploaded_file.rb
|
146
|
+
- lib/mortymer/uploaded_files.rb
|
139
147
|
- lib/mortymer/utils/string_transformations.rb
|
140
148
|
- lib/mortymer/version.rb
|
141
|
-
- lib/mortymermer.rb
|
142
149
|
- mortymer.gemspec
|
143
150
|
- package-lock.json
|
144
151
|
- package.json
|
data/lib/mortymermer.rb
DELETED
@@ -1,15 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
# typed: true
|
3
|
-
|
4
|
-
require "dry/struct"
|
5
|
-
require "mortymer/model"
|
6
|
-
require "mortymer/endpoint"
|
7
|
-
require "mortymer/dry_swagger"
|
8
|
-
require "mortymer/endpoint_registry"
|
9
|
-
require "mortymer/utils/string_transformations"
|
10
|
-
require "mortymer/api_metadata"
|
11
|
-
require "mortymer/openapi_generator"
|
12
|
-
require "mortymer/container"
|
13
|
-
require "mortymer/dependencies_dsl"
|
14
|
-
require "mortymer/rails" if defined?(Rails)
|
15
|
-
require "mortymer/railtie" if defined?(Rails::Railtie)
|