wcc-contentful 0.3.0.pre.rc3 → 1.0.0.pre.rc1

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 (69) hide show
  1. checksums.yaml +5 -5
  2. data/.rspec +1 -0
  3. data/Guardfile +101 -0
  4. data/README.md +161 -11
  5. data/app/controllers/wcc/contentful/webhook_controller.rb +25 -24
  6. data/app/jobs/wcc/contentful/webhook_enable_job.rb +36 -2
  7. data/config/routes.rb +1 -1
  8. data/doc/wcc-contentful.png +0 -0
  9. data/lib/tasks/download_schema.rake +12 -0
  10. data/lib/wcc/contentful.rb +70 -16
  11. data/lib/wcc/contentful/active_record_shim.rb +72 -0
  12. data/lib/wcc/contentful/configuration.rb +177 -46
  13. data/lib/wcc/contentful/content_type_indexer.rb +14 -0
  14. data/lib/wcc/contentful/downloads_schema.rb +112 -0
  15. data/lib/wcc/contentful/engine.rb +33 -14
  16. data/lib/wcc/contentful/event.rb +171 -0
  17. data/lib/wcc/contentful/events.rb +41 -0
  18. data/lib/wcc/contentful/exceptions.rb +3 -0
  19. data/lib/wcc/contentful/indexed_representation.rb +2 -2
  20. data/lib/wcc/contentful/instrumentation.rb +31 -0
  21. data/lib/wcc/contentful/link.rb +28 -0
  22. data/lib/wcc/contentful/link_visitor.rb +122 -0
  23. data/lib/wcc/contentful/middleware.rb +7 -0
  24. data/lib/wcc/contentful/middleware/store.rb +158 -0
  25. data/lib/wcc/contentful/middleware/store/caching_middleware.rb +114 -0
  26. data/lib/wcc/contentful/model.rb +37 -3
  27. data/lib/wcc/contentful/model_builder.rb +1 -0
  28. data/lib/wcc/contentful/model_methods.rb +40 -15
  29. data/lib/wcc/contentful/model_singleton_methods.rb +47 -30
  30. data/lib/wcc/contentful/rake.rb +3 -0
  31. data/lib/wcc/contentful/rspec.rb +46 -0
  32. data/lib/wcc/contentful/services.rb +61 -27
  33. data/lib/wcc/contentful/simple_client.rb +81 -25
  34. data/lib/wcc/contentful/simple_client/management.rb +43 -10
  35. data/lib/wcc/contentful/simple_client/response.rb +61 -22
  36. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +17 -17
  37. data/lib/wcc/contentful/store.rb +7 -66
  38. data/lib/wcc/contentful/store/README.md +85 -0
  39. data/lib/wcc/contentful/store/base.rb +34 -119
  40. data/lib/wcc/contentful/store/cdn_adapter.rb +71 -12
  41. data/lib/wcc/contentful/store/factory.rb +186 -0
  42. data/lib/wcc/contentful/store/instrumentation.rb +55 -0
  43. data/lib/wcc/contentful/store/interface.rb +82 -0
  44. data/lib/wcc/contentful/store/memory_store.rb +27 -24
  45. data/lib/wcc/contentful/store/postgres_store.rb +268 -101
  46. data/lib/wcc/contentful/store/postgres_store/schema_1.sql +73 -0
  47. data/lib/wcc/contentful/store/postgres_store/schema_2.sql +21 -0
  48. data/lib/wcc/contentful/store/query.rb +246 -0
  49. data/lib/wcc/contentful/store/query/interface.rb +63 -0
  50. data/lib/wcc/contentful/store/rspec_examples.rb +48 -0
  51. data/lib/wcc/contentful/store/rspec_examples/basic_store.rb +629 -0
  52. data/lib/wcc/contentful/store/rspec_examples/include_param.rb +283 -0
  53. data/lib/wcc/contentful/store/rspec_examples/nested_queries.rb +342 -0
  54. data/lib/wcc/contentful/sync_engine.rb +181 -0
  55. data/lib/wcc/contentful/test.rb +7 -0
  56. data/lib/wcc/contentful/test/attributes.rb +56 -0
  57. data/lib/wcc/contentful/test/double.rb +76 -0
  58. data/lib/wcc/contentful/test/factory.rb +101 -0
  59. data/lib/wcc/contentful/version.rb +1 -1
  60. data/wcc-contentful.gemspec +22 -10
  61. metadata +282 -103
  62. data/Gemfile +0 -8
  63. data/app/jobs/wcc/contentful/delayed_sync_job.rb +0 -63
  64. data/lib/wcc/contentful/client_ext.rb +0 -28
  65. data/lib/wcc/contentful/graphql.rb +0 -14
  66. data/lib/wcc/contentful/graphql/builder.rb +0 -177
  67. data/lib/wcc/contentful/graphql/types.rb +0 -54
  68. data/lib/wcc/contentful/simple_client/http_adapter.rb +0 -24
  69. data/lib/wcc/contentful/store/lazy_cache_store.rb +0 -161
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 6e123c479a40f96081faa2b5f4547353bffccd3d
4
- data.tar.gz: 5b4d21cd6917cb8c94b7b4d61c321c555ac70b8e
2
+ SHA256:
3
+ metadata.gz: e8c289b7bbddc39150c9ab86fb781e4dcdd740153a6e39906f438caa11740077
4
+ data.tar.gz: 36d446fb8abe8b7dbed82f68d36280d85528f4ba60a9af2707bbccbdb4a04794
5
5
  SHA512:
6
- metadata.gz: f32d6307f5bd7514dbfef1dbf0ba1ca14d1a5c217e26276727a5367ab8fa7af4b0b68fd7a31edb5b405d8355172dd9610da13ab62d478bf9b4f684f840e507bd
7
- data.tar.gz: 46df09765aaa0c26e75b1b7d1705d94a11ccdf44813dabec24dbe656d1631073397cf872d05b36dcfb4dae25131559c087fabd053dd062efe7439f7a7383fe7a
6
+ metadata.gz: a3a992e38c9688f7bc0834bf0629c5df5e6d54fdca044295a534d741ce3fc6c6e4017a9a48ba65c765af91c6ff9733ec5469ecb0aaa5a1302e371c6fd3325de1
7
+ data.tar.gz: 90aa3d052268446d7d1bb15cea05048b643904026d8406ea274045db6a68b3c395b09e60178a13f028e3238a8a3606c3f63abc58a47ba5c8b822834b6a8c3064
data/.rspec CHANGED
@@ -1,3 +1,4 @@
1
+ --require spec_helper
1
2
  --format documentation
2
3
  --color
3
4
  --order rand
@@ -0,0 +1,101 @@
1
+ # A guardfile for making Danger Plugins
2
+ # For more info see https://github.com/guard/guard#readme
3
+
4
+ # To run, use `bundle exec guard`.
5
+
6
+ def watch_async(regexp)
7
+ raise ArgumentError, "No block given" unless block_given?
8
+ match_queue = Queue.new
9
+
10
+ watch(regexp) do |match|
11
+ # Producer - add matches to the match queue
12
+ match_queue << match
13
+ nil
14
+ end
15
+
16
+ # Consumer - process matches as a batch
17
+ Thread.new do
18
+ loop do
19
+ matches = []
20
+ matches << match_queue.pop
21
+
22
+ loop do
23
+ begin
24
+ matches << match_queue.pop(true)
25
+ rescue ThreadError
26
+ break
27
+ end
28
+ end
29
+
30
+ begin
31
+ yield matches if matches.length > 0
32
+ rescue StandardError => ex
33
+ STDERR.puts "Error! #{ex}"
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ group :red_green_refactor, halt_on_fail: true do
40
+ guard :rspec, cmd: 'bundle exec rspec' do
41
+ require 'guard/rspec/dsl'
42
+ dsl = Guard::RSpec::Dsl.new(self)
43
+
44
+ # RSpec files
45
+ rspec = dsl.rspec
46
+ watch(rspec.spec_helper) { rspec.spec_dir }
47
+ # watch(rspec.spec_support) { rspec.spec_dir }
48
+ watch(rspec.spec_files)
49
+
50
+ # Ruby files
51
+ ruby = dsl.ruby
52
+ watch(%r{lib/wcc/(.+)\.rb$}) { |m| rspec.spec.call("wcc/#{m[1]}") }
53
+ watch(%r{lib/generators/(.+)\.rb$}) { |m| rspec.spec.call("generators/#{m[1]}") }
54
+
55
+ # Rails files
56
+ rails = dsl.rails(view_extensions: %w[erb haml slim])
57
+ dsl.watch_spec_files_for(rails.app_files)
58
+ dsl.watch_spec_files_for(rails.views)
59
+
60
+ watch(rails.controllers) do |m|
61
+ [
62
+ rspec.spec.call("routing/#{m[1]}_routing"),
63
+ rspec.spec.call("controllers/#{m[1]}_controller"),
64
+ rspec.spec.call("acceptance/#{m[1]}")
65
+ ]
66
+ end
67
+
68
+ # Rails config changes
69
+ watch(rails.spec_helper) { rspec.spec_dir }
70
+ watch(rails.routes) { "#{rspec.spec_dir}/routing" }
71
+ watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }
72
+
73
+ # Capybara features specs
74
+ watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") }
75
+ watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") }
76
+ end
77
+
78
+ guard :rubocop, cli: ['--display-cop-names'] do
79
+ watch(%r{.+\.rb$})
80
+ watch(%r{(?:.+/)?\.rubocop(?:_todo)?\.yml$}) { |m| File.dirname(m[0]) }
81
+ end
82
+
83
+ guard :shell, all_on_start: false do
84
+ watch_async(%r{app/views/(.+\.html.*\.erb)}) { |matches|
85
+
86
+ matches = matches.map { |m| File.absolute_path(m[0]) }
87
+ Dir.chdir('..') {
88
+ system("bundle exec erblint #{matches.join(' ')}")
89
+ }
90
+ }
91
+ end
92
+ end
93
+
94
+ group :autofix do
95
+ guard :rubocop, all_on_start: false, cli: ['--auto-correct', '--display-cop-names'] do
96
+ watch(%r{.+\.rb$})
97
+ watch(%r{(?:.+/)?\.rubocop(?:_todo)?\.yml$}) { |m| File.dirname(m[0]) }
98
+ end
99
+ end
100
+
101
+ scope group: :red_green_refactor
data/README.md CHANGED
@@ -1,7 +1,8 @@
1
- [![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://badge.fury.io/rb/wcc-contentful)
2
- [![CircleCI](https://circleci.com/gh/watermarkchurch/wcc-contentful.svg?style=svg)](https://circleci.com/gh/watermarkchurch/wcc-contentful)
1
+ [![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://rubygems.org/gems/wcc-contentful)
2
+ [![Build Status](https://travis-ci.org/watermarkchurch/wcc-contentful.svg?branch=master)](https://travis-ci.org/watermarkchurch/wcc-contentful)
3
+ [![Coverage Status](https://coveralls.io/repos/github/watermarkchurch/wcc-contentful/badge.svg?branch=master)](https://coveralls.io/github/watermarkchurch/wcc-contentful?branch=master)
3
4
 
4
- Full documentation: https://www.rubydoc.info/github/watermarkchurch/wcc-contentful
5
+ Full documentation: https://www.rubydoc.info/gems/wcc-contentful
5
6
 
6
7
  # WCC::Contentful
7
8
 
@@ -13,17 +14,26 @@ Add this line to your application's Gemfile:
13
14
  gem 'wcc-contentful', require: 'wcc/contentful/rails'
14
15
  ```
15
16
 
16
- And then execute:
17
+ If you're not using rails, exclude the `require:` parameter.
17
18
 
18
- $ bundle
19
+ ```ruby
20
+ gem 'wcc-contentful'
21
+ ```
19
22
 
23
+ And then execute:
24
+ ```
25
+ $ bundle
26
+ ```
20
27
  Or install it yourself as:
21
-
28
+ ```
22
29
  $ gem install wcc-contentful
30
+ ```
23
31
 
24
32
  ## Configure
25
33
 
34
+ Put this in an initializer:
26
35
  ```ruby
36
+ # config/initializers/wcc_contentful.rb
27
37
  WCC::Contentful.configure do |config|
28
38
  config.access_token = <CONTENTFUL_ACCESS_TOKEN>
29
39
  config.space = <CONTENTFUL_SPACE_ID>
@@ -32,13 +42,16 @@ end
32
42
  WCC::Contentful.init!
33
43
  ```
34
44
 
45
+ All configuration options can be found [in the rubydoc](https://www.rubydoc.info/gems/wcc-contentful/WCC/Contentful/Configuration) under
46
+ {WCC::Contentful::Configuration}
47
+
35
48
  ## Usage
36
49
 
37
50
  ### WCC::Contentful::Model API
38
51
 
39
52
  The WCC::Contentful::Model API exposes Contentful data as a set of dynamically
40
53
  generated Ruby objects. These objects are based on the content types in your
41
- Contentful space. All these objects are generated by WCC::Contentful.init!
54
+ Contentful space. All these objects are generated by `WCC::Contentful.init!`
42
55
 
43
56
  The following examples show how to use this API to find entries of the `page`
44
57
  content type:
@@ -163,7 +176,8 @@ response.includes
163
176
 
164
177
  The client handles Paging automatically within the lazy iterator returned by #items.
165
178
  This lazy iterator does not respect the `limit` param - that param is only passed
166
- through to the API to set the page size.
179
+ through to the API to set the page size. If you truly want a limited subset of
180
+ response items, use [`response.items.take(n)`](https://ruby-doc.org/core-2.5.3/Enumerable.html#method-i-take)
167
181
 
168
182
  Entries included via the `include` parameter are made available on the #includes
169
183
  field. This is a hash of `<entry ID> => <raw entry>` and makes it easy to grab
@@ -210,11 +224,147 @@ class MyJob < ApplicationJob
210
224
  end
211
225
  ```
212
226
 
213
- ## Development
227
+ ## Architecture
228
+
229
+ ![wcc-contentful diagram](./doc/wcc-contentful.png)
230
+
231
+ ## Test Helpers
232
+
233
+ To use the test helpers, include the following in your rails_helper.rb:
234
+
235
+ ```ruby
236
+ require 'wcc/contentful/rspec'
237
+ ```
238
+
239
+ This adds the following helpers to all your specs:
240
+
241
+ ```ruby
242
+ ##
243
+ # Builds a in-memory instance of the Contentful model for the given content_type.
244
+ # All attributes that are known to be required fields on the content type
245
+ # will return a default value based on the field type.
246
+ instance = contentful_create('my-content-type', my_field: 'some-value')
247
+ # => #<WCC::Contentful::Model::MyContentType:0x0000000005c71a78 @created_at=2018-04-16 18:41:17 UTC...>
248
+
249
+ instance.my_field
250
+ # => "some-value"
251
+
252
+ instance.other_required_field
253
+ # => "default-value"
254
+
255
+ instance.other_optional_field
256
+ # => nil
257
+
258
+ instance.not_a_field
259
+ # NoMethodError: undefined method `not_a_field' for #<MyContentType:0x00007fbac81ee490>
260
+
261
+ ##
262
+ # Builds a rspec double of the Contentful model for the given content_type.
263
+ # All attributes that are known to be required fields on the content type
264
+ # will return a default value based on the field type.
265
+ dbl = contentful_double('my-content-type', my_field: 'other-value')
266
+ # => #<Double (anonymous)>
267
+
268
+ dbl.my_field
269
+ # => "other-value"
270
+
271
+ dbl.other_optional_field
272
+ # => nil
214
273
 
215
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
274
+ dbl.not_a_field
275
+ # => #<Double (anonymous)> received unexpected message :not_a_field with (no args)
276
+
277
+ ##
278
+ # Builds out a fake Contentful entry for the given content type, and then
279
+ # stubs the Model API to return that content type for `.find` and `.find_by`
280
+ # query methods.
281
+ stubbed = contentful_stub('my-content-type', id: '1234', my_field: 'test')
282
+
283
+ WCC::Contentful::Model.find('1234') == stubbed
284
+ # => true
285
+
286
+ MyContentType.find('1234') == stubbed
287
+ # => true
288
+
289
+ MyContentType.find_by(my_field: 'test') == stubbed
290
+ # => true
291
+ ```
292
+
293
+ ## Advanced Configuration Example
294
+
295
+ Here's an example containing all the configuration options, and a sample setup for
296
+ automatic deployment to Heroku. This is intended to make you aware of what is possible,
297
+ and not as a general recommendation of what your setup should look like.
298
+
299
+ ```ruby
300
+ # config/initializers/wcc_contentful.rb
301
+ WCC::Contentful.configure do |config|
302
+ config.access_token = ENV['CONTENTFUL_ACCESS_TOKEN']
303
+ config.space = ENV['CONTENTFUL_SPACE_ID']
304
+ config.environment = ENV['CONTENTFUL_ENVIRONMENT']
305
+ config.preview_token = ENV['CONTENTFUL_PREVIEW_ACCESS_TOKEN']
306
+
307
+ # You may or may not want to provide this to your production server...
308
+ config.management_token = ENV['CONTENTFUL_MANAGEMENT_TOKEN'] unless Rails.env.production?
309
+
310
+ config.app_url = "https://#{ENV['HOSTNAME']}"
311
+ config.webhook_username = 'my-app-webhook'
312
+ config.webhook_password = Rails.application.secrets.webhook_password
313
+ config.webhook_jobs << MyOnWebhookJob
314
+
315
+ config.store = :lazy_sync, Rails.cache if Rails.env.production?
316
+ # config.store = MyCustomStore.new
317
+
318
+ # Use a custom Faraday connection
319
+ config.connection = Faraday.new do |builder|
320
+ f.request :retry
321
+ f.request MyFaradayRequestAdapter.new
322
+ ...
323
+ end
324
+ # OR implement some adapter like this to use another HTTP client
325
+ config.connection = MyNetHttpAdapter.new
326
+
327
+ config.update_schema_file = :never
328
+ end
329
+
330
+ WCC::Contentful.init!
331
+ ```
332
+
333
+ For Heroku:
334
+
335
+ ```yaml
336
+ # Procfile
337
+ web: bundle exec rails s
338
+ worker: bundle exec sidekiq
339
+ release: bin/release
340
+ ```
341
+
342
+ ```sh
343
+ # bin/release
344
+ #!/bin/sh
345
+
346
+ set -e
347
+
348
+ echo "Migrating database..."
349
+ bin/rake db:migrate
350
+
351
+ echo "Migrating contentful..."
352
+ migrations_to_be_run=$( ... ) # somehow figure this out
353
+ node_modules/.bin/contentful-migration \
354
+ -s $CONTENTFUL_SPACE_ID -a $CONTENTFUL_MANAGEMENT_TOKEN \
355
+ -y -p "$migrations_to_be_run"
356
+
357
+ echo "Updating schema file..."
358
+ rake wcc_contentful:download_schema
359
+ ```
360
+
361
+ All configuration options can be found [in the rubydoc](https://www.rubydoc.info/gems/wcc-contentful/WCC/Contentful/Configuration) under
362
+ {WCC::Contentful::Configuration}
363
+
364
+
365
+ ## Development
216
366
 
217
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
367
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
218
368
 
219
369
  ## Contributing
220
370
 
@@ -8,6 +8,7 @@ module WCC::Contentful
8
8
  # the jobs configured in {WCC::Contentful::Configuration WCC::Contentful::Configuration#webhook_jobs}
9
9
  class WebhookController < ApplicationController
10
10
  include WCC::Contentful::ServiceAccessors
11
+ include Wisper::Publisher
11
12
 
12
13
  before_action :authorize_contentful
13
14
  protect_from_forgery unless: -> { request.format.json? }
@@ -17,27 +18,17 @@ module WCC::Contentful
17
18
  end
18
19
 
19
20
  def receive
20
- event = params.require('webhook').permit!
21
- event.require('sys').require(%w[id type])
22
- event = event.to_h
23
-
24
- # Immediately update the store, we may update again later using DelayedSyncJob.
25
- store.index(event) if store.respond_to?(:index)
26
-
27
- jobs.each do |job|
28
- begin
29
- if job.respond_to?(:perform_later)
30
- job.perform_later(event)
31
- elsif job.respond_to?(:call)
32
- job.call(event)
33
- else
34
- Rails.logger.error "Misconfigured webhook job: #{job} does not respond to " \
35
- ':perform_later or :call'
36
- end
37
- rescue StandardError => e
38
- Rails.logger.error "Error in job #{job}: #{e}"
39
- end
40
- end
21
+ params.require('sys').require(%w[id type])
22
+ params.permit('sys', 'fields')
23
+ event = params.slice('sys', 'fields').permit!.to_h
24
+
25
+ return unless check_environment(event)
26
+
27
+ # Immediately update the store, we may update again later using SyncEngine::Job.
28
+ store.index(event) if store.index?
29
+
30
+ event = WCC::Contentful::Event.from_raw(event, source: self)
31
+ emit_event(event)
41
32
  end
42
33
 
43
34
  private
@@ -62,9 +53,19 @@ module WCC::Contentful
62
53
  render json: { msg: 'This endpoint only responds to webhooks from Contentful' }, status: 406
63
54
  end
64
55
 
65
- def jobs
66
- jobs = [WCC::Contentful::DelayedSyncJob]
67
- jobs.push(*WCC::Contentful.configuration.webhook_jobs)
56
+ def check_environment(event)
57
+ environment_id = event.dig('sys', 'environment', 'sys', 'id')
58
+ return true unless environment_id.present?
59
+
60
+ configured_environment = WCC::Contentful.configuration.environment.presence || 'master'
61
+ configured_environment.casecmp(environment_id) == 0
62
+ end
63
+
64
+ def emit_event(event)
65
+ type = event.dig('sys', 'type')
66
+ raise ArgumentError, "Unknown event type #{event}" unless type.present?
67
+
68
+ broadcast(type, event)
68
69
  end
69
70
  end
70
71
  end
@@ -7,7 +7,9 @@ module WCC::Contentful
7
7
  self.queue_adapter = :async
8
8
  queue_as :default
9
9
 
10
- def perform(args)
10
+ def perform(args = {})
11
+ args = default_configuration.merge!(args)
12
+
11
13
  client = WCC::Contentful::SimpleClient::Management.new(
12
14
  args
13
15
  )
@@ -26,7 +28,8 @@ module WCC::Contentful
26
28
  'topics' => [
27
29
  '*.publish',
28
30
  '*.unpublish'
29
- ]
31
+ ],
32
+ 'filters' => webhook_filters
30
33
  }
31
34
  body['httpBasicUsername'] = webhook_username if webhook_username.present?
32
35
  body['httpBasicPassword'] = webhook_password if webhook_password.present?
@@ -39,5 +42,36 @@ module WCC::Contentful
39
42
  raise
40
43
  end
41
44
  end
45
+
46
+ private
47
+
48
+ def default_configuration
49
+ return {} unless config = WCC::Contentful&.configuration
50
+
51
+ {
52
+ management_token: config.management_token,
53
+ app_url: config.app_url,
54
+ space: config.space,
55
+ environment: config.environment,
56
+ default_locale: config.default_locale,
57
+ connection: config.connection,
58
+ webhook_username: config.webhook_username,
59
+ webhook_password: config.webhook_password
60
+ }
61
+ end
62
+
63
+ def webhook_filters
64
+ filters = []
65
+
66
+ if (environment_id = WCC::Contentful.configuration&.environment).present?
67
+ filters << {
68
+ 'equals' => [
69
+ { 'doc' => 'sys.environment.sys.id' },
70
+ environment_id
71
+ ]
72
+ }
73
+ end
74
+ filters
75
+ end
42
76
  end
43
77
  end