simple_jsonapi_client 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 (34) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +12 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +8 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Dockerfile +9 -0
  7. data/Gemfile +6 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +264 -0
  10. data/Rakefile +8 -0
  11. data/bin/console +3 -0
  12. data/bin/development_start +12 -0
  13. data/bin/rails +3 -0
  14. data/bin/setup +7 -0
  15. data/bin/wait_for_it +178 -0
  16. data/docker-compose.yml +50 -0
  17. data/lib/simple_jsonapi_client.rb +5 -0
  18. data/lib/simple_jsonapi_client/base.rb +325 -0
  19. data/lib/simple_jsonapi_client/error.rb +6 -0
  20. data/lib/simple_jsonapi_client/errors/api_error.rb +84 -0
  21. data/lib/simple_jsonapi_client/redirection/fetch_all.rb +43 -0
  22. data/lib/simple_jsonapi_client/redirection/proxy.rb +54 -0
  23. data/lib/simple_jsonapi_client/relationships/array_data_relationship.rb +19 -0
  24. data/lib/simple_jsonapi_client/relationships/array_link_relationship.rb +15 -0
  25. data/lib/simple_jsonapi_client/relationships/data_relationship_proxy.rb +30 -0
  26. data/lib/simple_jsonapi_client/relationships/has_many_relationship.rb +16 -0
  27. data/lib/simple_jsonapi_client/relationships/has_one_relationship.rb +16 -0
  28. data/lib/simple_jsonapi_client/relationships/link_relationship_proxy.rb +19 -0
  29. data/lib/simple_jsonapi_client/relationships/relationship.rb +34 -0
  30. data/lib/simple_jsonapi_client/relationships/singular_data_relationship.rb +17 -0
  31. data/lib/simple_jsonapi_client/relationships/singular_link_relationship.rb +13 -0
  32. data/lib/simple_jsonapi_client/version.rb +3 -0
  33. data/simple_jsonapi_client.gemspec +31 -0
  34. metadata +180 -0
data/bin/rails ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+
3
+ docker-compose run -p 3000:3000 jsonapi_app_console rails "$@"
data/bin/setup ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ docker-compose build
7
+ bin/rails db:setup
data/bin/wait_for_it ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env bash
2
+ # Use this script to test if a given TCP host/port are available
3
+ # Credit: https://github.com/vishnubob/wait-for-it/blob/db049716e42767d39961e95dd9696103dca813f1/wait-for-it.sh
4
+
5
+ cmdname=$(basename $0)
6
+
7
+ echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
8
+
9
+ usage()
10
+ {
11
+ cat << USAGE >&2
12
+ Usage:
13
+ $cmdname host:port [-s] [-t timeout] [-- command args]
14
+ -h HOST | --host=HOST Host or IP under test
15
+ -p PORT | --port=PORT TCP port under test
16
+ Alternatively, you specify the host and port as host:port
17
+ -s | --strict Only execute subcommand if the test succeeds
18
+ -q | --quiet Don't output any status messages
19
+ -t TIMEOUT | --timeout=TIMEOUT
20
+ Timeout in seconds, zero for no timeout
21
+ -- COMMAND ARGS Execute command with args after the test finishes
22
+ USAGE
23
+ exit 1
24
+ }
25
+
26
+ wait_for()
27
+ {
28
+ if [[ $TIMEOUT -gt 0 ]]; then
29
+ echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT"
30
+ else
31
+ echoerr "$cmdname: waiting for $HOST:$PORT without a timeout"
32
+ fi
33
+ start_ts=$(date +%s)
34
+ while :
35
+ do
36
+ if [[ $ISBUSY -eq 1 ]]; then
37
+ nc -z $HOST $PORT
38
+ result=$?
39
+ else
40
+ (echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1
41
+ result=$?
42
+ fi
43
+ if [[ $result -eq 0 ]]; then
44
+ end_ts=$(date +%s)
45
+ echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds"
46
+ break
47
+ fi
48
+ sleep 1
49
+ done
50
+ return $result
51
+ }
52
+
53
+ wait_for_wrapper()
54
+ {
55
+ # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
56
+ if [[ $QUIET -eq 1 ]]; then
57
+ timeout $BUSYTIMEFLAG $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT &
58
+ else
59
+ timeout $BUSYTIMEFLAG $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT &
60
+ fi
61
+ PID=$!
62
+ trap "kill -INT -$PID" INT
63
+ wait $PID
64
+ RESULT=$?
65
+ if [[ $RESULT -ne 0 ]]; then
66
+ echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT"
67
+ fi
68
+ return $RESULT
69
+ }
70
+
71
+ # process arguments
72
+ while [[ $# -gt 0 ]]
73
+ do
74
+ case "$1" in
75
+ *:* )
76
+ hostport=(${1//:/ })
77
+ HOST=${hostport[0]}
78
+ PORT=${hostport[1]}
79
+ shift 1
80
+ ;;
81
+ --child)
82
+ CHILD=1
83
+ shift 1
84
+ ;;
85
+ -q | --quiet)
86
+ QUIET=1
87
+ shift 1
88
+ ;;
89
+ -s | --strict)
90
+ STRICT=1
91
+ shift 1
92
+ ;;
93
+ -h)
94
+ HOST="$2"
95
+ if [[ $HOST == "" ]]; then break; fi
96
+ shift 2
97
+ ;;
98
+ --host=*)
99
+ HOST="${1#*=}"
100
+ shift 1
101
+ ;;
102
+ -p)
103
+ PORT="$2"
104
+ if [[ $PORT == "" ]]; then break; fi
105
+ shift 2
106
+ ;;
107
+ --port=*)
108
+ PORT="${1#*=}"
109
+ shift 1
110
+ ;;
111
+ -t)
112
+ TIMEOUT="$2"
113
+ if [[ $TIMEOUT == "" ]]; then break; fi
114
+ shift 2
115
+ ;;
116
+ --timeout=*)
117
+ TIMEOUT="${1#*=}"
118
+ shift 1
119
+ ;;
120
+ --)
121
+ shift
122
+ CLI=("$@")
123
+ break
124
+ ;;
125
+ --help)
126
+ usage
127
+ ;;
128
+ *)
129
+ echoerr "Unknown argument: $1"
130
+ usage
131
+ ;;
132
+ esac
133
+ done
134
+
135
+ if [[ "$HOST" == "" || "$PORT" == "" ]]; then
136
+ echoerr "Error: you need to provide a host and port to test."
137
+ usage
138
+ fi
139
+
140
+ TIMEOUT=${TIMEOUT:-15}
141
+ STRICT=${STRICT:-0}
142
+ CHILD=${CHILD:-0}
143
+ QUIET=${QUIET:-0}
144
+
145
+ # check to see if timeout is from busybox?
146
+ # check to see if timeout is from busybox?
147
+ TIMEOUT_PATH=$(realpath $(which timeout))
148
+ if [[ $TIMEOUT_PATH =~ "busybox" ]]; then
149
+ ISBUSY=1
150
+ BUSYTIMEFLAG="-t"
151
+ else
152
+ ISBUSY=0
153
+ BUSYTIMEFLAG=""
154
+ fi
155
+
156
+ if [[ $CHILD -gt 0 ]]; then
157
+ wait_for
158
+ RESULT=$?
159
+ exit $RESULT
160
+ else
161
+ if [[ $TIMEOUT -gt 0 ]]; then
162
+ wait_for_wrapper
163
+ RESULT=$?
164
+ else
165
+ wait_for
166
+ RESULT=$?
167
+ fi
168
+ fi
169
+
170
+ if [[ $CLI != "" ]]; then
171
+ if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then
172
+ echoerr "$cmdname: strict mode, refusing to execute subprocess"
173
+ exit $RESULT
174
+ fi
175
+ exec "${CLI[@]}"
176
+ else
177
+ exit $RESULT
178
+ fi
@@ -0,0 +1,50 @@
1
+ version: '3'
2
+ services:
3
+ db:
4
+ image: postgres
5
+ spec:
6
+ build: .
7
+ entrypoint: bin/wait_for_it jsonapi_app_spec:3001 -t 30 --
8
+ command: bundle exec rspec
9
+ volumes:
10
+ - .:/simple_jsonapi_client
11
+ depends_on:
12
+ - jsonapi_app_spec
13
+ environment:
14
+ API_URL: jsonapi_app_spec
15
+ API_PORT: 3001
16
+ jsonapi_app_spec:
17
+ build: ./spec/jsonapi_app
18
+ entrypoint: bin/wait_for_it db:5432 -t 30 --
19
+ volumes:
20
+ - ./spec/jsonapi_app:/jsonapi_app
21
+ - /jsonapi_app/tmp/
22
+ command: bundle exec rails s -p 3001 -b '0.0.0.0'
23
+ ports:
24
+ - "3001:3001"
25
+ environment:
26
+ API_PORT: 3001
27
+ depends_on:
28
+ - db
29
+ console:
30
+ build: .
31
+ entrypoint: bin/wait_for_it jsonapi_app_console:3002 -t 30 --
32
+ command: bin/development_start
33
+ volumes:
34
+ - .:/simple_jsonapi_client
35
+ depends_on:
36
+ - jsonapi_app_console
37
+ environment:
38
+ API_URL: jsonapi_app_console
39
+ API_PORT: 3002
40
+ jsonapi_app_console:
41
+ build: ./spec/jsonapi_app
42
+ entrypoint: bin/wait_for_it db:5432 -t 30 --
43
+ volumes:
44
+ - ./spec/jsonapi_app:/jsonapi_app
45
+ - /jsonapi_app/tmp/
46
+ command: bundle exec rails s -p 3002 -b '0.0.0.0'
47
+ ports:
48
+ - "3002:3002"
49
+ depends_on:
50
+ - db
@@ -0,0 +1,5 @@
1
+ require 'simple_jsonapi_client/version'
2
+
3
+ module SimpleJSONAPIClient
4
+ require 'simple_jsonapi_client/base'
5
+ end
@@ -0,0 +1,325 @@
1
+ require 'active_support/core_ext/hash/keys'
2
+ require 'active_support/core_ext/hash/transform_values'
3
+ require 'simple_jsonapi_client/error'
4
+ require 'simple_jsonapi_client/relationships/relationship'
5
+ require 'simple_jsonapi_client/redirection/fetch_all'
6
+
7
+ module SimpleJSONAPIClient
8
+ class Base
9
+ class << self
10
+ def relationships
11
+ @relationships ||= {}
12
+ end
13
+
14
+ def _attributes
15
+ @_attributes ||= {}
16
+ end
17
+
18
+ def attributes(*attrs)
19
+ attrs.each do |attr|
20
+ define_method(attr) { attributes[attr] }
21
+ define_method("#{attr}=") { |x| attributes[attr] = x }
22
+ _attributes[attr] = true
23
+ end
24
+ end
25
+
26
+ def meta(*attrs)
27
+ attrs.each do |attr|
28
+ define_method(attr) { meta[attr] }
29
+ define_method("#{attr}=") { |x| meta[attr] = x }
30
+ end
31
+ end
32
+
33
+ def has_many(relationship_name, opts)
34
+ model_class = opts.fetch(:class) { opts.fetch(:class_name) }
35
+ define_relationship_methods!(relationship_name)
36
+ relationships[relationship_name.to_sym] =
37
+ Relationships::HasManyRelationship.new(model_class)
38
+ end
39
+
40
+ def has_one(relationship_name, opts)
41
+ define_relationship_methods!(relationship_name)
42
+ model_class = opts.fetch(:class) { opts.fetch(:class_name) }
43
+ relationships[relationship_name.to_sym] =
44
+ Relationships::HasOneRelationship.new(model_class)
45
+ end
46
+
47
+ def fetch(opts)
48
+ operation(:fetch_request, :singular, opts)
49
+ end
50
+
51
+ def fetch_all(opts)
52
+ Redirection::FetchAll.new(opts) do |request_opts|
53
+ operation(:fetch_all_request, :plural, request_opts)
54
+ end
55
+ end
56
+
57
+ def create(opts)
58
+ operation(:create_request, :singular, opts)
59
+ end
60
+
61
+ def update(opts)
62
+ operation(:update_request, :singular, opts)
63
+ end
64
+
65
+ def delete(opts)
66
+ operation(:delete_request, :empty, opts)
67
+ true
68
+ end
69
+
70
+ def model_from(record, included, connection, context = nil)
71
+ return unless record
72
+ new(
73
+ meta: record['meta'],
74
+ id: record['id'],
75
+ attributes: record.fetch('attributes', {}),
76
+ relationships: record.fetch('relationships', {}),
77
+ context: context,
78
+ included: included,
79
+ connection: connection
80
+ )
81
+ end
82
+
83
+ def interpreted_included(records, included)
84
+ {}.tap do |included_hash|
85
+ include_records(included_hash, records)
86
+ include_records(included_hash, included)
87
+ end
88
+ end
89
+
90
+ def include_records(included_hash, records)
91
+ records.to_a.each do |record|
92
+ included_hash[{ 'id' => record['id'], 'type' => record['type'] }] = record
93
+ end
94
+ end
95
+
96
+ def template(id: nil, attributes:, relationships: {})
97
+ data = {
98
+ type: self::TYPE,
99
+ attributes: attributes,
100
+ relationships: interpreted_relationships(relationships)
101
+ }
102
+ data[:id] = id if id
103
+ { data: data }
104
+ end
105
+
106
+ private
107
+
108
+ def define_relationship_methods!(relationship_name)
109
+ define_method(relationship_name) { relationships[relationship_name] }
110
+ define_method("#{relationship_name}=") { |x| relationships[relationship_name] = x }
111
+ end
112
+
113
+ def operation(request_method, response_type, opts)
114
+ response = send(request_method, opts)
115
+ handling_error(response) do
116
+ send(:"interpret_#{response_type}_response", response, opts[:connection])
117
+ end
118
+ end
119
+
120
+ def create_request(connection:,
121
+ url_opts: {},
122
+ url: self::COLLECTION_URL % url_opts,
123
+ attributes: {},
124
+ relationships: {},
125
+ **attrs)
126
+ attributes, relationships = extract_attrs(attrs, attributes, relationships)
127
+ body = template(attributes: attributes, relationships: relationships)
128
+ connection.post(url, body)
129
+ end
130
+
131
+ def update_request(connection:,
132
+ id:,
133
+ url_opts: {},
134
+ attributes: {},
135
+ relationships: {},
136
+ **attrs)
137
+ attributes, relationships = extract_attrs(attrs, attributes, relationships)
138
+ connection.patch(self::INDIVIDUAL_URL % url_opts) do |request|
139
+ request.body = template(
140
+ id: id,
141
+ attributes: attributes,
142
+ relationships: relationships
143
+ )
144
+ end
145
+ end
146
+
147
+ def delete_request(connection:, url_opts: {})
148
+ connection.delete(self::INDIVIDUAL_URL % url_opts)
149
+ end
150
+
151
+ def fetch_request(connection:,
152
+ url_opts: {},
153
+ filter_opts: {},
154
+ url: self::INDIVIDUAL_URL % url_opts,
155
+ includes: [])
156
+ params = {}
157
+ params[:include] = includes.join(',') unless includes.empty?
158
+ params[:filter] = filter_opts unless filter_opts.empty?
159
+ connection.get(url, params)
160
+ end
161
+
162
+ def fetch_all_request(connection:,
163
+ url_opts: {},
164
+ url: self::COLLECTION_URL % url_opts,
165
+ filter_opts: {},
166
+ includes: [])
167
+ params = {}
168
+ params[:include] = includes.join(',') unless includes.empty?
169
+ params[:filter] = filter_opts unless filter_opts.empty?
170
+ connection.get(url, params)
171
+ end
172
+
173
+ def extract_attrs(attrs, attributes, relationships)
174
+ attrs.each do |attr, value|
175
+ if _attributes.key?(attr)
176
+ attributes[attr] = value
177
+ elsif self.relationships.key?(attr)
178
+ relationships[attr] = value
179
+ else
180
+ raise ArgumentError, %{Invalid attribute "#{attr}"}
181
+ end
182
+ end
183
+ [attributes, relationships]
184
+ end
185
+
186
+ def interpret_singular_response(response, connection)
187
+ body = response.body
188
+ record = body['data']
189
+ records = [record].compact
190
+ included = interpreted_included(records, body['included'])
191
+ model_from(record, included, connection, response)
192
+ end
193
+
194
+ def interpret_plural_response(response, connection)
195
+ body = response.body
196
+ records = body['data']
197
+ included = interpreted_included(records, body['included'])
198
+ {
199
+ 'links' => body['links'],
200
+ 'data' => records.map { |record|
201
+ model_from(record, included, connection, response)
202
+ }
203
+ }
204
+ end
205
+
206
+ def interpret_empty_response(response, connection)
207
+ end
208
+
209
+ def interpreted_relationships(relationships)
210
+ relationships.transform_values { |value|
211
+ if (relationship = relationship_from(value))
212
+ { data: relationship }
213
+ end
214
+ }
215
+ end
216
+
217
+ def relationship_from(value)
218
+ if value.respond_to?(:to_relationship)
219
+ value.to_relationship
220
+ elsif value.respond_to?(:map)
221
+ value.map(&:to_relationship)
222
+ elsif !value.nil?
223
+ raise ArgumentError, "#{value} cannot be converted to relationship!"
224
+ end
225
+ end
226
+
227
+ def handling_error(response)
228
+ if response.success?
229
+ yield
230
+ else
231
+ raise ::SimpleJSONAPIClient::Errors::APIError.generate(response)
232
+ end
233
+ end
234
+ end
235
+
236
+ attr_reader :id, :context
237
+
238
+ def initialize(meta: nil, id:, attributes: nil, relationships: nil, included: {}, connection:, context: nil)
239
+ @meta = meta.symbolize_keys if meta
240
+ @id = id
241
+ @included = included
242
+ @connection = connection
243
+ @context = context
244
+ @attributes = attributes.symbolize_keys if attributes
245
+ @input_relationships = relationships
246
+ end
247
+
248
+ def to_relationship
249
+ { type: self.class::TYPE, id: id }
250
+ end
251
+
252
+ def same_record_as?(other)
253
+ to_relationship == other.to_relationship
254
+ end
255
+
256
+ def attributes
257
+ @attributes ||= loaded_record.attributes
258
+ end
259
+
260
+ def meta
261
+ @meta ||= loaded_record.meta
262
+ end
263
+
264
+ def relationships
265
+ @relationships ||=
266
+ begin
267
+ if input_relationships
268
+ relationships_to_models(input_relationships.symbolize_keys)
269
+ else
270
+ loaded_record.relationships
271
+ end
272
+ end
273
+ end
274
+
275
+ def update(**attrs)
276
+ self.class.update(
277
+ connection: connection,
278
+ id: id,
279
+ url_opts: { id: id },
280
+ **attrs
281
+ )
282
+ end
283
+
284
+ def delete
285
+ self.class.delete(
286
+ connection: connection,
287
+ url_opts: { id: id }
288
+ )
289
+ end
290
+
291
+ def as_json
292
+ self.class.template(
293
+ id: id,
294
+ attributes: attributes,
295
+ relationships: relationships
296
+ )
297
+ end
298
+
299
+ def to_json(*args)
300
+ as_json.to_json(*args)
301
+ end
302
+
303
+ def inspect
304
+ parsed_attributes = attributes.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')
305
+ parsed_attributes = " #{parsed_attributes}" unless parsed_attributes.empty?
306
+ parsed_relationships = relationships.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')
307
+ parsed_relationships = " #{parsed_relationships}" unless parsed_relationships.empty?
308
+ "#<#{self.class.name} id=#{id}#{parsed_attributes}#{parsed_relationships}>"
309
+ end
310
+
311
+ private
312
+ attr_reader :input_relationships, :included, :connection
313
+
314
+ def relationships_to_models(relationships)
315
+ relationships.each_with_object({}) do |(relationship, info), memo|
316
+ next unless implementation = self.class.relationships[relationship]
317
+ memo[relationship] = implementation.call(info, included, connection)
318
+ end
319
+ end
320
+
321
+ def loaded_record
322
+ @loaded_record ||= self.class.fetch(connection: connection, url_opts: { id: id })
323
+ end
324
+ end
325
+ end