wcc-contentful 0.0.3 → 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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +51 -0
  3. data/.gitignore +8 -0
  4. data/.rspec +1 -0
  5. data/.rubocop.yml +240 -0
  6. data/.rubocop_todo.yml +13 -0
  7. data/CHANGELOG.md +7 -1
  8. data/Gemfile +4 -2
  9. data/Guardfile +36 -0
  10. data/README.md +1 -1
  11. data/Rakefile +5 -3
  12. data/bin/rspec +3 -0
  13. data/lib/generators/wcc/USAGE +24 -0
  14. data/lib/generators/wcc/menu_generator.rb +67 -0
  15. data/lib/generators/wcc/templates/.keep +0 -0
  16. data/lib/generators/wcc/templates/Procfile +3 -0
  17. data/lib/generators/wcc/templates/contentful_shell_wrapper +342 -0
  18. data/lib/generators/wcc/templates/menu/generated_add_menus.ts +85 -0
  19. data/lib/generators/wcc/templates/menu/menu.rb +25 -0
  20. data/lib/generators/wcc/templates/menu/menu_button.rb +25 -0
  21. data/lib/generators/wcc/templates/release +9 -0
  22. data/lib/generators/wcc/templates/wcc_contentful.rb +18 -0
  23. data/lib/wcc/contentful.rb +93 -26
  24. data/lib/wcc/contentful/client_ext.rb +15 -0
  25. data/lib/wcc/contentful/configuration.rb +93 -0
  26. data/lib/wcc/contentful/content_type_indexer.rb +153 -0
  27. data/lib/wcc/contentful/exceptions.rb +34 -0
  28. data/lib/wcc/contentful/graphql.rb +15 -0
  29. data/lib/wcc/contentful/graphql/builder.rb +172 -0
  30. data/lib/wcc/contentful/graphql/types.rb +54 -0
  31. data/lib/wcc/contentful/helpers.rb +28 -0
  32. data/lib/wcc/contentful/indexed_representation.rb +111 -0
  33. data/lib/wcc/contentful/model.rb +24 -0
  34. data/lib/wcc/contentful/model/menu.rb +7 -0
  35. data/lib/wcc/contentful/model/menu_button.rb +15 -0
  36. data/lib/wcc/contentful/model_builder.rb +151 -0
  37. data/lib/wcc/contentful/model_validators.rb +64 -0
  38. data/lib/wcc/contentful/model_validators/dsl.rb +165 -0
  39. data/lib/wcc/contentful/simple_client.rb +127 -0
  40. data/lib/wcc/contentful/simple_client/response.rb +160 -0
  41. data/lib/wcc/contentful/store.rb +8 -0
  42. data/lib/wcc/contentful/store/cdn_adapter.rb +79 -0
  43. data/lib/wcc/contentful/store/memory_store.rb +75 -0
  44. data/lib/wcc/contentful/store/postgres_store.rb +132 -0
  45. data/lib/wcc/contentful/version.rb +3 -1
  46. data/wcc-contentful.gemspec +49 -24
  47. metadata +261 -16
  48. data/lib/wcc/contentful/redirect.rb +0 -33
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WCC::Contentful::Model::MenuButton < WCC::Contentful::Model
4
+ validate_field :text, :String, :required
5
+ validate_field :icon, :Asset, :optional
6
+ validate_field :external_link, :String, :optional
7
+ validate_field :link, :Link, :optional, link_to: 'page'
8
+
9
+ # Gets either the external link or the slug from the referenced page.
10
+ # Example usage: `<%= link_to button.title, button.href %>`
11
+ def href
12
+ return external_link if external_link
13
+ link&.try(:slug) || link&.try(:url)
14
+ end
15
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::Contentful
4
+ class ModelBuilder
5
+ include Helpers
6
+
7
+ def initialize(types)
8
+ @types = types
9
+ end
10
+
11
+ def build_models
12
+ @types.each_with_object([]) do |(_k, v), a|
13
+ a << build_model(v)
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def build_model(typedef)
20
+ const = typedef.name
21
+ return WCC::Contentful::Model.const_get(const) if WCC::Contentful::Model.const_defined?(const)
22
+
23
+ # TODO: https://github.com/dkubb/ice_nine ?
24
+ typedef = typedef.deep_dup.freeze
25
+ fields = typedef.fields.keys
26
+ WCC::Contentful::Model.const_set(const,
27
+ Class.new(WCC::Contentful::Model) do
28
+ include Helpers
29
+
30
+ define_singleton_method(:content_type) do
31
+ typedef.content_type
32
+ end
33
+
34
+ define_singleton_method(:content_type_definition) do
35
+ typedef
36
+ end
37
+
38
+ define_singleton_method(:find) do |id, context = nil|
39
+ raw = WCC::Contentful::Model.store.find(id)
40
+ new(raw, context) if raw.present?
41
+ end
42
+
43
+ define_singleton_method(:find_all) do |context = nil|
44
+ raw = WCC::Contentful::Model.store.find_by(content_type: content_type)
45
+ raw.map { |r| new(r, context) }
46
+ end
47
+
48
+ define_singleton_method(:find_by) do |filter, context = nil|
49
+ filter.transform_keys! { |k| k.to_s.camelize(:lower) }
50
+ bad_fields = filter.keys.reject { |k| fields.include?(k) }
51
+ raise ArgumentError, "These fields do not exist: #{bad_fields}" unless bad_fields.empty?
52
+
53
+ query = WCC::Contentful::Model.store.find_by(content_type: content_type)
54
+ filter.each do |field, v|
55
+ query = query.eq(field, v, context)
56
+ end
57
+ query.map { |r| new(r, context) }
58
+ end
59
+
60
+ define_method(:initialize) do |raw, context = nil|
61
+ ct = content_type_from_raw(raw)
62
+ if ct != typedef.content_type
63
+ raise ArgumentError, 'Wrong Content Type - ' \
64
+ "'#{raw.dig('sys', 'id')}' is a #{ct}, expected #{typedef.content_type}"
65
+ end
66
+
67
+ @locale = context[:locale] if context.present?
68
+ @locale ||= 'en-US'
69
+ @id = raw.dig('sys', 'id')
70
+ @space = raw.dig('sys', 'space', 'sys', 'id')
71
+ @created_at = raw.dig('sys', 'createdAt')
72
+ @created_at = Time.parse(@created_at) if @created_at.present?
73
+ @updated_at = raw.dig('sys', 'updatedAt')
74
+ @updated_at = Time.parse(@updated_at) if @updated_at.present?
75
+ @revision = raw.dig('sys', 'revision')
76
+
77
+ typedef.fields.each_value do |f|
78
+ raw_value = raw.dig('fields', f.name, @locale)
79
+ if raw_value.present?
80
+ case f.type
81
+ when :DateTime
82
+ raw_value = Time.zone.parse(raw_value)
83
+ when :Int
84
+ raw_value = Integer(raw_value)
85
+ when :Float
86
+ raw_value = Float(raw_value)
87
+ end
88
+ end
89
+ instance_variable_set('@' + f.name, raw_value)
90
+ end
91
+ end
92
+
93
+ attr_reader :id
94
+ attr_reader :space
95
+ attr_reader :created_at
96
+ attr_reader :updated_at
97
+ attr_reader :revision
98
+
99
+ # Make a field for each column:
100
+ typedef.fields.each_value do |f|
101
+ name = f.name
102
+ var_name = '@' + name
103
+ case f.type
104
+ when :Asset, :Link
105
+ define_method(name) do
106
+ val = instance_variable_get(var_name + '_resolved')
107
+ return val if val.present?
108
+
109
+ return unless val = instance_variable_get(var_name)
110
+
111
+ val =
112
+ if val.is_a? Array
113
+ val.map { |v| WCC::Contentful::Model.find(v.dig('sys', 'id')) }
114
+ else
115
+ WCC::Contentful::Model.find(val.dig('sys', 'id'))
116
+ end
117
+
118
+ instance_variable_set(var_name + '_resolved', val)
119
+ val
120
+ end
121
+ when :Coordinates
122
+ define_method(name) do
123
+ val = instance_variable_get(var_name)
124
+ OpenStruct.new(val.slice('lat', 'lon')) if val
125
+ end
126
+ when :Json
127
+ define_method(name) do
128
+ value = instance_variable_get(var_name)
129
+
130
+ parse_value =
131
+ ->(v) do
132
+ return v.to_h if v.respond_to?(:to_h)
133
+
134
+ raise ArgumentError, "Cannot coerce value '#{value}' to a hash"
135
+ end
136
+
137
+ return value.map { |v| OpenStruct.new(parse_value.call(v)) } if value.is_a?(Array)
138
+
139
+ OpenStruct.new(parse_value.call(value))
140
+ end
141
+ else
142
+ define_method(name) do
143
+ instance_variable_get(var_name)
144
+ end
145
+ end
146
+ alias_method name.underscore, name
147
+ end
148
+ end)
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-validation'
4
+
5
+ require_relative 'model_validators/dsl'
6
+
7
+ module WCC::Contentful::ModelValidators
8
+ def schema
9
+ return if @field_validations.nil? || @field_validations.empty?
10
+ field_validations = @field_validations
11
+
12
+ # "page": {
13
+ # "sys": { ... }
14
+ # "fields": {
15
+ # "title": { ... },
16
+ # "sections": { ... },
17
+ # ...
18
+ # }
19
+ # }
20
+
21
+ fields_schema =
22
+ Dry::Validation.Schema do
23
+ # Had to dig through the internals of Dry::Validation to find
24
+ # this magic incantation
25
+ field_validations.each { |dsl| instance_eval(&dsl.to_proc) }
26
+ end
27
+
28
+ Dry::Validation.Schema do
29
+ required('fields').schema(fields_schema)
30
+ end
31
+ end
32
+
33
+ def validate_fields(&block)
34
+ raise ArgumentError, 'validate_fields requires a block' unless block_given?
35
+ dsl = ProcDsl.new(Proc.new(&block))
36
+
37
+ (@field_validations ||= []) << dsl
38
+ end
39
+
40
+ def validate_field(field, type, *options)
41
+ dsl = FieldDsl.new(field, type, options)
42
+
43
+ (@field_validations ||= []) << dsl
44
+ end
45
+
46
+ # Accepts a content types response from the API and transforms it
47
+ # to be acceptible for the validator.
48
+ def self.transform_content_types_for_validation(content_types)
49
+ if !content_types.is_a?(Array) && items = content_types.try(:[], 'items')
50
+ content_types = items
51
+ end
52
+
53
+ # Transform the array into a hash keyed by content type ID
54
+ content_types.each_with_object({}) do |ct, ct_hash|
55
+ # Transform the fields into a hash keyed by field ID
56
+ ct['fields'] =
57
+ ct['fields'].each_with_object({}) do |f, f_hash|
58
+ f_hash[f['id']] = f
59
+ end
60
+
61
+ ct_hash[ct.dig('sys', 'id')] = ct
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::Contentful::ModelValidators
4
+ class ProcDsl
5
+ def to_proc
6
+ @proc
7
+ end
8
+
9
+ def initialize(proc)
10
+ @proc = proc
11
+ end
12
+ end
13
+
14
+ class FieldDsl
15
+ attr_reader :field
16
+
17
+ # "sections": {
18
+ # "id": "sections",
19
+ # "name": "Sections",
20
+ # "type": "Array",
21
+ # "localized": false,
22
+ # "required": false,
23
+ # "validations": [],
24
+ # "disabled": false,
25
+ # "omitted": false,
26
+ # "items": {
27
+ # "type": "Link",
28
+ # "validations": [
29
+ # {
30
+ # "linkContentType": [
31
+ # "Section"
32
+ # ]
33
+ # }
34
+ # ],
35
+ # "linkType": "Entry"
36
+ # }
37
+ # }
38
+
39
+ def schema
40
+ return @field_schema if @field_schema
41
+
42
+ # example: required('type').value(...)
43
+ type_pred = parse_type_predicate(@type)
44
+
45
+ # example: [required('required').value(eq?: true), ...]
46
+ procs =
47
+ @options.map do |opt|
48
+ if opt.is_a?(Hash)
49
+ opt.map { |k, v| parse_option(k, v) }
50
+ else
51
+ parse_option(opt)
52
+ end
53
+ end
54
+
55
+ @field_schema =
56
+ Dry::Validation.Schema do
57
+ instance_eval(&type_pred)
58
+
59
+ procs.flatten.each { |p| instance_eval(&p) }
60
+ end
61
+ end
62
+
63
+ def to_proc
64
+ f = field
65
+ s = schema
66
+ proc { required(f).schema(s) }
67
+ end
68
+
69
+ def initialize(field, field_type, options)
70
+ @field = field.to_s.camelize(:lower) unless field.is_a?(String)
71
+ @type = field_type
72
+ @options = options
73
+ end
74
+
75
+ private
76
+
77
+ def parse_type_predicate(type)
78
+ case type
79
+ when :String
80
+ proc { required('type').value(included_in?: %w[Symbol Text]) }
81
+ when :Int
82
+ proc { required('type').value(eql?: 'Integer') }
83
+ when :Float
84
+ proc { required('type').value(eql?: 'Number') }
85
+ when :DateTime
86
+ proc { required('type').value(eql?: 'Date') }
87
+ when :Asset
88
+ proc {
89
+ required('type').value(eql?: 'Link')
90
+ required('linkType').value(eql?: 'Asset')
91
+ }
92
+ else
93
+ proc { required('type').value(eql?: type.to_s.camelize) }
94
+ end
95
+ end
96
+
97
+ def parse_option(option, option_arg = nil)
98
+ case option
99
+ when :required
100
+ proc { required('required').value(eql?: true) }
101
+ when :optional
102
+ proc { required('required').value(eql?: false) }
103
+ when :link_to
104
+ link_to_proc = parse_field_link_to(option_arg)
105
+ return link_to_proc unless @type.to_s.camelize == 'Array'
106
+ proc {
107
+ required('items').schema do
108
+ required('type').value(eql?: 'Link')
109
+ instance_eval(&link_to_proc)
110
+ end
111
+ }
112
+ when :items
113
+ type_pred = parse_type_predicate(option_arg)
114
+ proc {
115
+ required('items').schema do
116
+ instance_eval(&type_pred)
117
+ end
118
+ }
119
+ else
120
+ raise ArgumentError, "unknown validation requirement: #{option}"
121
+ end
122
+ end
123
+
124
+ def parse_field_link_to(option_arg)
125
+ raise ArgumentError, 'validation link_to: requires an argument' unless option_arg
126
+
127
+ # this works because a Link can only have one validation in its "validations" array -
128
+ # this will fail if Contentful ever changes that.
129
+
130
+ # the 'validations' schema needs to be optional because if we get the content
131
+ # types from the CDN instead of the management API, sometimes the validations
132
+ # don't get sent back.
133
+
134
+ # "validations": [
135
+ # {
136
+ # "linkContentType": [
137
+ # "section-CardSearch",
138
+ # "section-Faq",
139
+ # "section-Testimonials",
140
+ # "section-VideoHighlight"
141
+ # ]
142
+ # }
143
+ # ]
144
+
145
+ if option_arg.is_a?(Regexp)
146
+ return proc {
147
+ optional('validations').each do
148
+ schema do
149
+ required('linkContentType').each(format?: option_arg)
150
+ end
151
+ end
152
+ }
153
+ end
154
+
155
+ option_arg = [option_arg] unless option_arg.is_a?(Array)
156
+ proc {
157
+ optional('validations').each do
158
+ schema do
159
+ required('linkContentType').value(eql?: option_arg)
160
+ end
161
+ end
162
+ }
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http'
4
+
5
+ require_relative 'simple_client/response'
6
+
7
+ module WCC::Contentful
8
+ class SimpleClient
9
+ def initialize(api_url:, space:, access_token:, **options)
10
+ @api_url = URI.join(api_url, '/spaces/', space + '/')
11
+ @space = space
12
+ @access_token = access_token
13
+
14
+ @get_http = options[:override_get_http] if options[:override_get_http].present?
15
+
16
+ @options = options
17
+ @query_defaults = {}
18
+ @query_defaults[:locale] = @options[:default_locale] if @options[:default_locale]
19
+ end
20
+
21
+ def get(path, query = {})
22
+ url = URI.join(@api_url, path)
23
+
24
+ Response.new(self,
25
+ { url: url, query: query },
26
+ get_http(url, query))
27
+ end
28
+
29
+ private
30
+
31
+ def get_http(url, query, headers = {}, proxy = {})
32
+ headers = {
33
+ Authorization: "Bearer #{@access_token}"
34
+ }.merge(headers || {})
35
+
36
+ q = @query_defaults.dup
37
+ q = q.merge(query) if query
38
+
39
+ resp =
40
+ if @get_http
41
+ @get_http.call(url, q, headers, proxy)
42
+ else
43
+ default_get_http(url, q, headers, proxy)
44
+ end
45
+ if [301, 302, 307].include?(resp.code) && !@options[:no_follow_redirects]
46
+ resp = get_http(resp.headers['location'], nil, headers, proxy)
47
+ end
48
+ resp
49
+ end
50
+
51
+ def default_get_http(url, query, headers = {}, proxy = {})
52
+ if proxy[:host]
53
+ HTTP[headers].via(proxy[:host], proxy[:port], proxy[:username], proxy[:password])
54
+ .get(url, params: query)
55
+ else
56
+ HTTP[headers].get(url, params: query)
57
+ end
58
+ end
59
+
60
+ class Cdn < SimpleClient
61
+ def initialize(space:, access_token:, **options)
62
+ super(
63
+ api_url: options[:api_url] || 'https://cdn.contentful.com/',
64
+ space: space,
65
+ access_token: access_token,
66
+ **options
67
+ )
68
+ end
69
+
70
+ def entry(key, query = {})
71
+ resp = get("entries/#{key}", query)
72
+ resp.assert_ok!
73
+ end
74
+
75
+ def entries(query = {})
76
+ resp = get('entries', query)
77
+ resp.assert_ok!
78
+ end
79
+
80
+ def asset(key, query = {})
81
+ resp = get("assets/#{key}", query)
82
+ resp.assert_ok!
83
+ end
84
+
85
+ def assets(query = {})
86
+ resp = get('assets', query)
87
+ resp.assert_ok!
88
+ end
89
+
90
+ def content_types(query = {})
91
+ resp = get('content_types', query)
92
+ resp.assert_ok!
93
+ end
94
+
95
+ def sync(sync_token: nil, **query)
96
+ sync_token =
97
+ if sync_token
98
+ { sync_token: sync_token }
99
+ else
100
+ { initial: true }
101
+ end
102
+ query = query.merge(sync_token)
103
+ resp = SyncResponse.new(get('sync', query))
104
+ resp.assert_ok!
105
+ end
106
+ end
107
+
108
+ class Management < SimpleClient
109
+ def initialize(management_token:, **options)
110
+ super(
111
+ api_url: options[:api_url] || 'https://api.contentful.com',
112
+ space: options[:space] || '/',
113
+ access_token: management_token,
114
+ **options
115
+ )
116
+ end
117
+
118
+ def content_types(space: nil, **query)
119
+ space ||= @space
120
+ raise ArgumentError, 'please provide a space ID' if space.nil?
121
+
122
+ resp = get("/spaces/#{space}/content_types", query)
123
+ resp.assert_ok!
124
+ end
125
+ end
126
+ end
127
+ end