JSONiCal 1.0.0

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