roda-endpoints 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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