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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23f37db069ed2b34095d8c6d60bb257478af6544361fd4bc61349bdb8d764ca7
4
- data.tar.gz: 6dd9aa47b963f63de8f5f50a6e81341e4f090d0deeff244d2f8094e468438f03
3
+ metadata.gz: c13887be646cf5c591070d4d51c85e657c3509b11858d3d1cc7fa9650ec3c5ed
4
+ data.tar.gz: 0ca382da28037d53ab387f553854fc24047450b1b1d8589078f091130b15cbee
5
5
  SHA512:
6
- metadata.gz: ce8c2077ad575b4c7cc289d14d2cddbb8c3a7f8c970f57f8a57df6046a13df05dfc7836b679784b641092214ba938e028661dc57b7ef7ac281dd6c453e415ced
7
- data.tar.gz: f87678c7131a5abefe616819e42befc9b0065ac7e6d31a5e8800bf3806d63fa903e22f7f8006b6d3a10cbfcd66dbb8980a948823e59ce23a8a8144d76fb94473
6
+ metadata.gz: 5fbe4192b479624078104e07290a0e6052eb7b752a59d566ad4501c197f325c97080ec00605654f9ecdda34a16f466a71fb0ea8807a6da0e08ed0b1bee65c1fd
7
+ data.tar.gz: 7da23bf7783533a1ba3dbe56ec41a800c5e1473ab22b51bd9d1fc4ab595dae81b43f48767c145d1d429b13c601d225d1599bbaff31746b263bea68aa19be4b9c
Binary file
@@ -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/api/books_controller.rb
113
- module Api
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
- # GET /api/books
134
- get input: Empty, output: ListAllBooksOutput
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
+ ![Swagger UI](../assets/swagg.png)
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 get(input:, output:, path: nil)
19
- register_endpoint(:get, input, output, path)
18
+ def secured_with(security)
19
+ @__endpoint_security__ = security
20
20
  end
21
21
 
22
- def post(input:, output:, path: nil)
23
- register_endpoint(:post, input, output, path)
22
+ def remove_security!
23
+ @__endpoint_security__ = nil
24
24
  end
25
25
 
26
- def put(input:, output:, path: nil)
27
- register_endpoint(:put, input, output, path)
26
+ def tags(*tag_list)
27
+ @__endpoint_tags__ = tag_list
28
28
  end
29
29
 
30
- def delete(input:, output:, path: nil)
31
- register_endpoint(:delete, input, output, path)
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.new(params.to_unsafe_h.to_h.deep_transform_keys(&:to_sym))
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[dep[:var_name].to_sym]
55
+ overrides.delete(dep[:var_name].to_sym)
56
56
  else
57
57
  container.resolve_constant(dep[:constant])
58
58
  end
@@ -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)
@@ -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 = 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
- }
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
- Dry::Swagger::DocumentationGenerator.new.from_struct(@input_class)
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
- "application/json" => {
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
@@ -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: { schemas: generate_schemas }
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 = schema.transform_keys { |k| @prefix + k }
36
- paths.merge!(schema)
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
- Dry::Swagger::DocumentationGenerator.new.from_struct(endpoint.input_class)
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
- if endpoint.output_class && !schemas.key?(demodulize(endpoint.output_class.name))
69
- schemas[demodulize(endpoint.output_class.name)] =
70
- Dry::Swagger::DocumentationGenerator.new.from_struct(endpoint.output_class)
71
- end
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
  )
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "types"
4
+
5
+ UploadedFile = Mortymer::Types::UploadedFile
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "types"
4
+
5
+ UploadedFiles = Mortymer::Types::UploadedFiles
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mortymer
4
- VERSION = "0.0.6"
4
+ VERSION = "0.0.8"
5
5
  end
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.6
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-10 00:00:00.000000000 Z
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)