roda-endpoints 0.1.0

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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +98 -0
  3. data/.gitignore +9 -0
  4. data/.gitlab-ci.yml +85 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +51 -0
  7. data/.ruby-version +1 -0
  8. data/.simplecov +7 -0
  9. data/.travis.yml +5 -0
  10. data/CODE_OF_CONDUCT.md +74 -0
  11. data/Dockerfile +11 -0
  12. data/Gemfile +5 -0
  13. data/LICENSE.txt +21 -0
  14. data/README.md +42 -0
  15. data/Rakefile +4 -0
  16. data/bin/bundle-audit +17 -0
  17. data/bin/console +15 -0
  18. data/bin/rspec +17 -0
  19. data/bin/rubocop +17 -0
  20. data/bin/setup +8 -0
  21. data/bin/yard +17 -0
  22. data/bin/yardoc +17 -0
  23. data/bin/yri +17 -0
  24. data/lib/roda/endpoints/endpoint/caching.rb +28 -0
  25. data/lib/roda/endpoints/endpoint/class_interface.rb +123 -0
  26. data/lib/roda/endpoints/endpoint/collection.rb +77 -0
  27. data/lib/roda/endpoints/endpoint/data.rb +29 -0
  28. data/lib/roda/endpoints/endpoint/item.rb +117 -0
  29. data/lib/roda/endpoints/endpoint/namespace.rb +48 -0
  30. data/lib/roda/endpoints/endpoint/operations.rb +28 -0
  31. data/lib/roda/endpoints/endpoint/transactions.rb +67 -0
  32. data/lib/roda/endpoints/endpoint/validations.rb +98 -0
  33. data/lib/roda/endpoints/endpoint/verbs.rb +45 -0
  34. data/lib/roda/endpoints/endpoint.rb +83 -0
  35. data/lib/roda/endpoints/functions.rb +26 -0
  36. data/lib/roda/endpoints/repository.rb +26 -0
  37. data/lib/roda/endpoints/transactions.rb +75 -0
  38. data/lib/roda/endpoints/types.rb +13 -0
  39. data/lib/roda/endpoints/version.rb +7 -0
  40. data/lib/roda/endpoints.rb +21 -0
  41. data/lib/roda/plugins/endpoints.rb +161 -0
  42. data/lib/rom/struct/to_json.rb +18 -0
  43. data/rakelib/bundle_audit.rake +4 -0
  44. data/rakelib/bundler.rake +2 -0
  45. data/rakelib/rspec.rake +6 -0
  46. data/rakelib/rubocop.rake +5 -0
  47. data/rakelib/yard.rake +5 -0
  48. data/roda-endpoints.gemspec +49 -0
  49. metadata +385 -0
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda/endpoints'
4
+ require 'roda/endpoints/transactions'
5
+ require 'inflecto'
6
+
7
+ class Roda
8
+ module Endpoints
9
+ class Endpoint
10
+ module ClassInterface
11
+ # @return [<Symbol>]
12
+ attr_accessor :attributes
13
+
14
+ # @return [{Symbol=>Object}]
15
+ attr_accessor :defaults
16
+
17
+ # @return [{Symbol=>{Symbol=>Symbol}}]
18
+ attr_accessor :statuses
19
+
20
+ # @return [<Symbol>]
21
+ attr_accessor :verbs
22
+
23
+ # @param [Symbol] key
24
+ def define_attribute(key)
25
+ self.attributes += [key]
26
+ attr_reader key
27
+ end
28
+
29
+ # @return [Dry::Container]
30
+ def container
31
+ @container ||= Roda::Endpoints.container
32
+ end
33
+
34
+ # @param [Class] child
35
+ def inherited(child)
36
+ child.attributes = attributes.dup
37
+ child.defaults = defaults.dup
38
+ child.statuses = statuses.dup
39
+ child.verbs = verbs.dup
40
+ super
41
+ end
42
+
43
+ def ns
44
+ @ns ||= name.gsub(/^Roda::Endpoints::/, '').underscore.tr('/', '.')
45
+ end
46
+
47
+ # @return [Symbol]
48
+ def type
49
+ @type ||= Inflecto.underscore(Inflecto.demodulize(name)).to_sym
50
+ end
51
+
52
+ # @param [Symbol] name
53
+ # @param [Proc] block
54
+ # @return [Symbol] name of the defined method
55
+ def verb(name, rescue_from: [], &block)
56
+ rescue_from = Array(rescue_from).flatten
57
+ if rescue_from.any?
58
+ block = proc do |*args|
59
+ begin
60
+ instance_exec(*args, &block)
61
+ rescue *rescue_from
62
+ Left($ERROR_INFO)
63
+ end
64
+ end
65
+ end
66
+ define_method(name, &block)
67
+ container.register "operations.#{type}.#{name}", block
68
+ self.verbs ||= superclass.verbs
69
+ (self.verbs += [name]).freeze
70
+ end
71
+
72
+ # @param [String, Symbol] key
73
+ # @param [Proc] block
74
+ #
75
+ # @example
76
+ # r.collection :articles do |articles|
77
+ # # register validation at 'validations.endpoints.articles.default'
78
+ # articles.validate do
79
+ # required(:title).filled?
80
+ # required(:contents).filled?
81
+ # end
82
+ # # redefine validation for patch method at
83
+ # # 'validations.endpoints.articles.patch'
84
+ # articles.validate(:patch) do
85
+ # required(:title).filled?
86
+ # required(:contents).filled?
87
+ # require(:confirm_changes).
88
+ # end
89
+ # end
90
+ def validate(key = :default, &block)
91
+ key = "validations.#{ns}.#{key}" if key.is_a?(Symbol)
92
+ schema = Dry::Validation.Form(&block)
93
+ container.register(key) do |params|
94
+ validation = schema.call(params)
95
+ if validation.success?
96
+ Right(validation.output)
97
+ else
98
+ Left([:unprocessable_entity, {}, validation])
99
+ end
100
+ end
101
+ schema
102
+ end
103
+ # rubocop:enable Metrics/MethodLength
104
+
105
+ # @param [Proc] block
106
+ # @return [Proc]
107
+ def route(&block)
108
+ @route_block = block if block_given?
109
+ @route_block
110
+ end
111
+
112
+ def transaction(name, &block)
113
+ transactions << [name, block]
114
+ end
115
+
116
+ # @return [(Symbol, Hash, Proc)]
117
+ def transactions
118
+ @transactions ||= []
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda/endpoints/endpoint'
4
+ require 'roda/endpoints/endpoint/data'
5
+ require 'roda/endpoints/endpoint/caching'
6
+ require 'inflecto'
7
+ require 'rom/sql'
8
+
9
+ class Roda
10
+ module Endpoints
11
+ class Endpoint
12
+ # HTTP endpoint representing a collection of items of the same type.
13
+ class Collection < Endpoint
14
+ include Data
15
+ prepend Caching
16
+
17
+ self.attributes += %i(item)
18
+ self.defaults = defaults.merge(
19
+ type: :collection,
20
+ last_modified: :last_modified
21
+ )
22
+
23
+ # @return [{Symbol=>Object}]
24
+ attr_reader :item
25
+
26
+ # @return [Time]
27
+ def last_modified
28
+ @last_modified ? repository.public_send(@last_modified) : super
29
+ end
30
+
31
+ def child(**params)
32
+ with(
33
+ type: Item,
34
+ name: resource_name,
35
+ **params
36
+ )
37
+ end
38
+
39
+ # @return [Symbol]
40
+ def resource_name
41
+ Inflecto.singularize(name).to_sym
42
+ end
43
+
44
+ route do |r, endpoint| # r.collection :articles do |articles|
45
+ r.last_modified endpoint.last_modified if endpoint.last_modified
46
+
47
+ endpoint.verbs.each do |verb|
48
+ # @route #{verb} /:name
49
+ r.public_send(verb, transaction: verb)
50
+
51
+ r.child **endpoint.item if endpoint.item # child by: :id
52
+ end
53
+ end
54
+
55
+ verb :get do |params|
56
+ Right(repository.list(**params))
57
+ end
58
+
59
+ verb :post do |params|
60
+ params = params[resource_name] || {}
61
+ Right(repository.create(**params))
62
+ end
63
+
64
+ # @route GET /{collection.name}
65
+ transaction :get do |endpoint|
66
+ step :retrieve, with: endpoint.operation_for(:get)
67
+ end
68
+
69
+ # @route POST /{collection.name}
70
+ transaction :post do |endpoint|
71
+ step :validate, with: endpoint.validation_for(:post)
72
+ step :persist, with: endpoint.operation_for(:post)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Roda
4
+ module Endpoints
5
+ # Generic HTTP endpoint abstraction.
6
+ class Endpoint
7
+ # Accessing data inside of endpoint.
8
+ module Data
9
+ # @param name [String]
10
+ # @param repository [String]
11
+ # @param attributes [{Symbol=>Object}]
12
+ def initialize(name:, repository: "repositories.#{name}", **attributes)
13
+ @repository_key = repository
14
+ super(name: name, **attributes)
15
+ end
16
+
17
+ # @return [ROM::Repository]
18
+ def repository
19
+ container[@repository_key] if @repository_key
20
+ end
21
+
22
+ # @return [{Symbol=>Object}]
23
+ def to_hash
24
+ super.merge(repository: @repository_key)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda/endpoints/endpoint'
4
+ require 'roda/endpoints/endpoint/data'
5
+ require 'roda/endpoints/endpoint/caching'
6
+
7
+ class Roda
8
+ module Endpoints
9
+ class Endpoint
10
+ # HTTP endpoint representing a specific item of collection uniquely
11
+ # identified by some parameter.
12
+ class Item < Endpoint
13
+ include Data
14
+ include Caching
15
+
16
+ self.attributes += %i(id by finder last_modified)
17
+ self.defaults = defaults.merge(
18
+ by: :fetch,
19
+ finder: lambda do
20
+ repository.public_send(by, id)
21
+ end,
22
+ last_modified: :updated_at
23
+ )
24
+
25
+ # @return [Symbol]
26
+ attr_reader :by
27
+
28
+ # @return [Symbol]
29
+ attr_reader :id
30
+
31
+ # @return [Symbol]
32
+ attr_reader :finder
33
+
34
+ # @return [Endpoint::Collection]
35
+ def collection
36
+ parent
37
+ end
38
+
39
+ # @return [ROM::Struct]
40
+ def entity
41
+ @entity ||= fetch_entity
42
+ end
43
+
44
+ def fetch_entity
45
+ instance_exec(&finder)
46
+ end
47
+
48
+ def last_modified
49
+ @last_modified ? entity.public_send(@last_modified) : super
50
+ end
51
+
52
+ route do |r, endpoint|
53
+ endpoint.verbs.each do |verb|
54
+ # @route #{verb} /{collection.name}/{id}
55
+ # STDOUT.puts [verb].pretty_inspect
56
+ r.public_send(verb, transaction: verb)
57
+ end
58
+ end
59
+
60
+ transaction :get do |endpoint|
61
+ step :retrieve, with: endpoint.operation_for(:get)
62
+ end
63
+
64
+ transaction :patch do |endpoint|
65
+ step :validate, with: endpoint.validation_for(:patch)
66
+ step :persist, with: endpoint.operation_for(:patch)
67
+ end
68
+
69
+ transaction :put do |endpoint|
70
+ step :validate, with: endpoint.validation_for(:put)
71
+ # step :reset, with: 'endpoints.operations.reset'
72
+ step :persist, with: endpoint.operation_for(:put)
73
+ end
74
+
75
+ transaction :delete do |endpoint|
76
+ step :validate, with: endpoint.validation_for(:delete)
77
+ step :persist, with: endpoint.operation_for(:delete)
78
+ end
79
+
80
+ # @route GET /{collection.name}/{id}
81
+ # @param [Hash] params
82
+ # @return [Dry::Monads::Either]
83
+ verb :get do |_params|
84
+ Right(entity)
85
+ end
86
+
87
+ # @route PATCH /{collection.name}/{id}
88
+ # @return [Dry::Monads::Either]
89
+ verb :patch do |params|
90
+ changeset = params[name]
91
+ Right(repository.update(id, changeset))
92
+ end
93
+
94
+ # @route PUT /{collection.name}/{id}
95
+ # @return [Dry::Monads::Either]
96
+ verb :put do |params|
97
+ changeset = entity.to_hash.keys.each_with_object(
98
+ {}
99
+ ) do |key, changeset|
100
+ changeset[key] = nil
101
+ end.merge(params[name] || {})
102
+ Right(repository.update(id, changeset))
103
+ end
104
+
105
+ # @route DELETE /{collection.name}/{id}
106
+ # @return [Dry::Monads::Either]
107
+ verb :delete do |_params|
108
+ if (result = repository.delete(id))
109
+ Right(nil)
110
+ else
111
+ Left(result)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Roda
4
+ module Endpoints
5
+ # Generic HTTP endpoint abstraction.
6
+ class Endpoint
7
+ # Namespacing operations, validations, etc.
8
+ module Namespace
9
+ def self.included(child)
10
+ child.attributes += %i(name ns parent)
11
+ end
12
+
13
+ # @param name [Symbol]
14
+ # @param ns [String]
15
+ # @param attributes [{Symbol=>Object}]
16
+ # @param parent [Endpoint?]
17
+ def initialize(name:, ns: name.to_s, parent: Undefined, **attributes)
18
+ @name = name
19
+ @ns = ns
20
+ unless parent == Undefined
21
+ @parent = parent
22
+ @ns = "#{parent.ns}.#{ns}"
23
+ end
24
+ super(name: name, **attributes)
25
+ end
26
+
27
+ # @return [Symbol]
28
+ attr_reader :name
29
+
30
+ # @return [String]
31
+ attr_reader :ns
32
+
33
+ # @return [Endpoint]
34
+ attr_reader :parent
35
+
36
+ # @return [ROM::Repository]
37
+ def repository
38
+ container[@repository_key] if @repository_key
39
+ end
40
+
41
+ # @return [{Symbol=>Object}]
42
+ def to_hash
43
+ super.merge(repository: @repository_key)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Roda
4
+ module Endpoints
5
+ # Generic HTTP endpoint abstraction.
6
+ class Endpoint
7
+ # Parameters validations for {Endpoints} based on `Dry::Validation` gem.
8
+ module Operations
9
+ def initialize(**kwargs)
10
+ super(**kwargs)
11
+ container.merge(Endpoints.container)
12
+ self.class.verbs.each do |verb|
13
+ container.register "operations.#{ns}.#{verb}", &method(verb)
14
+ end
15
+ end
16
+
17
+ # @param [Symbol] verb
18
+ # @return [String]
19
+ def operation_for(verb)
20
+ %W(
21
+ operations.#{ns}.#{verb}
22
+ operations.#{type}.#{verb}
23
+ ).detect { |key| container.key?(key) }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda/endpoints/transactions'
4
+
5
+ class Roda
6
+ module Endpoints
7
+ # Generic HTTP endpoint abstraction.
8
+ class Endpoint
9
+ # Namespacing operations, validations, etc.
10
+ module Transactions
11
+ # @return [Endpoints::Transactions]
12
+ def transactions
13
+ endpoint = self
14
+ @transactions ||=
15
+ begin
16
+ transactions = Endpoints::Transactions.new(endpoint: self)
17
+ self.class.transactions.each do |(name, block)|
18
+ transactions.define(name) { instance_exec(endpoint, &block) }
19
+ end
20
+ transactions
21
+ end
22
+ yield @transaction if block_given?
23
+ @transactions
24
+ end
25
+
26
+ # @param [Symbol] verb
27
+ # @return [String]
28
+ def transaction_for(verb)
29
+ %W(
30
+ transactions.#{ns}.#{verb}
31
+ transactions.#{type}.#{verb}
32
+ ).detect { |key| container.key?(key) }
33
+ end
34
+
35
+ # @param [Symbol, String] name
36
+ # @param [Proc] block
37
+ def step(name, only: [], **kwargs, &block)
38
+ name = "operations.#{ns}.#{name}"
39
+ container.register(name, &block) if block_given?
40
+ verbs = Array(only).flatten
41
+ verbs.each do |verb|
42
+ result = transactions[verb].insert(container: container, **kwargs) do
43
+ step name
44
+ end
45
+ @transaction = result
46
+ end
47
+ transactions
48
+ end
49
+
50
+ def before(name, **kwargs, &block)
51
+ step "before_#{name}", before: name, **kwargs, &block
52
+ end
53
+
54
+ def after(name, **kwargs, &block)
55
+ step "after_#{name}", after: name, **kwargs, &block
56
+ end
57
+
58
+ # @param [Symbol] operation
59
+ # @param [Array] args
60
+ # @return [Dry::Monads::Either]
61
+ def perform(operation, *args, **options)
62
+ transactions[operation].call(*args, **options)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda/endpoints'
4
+ require 'roda/endpoints/functions'
5
+ require 'dry-validation'
6
+
7
+ class Roda
8
+ module Endpoints
9
+ # Generic HTTP endpoint abstraction.
10
+ class Endpoint
11
+ # Parameters validations for {Endpoints} based on `Dry::Validation` gem.
12
+ module Validations
13
+ include Functions::Shortcut
14
+
15
+ # @param [Class] child
16
+ def self.included(child)
17
+ child.defaults = child.defaults.merge(validation: 'validation').freeze
18
+ super
19
+ end
20
+
21
+ # @param [String, Symbol] key
22
+ # @param [Proc] block
23
+ #
24
+ # @example
25
+ # r.collection :articles do |articles|
26
+ # # register validation at 'validations.endpoints.articles.default'
27
+ # articles.validate do
28
+ # required(:title).filled?
29
+ # required(:contents).filled?
30
+ # end
31
+ # # redefine validation for patch method at
32
+ # # 'validations.endpoints.articles.patch'
33
+ # articles.validate(:patch) do
34
+ # required(:title).filled?
35
+ # required(:contents).filled?
36
+ # require(:confirm_changes).
37
+ # end
38
+ # end
39
+ def validate(key = :default, &block)
40
+ defaults = "validations.defaults.#{ns}.#{key}"
41
+ key = "validations.#{ns}.#{key}"
42
+ schema = Dry::Validation.Form(&block)
43
+ container.register(key) do |params|
44
+ if container.key?(defaults)
45
+ params = f(:deep_merge).call(params, container[defaults])
46
+ end
47
+ validation = schema.call(params)
48
+ if validation.success?
49
+ Right(validation.output)
50
+ else
51
+ Left([:unprocessable_entity, {}, validation])
52
+ end
53
+ end
54
+ schema
55
+ end
56
+
57
+ # rubocop:enable Metrics/MethodLength
58
+
59
+ def defaults(verb = :default, **attributes)
60
+ key = "validations.defaults.#{ns}.#{verb}" if key.is_a?(Symbol)
61
+ container.register key, attributes
62
+ end
63
+
64
+ # @param [Symbol] verb
65
+ # @return [Dry::Validation::Schema::Form]
66
+ def validation(verb)
67
+ if (validation = validation_for(verb))
68
+ container[validation]
69
+ else
70
+ # default validation requires no params and provide no results
71
+ provide_default_validation!
72
+ end
73
+ end
74
+
75
+ def provide_default_validation!
76
+ validate {} unless container.key?("validations.#{ns}.default")
77
+ end
78
+
79
+ # @param [Symbol] verb
80
+ # @return [String]
81
+ def validation_for(verb)
82
+ validation = %W(
83
+ validations.#{ns}.#{verb}
84
+ validations.#{ns}.default
85
+ ).detect do |key|
86
+ container.key?(key)
87
+ end
88
+ validation = provide_default_validation! unless validation
89
+ validation
90
+ end
91
+
92
+ def validate!(verb, params)
93
+ validation(verb).call(params)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ class Roda
6
+ module Endpoints
7
+ # Generic HTTP endpoint abstraction.
8
+ class Endpoint
9
+ # Accessing data inside of endpoint.
10
+ module Verbs
11
+ extend Forwardable
12
+
13
+ # @param [<Symbol>] only
14
+ # @param [<Symbol>] except
15
+ # @param attributes [{Symbol=>Object}]
16
+ def initialize(only: implemented_verbs, except: [], **attributes)
17
+ only = Array(only).flatten
18
+ except = Array(except).flatten
19
+ if ((unknown_only = only - implemented_verbs) +
20
+ (unknown_except = except - implemented_verbs)).any?
21
+ params = { only: unknown_only, except: unknown_except }
22
+ raise ArgumentError, "unknown verbs in params: #{params}"
23
+ end
24
+ @verbs = only - except
25
+ super(**attributes)
26
+ end
27
+
28
+ # @return [<Symbol>]
29
+ def implemented_verbs
30
+ self.class.verbs.to_a
31
+ end
32
+
33
+ # @return [<Symbol>]
34
+ attr_reader :verbs
35
+
36
+ def verb(name, **kwargs, &block)
37
+ key = "operations.#{ns}.#{name}"
38
+ container.register key, &block
39
+ singleton_class.verb(name, **kwargs, &container[key])
40
+ end
41
+ def_delegator :singleton_class, :verb
42
+ end
43
+ end
44
+ end
45
+ end