Skip to content

Instantly share code, notes, and snippets.

@stakach
Created August 7, 2019 08:08
Show Gist options
  • Save stakach/5b4b658628773dcbce0a8cc4181bd5e0 to your computer and use it in GitHub Desktop.
Save stakach/5b4b658628773dcbce0a8cc4181bd5e0 to your computer and use it in GitHub Desktop.
Crystal Lang Google Calendar
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
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
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
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