wcc-contentful 0.0.3 → 0.1.0

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