JSONiCal 1.0.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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 536218be8562c31d4a3aaa4e2155c656189d7f3b
4
+ data.tar.gz: fe34b7cca898ea95d4c1fc4f4a8d6d0c3342832f
5
+ SHA512:
6
+ metadata.gz: 3e3302da13a38ce2bf815ba3317fb6c8efe5ab8a3684710624362c829b55b585e2c7f6af9f8d7e4591d54a5a79176ca39cfcea844e555d98cd8a41360d9674fe
7
+ data.tar.gz: 1218fc1a9316ccf19837fb243cff71a1bb8bea12fba1185871fb8b9f5e57471df7c1d6e212c94ac4e1bea237d24f793d5a9b09dd070d116d36ca07ecaa37997f
@@ -0,0 +1,2 @@
1
+ https://github.com/heroku/heroku-buildpack-ruby.git
2
+ https://github.com/miketheman/heroku-buildpack-datadog.git
@@ -0,0 +1,3 @@
1
+ export DBNAME=jsonical_dev
2
+ export DATABASE_URI=postgres://postgres@127.0.0.1:5432/$DBNAME
3
+ export BROKER_URI=amqp://root:root@localhost
@@ -0,0 +1 @@
1
+ .env
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
@@ -0,0 +1 @@
1
+ ruby-2.4.1
data/Gemfile ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ ruby '2.4.1'
7
+
8
+ gem 'activesupport'
9
+ gem 'bugsnag'
10
+ gem 'bunny', '>= 2.6.1'
11
+ gem 'dogstatsd-ruby'
12
+ gem 'icalendar'
13
+ gem 'json-schema'
14
+ gem 'oj'
15
+ gem 'pg'
16
+ gem 'puma', '~>3.8.2'
17
+ gem 'rails-html-sanitizer'
18
+ gem 'reasonable-value'
19
+ gem 'sequel'
20
+ gem 'sinatra'
21
+ gem 'tzinfo'
22
+
23
+ group :development, :test do
24
+ gem 'faker'
25
+ end
26
+
27
+ group :test do
28
+ gem 'codeclimate-test-reporter', '~> 1.0.0'
29
+ gem 'factory_girl'
30
+ gem 'pry'
31
+ gem 'rspec'
32
+ gem 'rspec-its'
33
+ gem 'simplecov'
34
+ end
@@ -0,0 +1,125 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ JSONiCal (1.0.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ activesupport (5.1.2)
10
+ concurrent-ruby (~> 1.0, >= 1.0.2)
11
+ i18n (~> 0.7)
12
+ minitest (~> 5.1)
13
+ tzinfo (~> 1.1)
14
+ addressable (2.5.1)
15
+ public_suffix (~> 2.0, >= 2.0.2)
16
+ amq-protocol (2.2.0)
17
+ bugsnag (5.3.2)
18
+ bunny (2.7.0)
19
+ amq-protocol (>= 2.2.0)
20
+ codeclimate-test-reporter (1.0.8)
21
+ simplecov (<= 0.13)
22
+ coderay (1.1.1)
23
+ concurrent-ruby (1.0.5)
24
+ crass (1.0.3)
25
+ diff-lcs (1.3)
26
+ docile (1.1.5)
27
+ dogstatsd-ruby (3.0.0)
28
+ factory_girl (4.8.0)
29
+ activesupport (>= 3.0.0)
30
+ faker (1.7.3)
31
+ i18n (~> 0.5)
32
+ i18n (0.8.4)
33
+ icalendar (2.4.1)
34
+ json (2.1.0)
35
+ json-schema (2.8.0)
36
+ addressable (>= 2.4)
37
+ loofah (2.2.0)
38
+ crass (~> 1.0.2)
39
+ nokogiri (>= 1.5.9)
40
+ method_source (0.8.2)
41
+ mini_portile2 (2.3.0)
42
+ minitest (5.10.2)
43
+ mustermann (1.0.0)
44
+ nokogiri (1.8.2)
45
+ mini_portile2 (~> 2.3.0)
46
+ oj (3.1.0)
47
+ pg (0.20.0)
48
+ pry (0.10.4)
49
+ coderay (~> 1.1.0)
50
+ method_source (~> 0.8.1)
51
+ slop (~> 3.4)
52
+ public_suffix (2.0.5)
53
+ puma (3.8.2)
54
+ rack (2.0.3)
55
+ rack-protection (2.0.0)
56
+ rack
57
+ rails-html-sanitizer (1.0.3)
58
+ loofah (~> 2.0)
59
+ reasonable-value (0.2.5)
60
+ activesupport
61
+ rspec (3.6.0)
62
+ rspec-core (~> 3.6.0)
63
+ rspec-expectations (~> 3.6.0)
64
+ rspec-mocks (~> 3.6.0)
65
+ rspec-core (3.6.0)
66
+ rspec-support (~> 3.6.0)
67
+ rspec-expectations (3.6.0)
68
+ diff-lcs (>= 1.2.0, < 2.0)
69
+ rspec-support (~> 3.6.0)
70
+ rspec-its (1.2.0)
71
+ rspec-core (>= 3.0.0)
72
+ rspec-expectations (>= 3.0.0)
73
+ rspec-mocks (3.6.0)
74
+ diff-lcs (>= 1.2.0, < 2.0)
75
+ rspec-support (~> 3.6.0)
76
+ rspec-support (3.6.0)
77
+ sequel (4.47.0)
78
+ simplecov (0.13.0)
79
+ docile (~> 1.1.0)
80
+ json (>= 1.8, < 3)
81
+ simplecov-html (~> 0.10.0)
82
+ simplecov-html (0.10.1)
83
+ sinatra (2.0.0)
84
+ mustermann (~> 1.0)
85
+ rack (~> 2.0)
86
+ rack-protection (= 2.0.0)
87
+ tilt (~> 2.0)
88
+ slop (3.6.0)
89
+ thread_safe (0.3.6)
90
+ tilt (2.0.7)
91
+ tzinfo (1.2.3)
92
+ thread_safe (~> 0.1)
93
+
94
+ PLATFORMS
95
+ ruby
96
+
97
+ DEPENDENCIES
98
+ JSONiCal!
99
+ activesupport
100
+ bugsnag
101
+ bunny (>= 2.6.1)
102
+ codeclimate-test-reporter (~> 1.0.0)
103
+ dogstatsd-ruby
104
+ factory_girl
105
+ faker
106
+ icalendar
107
+ json-schema
108
+ oj
109
+ pg
110
+ pry
111
+ puma (~> 3.8.2)
112
+ rails-html-sanitizer
113
+ reasonable-value
114
+ rspec
115
+ rspec-its
116
+ sequel
117
+ simplecov
118
+ sinatra
119
+ tzinfo
120
+
121
+ RUBY VERSION
122
+ ruby 2.4.1p111
123
+
124
+ BUNDLED WITH
125
+ 1.16.1
@@ -0,0 +1,2 @@
1
+ web: puma config.ru -C config/puma.rb -p $PORT
2
+ async: ruby app/listener.rb
@@ -0,0 +1,101 @@
1
+ # JSONiCal
2
+
3
+ [![Code Climate](https://codeclimate.com/repos/58493319815f2e272a000001/badges/b5a8c4a49ed59a669e9b/gpa.svg)](https://codeclimate.com/repos/58493319815f2e272a000001/feed)
4
+ [![Test Coverage](https://codeclimate.com/repos/58493319815f2e272a000001/badges/b5a8c4a49ed59a669e9b/coverage.svg)](https://codeclimate.com/repos/58493319815f2e272a000001/coverage)
5
+ [![CircleCI](https://circleci.com/gh/jobteaser/jsonical.svg?style=shield&circle-token=65192822c3403a624fb9935f30ea24f27eb28b74)](https://circleci.com/gh/jobteaser/jsonical)
6
+
7
+ This micro service provides an interface between your main application and calendar clients (e.g. outlook, lotus note, icalendar, etc.).
8
+
9
+ # Requirements
10
+
11
+ This application needs:
12
+ - ruby 2.3.3
13
+ - docker
14
+ - docker-compose
15
+
16
+ # Be careful
17
+
18
+ - This service use puma web server. The code in deps must be thread safe !
19
+ - This repo use auto deployment when commit is push on master !
20
+
21
+ # How does it work ?
22
+
23
+ ![schema](docs/schema.png)
24
+
25
+ An actor (e.g. your main application) pushes messages into AMQP. Listen queues are `jsonical.vevent.create`, `jsonical.vevent.update`, `jsonical.vevent.delete`.
26
+
27
+ Messages must be a stringified JSON, and must respect a strict [schema](https://github.com/jobteaser/jsonical/tree/master/schemas).
28
+
29
+ Example:
30
+ ```json
31
+ {
32
+ "version": "v1",
33
+ "data": {
34
+ "orn": "orn:cockpit:pilotage:U3241834:action/A93489010",
35
+ "calendar_orn": "orn:cockpit:pilotage:U3241834:company/C823510948",
36
+ "summary": "My awesome event",
37
+ "description": "No idea",
38
+ "begin_date": "2016-12-15 14:30:00 +0100",
39
+ "end_date": "2016-12-15 15:00:00 +0100",
40
+ "uri": "https://cockpit.jobteaser.com/actions/orn:cockpit:pilotage:U3241834:action%2FA93489010",
41
+ "uid": "https://cockpit.jobteaser.com/actions/orn:cockpit:pilotage:U3241834:action%2FA93489010",
42
+ "klass": "PUBLIC",
43
+ "created": "2016-10-10 08:32:52 +0100",
44
+ "updated": "2016-10-10 08:32:52 +0100"
45
+ }
46
+ }
47
+ ```
48
+
49
+ When an event is available in queue, the listener stores the event and acknowledges the message in AMQP.
50
+
51
+ # Links
52
+
53
+ - [monitoring](https://app.datadoghq.com/dash/224297/jsonical-health?live=true&page=0&is_auto=false&from_ts=1481187563852&to_ts=1481792363852&tile_size=l&utc_override=true&tv_mode=true)
54
+ - [heroku](https://dashboard.heroku.com/apps/jsonical)
55
+ - [issues](https://github.com/jobteaser/jsonical/issues)
56
+
57
+ # Install
58
+
59
+ - Up Postgres and AMQP server with docker
60
+ ```shell
61
+ $> docker-compose up
62
+ ```
63
+
64
+ - Create .env
65
+ ```shell
66
+ $> cp .env.sample .env
67
+ ```
68
+
69
+ - Source enviroment
70
+ ```shell
71
+ $> source .env
72
+ ```
73
+
74
+ - Run setup script
75
+ ```shell
76
+ $> bin/setup
77
+ ```
78
+ This will run bundler, create the database, run the migrations and seed it with
79
+ `db/seed.rb`
80
+
81
+ - Start application
82
+ ```shell
83
+ $> bundle exec foreman start
84
+ ```
85
+
86
+ # Seed
87
+
88
+ You can seed the database like that: `ruby db/seed.rb`. The seed generates a calendar with many event between today ± 30 days. You can run the seed again when you need more calendars.
89
+
90
+ The seed script uses the [faker gem](https://rubygems.org/gems/faker) to generate fake data. You can't run seed in production mode.
91
+
92
+ # Test
93
+
94
+ ```shell
95
+ $> bin/rspec
96
+ ```
97
+
98
+ # TODO
99
+
100
+ - [ ] Bump to ruby 2.4.0
101
+ - [ ] Write yard documentation
@@ -0,0 +1,69 @@
1
+ require 'bundler/setup'
2
+ require 'jsonical'
3
+ require 'datadog/statsd'
4
+ require 'bugsnag'
5
+
6
+ # For display log
7
+ # http://stackoverflow.com/questions/8717198/foreman-only-shows-line-with-started-wit-pid-and-nothing-else
8
+ $stdout.sync = true
9
+
10
+ statsd = Datadog::Statsd.new(
11
+ 'localhost',
12
+ 8125,
13
+ namespace: 'jsonical',
14
+ tag: 'production:true'
15
+ )
16
+
17
+ def symbolize_keys(object)
18
+ return object unless object.is_a?(Hash)
19
+
20
+ object.inject({}) do |memo, (key, value)|
21
+ memo[key.to_sym] = symbolize_keys(value)
22
+ memo
23
+ end
24
+ end
25
+
26
+ begin
27
+ conn = JSONiCal.broker
28
+
29
+ channel = conn.create_channel
30
+ x = channel.direct('jsonical.vevent')
31
+ queue = channel.queue('jsonical.vevent', durable: true, auto_delete: false)
32
+
33
+ JSONiCal::BINDED_EVENT.each do |event_name|
34
+ queue.bind(x, routing_key: event_name)
35
+ end
36
+
37
+ queue.subscribe(manual_ack: true, block: true) do |di, _, message|
38
+ begin
39
+ JSONiCal
40
+ .logger
41
+ .info("Event recived on #{di.routing_key}, with message: #{message}")
42
+
43
+ statsd.increment("vevent.#{di.routing_key}")
44
+
45
+ statsd.time('vevent.treatment') do
46
+ JSONiCal::VEVENTDispatcher.new(
47
+ key: di.routing_key,
48
+ message: symbolize_keys(Oj.load(message))
49
+ ).call
50
+ end
51
+ rescue Exception => e
52
+ statsd.increment('vevent.treatment_error')
53
+
54
+ JSONiCal.logger.error([e.class, e.message, e.backtrace].join("\n"))
55
+
56
+ Bugsnag.notify(e) do |notification|
57
+ notification.severity = 'error'
58
+ notification.context = 'message treatment'
59
+ notification.add_tab(:payload, raw: message)
60
+ end
61
+
62
+ raise e
63
+ ensure
64
+ channel.ack(di.delivery_tag)
65
+ end
66
+ end
67
+ ensure
68
+ conn.close
69
+ end
@@ -0,0 +1,85 @@
1
+ require 'bundler/setup'
2
+ require 'jsonical'
3
+ require 'sinatra'
4
+ require 'datadog/statsd'
5
+
6
+ statsd = Datadog::Statsd.new(
7
+ 'localhost',
8
+ 8125,
9
+ namespace: 'jsonical',
10
+ tag: 'production:true'
11
+ )
12
+
13
+ configure { set :server, :puma }
14
+ set :protection, except: :path_traversal
15
+
16
+ get '/events/:orn/links' do
17
+ content_type('application/json')
18
+
19
+ statsd.increment('events.view')
20
+
21
+ event = statsd.time('events.fetch') do
22
+ JSONiCal::VEVENTModel.new(JSONiCal::VEVENTRepo.find(params[:orn]))
23
+ end
24
+
25
+ statsd.time('template.render') do
26
+ Oj.dump(
27
+ JSONiCal::LinksBuilder.
28
+ new(event).
29
+ call
30
+ )
31
+ end
32
+ end
33
+
34
+ get '/events/:orn' do
35
+ content_type('text/calendar')
36
+ response.headers['Content-Disposition'] = 'filename="jobteaser.ics"'
37
+
38
+ statsd.increment('events.view')
39
+
40
+ event = statsd.time('events.fetch') do
41
+ JSONiCal::VEVENTModel.new(JSONiCal::VEVENTRepo.find(params[:orn]))
42
+ end
43
+
44
+ statsd.time('template.render') do
45
+ JSONiCal::CalendarBuilder.
46
+ new(event).
47
+ call(vendor: params[:vendor])
48
+ end
49
+ end
50
+
51
+ get '/calendars/:token' do
52
+ content_type('text/calendar')
53
+ response.headers['Content-Disposition'] = 'filename="jobteaser.ics"'
54
+
55
+ statsd.increment('calendar.view')
56
+
57
+ events = statsd.time('events.fetch') do
58
+ JSONiCal::VEVENTRepo.
59
+ events_with_token(params[:token]).
60
+ map { |event_hash| JSONiCal::VEVENTModel.new(event_hash) }
61
+ end
62
+
63
+ statsd.time('template.render') do
64
+ JSONiCal::CalendarBuilder.
65
+ new(*events).
66
+ call(calendar_name: 'JobTeaser')
67
+ end
68
+ end
69
+
70
+ get '/api/v1/calendars/:orn' do
71
+ content_type('application/json')
72
+
73
+ statsd.increment('api.fetch.count')
74
+
75
+ statsd.time('api.calendars.show') do
76
+ calendar = JSONiCal.database[:vcalendars].where(orn: params[:orn]).first
77
+ calendar or halt(404, Oj.dump('error' => '404 Not Found'))
78
+
79
+ @res = {
80
+ 'uri' => "webcal://jsonical.herokuapp.com/calendars/#{calendar[:token]}"
81
+ }
82
+ end
83
+
84
+ Oj.dump(@res)
85
+ end
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'jsonical'
5
+
6
+ require "irb"
7
+ IRB.start
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+
3
+ bundle exec sequel -m db/migrations $DATABASE_URI
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require 'bundler/setup'
3
+ load Gem.bin_path('rspec-core', 'rspec')
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ psql -U postgres -h 127.0.0.1 -tc "SELECT 1 FROM pg_database WHERE datname = '$DBNAME'" | grep -q 1 \
9
+ || psql -U postgres -h 127.0.0.1 -c "CREATE DATABASE $DBNAME"
10
+
11
+ bin/migration
12
+
13
+ ruby db/seed.rb
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+ bin/migration
@@ -0,0 +1,27 @@
1
+ machine:
2
+ services:
3
+ - rabbitmq-server
4
+ environment:
5
+ DATABASE_URI: postgres://postgres@localhost:5432/jsonical_test
6
+ CODECLIMATE_REPO_TOKEN: b825ccacc4597f8eae79b685bf01126994a0d38e4f4e6fb47ffa0e1fdad1ec20
7
+ COVERAGE: true
8
+ BROKER_URI: amqp://localhost:5672
9
+ database:
10
+ override:
11
+ - psql -c 'create database jsonical_test;' -U postgres
12
+ - bin/setup-ci
13
+ test:
14
+ override:
15
+ - bin/rspec
16
+ post:
17
+ - bundle exec codeclimate-test-reporter
18
+ deployment:
19
+ production:
20
+ branch: master
21
+ commands:
22
+ - heroku scale async=0 -a jsonical
23
+ - "[[ ! -s \"$(git rev-parse --git-dir)/shallow\" ]] || git fetch --unshallow"
24
+ - git push git@heroku.com:jsonical.git $CIRCLE_SHA1:refs/heads/master
25
+ - heroku run bin/migration -a jsonical:
26
+ timeout: 400
27
+ - heroku scale async=1 -a jsonical
@@ -0,0 +1,5 @@
1
+ require 'bundler/setup'
2
+ $LOAD_PATH << '.'
3
+ require 'app/web'
4
+
5
+ run Sinatra::Application
@@ -0,0 +1,9 @@
1
+ threads_count = Integer(ENV['MAX_THREADS'] || 5)
2
+ threads threads_count, threads_count
3
+
4
+ preload_app!
5
+
6
+ port ENV['PORT'] || 3000
7
+ environment ENV['RACK_ENV'] || 'development'
8
+
9
+ workers Integer(ENV['WEB_CONCURRENCY'] || 2)
@@ -0,0 +1,22 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:vevents) do
4
+ String :orn , null: false
5
+ String :calendar_orn , null: false
6
+ String :summary, null: false
7
+ String :description, null: true
8
+ String :uid, null: false
9
+ String :uri, null: true
10
+ String :klass, null: false
11
+ String :location, null: true
12
+ DateTime :begin_date, null: false
13
+ DateTime :end_date, null: true
14
+ DateTime :created, null: false
15
+ DateTime :updated, null: false
16
+ end
17
+
18
+ alter_table(:vevents) do
19
+ add_index(:orn, unique: true)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:vcalendars) do
4
+ String :orn, null: false
5
+ String :token, null: false
6
+ end
7
+
8
+ alter_table(:vcalendars) do
9
+ add_index(:orn, unique: true)
10
+ add_index(:token, unique: true)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ require 'bundler/setup'
2
+ require 'jsonical'
3
+ require 'faker'
4
+
5
+
6
+ calendar_name = Faker::Code.asin
7
+
8
+ rand(15..200).times do
9
+
10
+ datetime = Faker::Time.between(DateTime.now - 30, DateTime.now + 30)
11
+ vevent_name = Faker::Code.asin
12
+ event = JSONiCal::VEVENTModel.new(
13
+ begin_date: datetime,
14
+ end_date: datetime + rand(1800..25200),
15
+ summary: Faker::Company.name,
16
+ description: Faker::Lorem.paragraph,
17
+ uri: Faker::Internet.url,
18
+ orn: "orn:jsonical:calendars::vevent/#{calendar_name}/#{vevent_name}",
19
+ calendar_orn: "orn:jsonical:calendar::calendar/#{calendar_name}",
20
+ klass: 'PUBLIC',
21
+ uid: SecureRandom.uuid,
22
+ created: Time.now,
23
+ updated: Time.now
24
+ )
25
+ JSONiCal::VEVENTRepo.insert(event)
26
+
27
+ end
@@ -0,0 +1,16 @@
1
+ version: '2'
2
+ services:
3
+ database:
4
+ image: postgres
5
+ ports:
6
+ - "5432:5432"
7
+ environment:
8
+ - POSTGRES_DB=jsonical_dev
9
+ amqp:
10
+ image: rabbitmq:3
11
+ ports:
12
+ - "5672:5672"
13
+ environment:
14
+ - RABBITMQ_DEFAULT_USER=root
15
+ - RABBITMQ_DEFAULT_PASS=root
16
+
Binary file
@@ -0,0 +1,17 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "JSONiCal"
7
+ spec.version = '1.0.0'
8
+ spec.summary = ""
9
+ spec.author = ""
10
+
11
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
12
+ f.match(%r{^(test|spec|features)/})
13
+ end
14
+
15
+ spec.require_paths = ["lib"]
16
+
17
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sequel'
4
+ require 'bunny'
5
+ require 'oj'
6
+ require 'logger'
7
+ require 'forwardable'
8
+ require 'json-schema'
9
+ require 'icalendar'
10
+ require 'reasonable/value'
11
+ require 'rails-html-sanitizer'
12
+
13
+ require 'jsonical/vevent_model'
14
+ require 'jsonical/vevent_repo'
15
+ require 'jsonical/vevent_schema'
16
+ require 'jsonical/create_or_update_vevent_service'
17
+ require 'jsonical/delete_vevent_service'
18
+ require 'jsonical/vevent_dispatcher'
19
+ require 'jsonical/calendar_builder'
20
+ require 'jsonical/links_builder'
21
+
22
+ module JSONiCal
23
+
24
+ BINDED_EVENT = JSONiCal::VEVENTDispatcher::EVENT_MAP.keys.freeze
25
+
26
+ DATABASE_URI = ENV.fetch('DATABASE_URI').freeze
27
+
28
+ BROKER_URI = ENV.fetch('BROKER_URI').freeze
29
+
30
+ class << self
31
+
32
+ def broker
33
+ Thread.current[:broker] ||= Bunny.new(JSONiCal::BROKER_URI).start
34
+ end
35
+
36
+ def database
37
+ Thread.current[:database] ||= Sequel.connect(JSONiCal::DATABASE_URI, max_connections: 5)
38
+ end
39
+
40
+ def logger
41
+ Thread.current[:logger] ||= Logger.new(STDOUT)
42
+ end
43
+
44
+ end
45
+
46
+ end
@@ -0,0 +1,35 @@
1
+ require 'icalendar/tzinfo'
2
+
3
+ module JSONiCal
4
+ class CalendarBuilder
5
+
6
+ def initialize(*events)
7
+ @events = *events
8
+ end
9
+
10
+ def call(calendar_name: nil, vendor: nil)
11
+ calendar = Icalendar::Calendar.new
12
+
13
+ calendar.add_timezone(timezone)
14
+
15
+ @events.each { |event| calendar.add_event(event.to_ics(vendor: vendor)) }
16
+
17
+ calendar.publish
18
+
19
+ unless calendar_name.nil?
20
+ calendar.append_custom_property('X-WR-CALNAME', calendar_name)
21
+ end
22
+
23
+ calendar.to_ical
24
+ end
25
+
26
+ private
27
+
28
+ def timezone
29
+ TZInfo::Timezone.
30
+ get(JSONiCal::VEVENTModel::TIMEZONE).
31
+ ical_timezone(Time.now.utc)
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,15 @@
1
+ module JSONiCal
2
+ class CreateOrUpdateVEVENTService
3
+
4
+ def initialize(message)
5
+ @version = message[:version] || 'v1'
6
+ @data = message[:data] || message
7
+ end
8
+
9
+ def call
10
+ JSONiCal::VEVENTSchema.validate!(@data, version: @version)
11
+ JSONiCal::VEVENTRepo.insert(JSONiCal::VEVENTModel.new(@data))
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module JSONiCal
2
+ class DeleteVEVENTService
3
+
4
+ def initialize(message)
5
+ @version = message[:version] || 'v1'
6
+ @data = message[:data] || message
7
+ end
8
+
9
+ def call
10
+ JSONiCal::VEVENTSchema.validate!(@data, version: @version)
11
+ JSONiCal::VEVENTRepo.delete(JSONiCal::VEVENTModel.new(@data))
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,59 @@
1
+ module JSONiCal
2
+ class LinksBuilder
3
+
4
+ def initialize(event)
5
+ @event = event
6
+ end
7
+
8
+ def call
9
+ {
10
+ 'google' => google,
11
+ 'outlook' => outlook,
12
+ 'lotus_note' => lotus_note,
13
+ 'ical' => ical
14
+ }
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :event
20
+
21
+ SERVICE_URI = 'https://jsonical.herokuapp.com/events'.freeze
22
+
23
+ def google
24
+ begin_date = format_date(event.begin_date)
25
+ end_date = format_date(event.end_date)
26
+
27
+ [
28
+ 'https://www.google.com/calendar/render',
29
+ '?action=TEMPLATE',
30
+ "&text=#{event.summary}",
31
+ "&dates=#{begin_date}/#{end_date}",
32
+ "&details=#{event.description.gsub("\n", '<br />')}",
33
+ '&location=paris',
34
+ '&sprop=&sprop=name:'
35
+ ].join('')
36
+ end
37
+
38
+ def ics(vendor:)
39
+ "#{SERVICE_URI}/#{CGI.escape(event.orn)}?vendor=#{vendor}"
40
+ end
41
+
42
+ def outlook
43
+ ics(vendor: 'outlook')
44
+ end
45
+
46
+ def lotus_note
47
+ ics(vendor: 'lotus_note')
48
+ end
49
+
50
+ def ical
51
+ ics(vendor: 'ical')
52
+ end
53
+
54
+ def format_date(date)
55
+ date.utc.strftime('%Y%m%dT%H%M%SZ')
56
+ end
57
+
58
+ end
59
+ end
@@ -0,0 +1,21 @@
1
+ module JSONiCal
2
+ class VEVENTDispatcher
3
+
4
+ EVENT_MAP = {
5
+ 'create' => JSONiCal::CreateOrUpdateVEVENTService,
6
+ 'update' => JSONiCal::CreateOrUpdateVEVENTService,
7
+ 'delete' => JSONiCal::DeleteVEVENTService
8
+ }.freeze
9
+
10
+ def initialize(event_map: EVENT_MAP, key:, message:)
11
+ @map = event_map
12
+ @key = key
13
+ @message = message
14
+ end
15
+
16
+ def call
17
+ @map[@key].new(@message).call
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONiCal
4
+ class VEVENTModel < ::Reasonable::Value
5
+
6
+ attribute :begin_date, [DateTime, Time]
7
+ attribute :end_date, [DateTime, Time]
8
+ attribute :summary, String, optional: true
9
+ attribute :description, String, optional: true
10
+ attribute :location, String, optional: true
11
+ attribute :klass, String
12
+ attribute :created, [DateTime, Time]
13
+ attribute :updated, [DateTime, Time]
14
+ attribute :uid, String
15
+ attribute :uri, String
16
+ attribute :orn, String
17
+ attribute :calendar_orn, String
18
+
19
+ TIMEZONE = 'Etc/Universal'
20
+
21
+ alias to_h to_hash
22
+
23
+ def to_ics(vendor: nil)
24
+ ICSSerializer.serialize(self, vendor: vendor)
25
+ end
26
+
27
+ end
28
+
29
+ class ICSSerializer
30
+
31
+ class << self
32
+
33
+ def serialize(object, vendor:)
34
+ event = Icalendar::Event.new
35
+
36
+ event.dtstart = date(object.begin_date)
37
+ event.dtend = date(object.end_date)
38
+ event.summary = text(object.summary)
39
+ set_description(event, object.description, vendor: vendor)
40
+ event.ip_class = object.klass
41
+ event.url = uri(object.uri)
42
+
43
+ event
44
+ end
45
+
46
+ private
47
+
48
+ def set_description(event, string, vendor:)
49
+ stripped_description = Rails::Html::FullSanitizer.new.sanitize(string)
50
+ html_description = string&.gsub("\n", '<br />')
51
+
52
+ case vendor
53
+ when 'outlook'
54
+ event.append_custom_property(
55
+ 'X-ALT-DESC;FMTTYPE=text/html',
56
+ text(html_description)
57
+ )
58
+ when 'ical'
59
+ event.description = text(stripped_description)
60
+ else
61
+ event.append_custom_property(
62
+ 'X-ALT-DESC;FMTTYPE=text/html',
63
+ text(html_description)
64
+ )
65
+ event.description = text(stripped_description)
66
+ end
67
+ end
68
+
69
+ def uri(string)
70
+ Icalendar::Values::Uri.new(string)
71
+ end
72
+
73
+ def text(string)
74
+ Icalendar::Values::Text.new(string || '')
75
+ end
76
+
77
+ def date(date)
78
+ Icalendar::Values::DateTime.new(
79
+ date.utc,
80
+ tzid: JSONiCal::VEVENTModel::TIMEZONE
81
+ )
82
+ end
83
+
84
+ end
85
+
86
+ end
87
+ private_constant(:ICSSerializer)
88
+ end
@@ -0,0 +1,55 @@
1
+ module JSONiCal
2
+ module VEVENTRepo
3
+
4
+ class << self
5
+
6
+ def find(orn)
7
+ database[:vevents].where(orn: orn).first
8
+ end
9
+
10
+ def insert(vevent)
11
+ raise ArgumentError unless vevent.is_a?(JSONiCal::VEVENTModel)
12
+
13
+ unless database[:vcalendars].where(orn: vevent.calendar_orn).first
14
+ database[:vcalendars].insert(
15
+ [:orn, :token],
16
+ [vevent.calendar_orn, SecureRandom.hex(30)]
17
+ )
18
+ end
19
+
20
+ database[:vevents].insert_conflict(
21
+ target: :orn,
22
+ update: vevent.to_h,
23
+ update_where: Sequel.qualify(:vevents, :updated) < vevent.updated
24
+ ).insert(vevent.to_h)
25
+ end
26
+
27
+ def delete(vevent)
28
+ raise ArgumentError unless vevent.is_a?(JSONiCal::VEVENTModel)
29
+
30
+ database[:vevents].where(orn: vevent.orn).delete
31
+ end
32
+
33
+ def events_with_token(token)
34
+ # The 7 days rules is tempory and is here to fix a problem with google
35
+ # calendar not syncing more than a certain number of events.
36
+ database[:vevents].
37
+ where(calendar_orn: calendar_with_token(token).select_map(:orn)).
38
+ where { begin_date > Time.now - 604800 }. # > 7 days ago
39
+ order(:begin_date)
40
+ end
41
+
42
+ private
43
+
44
+ def calendar_with_token(token)
45
+ database[:vcalendars].where(token: token)
46
+ end
47
+
48
+ def database
49
+ JSONiCal.database
50
+ end
51
+
52
+ end
53
+
54
+ end
55
+ end
@@ -0,0 +1,26 @@
1
+ require 'yaml'
2
+
3
+ module JSONiCal
4
+ module VEVENTSchema
5
+
6
+ class BadVersionError < ArgumentError; end
7
+
8
+ supported_versions = (1..2)
9
+
10
+ versions = supported_versions.map do |version|
11
+ [:"v#{version}", YAML.load_file("schemas/v#{version}.yml").freeze]
12
+ end
13
+
14
+ SCHEMAS = Hash[versions]
15
+ private_constant(:SCHEMAS)
16
+
17
+ def self.validate!(payload, version:)
18
+ schema = SCHEMAS[version.to_sym]
19
+
20
+ raise(BadVersionError) if schema.nil?
21
+
22
+ JSON::Validator.validate!(schema, payload)
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ type: object
2
+ required:
3
+ - begin_date
4
+ - end_date
5
+ - summary
6
+ - uri
7
+ - uid
8
+ - updated
9
+ - created
10
+ - orn
11
+ - klass
12
+ - calendar_orn
13
+ properties:
14
+ begin_date: &string
15
+ type: string
16
+ end_date: *string
17
+ summary: *string
18
+ uri: *string
19
+ uid: *string
20
+ updated: *string
21
+ created: *string
22
+ orn: *string
23
+ klass: *string
24
+ calendar_orn: *string
@@ -0,0 +1,25 @@
1
+ type: object
2
+ required:
3
+ - begin_date
4
+ - end_date
5
+ - summary
6
+ - uri
7
+ - uid
8
+ - updated
9
+ - created
10
+ - orn
11
+ - klass
12
+ - calendar_orn
13
+ properties:
14
+ begin_date: &string
15
+ type: string
16
+ end_date: *string
17
+ summary: *string
18
+ uri: *string
19
+ uid: *string
20
+ updated: *string
21
+ created: *string
22
+ orn: *string
23
+ klass: *string
24
+ calendar_orn: *string
25
+ location: *string
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: JSONiCal
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - ''
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-07-03 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - ".buildpacks"
20
+ - ".env.sample"
21
+ - ".gitignore"
22
+ - ".rspec"
23
+ - ".ruby-version"
24
+ - Gemfile
25
+ - Gemfile.lock
26
+ - Procfile
27
+ - README.md
28
+ - app/listener.rb
29
+ - app/web.rb
30
+ - bin/console
31
+ - bin/migration
32
+ - bin/rspec
33
+ - bin/setup
34
+ - bin/setup-ci
35
+ - circle.yml
36
+ - config.ru
37
+ - config/puma.rb
38
+ - db/migrations/20161208154455_create_vevents.rb
39
+ - db/migrations/20161213183700_create_calendars.rb
40
+ - db/seed.rb
41
+ - docker-compose.yml
42
+ - docs/schema.png
43
+ - jsonical.gemspec
44
+ - lib/jsonical.rb
45
+ - lib/jsonical/calendar_builder.rb
46
+ - lib/jsonical/create_or_update_vevent_service.rb
47
+ - lib/jsonical/delete_vevent_service.rb
48
+ - lib/jsonical/links_builder.rb
49
+ - lib/jsonical/vevent_dispatcher.rb
50
+ - lib/jsonical/vevent_model.rb
51
+ - lib/jsonical/vevent_repo.rb
52
+ - lib/jsonical/vevent_schema.rb
53
+ - schemas/v1.yml
54
+ - schemas/v2.yml
55
+ homepage:
56
+ licenses: []
57
+ metadata: {}
58
+ post_install_message:
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubyforge_project:
74
+ rubygems_version: 2.6.11
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: ''
78
+ test_files: []