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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::Contentful
4
+ class ValidationError < StandardError
5
+ Message =
6
+ Struct.new(:path, :error) do
7
+ def to_s
8
+ "#{path}: #{error}"
9
+ end
10
+ end
11
+
12
+ attr_reader :errors
13
+
14
+ def initialize(errors)
15
+ @errors = ValidationError.join_msg_keys(errors)
16
+ super("Content Type Schema from Contentful failed validation!\n #{@errors.join("\n ")}")
17
+ end
18
+
19
+ # Turns the error messages hash into an array of message structs like:
20
+ # menu.fields.name.type: must be equal to String
21
+ def self.join_msg_keys(hash)
22
+ ret =
23
+ hash.map do |k, v|
24
+ if v.is_a?(Hash)
25
+ msgs = join_msg_keys(v)
26
+ msgs.map { |msg| Message.new(k.to_s + '.' + msg.path, msg.error) }
27
+ else
28
+ v.map { |msg| Message.new(k.to_s, msg) }
29
+ end
30
+ end
31
+ ret.flatten(1)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,15 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ gem 'graphql', '~> 1.7'
5
+ require 'graphql'
6
+
7
+ module WCC::Contentful
8
+ # This module builds a GraphQL schema out of our IndexedRepresentation.
9
+ # It is currently unused and not hooked up in the WCC::Contentful.init! method.
10
+ # TODO: https://zube.io/watermarkchurch/development/c/2234 hook it up
11
+ module Graphql
12
+ end
13
+ end
14
+
15
+ require_relative 'graphql/builder'
@@ -0,0 +1,172 @@
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).first
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_by(content_type: content_type)
62
+ relation = relation.apply(args[:filter], ctx) if args[:filter]
63
+ relation.result
64
+ }
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ def build_schema_types
71
+ @types.each_with_object({}) do |(k, v), h|
72
+ h[k] = build_schema_type(v)
73
+ end
74
+ end
75
+
76
+ def build_schema_type(typedef)
77
+ store = @store
78
+ builder = self
79
+ content_type = typedef.content_type
80
+
81
+ GraphQL::ObjectType.define do
82
+ name(typedef.name)
83
+
84
+ description("Generated from content type #{content_type}")
85
+
86
+ field :id, !types.ID do
87
+ resolve ->(obj, _args, _ctx) {
88
+ obj.dig('sys', 'id')
89
+ }
90
+ end
91
+
92
+ field :_content_type, !types.String do
93
+ resolve ->(_, _, _) {
94
+ content_type
95
+ }
96
+ end
97
+
98
+ # Make a field for each column:
99
+ typedef.fields.each_value do |f|
100
+ case f.type
101
+ when :Asset
102
+ field(f.name.to_sym, -> {
103
+ type = builder.schema_types['Asset']
104
+ type = type.to_list_type if f.array
105
+ type
106
+ }) do
107
+ resolve ->(obj, _args, ctx) {
108
+ links = obj.dig('fields', f.name, ctx[:locale] || 'en-US')
109
+ return if links.nil?
110
+
111
+ if links.is_a? Array
112
+ links.reject(&:nil?).map { |l| store.find(l.dig('sys', 'id')) }
113
+ else
114
+ store.find(links.dig('sys', 'id'))
115
+ end
116
+ }
117
+ end
118
+ when :Link
119
+ field(f.name.to_sym, -> {
120
+ type =
121
+ if f.link_types.nil? || f.link_types.empty?
122
+ builder.schema_types['AnyContentful'] ||=
123
+ Types::BuildUnionType.call(builder.schema_types, 'AnyContentful')
124
+ elsif f.link_types.length == 1
125
+ builder.schema_types[f.link_types.first]
126
+ else
127
+ from_types = builder.schema_types.select { |key| f.link_types.include?(key) }
128
+ name = "#{typedef.name}_#{f.name}"
129
+ builder.schema_types[name] ||= Types::BuildUnionType.call(from_types, name)
130
+ end
131
+ type = type.to_list_type if f.array
132
+ type
133
+ }) do
134
+ resolve ->(obj, _args, ctx) {
135
+ links = obj.dig('fields', f.name, ctx[:locale] || 'en-US')
136
+ return if links.nil?
137
+
138
+ if links.is_a? Array
139
+ links.reject(&:nil?).map { |l| store.find(l.dig('sys', 'id')) }
140
+ else
141
+ store.find(links.dig('sys', 'id'))
142
+ end
143
+ }
144
+ end
145
+ else
146
+ type =
147
+ case f.type
148
+ when :DateTime
149
+ Types::DateTimeType
150
+ when :Coordinates
151
+ Types::CoordinatesType
152
+ when :Json
153
+ Types::HashType
154
+ else
155
+ types.public_send(f.type)
156
+ end
157
+ type = type.to_list_type if f.array
158
+ field(f.name.to_sym, type) do
159
+ resolve ->(obj, _args, ctx) {
160
+ if obj.is_a? Array
161
+ obj.map { |o| o.dig('fields', f.name, ctx[:locale] || 'en-US') }
162
+ else
163
+ obj.dig('fields', f.name, ctx[:locale] || 'en-US')
164
+ end
165
+ }
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,54 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ module WCC::Contentful::Graphql::Types
5
+ DateTimeType =
6
+ GraphQL::ScalarType.define do
7
+ name 'DateTime'
8
+
9
+ coerce_result ->(value, _ctx) { Time.zone.parse(value) }
10
+ end
11
+
12
+ HashType =
13
+ GraphQL::ScalarType.define do
14
+ name 'Hash'
15
+
16
+ coerce_result ->(value, _ctx) {
17
+ return value if value.is_a? Array
18
+ return value.to_h if value.respond_to?(:to_h)
19
+ return JSON.parse(value) if value.is_a? String
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
@@ -0,0 +1,28 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ module WCC::Contentful::Helpers
5
+ extend self
6
+
7
+ def content_type_from_raw(value)
8
+ case value.dig('sys', 'type')
9
+ when 'Entry'
10
+ value.dig('sys', 'contentType', 'sys', 'id')
11
+ when 'Asset'
12
+ 'Asset'
13
+ else
14
+ raise ArgumentError, "Unknown content type '#{value.dig('sys', 'type') || 'null'}'"
15
+ end
16
+ end
17
+
18
+ def constant_from_content_type(content_type)
19
+ content_type.camelize.gsub(/[^_a-zA-Z0-9]/, '_')
20
+ end
21
+
22
+ def shared_prefix(string_array)
23
+ string_array.reduce do |l, s|
24
+ l = l.chop while l != s[0...l.length]
25
+ l
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::Contentful
4
+ # The result of running the indexer on raw content types to produce
5
+ # a type definition which can be used to build models or graphql types.
6
+ class IndexedRepresentation
7
+ def initialize(types = {})
8
+ @types = types
9
+ end
10
+
11
+ delegate :keys, to: :@types
12
+ delegate :[], to: :@types
13
+ delegate :each_with_object, to: :@types
14
+ delegate :each_value, to: :@types
15
+
16
+ def []=(id, value)
17
+ raise ArgumentError unless value.is_a?(ContentType)
18
+ @types[id] = value
19
+ end
20
+
21
+ def self.from_json(hash)
22
+ hash = JSON.parse(hash) if hash.is_a?(String)
23
+
24
+ ret = IndexedRepresentation.new
25
+ hash.each do |id, content_type_hash|
26
+ ret[id] = ContentType.new(content_type_hash)
27
+ end
28
+ ret
29
+ end
30
+
31
+ def to_json
32
+ @types.to_json
33
+ end
34
+
35
+ class ContentType
36
+ ATTRIBUTES = %i[
37
+ name
38
+ content_type
39
+ fields
40
+ ].freeze
41
+
42
+ attr_accessor(*ATTRIBUTES)
43
+
44
+ def initialize(hash_or_id = nil)
45
+ @fields = {}
46
+ return unless hash_or_id
47
+ if hash_or_id.is_a?(String)
48
+ @name = hash_or_id
49
+ return
50
+ end
51
+
52
+ if raw_fields = (hash_or_id.delete('fields') || hash_or_id.delete(:fields))
53
+ raw_fields.each do |field_name, raw_field|
54
+ @fields[field_name] = Field.new(raw_field)
55
+ end
56
+ end
57
+
58
+ hash_or_id.each { |k, v| public_send("#{k}=", v) }
59
+ end
60
+ end
61
+
62
+ class Field
63
+ ATTRIBUTES = %i[
64
+ name
65
+ type
66
+ array
67
+ required
68
+ link_types
69
+ ].freeze
70
+
71
+ attr_accessor(*ATTRIBUTES)
72
+
73
+ TYPES = %i[
74
+ String
75
+ Int
76
+ Float
77
+ DateTime
78
+ Boolean
79
+ Json
80
+ Coordinates
81
+ Link
82
+ Asset
83
+ ].freeze
84
+
85
+ def type=(raw_type)
86
+ unless TYPES.include?(raw_type)
87
+ raise ArgumentError, "Unknown type #{raw_type}, expected one of: #{TYPES}"
88
+ end
89
+ @type = raw_type
90
+ end
91
+
92
+ def initialize(hash_or_id = nil)
93
+ return unless hash_or_id
94
+ if hash_or_id.is_a?(String)
95
+ @name = hash_or_id
96
+ return
97
+ end
98
+
99
+ if raw_type = hash_or_id.delete('type')
100
+ raw_type = raw_type.to_sym
101
+ unless TYPES.include?(raw_type)
102
+ raise ArgumentError, "Unknown type #{raw_type}, expected one of: #{TYPES}"
103
+ end
104
+ @type = raw_type
105
+ end
106
+
107
+ hash_or_id.each { |k, v| public_send("#{k}=", v) }
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,24 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ class WCC::Contentful::Model
5
+ extend WCC::Contentful::Helpers
6
+ extend WCC::Contentful::ModelValidators
7
+
8
+ class << self
9
+ attr_accessor :store
10
+ end
11
+
12
+ def self.all_models
13
+ WCC::Contentful::Model.constants(false).map { |k| WCC::Contentful::Model.const_get(k) }
14
+ end
15
+
16
+ def self.find(id, context = nil)
17
+ return unless raw = store.find(id)
18
+
19
+ content_type = content_type_from_raw(raw)
20
+
21
+ const = WCC::Contentful::Model.const_get(constant_from_content_type(content_type))
22
+ const.new(raw, context)
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WCC::Contentful::Model::Menu < WCC::Contentful::Model
4
+ validate_field :name, :String
5
+ validate_field :top_button, :Link, :optional, link_to: 'menuButton'
6
+ validate_field :items, :Array, link_to: %w[menu menuButton]
7
+ end