him 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 (75) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +40 -0
  3. data/.gitignore +6 -0
  4. data/.qlty/qlty.toml +57 -0
  5. data/.rspec +1 -0
  6. data/.ruby-version +1 -0
  7. data/.yardopts +2 -0
  8. data/CONTRIBUTING.md +26 -0
  9. data/Gemfile +2 -0
  10. data/LICENSE +8 -0
  11. data/README.md +1007 -0
  12. data/Rakefile +11 -0
  13. data/UPGRADE.md +101 -0
  14. data/gemfiles/Gemfile.activemodel-6.1 +6 -0
  15. data/gemfiles/Gemfile.activemodel-7.0 +6 -0
  16. data/gemfiles/Gemfile.activemodel-7.1 +6 -0
  17. data/gemfiles/Gemfile.activemodel-7.2 +6 -0
  18. data/gemfiles/Gemfile.activemodel-8.0 +6 -0
  19. data/him.gemspec +28 -0
  20. data/lib/him/api.rb +121 -0
  21. data/lib/him/collection.rb +21 -0
  22. data/lib/him/errors.rb +29 -0
  23. data/lib/him/json_api/model.rb +42 -0
  24. data/lib/him/middleware/accept_json.rb +18 -0
  25. data/lib/him/middleware/first_level_parse_json.rb +37 -0
  26. data/lib/him/middleware/json_api_parser.rb +65 -0
  27. data/lib/him/middleware/parse_json.rb +22 -0
  28. data/lib/him/middleware/second_level_parse_json.rb +37 -0
  29. data/lib/him/middleware.rb +12 -0
  30. data/lib/him/model/associations/association.rb +147 -0
  31. data/lib/him/model/associations/association_proxy.rb +47 -0
  32. data/lib/him/model/associations/belongs_to_association.rb +95 -0
  33. data/lib/him/model/associations/has_many_association.rb +113 -0
  34. data/lib/him/model/associations/has_one_association.rb +79 -0
  35. data/lib/him/model/associations.rb +141 -0
  36. data/lib/him/model/attributes.rb +337 -0
  37. data/lib/him/model/base.rb +33 -0
  38. data/lib/him/model/http.rb +113 -0
  39. data/lib/him/model/introspection.rb +77 -0
  40. data/lib/him/model/nested_attributes.rb +45 -0
  41. data/lib/him/model/orm.rb +306 -0
  42. data/lib/him/model/parse.rb +224 -0
  43. data/lib/him/model/paths.rb +125 -0
  44. data/lib/him/model/relation.rb +212 -0
  45. data/lib/him/model.rb +79 -0
  46. data/lib/him/version.rb +3 -0
  47. data/lib/him.rb +22 -0
  48. data/spec/api_spec.rb +120 -0
  49. data/spec/collection_spec.rb +70 -0
  50. data/spec/json_api/model_spec.rb +260 -0
  51. data/spec/middleware/accept_json_spec.rb +11 -0
  52. data/spec/middleware/first_level_parse_json_spec.rb +63 -0
  53. data/spec/middleware/json_api_parser_spec.rb +52 -0
  54. data/spec/middleware/second_level_parse_json_spec.rb +35 -0
  55. data/spec/model/associations/association_proxy_spec.rb +29 -0
  56. data/spec/model/associations_spec.rb +1010 -0
  57. data/spec/model/attributes_spec.rb +384 -0
  58. data/spec/model/callbacks_spec.rb +194 -0
  59. data/spec/model/dirty_spec.rb +133 -0
  60. data/spec/model/http_spec.rb +187 -0
  61. data/spec/model/introspection_spec.rb +110 -0
  62. data/spec/model/nested_attributes_spec.rb +135 -0
  63. data/spec/model/orm_spec.rb +717 -0
  64. data/spec/model/parse_spec.rb +619 -0
  65. data/spec/model/paths_spec.rb +348 -0
  66. data/spec/model/relation_spec.rb +255 -0
  67. data/spec/model/validations_spec.rb +45 -0
  68. data/spec/model_spec.rb +55 -0
  69. data/spec/spec_helper.rb +25 -0
  70. data/spec/support/extensions/array.rb +6 -0
  71. data/spec/support/extensions/hash.rb +6 -0
  72. data/spec/support/macros/her_macros.rb +17 -0
  73. data/spec/support/macros/model_macros.rb +36 -0
  74. data/spec/support/macros/request_macros.rb +27 -0
  75. metadata +201 -0
@@ -0,0 +1,212 @@
1
+ module Him
2
+ module Model
3
+ class Relation
4
+
5
+ # @private
6
+ attr_accessor :params
7
+ attr_writer :parent
8
+
9
+ # @private
10
+ def initialize(parent)
11
+ @parent = parent
12
+ @params = {}
13
+ end
14
+
15
+ # @private
16
+ def apply_to(attributes)
17
+ @params.merge(attributes)
18
+ end
19
+
20
+ # Build a new resource
21
+ def build(attributes = {})
22
+ @parent.build(@params.merge(attributes))
23
+ end
24
+
25
+ # Add a query string parameter
26
+ #
27
+ # @example
28
+ # @users = User.all
29
+ # # Fetched via GET "/users"
30
+ #
31
+ # @example
32
+ # @users = User.where(:approved => 1).all
33
+ # # Fetched via GET "/users?approved=1"
34
+ def where(params = {})
35
+ return self if params.blank? && !@_fetch.nil?
36
+ clone.tap do |r|
37
+ r.params = r.params.merge(params)
38
+ r.clear_fetch_cache!
39
+ end
40
+ end
41
+ alias all where
42
+
43
+ # Bubble all methods to the fetched collection
44
+ #
45
+ # @private
46
+ def method_missing(method, *args, &blk)
47
+ fetch.send(method, *args, &blk)
48
+ end
49
+
50
+ # @private
51
+ def respond_to?(method, *args)
52
+ super || fetch.respond_to?(method, *args)
53
+ end
54
+
55
+ # @private
56
+ def nil?
57
+ fetch.nil?
58
+ end
59
+
60
+ # @private
61
+ def kind_of?(thing)
62
+ fetch.is_a?(thing)
63
+ end
64
+
65
+ # Fetch a collection of resources
66
+ #
67
+ # @private
68
+ def fetch
69
+ @_fetch ||= begin
70
+ if @params.values.include?([])
71
+ Him::Collection.new
72
+ else
73
+ path = @parent.build_request_path(@parent.collection_path, @params)
74
+ method = @parent.method_for(:find)
75
+ @parent.request(@params.merge(:_method => method, :_path => path)) do |parsed_data, _|
76
+ @parent.new_collection(parsed_data)
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ # Fetch specific resource(s) by their ID
83
+ #
84
+ # @example
85
+ # @user = User.find(1)
86
+ # # Fetched via GET "/users/1"
87
+ #
88
+ # @example
89
+ # @users = User.find([1, 2])
90
+ # # Fetched via GET "/users/1" and GET "/users/2"
91
+ def find(*ids)
92
+ params = @params.merge(ids.last.is_a?(Hash) ? ids.pop : {})
93
+ ids = Array(params[@parent.primary_key]) if params.key?(@parent.primary_key)
94
+
95
+ results = ids.flatten.compact.uniq.map do |id|
96
+ resource = nil
97
+ request_params = params.merge(
98
+ :_method => @parent.method_for(:find),
99
+ :_path => @parent.build_request_path(params.merge(@parent.primary_key => id))
100
+ )
101
+
102
+ @parent.request(request_params) do |parsed_data, response|
103
+ if response.success?
104
+ resource = @parent.new_from_parsed_data(parsed_data)
105
+ else
106
+ return nil
107
+ end
108
+ end
109
+
110
+ resource
111
+ end
112
+
113
+ ids.length > 1 || ids.first.is_a?(Array) ? results : results.first
114
+ end
115
+
116
+ # Fetch first resource with the given attributes.
117
+ #
118
+ # If no resource is found, returns <tt>nil</tt>.
119
+ #
120
+ # @example
121
+ # @user = User.find_by(name: "Tobias", age: 42)
122
+ # # Called via GET "/users?name=Tobias&age=42"
123
+ def find_by(params)
124
+ where(params).first
125
+ end
126
+
127
+ # Fetch first resource with the given attributes, or create a resource
128
+ # with the attributes if one is not found.
129
+ #
130
+ # @example
131
+ # @user = User.find_or_create_by(email: "remi@example.com")
132
+ #
133
+ # # Returns the first item in the collection if present:
134
+ # # Called via GET "/users?email=remi@example.com"
135
+ #
136
+ # # If collection is empty:
137
+ # # POST /users with `email=remi@example.com`
138
+ # @user.email # => "remi@example.com"
139
+ # @user.new? # => false
140
+ def find_or_create_by(attributes)
141
+ find_by(attributes) || create(attributes)
142
+ end
143
+
144
+ # Fetch first resource with the given attributes, or initialize a resource
145
+ # with the attributes if one is not found.
146
+ #
147
+ # @example
148
+ # @user = User.find_or_initialize_by(email: "remi@example.com")
149
+ #
150
+ # # Returns the first item in the collection if present:
151
+ # # Called via GET "/users?email=remi@example.com"
152
+ #
153
+ # # If collection is empty:
154
+ # @user.email # => "remi@example.com"
155
+ # @user.new? # => true
156
+ def find_or_initialize_by(attributes)
157
+ find_by(attributes) || build(attributes)
158
+ end
159
+
160
+ # Create a resource and return it
161
+ #
162
+ # @example
163
+ # @user = User.create(:fullname => "Tobias Fünke")
164
+ # # Called via POST "/users/1" with `&fullname=Tobias+Fünke`
165
+ #
166
+ # @example
167
+ # @user = User.where(:email => "tobias@bluth.com").create(:fullname => "Tobias Fünke")
168
+ # # Called via POST "/users/1" with `&email=tobias@bluth.com&fullname=Tobias+Fünke`
169
+ def create(attributes = {})
170
+ attributes ||= {}
171
+ resource = @parent.new(@params.merge(attributes))
172
+ resource.save
173
+
174
+ resource
175
+ end
176
+
177
+ # Fetch a resource and create it if it's not found
178
+ #
179
+ # @example
180
+ # @user = User.where(:email => "remi@example.com").find_or_create
181
+ #
182
+ # # Returns the first item of the collection if present:
183
+ # # GET "/users?email=remi@example.com"
184
+ #
185
+ # # If collection is empty:
186
+ # # POST /users with `email=remi@example.com`
187
+ def first_or_create(attributes = {})
188
+ fetch.first || create(attributes)
189
+ end
190
+
191
+ # Fetch a resource and build it if it's not found
192
+ #
193
+ # @example
194
+ # @user = User.where(:email => "remi@example.com").find_or_initialize
195
+ #
196
+ # # Returns the first item of the collection if present:
197
+ # # GET "/users?email=remi@example.com"
198
+ #
199
+ # # If collection is empty:
200
+ # @user.email # => "remi@example.com"
201
+ # @user.new? # => true
202
+ def first_or_initialize(attributes = {})
203
+ fetch.first || build(attributes)
204
+ end
205
+
206
+ # @private
207
+ def clear_fetch_cache!
208
+ instance_variable_set(:@_fetch, nil)
209
+ end
210
+ end
211
+ end
212
+ end
data/lib/him/model.rb ADDED
@@ -0,0 +1,79 @@
1
+ require "him/model/base"
2
+ require "him/model/http"
3
+ require "him/model/attributes"
4
+ require "him/model/relation"
5
+ require "him/model/orm"
6
+ require "him/model/parse"
7
+ require "him/model/associations"
8
+ require "him/model/introspection"
9
+ require "him/model/paths"
10
+ require "him/model/nested_attributes"
11
+ require "active_model"
12
+
13
+ module Him
14
+ # This module is the main element of Her. After creating a Him::API object,
15
+ # include this module in your models to get a few magic methods defined in them.
16
+ #
17
+ # @example
18
+ # class User
19
+ # include Him::Model
20
+ # end
21
+ #
22
+ # @user = User.new(:name => "Rémi")
23
+ # @user.save
24
+ module Model
25
+ extend ActiveSupport::Concern
26
+
27
+ # Her modules
28
+ include Him::Model::Base
29
+ include Him::Model::Attributes
30
+ include Him::Model::ORM
31
+ include Him::Model::HTTP
32
+ include Him::Model::Parse
33
+ include Him::Model::Introspection
34
+ include Him::Model::Paths
35
+ include Him::Model::Associations
36
+ include Him::Model::NestedAttributes
37
+
38
+ # Supported ActiveModel modules
39
+ include ActiveModel::AttributeMethods
40
+ include ActiveModel::Validations
41
+ include ActiveModel::Validations::Callbacks
42
+ include ActiveModel::Conversion
43
+ include ActiveModel::Dirty
44
+
45
+ # Override ActiveModel::Dirty's attribute_changed_in_place? to use
46
+ # Her's change tracking. This allows validates_numericality_of to work.
47
+ def attribute_changed_in_place?(attribute_name)
48
+ !changes[attribute_name.to_s].nil?
49
+ end
50
+
51
+ # Class methods
52
+ included do
53
+ # Assign the default API
54
+ use_api Him::API.default_api
55
+ method_for :create, :post
56
+ method_for :update, :put
57
+ method_for :find, :get
58
+ method_for :destroy, :delete
59
+ method_for :new, :get
60
+
61
+ # Define the default primary key
62
+ primary_key :id
63
+
64
+ # Define default storage accessors for errors and metadata
65
+ store_response_errors :response_errors
66
+ store_metadata :metadata
67
+
68
+ # Include ActiveModel naming methods
69
+ extend ActiveModel::Translation
70
+
71
+ # Configure ActiveModel callbacks
72
+ extend ActiveModel::Callbacks
73
+ define_model_callbacks :create, :update, :save, :find, :destroy, :initialize
74
+
75
+ # Define matchers for attr? and attr= methods
76
+ define_attribute_method_matchers
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,3 @@
1
+ module Him
2
+ VERSION = "0.1.0"
3
+ end
data/lib/him.rb ADDED
@@ -0,0 +1,22 @@
1
+ require "him/version"
2
+
3
+ require "json"
4
+ require "faraday"
5
+ require "active_support"
6
+ require "active_support/inflector"
7
+ require "active_support/core_ext/hash"
8
+
9
+ require "him/model"
10
+ require "him/api"
11
+ require "him/middleware"
12
+ require "him/errors"
13
+ require "him/collection"
14
+
15
+ module Him
16
+ module JsonApi
17
+ autoload :Model, "him/json_api/model"
18
+ end
19
+ end
20
+
21
+ # Backward compatibility alias for migration from Her
22
+ Her = Him
data/spec/api_spec.rb ADDED
@@ -0,0 +1,120 @@
1
+ # encoding: utf-8
2
+
3
+ require File.join(File.dirname(__FILE__), "spec_helper.rb")
4
+
5
+ describe Him::API do
6
+ subject { Him::API.new }
7
+
8
+ context "initialization" do
9
+ describe "#setup" do
10
+ context "when setting custom middleware" do
11
+ before do
12
+ class Foo; end
13
+ class Bar; end
14
+
15
+ subject.setup url: "https://api.example.com" do |connection|
16
+ connection.use Foo
17
+ connection.use Bar
18
+ end
19
+ end
20
+
21
+ specify { expect(subject.connection.builder.handlers).to eq([Foo, Bar]) }
22
+ end
23
+
24
+ context "when setting custom options" do
25
+ before { subject.setup foo: { bar: "baz" }, url: "https://api.example.com" }
26
+
27
+ describe "#options" do
28
+ it { expect(subject.options).to eq(foo: { bar: "baz" }, url: "https://api.example.com") }
29
+ end
30
+ end
31
+ end
32
+
33
+ describe "#request" do
34
+ before do
35
+ class SimpleParser < Faraday::Middleware
36
+
37
+ def on_complete(env)
38
+ env[:body] = { data: env[:body] }
39
+ end
40
+ end
41
+ end
42
+
43
+ context "making HTTP requests" do
44
+ let(:parsed_data) { subject.request(_method: :get, _path: "/foo")[:parsed_data] }
45
+ before do
46
+ subject.setup url: "https://api.example.com" do |builder|
47
+ builder.use SimpleParser
48
+ builder.adapter(:test) { |stub| stub.get("/foo") { [200, {}, "Foo, it is."] } }
49
+ end
50
+ end
51
+
52
+ specify { expect(parsed_data[:data]).to eq("Foo, it is.") }
53
+ end
54
+
55
+ context "making HTTP requests while specifying custom HTTP headers" do
56
+ let(:parsed_data) { subject.request(_method: :get, _path: "/foo", _headers: { "X-Page" => 2 })[:parsed_data] }
57
+
58
+ before do
59
+ subject.setup url: "https://api.example.com" do |builder|
60
+ builder.use SimpleParser
61
+ builder.adapter(:test) { |stub| stub.get("/foo") { |env| [200, {}, "Foo, it is page #{env[:request_headers]['X-Page']}."] } }
62
+ end
63
+ end
64
+
65
+ specify { expect(parsed_data[:data]).to eq("Foo, it is page 2.") }
66
+ end
67
+
68
+ context "parsing a request with the default parser" do
69
+ let(:parsed_data) { subject.request(_method: :get, _path: "users/1")[:parsed_data] }
70
+ before do
71
+ subject.setup url: "https://api.example.com" do |builder|
72
+ builder.use Him::Middleware::FirstLevelParseJSON
73
+ builder.adapter :test do |stub|
74
+ stub.get("/users/1") { [200, {}, JSON.generate(id: 1, name: "George Michael Bluth", errors: ["This is a single error"], metadata: { page: 1, per_page: 10 })] }
75
+ end
76
+ end
77
+ end
78
+
79
+ specify do
80
+ expect(parsed_data[:data]).to eq(id: 1, name: "George Michael Bluth")
81
+ expect(parsed_data[:errors]).to eq(["This is a single error"])
82
+ expect(parsed_data[:metadata]).to eq(page: 1, per_page: 10)
83
+ end
84
+ end
85
+
86
+ context "parsing a request with a custom parser" do
87
+ let(:parsed_data) { subject.request(_method: :get, _path: "users/1")[:parsed_data] }
88
+ before do
89
+ class CustomParser < Faraday::Middleware
90
+
91
+ def on_complete(env)
92
+ json = JSON.parse(env[:body], symbolize_names: true)
93
+ errors = json.delete(:errors) || []
94
+ metadata = json.delete(:metadata) || {}
95
+ env[:body] = {
96
+ data: json,
97
+ errors: errors,
98
+ metadata: metadata
99
+ }
100
+ end
101
+ end
102
+
103
+ subject.setup url: "https://api.example.com" do |builder|
104
+ builder.use CustomParser
105
+ builder.use Faraday::Request::UrlEncoded
106
+ builder.adapter :test do |stub|
107
+ stub.get("/users/1") { [200, {}, JSON.generate(id: 1, name: "George Michael Bluth")] }
108
+ end
109
+ end
110
+ end
111
+
112
+ specify do
113
+ expect(parsed_data[:data]).to eq(id: 1, name: "George Michael Bluth")
114
+ expect(parsed_data[:errors]).to eq([])
115
+ expect(parsed_data[:metadata]).to eq({})
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,70 @@
1
+ require "spec_helper"
2
+
3
+ describe Him::Collection do
4
+ let(:items) { [1, 2, 3, 4] }
5
+ let(:metadata) { { name: "Testname" } }
6
+ let(:errors) { { name: ["not_present"] } }
7
+
8
+ describe "#new" do
9
+ context "without parameters" do
10
+ subject { Him::Collection.new }
11
+
12
+ it { is_expected.to eq([]) }
13
+
14
+ describe "#metadata" do
15
+ subject { super().metadata }
16
+ it { is_expected.to eq({}) }
17
+ end
18
+
19
+ describe "#errors" do
20
+ subject { super().errors }
21
+ it { is_expected.to eq({}) }
22
+ end
23
+ end
24
+
25
+ context "with parameters" do
26
+ subject { Him::Collection.new(items, metadata, errors) }
27
+
28
+ it { is_expected.to eq([1, 2, 3, 4]) }
29
+
30
+ describe "#metadata" do
31
+ subject { super().metadata }
32
+ it { is_expected.to eq(name: "Testname") }
33
+ end
34
+
35
+ describe "#errors" do
36
+ subject { super().errors }
37
+ it { is_expected.to eq(name: ["not_present"]) }
38
+ end
39
+ end
40
+ end
41
+
42
+ describe "Array methods preserve Collection type" do
43
+ subject { Him::Collection.new(items, metadata, errors) }
44
+
45
+ it "returns a Collection from #select" do
46
+ result = subject.select(&:odd?)
47
+ expect(result).to be_a(Him::Collection)
48
+ expect(result).to eq([1, 3])
49
+ expect(result.metadata).to eq(name: "Testname")
50
+ end
51
+
52
+ it "returns a Collection from #reject" do
53
+ result = subject.reject(&:odd?)
54
+ expect(result).to be_a(Him::Collection)
55
+ expect(result).to eq([2, 4])
56
+ end
57
+
58
+ it "returns a Collection from #map" do
59
+ result = subject.map { |x| x * 2 }
60
+ expect(result).to be_a(Him::Collection)
61
+ expect(result).to eq([2, 4, 6, 8])
62
+ end
63
+
64
+ it "returns a Collection from #first with count" do
65
+ result = subject.first(2)
66
+ expect(result).to be_a(Him::Collection)
67
+ expect(result).to eq([1, 2])
68
+ end
69
+ end
70
+ end