lurch 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 (67) hide show
  1. checksums.yaml +7 -0
  2. data/.coveralls.yml +1 -0
  3. data/.gitignore +4 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +29 -0
  6. data/.travis.yml +7 -0
  7. data/CHANGELOG.md +12 -0
  8. data/Gemfile +2 -0
  9. data/LICENSE.md +9 -0
  10. data/README.md +229 -0
  11. data/Rakefile +12 -0
  12. data/TODO.md +22 -0
  13. data/lib/lurch/changeset.rb +32 -0
  14. data/lib/lurch/client.rb +92 -0
  15. data/lib/lurch/collection.rb +111 -0
  16. data/lib/lurch/configuration.rb +14 -0
  17. data/lib/lurch/error.rb +13 -0
  18. data/lib/lurch/errors/bad_request.rb +6 -0
  19. data/lib/lurch/errors/conflict.rb +6 -0
  20. data/lib/lurch/errors/forbidden.rb +6 -0
  21. data/lib/lurch/errors/json_api_error.rb +29 -0
  22. data/lib/lurch/errors/not_found.rb +6 -0
  23. data/lib/lurch/errors/not_loaded.rb +13 -0
  24. data/lib/lurch/errors/relationship_not_loaded.rb +9 -0
  25. data/lib/lurch/errors/resource_not_loaded.rb +9 -0
  26. data/lib/lurch/errors/server_error.rb +6 -0
  27. data/lib/lurch/errors/unauthorized.rb +6 -0
  28. data/lib/lurch/errors/unprocessable_entity.rb +6 -0
  29. data/lib/lurch/inflector.rb +60 -0
  30. data/lib/lurch/logger.rb +7 -0
  31. data/lib/lurch/middleware/json_api_request.rb +22 -0
  32. data/lib/lurch/middleware/json_api_response.rb +24 -0
  33. data/lib/lurch/paginator.rb +71 -0
  34. data/lib/lurch/payload_builder.rb +43 -0
  35. data/lib/lurch/query.rb +143 -0
  36. data/lib/lurch/query_builder.rb +26 -0
  37. data/lib/lurch/railtie.rb +9 -0
  38. data/lib/lurch/relationship/has_many.rb +17 -0
  39. data/lib/lurch/relationship/has_one.rb +21 -0
  40. data/lib/lurch/relationship/linked.rb +40 -0
  41. data/lib/lurch/relationship.rb +26 -0
  42. data/lib/lurch/resource.rb +82 -0
  43. data/lib/lurch/store.rb +149 -0
  44. data/lib/lurch/store_configuration.rb +27 -0
  45. data/lib/lurch/stored_resource.rb +63 -0
  46. data/lib/lurch/uri_builder.rb +32 -0
  47. data/lib/lurch/version.rb +3 -0
  48. data/lib/lurch.rb +65 -0
  49. data/lurch.gemspec +26 -0
  50. data/lurch.gif +0 -0
  51. data/test/helpers/lurch_test.rb +40 -0
  52. data/test/helpers/response_factory.rb +193 -0
  53. data/test/lurch/test_configuration.rb +20 -0
  54. data/test/lurch/test_create_resources.rb +55 -0
  55. data/test/lurch/test_delete_resources.rb +27 -0
  56. data/test/lurch/test_errors.rb +29 -0
  57. data/test/lurch/test_fetch_relationships.rb +50 -0
  58. data/test/lurch/test_fetch_resources.rb +77 -0
  59. data/test/lurch/test_inflector.rb +13 -0
  60. data/test/lurch/test_paginated_collections.rb +125 -0
  61. data/test/lurch/test_queries.rb +104 -0
  62. data/test/lurch/test_relationship.rb +17 -0
  63. data/test/lurch/test_resource.rb +34 -0
  64. data/test/lurch/test_update_relationships.rb +53 -0
  65. data/test/lurch/test_update_resources.rb +56 -0
  66. data/test/test_helper.rb +15 -0
  67. metadata +235 -0
@@ -0,0 +1,13 @@
1
+ module Lurch
2
+ module Errors
3
+ class NotLoaded < StandardError
4
+ def initialize(type)
5
+ @type = type
6
+ end
7
+
8
+ private
9
+
10
+ attr_reader :type
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ module Lurch
2
+ module Errors
3
+ class RelationshipNotLoaded < NotLoaded
4
+ def message
5
+ "Relationship (#{type}) not loaded, try calling #fetch first."
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Lurch
2
+ module Errors
3
+ class ResourceNotLoaded < NotLoaded
4
+ def message
5
+ "Resource (#{type}) not loaded, try calling #fetch first."
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ module Lurch
2
+ module Errors
3
+ class ServerError < JSONApiError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Lurch
2
+ module Errors
3
+ class Unauthorized < JSONApiError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Lurch
2
+ module Errors
3
+ class UnprocessableEntity < JSONApiError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,60 @@
1
+ module Lurch
2
+ class Inflector
3
+ def initialize(inflection_mode, types_mode)
4
+ define_encode_key(inflection_mode)
5
+ define_encode_type(types_mode)
6
+ end
7
+
8
+ def self.classify(s)
9
+ Inflecto.classify(s)
10
+ end
11
+
12
+ def self.decode_key(key)
13
+ Inflecto.underscore(key.to_s).to_sym
14
+ end
15
+
16
+ def self.decode_type(type)
17
+ Inflecto.pluralize(decode_key(type)).to_sym
18
+ end
19
+
20
+ def define_encode_key(inflection_mode)
21
+ case inflection_mode
22
+ when :dasherize
23
+ define_singleton_method(:encode_key) { |key| Inflecto.dasherize(key.to_s) }
24
+ when :underscore
25
+ define_singleton_method(:encode_key) { |key| Inflecto.underscore(key.to_s) }
26
+ else
27
+ raise ArgumentError, "Invalid inflection mode: #{inflection_mode}"
28
+ end
29
+ end
30
+
31
+ def define_encode_type(types_mode)
32
+ case types_mode
33
+ when :pluralize
34
+ define_singleton_method(:encode_type) do |type|
35
+ key = encode_key(type)
36
+ Inflecto.pluralize(key)
37
+ end
38
+ when :singularize
39
+ define_singleton_method(:encode_type) do |type|
40
+ key = encode_key(type)
41
+ Inflecto.singularize(key)
42
+ end
43
+ else
44
+ raise ArgumentError, "Invalid types mode: #{types_mode}"
45
+ end
46
+ end
47
+
48
+ def encode_keys(hash)
49
+ hash.each_with_object({}) do |(key, value), acc|
50
+ acc[encode_key(key)] = block_given? ? yield(value) : value
51
+ end
52
+ end
53
+
54
+ def encode_types(hash)
55
+ hash.each_with_object({}) do |(key, value), acc|
56
+ acc[encode_type(key)] = block_given? ? yield(value) : value
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,7 @@
1
+ module Lurch
2
+ class Logger
3
+ def self.debug(&block)
4
+ Lurch.configuration.logger.debug("Lurch", &block)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,22 @@
1
+ module Lurch
2
+ module Middleware
3
+ class JSONApiRequest < Faraday::Middleware
4
+ CONTENT_TYPE = "Content-Type".freeze
5
+ ACCEPT = "Accept".freeze
6
+ MIME_TYPE = "application/vnd.api+json".freeze
7
+
8
+ dependency do
9
+ require "json" unless defined?(::JSON)
10
+ end
11
+
12
+ def call(env)
13
+ env[:request_headers][CONTENT_TYPE] = MIME_TYPE
14
+ env[:request_headers][ACCEPT] = MIME_TYPE
15
+ env[:body] = JSON.generate(env[:body]) if env[:body].is_a?(Hash)
16
+ @app.call(env)
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ Faraday::Request.register_middleware jsonapi: -> { Lurch::Middleware::JSONApiRequest }
@@ -0,0 +1,24 @@
1
+ module Lurch
2
+ module Middleware
3
+ class JSONApiResponse < Faraday::Middleware
4
+ MIME_TYPE = "application/vnd.api+json".freeze
5
+
6
+ dependency do
7
+ require "json" unless defined?(::JSON)
8
+ end
9
+
10
+ def call(conn)
11
+ @app.call(conn).on_complete do |env|
12
+ env[:body_raw] = env[:body]
13
+ begin
14
+ env[:body] = JSON.parse(env[:body])
15
+ rescue StandardError => err
16
+ env[:parse_error] = err
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ Faraday::Response.register_middleware jsonapi: -> { Lurch::Middleware::JSONApiResponse }
@@ -0,0 +1,71 @@
1
+ module Lurch
2
+ class Paginator
3
+ def initialize(store, document, inflector, config)
4
+ @store = store
5
+ @links = document["links"]
6
+ @meta = document["meta"]
7
+ @config = config
8
+ @inflector = inflector
9
+ end
10
+
11
+ def record_count
12
+ key = @inflector.encode_key(@config.pagination_record_count_key)
13
+ @meta[key]
14
+ end
15
+
16
+ def page_count
17
+ key = @inflector.encode_key(@config.pagination_page_count_key)
18
+ @meta[key]
19
+ end
20
+
21
+ def next_collection
22
+ next_link && @store.load_from_url(next_link)
23
+ end
24
+
25
+ def prev_collection
26
+ prev_link && @store.load_from_url(prev_link)
27
+ end
28
+
29
+ def first_collection
30
+ first_link && @store.load_from_url(first_link)
31
+ end
32
+
33
+ def last_collection
34
+ last_link && @store.load_from_url(last_link)
35
+ end
36
+
37
+ def next?
38
+ !!next_link
39
+ end
40
+
41
+ def prev?
42
+ !!prev_link
43
+ end
44
+
45
+ def first?
46
+ !!first_link
47
+ end
48
+
49
+ def last?
50
+ !!last_link
51
+ end
52
+
53
+ private
54
+
55
+ def next_link
56
+ @links["next"]
57
+ end
58
+
59
+ def prev_link
60
+ @links["prev"]
61
+ end
62
+
63
+ def first_link
64
+ @links["first"]
65
+ end
66
+
67
+ def last_link
68
+ @links["last"]
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,43 @@
1
+ module Lurch
2
+ class PayloadBuilder
3
+ def initialize(inflector)
4
+ @inflector = inflector
5
+ end
6
+
7
+ def build(input, identifier_only = false)
8
+ { "data" => data(input, identifier_only) }
9
+ end
10
+
11
+ private
12
+
13
+ def data(input, identifier_only)
14
+ if input.is_a?(Enumerable)
15
+ input.map { |resource| resource_object_for(resource, identifier_only) }
16
+ else
17
+ resource_object_for(input, identifier_only)
18
+ end
19
+ end
20
+
21
+ def resource_object_for(resource, identifier_only)
22
+ return nil if resource.nil?
23
+ {
24
+ "id" => resource.id,
25
+ "type" => @inflector.encode_type(resource.type),
26
+ "attributes" => attributes_for(resource, identifier_only),
27
+ "relationships" => relationships_for(resource, identifier_only)
28
+ }.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
29
+ end
30
+
31
+ def attributes_for(resource, identifier_only)
32
+ return {} if identifier_only
33
+ @inflector.encode_keys(resource.attributes)
34
+ end
35
+
36
+ def relationships_for(resource, identifier_only)
37
+ return {} if identifier_only
38
+ @inflector.encode_keys(resource.relationships) do |value|
39
+ PayloadBuilder.new(@inflector).build(value, true)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,143 @@
1
+ module Lurch
2
+ class Query
3
+ def initialize(store, inflector)
4
+ @store = store
5
+ @inflector = inflector
6
+ @filter = {}
7
+ @include = []
8
+ @fields = Hash.new { [] }
9
+ @sort = []
10
+ @page = {}
11
+ end
12
+
13
+ def filter(params)
14
+ @filter.merge!(params)
15
+ self
16
+ end
17
+
18
+ def include(*relationship_paths)
19
+ @include += relationship_paths
20
+ self
21
+ end
22
+
23
+ def fields(type, fields = nil)
24
+ type, fields = [@type, type] if type.is_a?(Array) && fields.nil?
25
+ @fields[type] += fields
26
+ self
27
+ end
28
+
29
+ def sort(*sort_keys)
30
+ @sort += sort_keys.map { |sort_key| sort_key.is_a?(Hash) ? sort_key : { sort_key => :asc } }
31
+ self
32
+ end
33
+
34
+ def page(params)
35
+ @page.merge!(params)
36
+ self
37
+ end
38
+
39
+ def type(type)
40
+ @type = Inflector.decode_type(type)
41
+ self
42
+ end
43
+
44
+ # def link(uri)
45
+ # # TODO: fail if type already set
46
+ # # TODO: set uri and merge in query params from provided uri if any
47
+ # self
48
+ # end
49
+
50
+ def all
51
+ raise ArgumentError, "No type specified for query" if @type.nil?
52
+ @store.load_from_url(uri_builder.resources_uri(@type, to_query))
53
+ end
54
+
55
+ def find(id)
56
+ raise ArgumentError, "No type specified for query" if @type.nil?
57
+ raise ArgumentError, "Can't perform find for `nil`" if id.nil?
58
+ @store.peek(@type, id) || @store.load_from_url(uri_builder.resource_uri(@type, id, to_query))
59
+ end
60
+
61
+ def save(changeset)
62
+ raise ArgumentError, "No type specified for query" if @type.nil?
63
+ raise ArgumentError, "Type mismatch" if @type != changeset.type
64
+ @store.save(changeset, to_query)
65
+ end
66
+
67
+ def insert(changeset)
68
+ raise ArgumentError, "No type specified for query" if @type.nil?
69
+ raise ArgumentError, "Type mismatch" if @type != changeset.type
70
+ @store.insert(changeset, to_query)
71
+ end
72
+
73
+ def delete(resource)
74
+ raise ArgumentError, "No type specified for query" if @type.nil?
75
+ raise ArgumentError, "Type mismatch" if @type != resource.type
76
+ @store.delete(resource, to_query)
77
+ end
78
+
79
+ def inspect
80
+ type = @type.nil? ? "" : "[#{Inflector.classify(@type)}]"
81
+ query = to_query
82
+ query = query.empty? ? "" : " #{query.inspect}"
83
+ "#<#{self.class}#{type}#{query}>"
84
+ end
85
+
86
+ private
87
+
88
+ def uri_builder
89
+ @uri_builder ||= URIBuilder.new(@inflector)
90
+ end
91
+
92
+ def to_query
93
+ QueryBuilder.new(
94
+ {
95
+ filter: filter_query,
96
+ include: include_query,
97
+ fields: fields_query,
98
+ sort: sort_query,
99
+ page: page_query
100
+ }.merge(other_uri_params)
101
+ ).encode
102
+ end
103
+
104
+ def other_uri_params
105
+ # TODO: existing non-standard uri query params from the provided uri (if any)
106
+ {}
107
+ end
108
+
109
+ def filter_query
110
+ @inflector.encode_keys(@filter)
111
+ end
112
+
113
+ def include_query
114
+ @include.map { |path| @inflector.encode_key(path) }.compact.uniq.join(",")
115
+ end
116
+
117
+ def fields_query
118
+ @inflector.encode_types(@fields) do |fields|
119
+ fields.map { |field| @inflector.encode_key(field) }.compact.uniq.join(",")
120
+ end
121
+ end
122
+
123
+ def sort_query
124
+ @sort.flat_map(&:to_a).map { |(key, direction)| sort_key(key, direction) }.join(",")
125
+ end
126
+
127
+ def sort_key(key, direction)
128
+ encoded_key = @inflector.encode_key(key)
129
+ case direction
130
+ when :asc
131
+ encoded_key
132
+ when :desc
133
+ "-#{encoded_key}"
134
+ else
135
+ raise ArgumentError, "Invalid sort direction #{direction}"
136
+ end
137
+ end
138
+
139
+ def page_query
140
+ @page
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,26 @@
1
+ module Lurch
2
+ class QueryBuilder
3
+ def initialize(params)
4
+ @params = Hash(params)
5
+ end
6
+
7
+ def encode
8
+ encode_value(@params)
9
+ end
10
+
11
+ private
12
+
13
+ def encode_value(value, key = nil)
14
+ case value
15
+ when Hash then value.map { |k, v| encode_value(v, append_key(key, k)) }.reject(&:empty?).join("&")
16
+ when Array then value.map { |v| encode_value(v, "#{key}[]") }.reject(&:empty?).join("&")
17
+ else
18
+ value.to_s.empty? ? "" : "#{key}=#{CGI.escape(value.to_s)}"
19
+ end
20
+ end
21
+
22
+ def append_key(root_key, key)
23
+ root_key.nil? ? key : "#{root_key}[#{key}]"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,9 @@
1
+ module Lurch
2
+ class Railtie < ::Rails::Railtie
3
+ initializer "lurch.initialization" do
4
+ Lurch.configure do |config|
5
+ config.logger = Rails.logger
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ module Lurch
2
+ class Relationship
3
+ class HasMany < Relationship
4
+ def initialize(store, relationship_key, document)
5
+ @store = store
6
+ @relationship_key = relationship_key
7
+ @document = document
8
+ @data = @document["data"].map { |resource_identifier| Resource.new(@store, resource_identifier["type"], resource_identifier["id"]) }
9
+ end
10
+
11
+ def inspect
12
+ suffix = @data.first ? "[#{Inflector.classify(@data.first.type)}]" : ""
13
+ "#<#{self.class}#{suffix} size: #{@data.size}>"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ module Lurch
2
+ class Relationship
3
+ class HasOne < Relationship
4
+ def initialize(store, relationship_key, document)
5
+ @store = store
6
+ @relationship_key = relationship_key
7
+ if document["data"].nil?
8
+ @data = nil
9
+ else
10
+ @type = Inflector.decode_type(document["data"]["type"])
11
+ @id = document["data"]["id"]
12
+ @data = Resource.new(@store, @type, @id)
13
+ end
14
+ end
15
+
16
+ def inspect
17
+ @data.nil? ? "#<#{self.class} nil>" : "#<#{self.class}[#{Inflector.classify(@type)}] id: #{@id.inspect}>"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,40 @@
1
+ module Lurch
2
+ class Relationship
3
+ class Linked < Relationship
4
+ def initialize(store, relationship_key, document)
5
+ @store = store
6
+ @relationship_key = relationship_key
7
+ @href = document["links"]["related"]
8
+ end
9
+
10
+ def fetch
11
+ @data = @store.load_from_url(@href)
12
+ end
13
+
14
+ # def filter(*args)
15
+ # @store.query.link(@href).filter(*args)
16
+ # end
17
+ #
18
+ # def include(*args)
19
+ # @store.query.link(@href).include(*args)
20
+ # end
21
+ #
22
+ # def fields(*args)
23
+ # @store.query.link(@href).fields(*args)
24
+ # end
25
+ #
26
+ # def sort(*args)
27
+ # @store.query.link(@href).sort(*args)
28
+ # end
29
+ #
30
+ # def page(*args)
31
+ # @store.query.link(@href).page(*args)
32
+ # end
33
+
34
+ def inspect
35
+ suffix = loaded? ? " \"loaded\"" : ""
36
+ "#<#{self.class} href: #{@href.inspect}#{suffix}>"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,26 @@
1
+ module Lurch
2
+ class Relationship
3
+ attr_reader :data
4
+
5
+ def self.from_document(store, relationship_key, document)
6
+ return Relationship::HasMany.new(store, relationship_key, document) if document.key?("data") && document["data"].is_a?(Array)
7
+ return Relationship::HasOne.new(store, relationship_key, document) if document.key?("data")
8
+ return Relationship::Linked.new(store, relationship_key, document) if document.key?("links") && document["links"].key?("related")
9
+ raise ArgumentError, "Invalid relationship document"
10
+ end
11
+
12
+ def loaded?
13
+ !!defined?(@data)
14
+ end
15
+
16
+ def respond_to_missing?(method, all)
17
+ raise Errors::RelationshipNotLoaded, @relationship_key unless loaded?
18
+ super
19
+ end
20
+
21
+ def method_missing(method, *arguments, &block)
22
+ raise Errors::RelationshipNotLoaded, @relationship_key unless loaded?
23
+ super
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,82 @@
1
+ module Lurch
2
+ class Resource
3
+ attr_reader :id, :type
4
+
5
+ def initialize(store, type, id)
6
+ @store = store
7
+ @type = Inflector.decode_type(type)
8
+ @id = id
9
+ end
10
+
11
+ def loaded?
12
+ !!resource_from_store
13
+ end
14
+
15
+ def fetch
16
+ @store.from(type).find(id)
17
+ self
18
+ end
19
+
20
+ def attributes
21
+ raise Errors::ResourceNotLoaded, resource_class_name unless loaded?
22
+
23
+ resource_from_store.attributes
24
+ end
25
+
26
+ def relationships
27
+ raise Errors::ResourceNotLoaded, resource_class_name unless loaded?
28
+
29
+ resource_from_store.relationships
30
+ end
31
+
32
+ def ==(other)
33
+ eql?(other)
34
+ end
35
+
36
+ def eql?(other)
37
+ id == other.id && type == other.type
38
+ end
39
+
40
+ def [](attribute)
41
+ raise Errors::ResourceNotLoaded, resource_class_name unless loaded?
42
+
43
+ resource_from_store.attribute(attribute)
44
+ end
45
+
46
+ def resource_class_name
47
+ Inflector.classify(type)
48
+ end
49
+
50
+ def inspect
51
+ inspection = if loaded?
52
+ attributes.map { |name, value| "#{name}: #{value.inspect}" }.join(", ")
53
+ else
54
+ "not loaded"
55
+ end
56
+ "#<#{self.class}[#{resource_class_name}] id: #{id.inspect}, #{inspection}>"
57
+ end
58
+
59
+ private
60
+
61
+ def resource_from_store
62
+ @store.resource_from_store(type, id)
63
+ end
64
+
65
+ def respond_to_missing?(method, all)
66
+ return super unless loaded?
67
+ resource_from_store.attribute?(method) || resource_from_store.relationship?(method) || super
68
+ end
69
+
70
+ def method_missing(method, *arguments, &block)
71
+ raise Errors::ResourceNotLoaded, resource_class_name unless loaded?
72
+
73
+ return resource_from_store.attribute(method) if resource_from_store.attribute?(method)
74
+ if resource_from_store.relationship?(method)
75
+ rel = resource_from_store.relationship(method)
76
+ return rel.loaded? ? rel.data : rel
77
+ end
78
+
79
+ super
80
+ end
81
+ end
82
+ end