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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "endpoint_registry"
4
+ require_relative "utils/string_transformations"
5
+
6
+ module Mortymer
7
+ # Generate an openapi doc based on the registered endpoints
8
+ class OpenapiGenerator
9
+ include Utils::StringTransformations
10
+
11
+ def initialize(prefix: "", title: "Rick on Rails API", version: "v1", description: "", registry: [])
12
+ @prefix = prefix
13
+ @title = title
14
+ @version = version
15
+ @description = description
16
+ @endpoints_registry = registry
17
+ end
18
+
19
+ def generate
20
+ {
21
+ openapi: "3.0.1",
22
+ info: { title: @title, version: @version, description: @description },
23
+ paths: generate_paths,
24
+ components: { schemas: generate_schemas }
25
+ }
26
+ end
27
+
28
+ private
29
+
30
+ def generate_paths
31
+ @endpoints_registry.each_with_object({}) do |endpoint, paths|
32
+ next unless endpoint.routeable?
33
+
34
+ schema = endpoint.generate_openapi_schema || {}
35
+ schema = schema.transform_keys { |k| @prefix + k }
36
+ paths.merge!(schema)
37
+ end || {}
38
+ end
39
+
40
+ def generate_schemas # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
41
+ schemas = {
42
+ "Error422" => {
43
+ type: "object",
44
+ required: %w[error details],
45
+ properties: {
46
+ error: {
47
+ type: "string",
48
+ description: "Error type identifier",
49
+ example: "Validation Failed"
50
+ },
51
+ details: {
52
+ type: "string",
53
+ description: "Detailed error message",
54
+ example: 'type_error: ["foo"] is not a valid Integer'
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ @endpoints_registry.each do |endpoint|
61
+ next unless endpoint.routeable?
62
+
63
+ if endpoint.input_class && !schemas.key?(demodulize(endpoint.input_class.name))
64
+ schemas[demodulize(endpoint.input_class.name)] =
65
+ Dry::Swagger::DocumentationGenerator.new.from_struct(endpoint.input_class)
66
+ end
67
+
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
72
+ end
73
+ schemas
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mortymer
4
+ module Rails
5
+ # A configuration api for Rails & Morty integration
6
+ class Configuration
7
+ attr_accessor :base_controller_class, :error_handler, :wrap_methods
8
+
9
+ def initialize
10
+ @base_controller_class = "ApplicationController"
11
+ @error_handler = ->(error) { raise error }
12
+ @wrap_methods = true
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mortymer
4
+ module Rails
5
+ # This controller acts as a wrapper for Morty endpoints, handling param conversion
6
+ # and response rendering
7
+ class EndpointWrapperController < ::ActionController::API
8
+ rescue_from Dry::Struct::Error, with: :handle_invalid_params
9
+ rescue_from Dry::Types::CoercionError, with: :handle_invalid_params
10
+
11
+ # This method should be used when inheriting from a Controller
12
+ def execute
13
+ endpoint_class = request.env["morty.endpoint_class"]
14
+ dispatch_action_to(self, endpoint_class.action, endpoint_class.input_class)
15
+ end
16
+
17
+ def handle
18
+ endpoint_class = request.env["morty.endpoint_class"]
19
+ controller_class = endpoint_class.controller_class
20
+ handler = controller_class.new
21
+ dispatch_action_to(handler, endpoint_class.action, endpoint_class.input_class)
22
+ end
23
+
24
+ private
25
+
26
+ def action_params
27
+ p = params.to_unsafe_h.to_h.deep_transform_keys(&:to_sym)
28
+ p.delete(:controller)
29
+ p.delete(:actioncontroller)
30
+ p
31
+ end
32
+
33
+ def dispatch_action_to(controller, method, input_class)
34
+ input = input_class.new(action_params)
35
+ output = controller.send(method, input)
36
+ render json: output.to_h, status: :ok
37
+ end
38
+
39
+ def handle_invalid_params(error)
40
+ render json: { error: "Validation Failed", details: error.message }, status: :unprocessable_entity
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mortymer
4
+ module Rails
5
+ # This module is in charge of making each registered Morty Endpoint
6
+ # routeable through rails
7
+ class Routes
8
+ def initialize(drawer)
9
+ @drawer = drawer
10
+ end
11
+
12
+ def mount_morty_endpoints
13
+ Morty::EndpointRegistry.registry.each do |endpoint|
14
+ mount_endpoint(endpoint)
15
+ end
16
+ end
17
+
18
+ def mount_controllers
19
+ Morty::EndpointRegistry.registry.each do |endpoint|
20
+ mount_controller(endpoint)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def mount_controller(endpoint)
27
+ return unless endpoint.routeable?
28
+
29
+ @drawer.send(
30
+ endpoint.http_method,
31
+ endpoint.path,
32
+ to: "#{endpoint.controller_name.gsub(/_controller$/, "")}##{endpoint.action}",
33
+ as: "#{endpoint.http_method}_#{endpoint.api_name}"
34
+ )
35
+ end
36
+
37
+ def mount_endpoint(endpoint)
38
+ return unless endpoint.routeable?
39
+
40
+ path = endpoint.path || endpoint.infer_path_from_class
41
+
42
+ # Store the endpoint class in the request env for the wrapper to access
43
+ defaults = { controller: "morty/rails/endpoint_wrapper", action: "handle" }
44
+ constraints = lambda do |request|
45
+ request.env["morty.endpoint_class"] = endpoint
46
+ true
47
+ end
48
+
49
+ case endpoint.http_method
50
+ when :get
51
+ @drawer.get path, to: "morty/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
52
+ when :post
53
+ @drawer.post path, to: "morty/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
54
+ when :put
55
+ @drawer.put path, to: "morty/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
56
+ when :delete
57
+ @drawer.delete path, to: "morty/rails/endpoint_wrapper#handle", defaults: defaults, constraints: constraints
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "morty/rails/configuration"
4
+ require "morty/rails/endpoint_wrapper_controller"
5
+ require "morty/rails/routes"
6
+
7
+ module Mortymer
8
+ module Rails
9
+ class << self
10
+ def configure
11
+ yield(configuration)
12
+ end
13
+
14
+ def configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails/routes"
4
+ require_relative "rails/configuration"
5
+ require_relative "rails/endpoint_wrapper_controller"
6
+
7
+ module Mortymer
8
+ class Railtie < ::Rails::Railtie
9
+ config.morty = Mortymer::Rails::Configuration.new
10
+
11
+ initializer "mortymer.initialize" do |app|
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mortymer
4
+ module Utils
5
+ # Provides string transformation utilities similar to ActiveSupport's methods
6
+ module StringTransformations
7
+ module_function
8
+
9
+ def underscore(camel_cased_word)
10
+ return camel_cased_word unless camel_cased_word.is_a?(String)
11
+
12
+ word = camel_cased_word.to_s.dup
13
+ word.gsub!(/::/, "/")
14
+ word.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
15
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
16
+ word.tr!("-", "_")
17
+ word.downcase!
18
+ word
19
+ end
20
+
21
+ def demodulize(path)
22
+ return path unless path.is_a?(String)
23
+
24
+ path.split("::").last
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mortymer
4
+ VERSION = "0.0.2"
5
+ end
data/lib/mortymer.rb ADDED
@@ -0,0 +1,15 @@
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)
@@ -0,0 +1,15 @@
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)
data/mortymer.gemspec ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/mortymer/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "mortymer"
7
+ spec.version = Mortymer::VERSION
8
+ spec.authors = ["Adrian Gonzalez"]
9
+ spec.email = ["adriangonzalezsanchez1996@gmail.com"]
10
+
11
+ spec.summary = "A really simple DSL for writing API endpoints with openapi support"
12
+ spec.description = "A simple DSL to describe metadata for endpoints and automatic params construction based on dry-struct schemas. Support for openapi"
13
+ spec.homepage = "https://github.com/adriangs1996/morty"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = spec.homepage
21
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/CHANGELOG.md"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(__dir__) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (File.expand_path(f) == __FILE__) ||
28
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
29
+ end
30
+ end
31
+ spec.bindir = "exe"
32
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ["lib"]
34
+
35
+ # Uncomment to register a new dependency of your gem
36
+ spec.add_dependency "dry-monads", "~> 1.7"
37
+ spec.add_dependency "dry-struct", "~> 1.7"
38
+ spec.add_dependency "dry-swagger", "~> 2.0"
39
+ spec.add_dependency "dry-system", "~> 1.2"
40
+ spec.add_dependency "dry-validation", "~> 1.11"
41
+
42
+ spec.add_development_dependency "rails", "~> 8.0"
43
+ # For more information and examples about making a new gem, check out our
44
+ # guide at: https://bundler.io/guides/creating_gem.html
45
+ end