Created
August 7, 2019 08:08
-
-
Save stakach/5b4b658628773dcbce0a8cc4181bd5e0 to your computer and use it in GitHub Desktop.
Crystal Lang Google Calendar
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "jwt" | |
require "json" | |
require "http" | |
require "uri" | |
require "./http_proxy" | |
class GoogleAuth | |
GOOGLE_URI = URI.parse("https://www.googleapis.com") | |
TOKEN_PATH = "/oauth2/v4/token" | |
AUDIENCE = "https://www.googleapis.com/oauth2/v4/token" | |
SIGNING_ALGORITHM = JWT::Algorithm::RS256 | |
EXPIRY = 60.seconds | |
TOKENS = {} of String => Token | |
class Token | |
JSON.mapping( | |
access_token: String, | |
expires_in: Int32, | |
token_type: String | |
) | |
property expires : Time = Time.utc | |
def expired? | |
Time.utc >= @expires | |
end | |
def current? | |
Time.utc < @expires | |
end | |
end | |
def initialize(@issuer : String, @signing_key : String, scopes : String | Array(String)) | |
@scopes = if scopes.is_a? Array | |
scopes.join(", ") | |
else | |
scopes | |
end | |
end | |
@scopes : String | |
property sub : String = "" | |
# https://developers.google.com/identity/protocols/OAuth2ServiceAccount | |
def get_token : Token | |
sub = @sub | |
token_lookup = "#{@scopes}_#{sub}" | |
existing = TOKENS[token_lookup]? | |
return existing if existing && existing.current? | |
now = Time.new | |
assertion = { | |
"iss" => @issuer, | |
"scope" => @scopes, | |
"aud" => AUDIENCE, | |
"iat" => (now - EXPIRY).to_unix, | |
"exp" => (now + EXPIRY).to_unix, | |
} | |
assertion["sub"] = @sub unless @sub.empty? | |
jwt_token = JWT.encode(assertion, @signing_key, SIGNING_ALGORITHM) | |
response = HttpProxy::Client.new(GOOGLE_URI) do |client| | |
client.exec( | |
"POST", | |
TOKEN_PATH, | |
HTTP::Headers{ | |
"Content-Type" => "application/x-www-form-urlencoded", | |
}, | |
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=#{jwt_token}" | |
) | |
end | |
if response.success? | |
token = Token.from_json response.body | |
token.expires = token.expires + token.expires_in.seconds - EXPIRY | |
TOKENS[token_lookup] = token | |
token | |
else | |
raise "error fetching token #{response.status} (#{response.status_code})\n#{response.body}" | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "./google_auth" | |
require "json" | |
require "uri" | |
require "./http_proxy" | |
# using auth with scope: "https://www.googleapis.com/auth/calendar" | |
module RFC3339Converter | |
def self.from_json(time : JSON::PullParser) : Time | |
Time.parse_rfc3339(time.read_string) | |
end | |
def self.to_json(time : Time, json : JSON::Builder) | |
time.to_json | |
end | |
end | |
enum UpdateGuests | |
All | |
ExternalOnly | |
None | |
end | |
enum Visibility | |
Default | |
Public | |
Private | |
end | |
class GoogleCalendar | |
GOOGLE_URI = URI.parse("https://www.googleapis.com") | |
def initialize(@auth : GoogleAuth) | |
end | |
def sub=(user) | |
@auth.sub = user.not_nil! | |
end | |
def get_token | |
@auth.get_token.access_token | |
end | |
def calendar_list | |
HttpProxy::Client.new(GOOGLE_URI) do |client| | |
client.exec("GET", "/calendar/v3/users/me/calendarList", HTTP::Headers{ | |
"Authorization" => "Bearer #{get_token}", | |
}) | |
end | |
end | |
# example additional options: showDeleted | |
def events(calendar_id = "primary", period_start : Time = Time.local.at_beginning_of_day, period_end : Time? = nil, updated_since : Time? = nil, **opts) | |
other_options = opts.empty? ? nil : "&#{opts.map { |key, value| "#{key}=#{value}" }.join("&")}" | |
updated = updated_since ? "&updatedMin=#{updated_since.to_rfc3339}" : nil | |
pend = period_end ? "&timeMax=#{period_end.to_rfc3339}" : nil | |
response = HttpProxy::Client.new(GOOGLE_URI) do |client| | |
client.exec( | |
"GET", | |
"/calendar/v3/calendars/#{calendar_id}/events?maxResults=2500&singleEvents=true&timeMin=#{period_start.to_rfc3339}#{pend}#{updated}#{other_options}", | |
HTTP::Headers{ | |
"Authorization" => "Bearer #{get_token}", | |
} | |
) | |
end | |
raise "error fetching events from #{calendar_id} - #{response.status} (#{response.status_code})\n#{response.body}" unless response.success? | |
CalendarEvents.from_json response.body | |
end | |
def event(event_id, calendar_id = "primary") | |
response = HttpProxy::Client.new(GOOGLE_URI) do |client| | |
client.exec( | |
"GET", | |
"/calendar/v3/calendars/#{calendar_id}/events/#{event_id}", | |
HTTP::Headers{ | |
"Authorization" => "Bearer #{get_token}", | |
} | |
) | |
end | |
return nil if {HTTP::Status::GONE, HTTP::Status::NOT_FOUND}.includes?(response.status) | |
raise "error fetching events from #{calendar_id} - #{response.status} (#{response.status_code})\n#{response.body}" unless response.success? | |
CalendarEvent.from_json response.body | |
end | |
def delete(event_id, calendar_id = "primary", notify : UpdateGuests = UpdateGuests::None) | |
# convert ExternalOnly to externalOnly | |
update_guests = notify.to_s.camelcase(lower: true) | |
response = HttpProxy::Client.new(GOOGLE_URI) do |client| | |
client.exec("DELETE", | |
"/calendar/v3/calendars/#{calendar_id}/events/#{event_id}?sendUpdates=#{update_guests}", | |
HTTP::Headers{ | |
"Authorization" => "Bearer #{get_token}", | |
} | |
) | |
end | |
# Not an error if the booking doesn't exist | |
return true if {HTTP::Status::GONE, HTTP::Status::NOT_FOUND}.includes?(response.status) | |
raise "error deleting event #{event_id} from #{calendar_id} - #{response.status} (#{response.status_code})\n#{response.body}" unless response.success? | |
true | |
end | |
# Create an event | |
# Supports: summary, description, location | |
def create( | |
event_start : Time, | |
event_end : Time, | |
calendar_id = "primary", | |
attendees : (Array(String) | Tuple(String)) = [] of String, | |
all_day = false, | |
visibility : Visibility = Visibility::Default, | |
extended_properties = nil, | |
**opts | |
) | |
opts = extended_properties(opts, extended_properties) if extended_properties | |
body = opts.merge({ | |
start: CalendarEvent::GTime.new(event_start, all_day), | |
"end": CalendarEvent::GTime.new(event_end, all_day), | |
visibility: visibility.to_s.downcase, | |
attendees: attendees.map { |email| {email: email} }, | |
}).to_json | |
response = HttpProxy::Client.new(GOOGLE_URI) do |client| | |
client.exec( | |
"POST", | |
"/calendar/v3/calendars/#{calendar_id}/events?conferenceDataVersion=1", | |
HTTP::Headers{ | |
"Authorization" => "Bearer #{get_token}", | |
"Content-Type" => "application/json", | |
}, | |
body | |
) | |
end | |
raise "error creating booking on #{calendar_id} - #{response.status} (#{response.status_code})\nRequested: #{body}\n#{response.body}" unless response.success? | |
CalendarEvent.from_json response.body | |
end | |
def update( | |
event_id, | |
calendar_id = "primary", | |
event_start : Time? = nil, | |
event_end : Time? = nil, | |
attendees : (Array(String) | Tuple(String) | Nil) = nil, | |
all_day = false, | |
visibility : Visibility? = nil, | |
extended_properties = nil, | |
notify : UpdateGuests = UpdateGuests::None, | |
raw_json : String? = nil, | |
**opts | |
) | |
opts = opts.merge({start: CalendarEvent::GTime.new(event_start, all_day)}) if event_start | |
opts = opts.merge({"end": CalendarEvent::GTime.new(event_end, all_day)}) if event_end | |
opts = opts.merge({visibility: visibility.to_s.downcase}) if visibility | |
opts = opts.merge({attendees: attendees.map { |email| {email: email} }}) if attendees | |
opts = extended_properties(opts, extended_properties) if extended_properties | |
body = raw_json || opts.to_json | |
response = HttpProxy::Client.new(GOOGLE_URI) do |client| | |
client.exec( | |
"PATCH", | |
"/calendar/v3/calendars/#{calendar_id}/events/#{event_id}?sendUpdates=#{notify}", | |
HTTP::Headers{ | |
"Authorization" => "Bearer #{get_token}", | |
"Content-Type" => "application/json", | |
}, | |
body | |
) | |
end | |
raise "error creating booking on #{calendar_id} - #{response.status} (#{response.status_code})\nRequested: #{body}\n#{response.body}" unless response.success? | |
CalendarEvent.from_json response.body | |
end | |
protected def extended_properties(opts, extended_properties) | |
extended_keys = {} of String => String? | |
extended_properties.each do |key, value| | |
extended_keys[key.to_s] = case value | |
when Nil, String | |
value | |
else | |
value.to_json | |
end | |
end | |
opts = opts.merge({ | |
extendedProperties: { | |
shared: extended_keys, | |
}, | |
}) | |
opts | |
end | |
class CalendarEvent | |
class Attendee | |
JSON.mapping( | |
id: String?, | |
email: String, | |
displayName: String?, | |
organizer: Bool?, | |
self: Bool?, | |
resource: Bool?, | |
optional: Bool?, | |
responseStatus: String?, | |
comment: String?, | |
additionalGuests: Int32? | |
) | |
end | |
class GTime | |
def initialize(datetime : Time, all_day = false) | |
tz = datetime.location.name | |
# ignore special cases | |
tz = nil if {"Local", ""}.includes?(tz) | |
if all_day | |
@date = datetime | |
else | |
@dateTime = datetime | |
end | |
@timeZone = tz | |
end | |
def time : Time | |
if dtime = @dateTime | |
dtime | |
elsif dday = @date | |
dday | |
else | |
raise "no time provided?" | |
end | |
end | |
JSON.mapping( | |
# %F: ISO 8601 date (2016-04-05) | |
date: {type: ::Time, converter: ::Time::Format.new("%F"), nilable: true}, | |
dateTime: {type: ::Time, nilable: true}, | |
timeZone: String? | |
) | |
end | |
JSON.mapping( | |
kind: String, | |
etag: String, | |
id: String, | |
status: String?, | |
htmlLink: String, | |
created: {type: ::Time, converter: RFC3339Converter}, | |
updated: {type: ::Time, converter: RFC3339Converter}, | |
summary: String?, | |
description: String?, | |
location: String?, | |
colorId: String?, | |
creator: Attendee, | |
organizer: Attendee, | |
start: GTime, | |
end: GTime?, | |
endTimeUnspecified: Bool?, | |
recurrence: Array(String)?, | |
recurringEventId: String?, | |
originalStartTime: GTime?, | |
transparency: String?, | |
visibility: String?, | |
iCalUID: String, | |
sequence: Int64?, | |
attendees: Array(Attendee)?, | |
attendeesOmitted: Bool?, | |
extendedProperties: Hash(String, Hash(String, String))?, | |
hangoutLink: String?, | |
# conferenceData (we are ignoring this currently) | |
anyoneCanAddSelf: Bool?, | |
guestsCanInviteOthers: Bool?, | |
guestsCanModify: Bool?, | |
guestsCanSeeOtherGuests: Bool?, | |
privateCopy: Bool?, | |
locked: Bool?, | |
# reminders (we are ignoring these currently) | |
# source | |
# attachments | |
) | |
end | |
class CalendarEvents | |
JSON.mapping( | |
kind: String, | |
# etag: String, | |
summary: String, | |
description: String?, | |
updated: {type: ::Time, converter: RFC3339Converter}, | |
timeZone: String, | |
accessRole: String, | |
# defaultReminders | |
nextPageToken: String?, | |
nextSyncToken: String, | |
items: Array(CalendarEvent) | |
) | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "./google_auth" | |
require "json" | |
require "uri" | |
require "./http_proxy" | |
# using auth with scope: "https://www.googleapis.com/auth/admin.directory.user.readonly" | |
class GoogleDirectory | |
GOOGLE_URI = URI.parse("https://www.googleapis.com") | |
def initialize(@auth : GoogleAuth, @domain : String, @projection : String = "full", @view_type : String = "admin_view") | |
end | |
def sub=(user) | |
@auth.sub = user.not_nil! | |
end | |
def get_token | |
@auth.get_token.access_token | |
end | |
# API details: https://developers.google.com/admin-sdk/directory/v1/reference/users/list | |
def users(query = nil, limit = 500, **opts) | |
opts = opts.merge({ | |
domain: @domain, | |
maxResults: limit, | |
projection: @projection, | |
viewType: @view_type, | |
}) | |
opts = opts.merge({query: query}) if query | |
options = opts.map { |key, value| "#{key}=#{value}" }.join("&") | |
response = HttpProxy::Client.new(GOOGLE_URI) do |client| | |
client.exec( | |
"GET", | |
"/admin/directory/v1/users?#{options}", | |
HTTP::Headers{ | |
"Authorization" => "Bearer #{get_token}", | |
} | |
) | |
end | |
raise "error fetching users from #{@domain} - #{response.status} (#{response.status_code})\n#{response.body}" unless response.success? | |
UserQuery.from_json response.body | |
end | |
# https://developers.google.com/admin-sdk/directory/v1/reference/users/get | |
def lookup(user_id) | |
response = HttpProxy::Client.new(GOOGLE_URI) do |client| | |
client.exec( | |
"GET", | |
"/admin/directory/v1/users/#{user_id}?projection=#{@projection}&viewType=#{@view_type}", | |
HTTP::Headers{ | |
"Authorization" => "Bearer #{get_token}", | |
} | |
) | |
end | |
raise "error requesting user #{user_id} - #{response.status} (#{response.status_code})\n#{response.body}" unless response.success? | |
User.from_json response.body | |
end | |
class UserQuery | |
include JSON::Serializable | |
property kind : String | |
property users : Array(User) | |
property nextPageToken : String? | |
end | |
class User | |
include JSON::Serializable | |
class Name | |
include JSON::Serializable | |
property givenName : String | |
property familyName : String | |
property fullName : String? | |
end | |
class Email | |
include JSON::Serializable | |
property address : String | |
property type : String? | |
property customType : String? | |
property primary : Bool? | |
end | |
class Relation | |
include JSON::Serializable | |
property value : String | |
property type : String | |
property customType : String? | |
end | |
class Address | |
include JSON::Serializable | |
property type : String | |
property customType : String? | |
property sourceIsStructured : Bool? | |
property formatted : String? | |
property poBox : String? | |
property extendedAddress : String? | |
property streetAddress : String? | |
property locality : String | |
property region : String? | |
property postalCode : String? | |
property country : String? | |
property primary : Bool? | |
property countryCode : String? | |
end | |
class Organization | |
include JSON::Serializable | |
property name : String | |
property title : String | |
property primary : Bool? | |
property type : String? | |
property customType : String? | |
property department : String? | |
property symbol : String? | |
property location : String? | |
property description : String? | |
property domain : String? | |
property costCenter : String? | |
property fullTimeEquivalent : Int32? | |
end | |
class Phone | |
include JSON::Serializable | |
property value : String | |
property primary : Bool? | |
property type : String | |
property customType : String? | |
end | |
class Language | |
include JSON::Serializable | |
property languageCode : String | |
property customLanguage : String? | |
end | |
class Gender | |
include JSON::Serializable | |
property type : String | |
property customGender : String? | |
property addressMeAs : String? | |
end | |
class Location | |
include JSON::Serializable | |
property type : String | |
property customType : String? | |
property area : String? | |
property buildingId : String? | |
property floorName : String? | |
property floorSection : String? | |
property deskCode : String? | |
end | |
# Optional for creating a user | |
property kind : String? | |
property id : String? | |
property etag : String? | |
property primaryEmail : String | |
property isAdmin : Bool | |
property isDelegatedAdmin : Bool | |
property lastLoginTime : Time? | |
property creationTime : Time | |
property deletionTime : Time? | |
property agreedToTerms : Bool | |
property password : String? | |
property hashFunction : String? | |
property suspended : Bool | |
property suspensionReason : String? | |
property archived : Bool? | |
property changePasswordAtNextLogin : Bool | |
property ipWhitelisted : Bool | |
property emails : Array(Email) | |
property relations : Array(Relation)? | |
property externalIds : Array(Relation)? | |
property addresses : Array(Address)? | |
property organizations : Array(Organization)? | |
property phones : Array(Phone)? | |
property languages : Array(Language)? | |
property aliases : Array(String)? | |
property nonEditableAliases : Array(String)? | |
property notes : NamedTuple(value: String, contentType: String)? | |
property websites : Array(Phone)? | |
property locations : Array(Location)? | |
property keywords : Array(Relation)? | |
property gender : Gender? | |
property customerId : String? | |
property orgUnitPath : String? | |
property isMailboxSetup : Bool | |
property isEnrolledIn2Sv : Bool? | |
property isEnforcedIn2Sv : Bool? | |
property includeInGlobalAddressList : Bool | |
property thumbnailPhotoUrl : String? | |
property thumbnailPhotoEtag : String? | |
property customSchemas : Hash(String, Hash(String, String))? | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "http" | |
require "socket" | |
require "base64" | |
require "openssl" | |
# Based on https://github.com/net-ssh/net-ssh/blob/master/lib/net/ssh/proxy/http.rb | |
class HttpProxy | |
PROXY_PASS = ENV["PROXY_PASSWORD"]? | |
PROXY_USER = ENV["PROXY_USERNAME"]? | |
# The hostname or IP address of the HTTP proxy. | |
getter proxy_host : String | |
# The port number of the proxy. | |
getter proxy_port : Int32 | |
# The map of additional options that were given to the object at | |
# initialization. | |
getter tls : OpenSSL::SSL::Context::Client? | |
# Simple check for relevant environment | |
# | |
def self.behind_proxy? | |
!!(ENV["https_proxy"]? || ENV["http_proxy"]?) | |
end | |
# Grab the host, port | |
# | |
def self.parse_proxy_url | |
proxy_url = ENV["https_proxy"]? || ENV["http_proxy"] | |
uri = URI.parse(proxy_url) | |
({uri.host.as(String), uri.port.as(Int32)}) | |
rescue | |
raise "Missing/malformed $http_proxy or $https_proxy in environment" | |
end | |
# Create a new socket factory that tunnels via the given host and | |
# port. The +options+ parameter is a hash of additional settings that | |
# can be used to tweak this proxy connection. Specifically, the following | |
# options are supported: | |
# | |
# * :user => the user name to use when authenticating to the proxy | |
# * :password => the password to use when authenticating | |
def initialize(host, port, @auth : NamedTuple(username: String, password: String)? = nil) | |
@auth = {username: PROXY_USER.as(String), password: PROXY_PASS.as(String)} if !@auth && PROXY_USER && PROXY_PASS | |
@proxy_host = host.gsub(/^http[s]?\:\/\//, "") | |
@proxy_port = port | |
end | |
# Return a new socket connected to the given host and port via the | |
# proxy that was requested when the socket factory was instantiated. | |
def open(host, port, tls = nil, **connection_options) | |
dns_timeout = connection_options.fetch(:dns_timeout, nil) | |
connect_timeout = connection_options.fetch(:connect_timeout, nil) | |
read_timeout = connection_options.fetch(:read_timeout, nil) | |
socket = TCPSocket.new @proxy_host, @proxy_port, dns_timeout, connect_timeout | |
socket.read_timeout = read_timeout if read_timeout | |
socket.sync = true | |
host = host.gsub(/^http[s]?\:\/\//, "") | |
socket << "CONNECT #{host}:#{port} HTTP/1.0\r\n" | |
socket << "Host: #{host}:#{port}\r\n" | |
if auth = @auth | |
credentials = Base64.strict_encode("#{auth[:username]}:#{auth[:password]}") | |
credentials = "#{credentials}\n".gsub(/\s/, "") | |
socket << "Proxy-Authorization: Basic #{credentials}\r\n" | |
end | |
socket << "\r\n" | |
resp = parse_response(socket) | |
if resp[:code]? == 200 | |
if tls | |
tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host) | |
socket = tls_socket | |
end | |
socket | |
else | |
socket.close | |
raise IO::Error.new(resp.inspect) | |
end | |
end | |
private def parse_response(socket) | |
resp = {} of Symbol => Int32 | String | Hash(String, String) | |
begin | |
version, code, reason = socket.gets.as(String).chomp.split(/ /, 3) | |
headers = {} of String => String | |
while (line = socket.gets.as(String)) && (line.chomp != "") | |
name, value = line.split(/:/, 2) | |
headers[name.strip] = value.strip | |
end | |
resp[:version] = version | |
resp[:code] = code.to_i | |
resp[:reason] = reason | |
resp[:headers] = headers | |
rescue | |
end | |
resp | |
end | |
class Client < ::HTTP::Client | |
def self.new(uri : URI, tls = nil) | |
inst = super(uri, tls) | |
inst.set_proxy if HttpProxy.behind_proxy? | |
inst | |
end | |
def self.new(uri : URI, tls = nil) | |
inst = super(uri, tls) | |
inst.set_proxy if HttpProxy.behind_proxy? | |
yield inst | |
end | |
def set_proxy(proxy : HttpProxy? = nil) | |
if !proxy | |
host, port = HttpProxy.parse_proxy_url | |
proxy = HttpProxy.new(host, port) | |
end | |
socket = @socket | |
return if socket && !socket.closed? | |
begin | |
@socket = proxy.open(@host, @port, @tls, **proxy_connection_options) | |
rescue IO::Error | |
@socket = nil | |
end | |
end | |
def proxy_connection_options | |
{ | |
dns_timeout: @dns_timeout, | |
connect_timeout: @connect_timeout, | |
read_timeout: @read_timeout, | |
} | |
end | |
def check_socket_valid | |
socket = @socket | |
@socket = nil if socket && socket.closed? | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment