lurch 0.1.0

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