wcc-contentful 0.4.0.pre.rc → 1.0.0.pre.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +5 -5
  2. data/Guardfile +43 -0
  3. data/README.md +101 -12
  4. data/app/controllers/wcc/contentful/webhook_controller.rb +25 -24
  5. data/app/jobs/wcc/contentful/webhook_enable_job.rb +36 -2
  6. data/config/routes.rb +1 -1
  7. data/doc/wcc-contentful.png +0 -0
  8. data/lib/tasks/download_schema.rake +12 -0
  9. data/lib/wcc/contentful.rb +70 -16
  10. data/lib/wcc/contentful/active_record_shim.rb +72 -0
  11. data/lib/wcc/contentful/configuration.rb +177 -46
  12. data/lib/wcc/contentful/content_type_indexer.rb +14 -0
  13. data/lib/wcc/contentful/downloads_schema.rb +112 -0
  14. data/lib/wcc/contentful/engine.rb +33 -14
  15. data/lib/wcc/contentful/event.rb +171 -0
  16. data/lib/wcc/contentful/events.rb +41 -0
  17. data/lib/wcc/contentful/exceptions.rb +3 -0
  18. data/lib/wcc/contentful/indexed_representation.rb +2 -2
  19. data/lib/wcc/contentful/instrumentation.rb +31 -0
  20. data/lib/wcc/contentful/link.rb +28 -0
  21. data/lib/wcc/contentful/link_visitor.rb +122 -0
  22. data/lib/wcc/contentful/middleware.rb +7 -0
  23. data/lib/wcc/contentful/middleware/store.rb +158 -0
  24. data/lib/wcc/contentful/middleware/store/caching_middleware.rb +114 -0
  25. data/lib/wcc/contentful/model.rb +37 -3
  26. data/lib/wcc/contentful/model_builder.rb +1 -0
  27. data/lib/wcc/contentful/model_methods.rb +40 -15
  28. data/lib/wcc/contentful/model_singleton_methods.rb +47 -30
  29. data/lib/wcc/contentful/rake.rb +3 -0
  30. data/lib/wcc/contentful/rspec.rb +13 -8
  31. data/lib/wcc/contentful/services.rb +61 -27
  32. data/lib/wcc/contentful/simple_client.rb +81 -25
  33. data/lib/wcc/contentful/simple_client/management.rb +43 -10
  34. data/lib/wcc/contentful/simple_client/response.rb +61 -22
  35. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +17 -17
  36. data/lib/wcc/contentful/store.rb +7 -66
  37. data/lib/wcc/contentful/store/README.md +85 -0
  38. data/lib/wcc/contentful/store/base.rb +34 -119
  39. data/lib/wcc/contentful/store/cdn_adapter.rb +71 -12
  40. data/lib/wcc/contentful/store/factory.rb +186 -0
  41. data/lib/wcc/contentful/store/instrumentation.rb +55 -0
  42. data/lib/wcc/contentful/store/interface.rb +82 -0
  43. data/lib/wcc/contentful/store/memory_store.rb +27 -24
  44. data/lib/wcc/contentful/store/postgres_store.rb +253 -107
  45. data/lib/wcc/contentful/store/postgres_store/schema_1.sql +73 -0
  46. data/lib/wcc/contentful/store/postgres_store/schema_2.sql +21 -0
  47. data/lib/wcc/contentful/store/query.rb +246 -0
  48. data/lib/wcc/contentful/store/query/interface.rb +63 -0
  49. data/lib/wcc/contentful/store/rspec_examples.rb +48 -0
  50. data/lib/wcc/contentful/store/rspec_examples/basic_store.rb +629 -0
  51. data/lib/wcc/contentful/store/rspec_examples/include_param.rb +283 -0
  52. data/lib/wcc/contentful/store/rspec_examples/nested_queries.rb +342 -0
  53. data/lib/wcc/contentful/sync_engine.rb +181 -0
  54. data/lib/wcc/contentful/test/attributes.rb +17 -5
  55. data/lib/wcc/contentful/test/factory.rb +22 -46
  56. data/lib/wcc/contentful/version.rb +1 -1
  57. data/wcc-contentful.gemspec +14 -11
  58. metadata +201 -146
  59. data/Gemfile +0 -6
  60. data/app/jobs/wcc/contentful/delayed_sync_job.rb +0 -63
  61. data/lib/wcc/contentful/client_ext.rb +0 -28
  62. data/lib/wcc/contentful/graphql.rb +0 -14
  63. data/lib/wcc/contentful/graphql/builder.rb +0 -177
  64. data/lib/wcc/contentful/graphql/types.rb +0 -54
  65. data/lib/wcc/contentful/simple_client/http_adapter.rb +0 -24
  66. data/lib/wcc/contentful/store/lazy_cache_store.rb +0 -161
data/Gemfile DELETED
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source 'https://rubygems.org'
4
-
5
- # Specify your gem's dependencies in wcc-contentful.gemspec
6
- gemspec
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'active_job'
4
-
5
- module WCC::Contentful
6
- # This job uses the Contentful Sync API to update the configured store with
7
- # the latest data from Contentful.
8
- class DelayedSyncJob < ActiveJob::Base
9
- include WCC::Contentful::ServiceAccessors
10
-
11
- self.queue_adapter = :async
12
- queue_as :default
13
-
14
- def self.mutex
15
- @mutex ||= Mutex.new
16
- end
17
-
18
- def perform(event = nil)
19
- up_to_id = nil
20
- up_to_id = event[:up_to_id] || event.dig('sys', 'id') if event
21
- sync!(up_to_id: up_to_id)
22
- end
23
-
24
- # Calls the Contentful Sync API and updates the configured store with the returned
25
- # data.
26
- #
27
- # @param [String] up_to_id
28
- # An ID that we know has changed and should come back from the sync.
29
- # If we don't find this ID in the sync data, then drop a job to try
30
- # the sync again after a few minutes.
31
- #
32
- def sync!(up_to_id: nil)
33
- return unless store.respond_to?(:index)
34
-
35
- self.class.mutex.synchronize do
36
- next_sync_token = store.find('sync:token')&.fetch('token')
37
- sync_resp = client.sync(sync_token: next_sync_token)
38
-
39
- id_found = up_to_id.nil?
40
-
41
- count = 0
42
- sync_resp.items.each do |item|
43
- id = item.dig('sys', 'id')
44
- id_found ||= id == up_to_id
45
- store.index(item)
46
- count += 1
47
- end
48
- store.set('sync:token', token: sync_resp.next_sync_token)
49
-
50
- logger.info "Synced #{count} entries. Next sync token:\n #{sync_resp.next_sync_token}"
51
- sync_later!(up_to_id: up_to_id) unless id_found
52
- sync_resp.next_sync_token
53
- end
54
- end
55
-
56
- # Drops an ActiveJob job to invoke WCC::Contentful.sync! after a given amount
57
- # of time.
58
- def sync_later!(up_to_id: nil, wait: 10.minutes)
59
- WCC::Contentful::DelayedSyncJob.set(wait: wait)
60
- .perform_later(up_to_id: up_to_id)
61
- end
62
- end
63
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Contentful::Client
4
- class << self
5
- alias_method :old_get_http, :get_http
6
- end
7
-
8
- def self.adapter
9
- @adapter ||=
10
- WCC::Contentful::SimpleClient.load_adapter(WCC::Contentful.configuration.http_adapter) ||
11
- ->(url, query, headers, proxy) { old_get_http(url, query, headers, proxy) }
12
- end
13
-
14
- def self.get_http(url, query, headers = {}, proxy = {})
15
- if environment = WCC::Contentful.configuration.environment
16
- url = rewrite_to_environment(url, environment)
17
- end
18
-
19
- adapter.call(url, query, headers, proxy)
20
- end
21
-
22
- REWRITE_REGEXP = /^(https?\:\/\/(?:\w+)\.contentful\.com\/spaces\/[^\/]+\/)(?!environments)(.+)$/
23
- def self.rewrite_to_environment(url, environment)
24
- return url unless m = REWRITE_REGEXP.match(url)
25
-
26
- File.join(m[1], 'environments', environment, m[2])
27
- end
28
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- gem 'graphql', '~> 1.7'
4
- require 'graphql'
5
-
6
- module WCC::Contentful
7
- # This module builds a GraphQL schema out of our IndexedRepresentation.
8
- # It is currently unused and not hooked up in the WCC::Contentful.init! method.
9
- # TODO: https://zube.io/watermarkchurch/development/c/2234 hook it up
10
- module Graphql
11
- end
12
- end
13
-
14
- require_relative 'graphql/builder'
@@ -1,177 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'graphql'
4
-
5
- require_relative 'types'
6
-
7
- module WCC::Contentful::Graphql
8
- class Builder
9
- attr_reader :schema_types
10
-
11
- def initialize(types, store)
12
- @types = types
13
- @store = store
14
- end
15
-
16
- def build_schema
17
- @schema_types = build_schema_types
18
-
19
- root_query_type = build_root_query(@schema_types)
20
-
21
- builder = self
22
- GraphQL::Schema.define do
23
- query root_query_type
24
-
25
- resolve_type ->(_type, obj, _ctx) {
26
- content_type = WCC::Contentful::Helpers.content_type_from_raw(obj)
27
- builder.schema_types[content_type]
28
- }
29
- end
30
- end
31
-
32
- private
33
-
34
- def build_root_query(schema_types)
35
- store = @store
36
-
37
- GraphQL::ObjectType.define do
38
- name 'Query'
39
- description 'The query root of this schema'
40
-
41
- schema_types.each do |content_type, schema_type|
42
- field schema_type.name.to_sym do
43
- type schema_type
44
- argument :id, types.ID
45
- description "Find a #{schema_type.name} by ID"
46
-
47
- resolve ->(_obj, args, _ctx) {
48
- if args['id'].nil?
49
- store.find_by(content_type: content_type)
50
- else
51
- store.find(args['id'])
52
- end
53
- }
54
- end
55
-
56
- field "all#{schema_type.name}".to_sym do
57
- type schema_type.to_list_type
58
- argument :filter, Types::FilterType
59
-
60
- resolve ->(_obj, args, ctx) {
61
- relation = store.find_all(content_type: content_type)
62
- # TODO: improve this POC
63
- if args[:filter]
64
- filter = {}
65
- filter[args[:filter]['field']] = { eq: args[:filter][:eq] }
66
- relation = relation.apply(filter, ctx)
67
- end
68
- relation.result
69
- }
70
- end
71
- end
72
- end
73
- end
74
-
75
- def build_schema_types
76
- @types.each_with_object({}) do |(k, v), h|
77
- h[k] = build_schema_type(v)
78
- end
79
- end
80
-
81
- def build_schema_type(typedef)
82
- store = @store
83
- builder = self
84
- content_type = typedef.content_type
85
-
86
- GraphQL::ObjectType.define do
87
- name(typedef.name)
88
-
89
- description("Generated from content type #{content_type}")
90
-
91
- field :id, !types.ID do
92
- resolve ->(obj, _args, _ctx) {
93
- obj.dig('sys', 'id')
94
- }
95
- end
96
-
97
- field :_content_type, !types.String do
98
- resolve ->(_, _, _) {
99
- content_type
100
- }
101
- end
102
-
103
- # Make a field for each column:
104
- typedef.fields.each_value do |f|
105
- case f.type
106
- when :Asset
107
- field(f.name.to_sym, -> {
108
- type = builder.schema_types['Asset']
109
- type = type.to_list_type if f.array
110
- type
111
- }) do
112
- resolve ->(obj, _args, ctx) {
113
- links = obj.dig('fields', f.name, ctx[:locale] || 'en-US')
114
- return if links.nil?
115
-
116
- if links.is_a? Array
117
- links.reject(&:nil?).map { |l| store.find(l.dig('sys', 'id')) }
118
- else
119
- store.find(links.dig('sys', 'id'))
120
- end
121
- }
122
- end
123
- when :Link
124
- field(f.name.to_sym, -> {
125
- type =
126
- if f.link_types.nil? || f.link_types.empty?
127
- builder.schema_types['AnyContentful'] ||=
128
- Types::BuildUnionType.call(builder.schema_types, 'AnyContentful')
129
- elsif f.link_types.length == 1
130
- builder.schema_types[f.link_types.first]
131
- else
132
- from_types = builder.schema_types.select { |key| f.link_types.include?(key) }
133
- name = "#{typedef.name}_#{f.name}"
134
- builder.schema_types[name] ||= Types::BuildUnionType.call(from_types, name)
135
- end
136
- type = type.to_list_type if f.array
137
- type
138
- }) do
139
- resolve ->(obj, _args, ctx) {
140
- links = obj.dig('fields', f.name, ctx[:locale] || 'en-US')
141
- return if links.nil?
142
-
143
- if links.is_a? Array
144
- links.reject(&:nil?).map { |l| store.find(l.dig('sys', 'id')) }
145
- else
146
- store.find(links.dig('sys', 'id'))
147
- end
148
- }
149
- end
150
- else
151
- type =
152
- case f.type
153
- when :DateTime
154
- Types::DateTimeType
155
- when :Coordinates
156
- Types::CoordinatesType
157
- when :Json
158
- Types::HashType
159
- else
160
- types.public_send(f.type)
161
- end
162
- type = type.to_list_type if f.array
163
- field(f.name.to_sym, type) do
164
- resolve ->(obj, _args, ctx) {
165
- if obj.is_a? Array
166
- obj.map { |o| o.dig('fields', f.name, ctx[:locale] || 'en-US') }
167
- else
168
- obj.dig('fields', f.name, ctx[:locale] || 'en-US')
169
- end
170
- }
171
- end
172
- end
173
- end
174
- end
175
- end
176
- end
177
- end
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WCC::Contentful::Graphql::Types
4
- DateTimeType =
5
- GraphQL::ScalarType.define do
6
- name 'DateTime'
7
-
8
- coerce_result ->(value, _ctx) { Time.zone.parse(value) }
9
- end
10
-
11
- HashType =
12
- GraphQL::ScalarType.define do
13
- name 'Hash'
14
-
15
- coerce_result ->(value, _ctx) {
16
- return value if value.is_a? Array
17
- return value.to_h if value.respond_to?(:to_h)
18
- return JSON.parse(value) if value.is_a? String
19
-
20
- raise ArgumentError, "Cannot coerce value '#{value}' to a hash"
21
- }
22
- end
23
-
24
- CoordinatesType =
25
- GraphQL::ObjectType.define do
26
- name 'Coordinates'
27
-
28
- field :lat, !types.Float, hash_key: 'lat'
29
- field :lon, !types.Float, hash_key: 'lon'
30
- end
31
-
32
- AnyScalarInputType =
33
- GraphQL::ScalarType.define do
34
- name 'Any'
35
- end
36
-
37
- FilterType =
38
- GraphQL::InputObjectType.define do
39
- name 'filter'
40
-
41
- argument :field, !types.String
42
- argument :eq, AnyScalarInputType
43
- end
44
-
45
- BuildUnionType =
46
- ->(from_types, union_type_name) do
47
- possible_types = from_types.values.reject { |t| t.is_a? GraphQL::UnionType }
48
-
49
- GraphQL::UnionType.define do
50
- name union_type_name
51
- possible_types possible_types
52
- end
53
- end
54
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- gem 'http'
4
- require 'http'
5
-
6
- class HttpAdapter
7
- def call(url, query, headers = {}, proxy = {})
8
- if proxy[:host]
9
- HTTP[headers].via(proxy[:host], proxy[:port], proxy[:username], proxy[:password])
10
- .get(url, params: query)
11
- else
12
- HTTP[headers].get(url, params: query)
13
- end
14
- end
15
-
16
- def post(url, body, headers = {}, proxy = {})
17
- if proxy[:host]
18
- HTTP[headers].via(proxy[:host], proxy[:port], proxy[:username], proxy[:password])
19
- .post(url, json: body)
20
- else
21
- HTTP[headers].post(url, json: body)
22
- end
23
- end
24
- end
@@ -1,161 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WCC::Contentful::Store
4
- class LazyCacheStore
5
- def initialize(client, cache: nil)
6
- @cdn = CDNAdapter.new(client)
7
- @cache = cache || ActiveSupport::Cache::MemoryStore.new
8
- @client = client
9
- end
10
-
11
- def find(key, **options)
12
- found =
13
- @cache.fetch(key) do
14
- # if it's not a contentful ID don't hit the API.
15
- # Store a nil object if we can't find the object on the CDN.
16
- (@cdn.find(key, options) || nil_obj(key)) if key =~ /^\w+$/
17
- end
18
-
19
- case found.try(:dig, 'sys', 'type')
20
- when 'Nil', 'DeletedEntry', 'DeletedAsset'
21
- nil
22
- else
23
- found
24
- end
25
- end
26
-
27
- # TODO: https://github.com/watermarkchurch/wcc-contentful/issues/18
28
- # figure out how to cache the results of a find_by query, ex:
29
- # `find_by('slug' => '/about')`
30
- def find_by(content_type:, filter: nil, options: nil)
31
- if filter.keys == ['sys.id']
32
- # Direct ID lookup, like what we do in `WCC::Contentful::ModelMethods.resolve`
33
- # We can return just this item. Stores are not required to implement :include option.
34
- if found = @cache.read(filter['sys.id'])
35
- return found
36
- end
37
- end
38
-
39
- q = find_all(content_type: content_type, options: { limit: 1 }.merge!(options || {}))
40
- q = q.apply(filter) if filter
41
- q.first
42
- end
43
-
44
- def find_all(content_type:, options: nil)
45
- Query.new(
46
- store: self,
47
- client: @client,
48
- relation: { content_type: content_type },
49
- cache: @cache,
50
- options: options
51
- )
52
- end
53
-
54
- # #index is called whenever the sync API comes back with more data.
55
- def index(json)
56
- id = json.dig('sys', 'id')
57
- return unless prev = @cache.read(id)
58
-
59
- if (prev_rev = prev&.dig('sys', 'revision')) && (next_rev = json.dig('sys', 'revision'))
60
- return prev if next_rev < prev_rev
61
- end
62
-
63
- # we also set deletes in the cache - no need to go hit the API when we know
64
- # this is a nil object
65
- ensure_hash json
66
- @cache.write(id, json)
67
-
68
- case json.dig('sys', 'type')
69
- when 'DeletedEntry', 'DeletedAsset'
70
- nil
71
- else
72
- json
73
- end
74
- end
75
-
76
- def set(key, value)
77
- ensure_hash value
78
- old = @cache.read(key)
79
- @cache.write(key, value)
80
- old
81
- end
82
-
83
- def delete(key)
84
- old = @cache.read(key)
85
- @cache.delete(key)
86
- old
87
- end
88
-
89
- def nil_obj(id)
90
- {
91
- 'sys' => {
92
- 'id' => id,
93
- 'type' => 'Nil',
94
- 'revision' => 1
95
- }
96
- }
97
- end
98
-
99
- def ensure_hash(val)
100
- raise ArgumentError, 'Value must be a Hash' unless val.is_a?(Hash)
101
- end
102
-
103
- class Query < CDNAdapter::Query
104
- def initialize(cache:, **extra)
105
- super(cache: cache, **extra)
106
- @cache = cache
107
- end
108
-
109
- private
110
-
111
- def response
112
- # Disabling because the superclass already took `@response`
113
- # rubocop:disable Naming/MemoizedInstanceVariableName
114
- @wrapped_response ||= ResponseWrapper.new(super, @cache)
115
- # rubocop:enable Naming/MemoizedInstanceVariableName
116
- end
117
-
118
- ResponseWrapper =
119
- Struct.new(:response, :cache) do
120
- delegate :count, to: :response
121
-
122
- def items
123
- @items ||=
124
- response.items.map do |item|
125
- id = item.dig('sys', 'id')
126
- prev = cache.read(id)
127
- unless (prev_rev = prev&.dig('sys', 'revision')) &&
128
- (next_rev = item.dig('sys', 'revision')) &&
129
- next_rev < prev_rev
130
-
131
- cache.write(id, item)
132
- end
133
-
134
- item
135
- end
136
- end
137
-
138
- def includes
139
- @includes ||= IncludesWrapper.new(response, cache)
140
- end
141
- end
142
-
143
- IncludesWrapper =
144
- Struct.new(:response, :cache) do
145
- def [](id)
146
- return unless item = response.includes[id]
147
-
148
- prev = cache.read(id)
149
- unless (prev_rev = prev&.dig('sys', 'revision')) &&
150
- (next_rev = item.dig('sys', 'revision')) &&
151
- next_rev < prev_rev
152
-
153
- cache.write(id, item)
154
- end
155
-
156
- item
157
- end
158
- end
159
- end
160
- end
161
- end