webuntis-api 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +241 -0
- data/examples/class_teachers.rb +66 -0
- data/lib/webuntis/client.rb +540 -0
- data/lib/webuntis/cookie_jar.rb +55 -0
- data/lib/webuntis/element_type.rb +49 -0
- data/lib/webuntis/errors.rb +64 -0
- data/lib/webuntis/http/fake.rb +45 -0
- data/lib/webuntis/http/net_http.rb +93 -0
- data/lib/webuntis/http.rb +81 -0
- data/lib/webuntis/rest.rb +319 -0
- data/lib/webuntis/rpc.rb +182 -0
- data/lib/webuntis/school.rb +146 -0
- data/lib/webuntis/session.rb +33 -0
- data/lib/webuntis/totp.rb +29 -0
- data/lib/webuntis/util.rb +85 -0
- data/lib/webuntis/version.rb +5 -0
- data/lib/webuntis.rb +29 -0
- metadata +63 -0
data/lib/webuntis/rpc.rb
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebUntis
|
|
4
|
+
# The officially documented JSON-RPC API at `/WebUntis/jsonrpc.do`.
|
|
5
|
+
#
|
|
6
|
+
# Every method returns the raw parsed `result` payload (Hashes and Arrays with
|
|
7
|
+
# String keys) and raises {RpcError} instead of returning an error envelope.
|
|
8
|
+
class RPC
|
|
9
|
+
TIMETABLE_OPTIONS = {
|
|
10
|
+
only_base_timetable: "onlyBaseTimetable",
|
|
11
|
+
show_booking: "showBooking",
|
|
12
|
+
show_info: "showInfo",
|
|
13
|
+
show_subst_text: "showSubstText",
|
|
14
|
+
show_ls_text: "showLsText",
|
|
15
|
+
show_ls_number: "showLsNumber",
|
|
16
|
+
show_studentgroup: "showStudentgroup",
|
|
17
|
+
klasse_fields: "klasseFields",
|
|
18
|
+
room_fields: "roomFields",
|
|
19
|
+
subject_fields: "subjectFields",
|
|
20
|
+
teacher_fields: "teacherFields"
|
|
21
|
+
}.freeze
|
|
22
|
+
TIMETABLE_OPTION_KEYS = (TIMETABLE_OPTIONS.keys + TIMETABLE_OPTIONS.values.map(&:to_sym)).freeze
|
|
23
|
+
|
|
24
|
+
def initialize(client)
|
|
25
|
+
@client = client
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Calls any JSON-RPC method and returns its `result`; nil params are dropped.
|
|
29
|
+
def call(method, params = {})
|
|
30
|
+
@client.rpc_call(method, params)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# getTeachers
|
|
34
|
+
def teachers(**extra)
|
|
35
|
+
call("getTeachers", stringify(extra))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# getStudents
|
|
39
|
+
def students(**extra)
|
|
40
|
+
call("getStudents", stringify(extra))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# getKlassen
|
|
44
|
+
def classes(school_year_id: nil, **extra)
|
|
45
|
+
call("getKlassen", { "schoolyearId" => school_year_id }.merge(stringify(extra)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# getSubjects
|
|
49
|
+
def subjects(**extra)
|
|
50
|
+
call("getSubjects", stringify(extra))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# getRooms
|
|
54
|
+
def rooms(**extra)
|
|
55
|
+
call("getRooms", stringify(extra))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# getDepartments
|
|
59
|
+
def departments(**extra)
|
|
60
|
+
call("getDepartments", stringify(extra))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# getHolidays
|
|
64
|
+
def holidays(**extra)
|
|
65
|
+
call("getHolidays", stringify(extra))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# getTimegridUnits
|
|
69
|
+
def timegrid(**extra)
|
|
70
|
+
call("getTimegridUnits", stringify(extra))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# getStatusData
|
|
74
|
+
def status_data(**extra)
|
|
75
|
+
call("getStatusData", stringify(extra))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# getCurrentSchoolyear
|
|
79
|
+
def current_school_year(**extra)
|
|
80
|
+
call("getCurrentSchoolyear", stringify(extra))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# getSchoolyears
|
|
84
|
+
def school_years(**extra)
|
|
85
|
+
call("getSchoolyears", stringify(extra))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# getLatestImportTime, converted from milliseconds to a Time.
|
|
89
|
+
def latest_import_time(**extra)
|
|
90
|
+
value = call("getLatestImportTime", stringify(extra))
|
|
91
|
+
value.is_a?(Numeric) ? Time.at(value / 1000.0) : value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# getPersonId
|
|
95
|
+
def person_id(type: nil, surname: nil, forename: nil, birthday: 0, **extra)
|
|
96
|
+
call("getPersonId", {
|
|
97
|
+
"type" => ElementType.resolve(type),
|
|
98
|
+
"sn" => surname,
|
|
99
|
+
"fn" => forename,
|
|
100
|
+
"dob" => birthday
|
|
101
|
+
}.merge(stringify(extra)))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# getSubstitutions
|
|
105
|
+
def substitutions(start_date: nil, end_date: nil, department_id: 0, **extra)
|
|
106
|
+
call("getSubstitutions", {
|
|
107
|
+
"startDate" => Util.to_untis_date(start_date),
|
|
108
|
+
"endDate" => Util.to_untis_date(end_date),
|
|
109
|
+
"departmentId" => department_id
|
|
110
|
+
}.merge(stringify(extra)))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# getExams; `examTypeId` is mandatory and `0` returns every exam type.
|
|
114
|
+
def exams(start_date: nil, end_date: nil, exam_type_id: 0, **extra)
|
|
115
|
+
call("getExams", {
|
|
116
|
+
"examTypeId" => exam_type_id,
|
|
117
|
+
"startDate" => Util.to_untis_date(start_date),
|
|
118
|
+
"endDate" => Util.to_untis_date(end_date)
|
|
119
|
+
}.merge(stringify(extra)))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# getExamTypes
|
|
123
|
+
def exam_types(**extra)
|
|
124
|
+
call("getExamTypes", stringify(extra))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# getClassregEvents
|
|
128
|
+
def classreg_events(start_date: nil, end_date: nil, element: nil, **extra)
|
|
129
|
+
call("getClassregEvents", {
|
|
130
|
+
"startDate" => Util.to_untis_date(start_date),
|
|
131
|
+
"endDate" => Util.to_untis_date(end_date),
|
|
132
|
+
"element" => element
|
|
133
|
+
}.merge(stringify(extra)))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# getClassregCategories
|
|
137
|
+
def classreg_categories(**extra)
|
|
138
|
+
call("getClassregCategories", stringify(extra))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# getClassregCategoryGroups
|
|
142
|
+
def classreg_category_groups(**extra)
|
|
143
|
+
call("getClassregCategoryGroups", stringify(extra))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# getTimetable, always in its `options` form; only the options given are sent and unknown ones raise.
|
|
147
|
+
def timetable(element_id:, element_type:, start_date:, end_date:, key_type: "id", **options)
|
|
148
|
+
body = {
|
|
149
|
+
"element" => { "id" => element_id, "type" => ElementType.resolve(element_type), "keyType" => key_type },
|
|
150
|
+
"startDate" => Util.to_untis_date(start_date),
|
|
151
|
+
"endDate" => Util.to_untis_date(end_date)
|
|
152
|
+
}
|
|
153
|
+
call("getTimetable", { "options" => body.merge(timetable_options(options)) })
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# getTimetableWithAbsences
|
|
157
|
+
def timetable_with_absences(start_date: nil, end_date: nil, **extra)
|
|
158
|
+
call("getTimetableWithAbsences", {
|
|
159
|
+
"options" => {
|
|
160
|
+
"startDate" => Util.to_untis_date(start_date),
|
|
161
|
+
"endDate" => Util.to_untis_date(end_date)
|
|
162
|
+
}.merge(stringify(extra))
|
|
163
|
+
})
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def timetable_options(options)
|
|
169
|
+
unknown = options.keys - TIMETABLE_OPTION_KEYS
|
|
170
|
+
raise ArgumentError, "unknown getTimetable options: #{unknown.join(", ")}" unless unknown.empty?
|
|
171
|
+
|
|
172
|
+
TIMETABLE_OPTIONS.each_with_object({}) do |(snake, name), memo|
|
|
173
|
+
value = options.key?(snake) ? options[snake] : options[name.to_sym]
|
|
174
|
+
memo[name] = value unless value.nil?
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def stringify(params)
|
|
179
|
+
params.transform_keys(&:to_s)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module WebUntis
|
|
8
|
+
# One WebUntis tenant as the public school search returns it.
|
|
9
|
+
class School
|
|
10
|
+
SEARCH_URL = "https://schoolsearch.webuntis.com/schoolquery2"
|
|
11
|
+
|
|
12
|
+
attr_reader :server, :login_name, :display_name, :server_url, :school_id, :tenant_id, :address
|
|
13
|
+
|
|
14
|
+
def initialize(server:, login_name: nil, display_name: nil, server_url: nil,
|
|
15
|
+
school_id: nil, tenant_id: nil, address: nil)
|
|
16
|
+
@server = server
|
|
17
|
+
@login_name = login_name
|
|
18
|
+
@display_name = display_name
|
|
19
|
+
@server_url = server_url
|
|
20
|
+
@school_id = school_id
|
|
21
|
+
@tenant_id = tenant_id
|
|
22
|
+
@address = address
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Searches every WebUntis tenant via the `searchSchool` JSON-RPC method on schoolquery2.
|
|
27
|
+
def search(query, http: nil)
|
|
28
|
+
adapter = http || HTTP::NetHttp.new
|
|
29
|
+
response = adapter.call(
|
|
30
|
+
:post, SEARCH_URL,
|
|
31
|
+
headers: { "Content-Type" => "application/json", "Accept" => "application/json" },
|
|
32
|
+
body: JSON.generate(
|
|
33
|
+
"id" => SecureRandom.hex(8),
|
|
34
|
+
"method" => "searchSchool",
|
|
35
|
+
"params" => [{ "search" => query.to_s }],
|
|
36
|
+
"jsonrpc" => "2.0"
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
parse_search(response, query)
|
|
40
|
+
ensure
|
|
41
|
+
adapter.close if http.nil? && adapter.respond_to?(:close)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Resolves a query to exactly one school, preferring an exact case-insensitive name match.
|
|
45
|
+
def find(query, http: nil)
|
|
46
|
+
candidates = search(query, http: http)
|
|
47
|
+
raise DiscoveryError.new("no WebUntis school matched #{query.inspect}", query: query) if candidates.empty?
|
|
48
|
+
|
|
49
|
+
exact = exact_matches(candidates, query)
|
|
50
|
+
return exact.first if exact.length == 1
|
|
51
|
+
raise ambiguous(query, exact) if exact.length > 1
|
|
52
|
+
return candidates.first if candidates.length == 1
|
|
53
|
+
|
|
54
|
+
raise ambiguous(query, candidates)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def parse_search(response, query)
|
|
60
|
+
unless response.success?
|
|
61
|
+
raise HttpError.new("school search failed with HTTP #{response.status}", status: response.status,
|
|
62
|
+
body: response.body, method: :post,
|
|
63
|
+
path: SEARCH_URL)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
payload = response.json
|
|
67
|
+
payload = {} unless payload.is_a?(Hash)
|
|
68
|
+
if payload["error"]
|
|
69
|
+
raise DiscoveryError.new("school search failed: #{payload.dig("error", "message")}", query: query)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
Array(payload.dig("result", "schools")).map { |entry| from_search_result(entry) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def from_search_result(entry)
|
|
76
|
+
new(
|
|
77
|
+
server: entry["server"],
|
|
78
|
+
login_name: entry["loginName"],
|
|
79
|
+
display_name: entry["displayName"],
|
|
80
|
+
server_url: entry["serverUrl"],
|
|
81
|
+
school_id: entry["schoolId"],
|
|
82
|
+
tenant_id: entry["tenantId"],
|
|
83
|
+
address: entry["address"]
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def exact_matches(candidates, query)
|
|
88
|
+
needle = query.to_s.strip.downcase
|
|
89
|
+
candidates.select do |school|
|
|
90
|
+
[school.display_name, school.login_name, school.server, school.server_url]
|
|
91
|
+
.compact.any? { |value| value.downcase == needle }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ambiguous(query, candidates)
|
|
96
|
+
DiscoveryError.new(
|
|
97
|
+
"#{candidates.length} WebUntis schools matched #{query.inspect}; pass `server:` and the login name",
|
|
98
|
+
query: query,
|
|
99
|
+
matches: candidates.map { |school| "#{school.display_name} <#{school.server}>" }
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The `/WebUntis` prefix every request is built from, normalised from whatever `server` holds.
|
|
105
|
+
def base_url
|
|
106
|
+
raw = @server.to_s.strip
|
|
107
|
+
raw = "https://#{raw.sub(%r{\A/+}, "")}" unless raw.match?(%r{\Ahttps?://}i)
|
|
108
|
+
uri = URI.parse(raw)
|
|
109
|
+
raise Error, "server #{@server.inspect} has no host" if uri.host.to_s.empty?
|
|
110
|
+
|
|
111
|
+
"#{origin(uri)}#{prefix(uri)}/WebUntis"
|
|
112
|
+
rescue URI::InvalidURIError => e
|
|
113
|
+
raise Error, "server #{@server.inspect} is not a valid URL: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The school identifier sent as the `school` query parameter and the `schoolname` cookie.
|
|
117
|
+
def name
|
|
118
|
+
@login_name || @display_name || @server
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Every field of this school as a Hash.
|
|
122
|
+
def to_h
|
|
123
|
+
{
|
|
124
|
+
server: @server, login_name: @login_name, display_name: @display_name, server_url: @server_url,
|
|
125
|
+
school_id: @school_id, tenant_id: @tenant_id, address: @address
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def inspect
|
|
130
|
+
"#<WebUntis::School #{@display_name.inspect} login_name=#{@login_name.inspect} server=#{@server.inspect}>"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def origin(uri)
|
|
136
|
+
default_port = uri.scheme == "https" ? 443 : 80
|
|
137
|
+
port = uri.port == default_port ? nil : ":#{uri.port}"
|
|
138
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The path before `/WebUntis`, if the server was given with one (`https://host/prefix/WebUntis/...`).
|
|
142
|
+
def prefix(uri)
|
|
143
|
+
uri.path.to_s.sub(%r{/WebUntis(/.*)?\z}, "").sub(%r{/+\z}, "")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebUntis
|
|
4
|
+
# Everything one logged-in WebUntis session carries.
|
|
5
|
+
class Session
|
|
6
|
+
attr_accessor :session_id, :person_id, :person_type, :klasse_id, :tenant_id,
|
|
7
|
+
:token, :token_expires_at, :school_year_id, :stale_at
|
|
8
|
+
|
|
9
|
+
# True once a login has produced a session cookie.
|
|
10
|
+
def open?
|
|
11
|
+
!@session_id.nil?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# True when there is no JWT yet or the cached one is due for a refresh.
|
|
15
|
+
def token_stale?(now)
|
|
16
|
+
return true if @token.nil? || @stale_at.nil?
|
|
17
|
+
|
|
18
|
+
now >= @stale_at
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Forgets the JWT while keeping the session cookie.
|
|
22
|
+
def forget_token
|
|
23
|
+
@token = nil
|
|
24
|
+
@token_expires_at = nil
|
|
25
|
+
@stale_at = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def inspect
|
|
29
|
+
"#<WebUntis::Session person_id=#{@person_id.inspect} person_type=#{@person_type.inspect} " \
|
|
30
|
+
"tenant_id=#{@tenant_id.inspect} open=#{open?}>"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module WebUntis
|
|
6
|
+
# RFC 6238 time-based one-time passwords, as the WebUntis app secret login uses them.
|
|
7
|
+
module TOTP
|
|
8
|
+
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The current 6-digit SHA1 TOTP for a base32 app secret.
|
|
13
|
+
def code(secret, at: Time.now, step: 30, digits: 6)
|
|
14
|
+
counter = at.to_i / step
|
|
15
|
+
digest = OpenSSL::HMAC.digest("SHA1", decode_base32(secret), [counter].pack("Q>"))
|
|
16
|
+
offset = digest[-1].ord & 0x0f
|
|
17
|
+
binary = digest[offset, 4].unpack1("N") & 0x7fffffff
|
|
18
|
+
format("%0#{digits}d", binary % (10**digits))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Decodes an RFC 4648 base32 String into raw bytes.
|
|
22
|
+
def decode_base32(secret)
|
|
23
|
+
bits = secret.to_s.upcase.delete("^#{ALPHABET}").each_char.map do |char|
|
|
24
|
+
ALPHABET.index(char).to_s(2).rjust(5, "0")
|
|
25
|
+
end.join
|
|
26
|
+
[bits[0, bits.length - (bits.length % 8)].to_s].pack("B*")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module WebUntis
|
|
7
|
+
# Conversions between Ruby dates/times and the integer formats WebUntis uses.
|
|
8
|
+
module Util
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Converts a Date/Time/DateTime, an Integer or a "YYYYMMDD" / "YYYY-MM-DD" String to an Integer `YYYYMMDD`.
|
|
12
|
+
def to_untis_date(value)
|
|
13
|
+
return nil if value.nil?
|
|
14
|
+
return value if value.is_a?(Integer)
|
|
15
|
+
return value.strftime("%Y%m%d").to_i if value.respond_to?(:strftime)
|
|
16
|
+
|
|
17
|
+
digits = value.to_s.delete("^0-9")
|
|
18
|
+
raise Error, "cannot read #{value.inspect} as a date" unless digits.length == 8 && untis_digits?(digits)
|
|
19
|
+
|
|
20
|
+
digits.to_i
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Converts a Date/Time/DateTime, an Integer `YYYYMMDD` or a String to an ISO `YYYY-MM-DD` String.
|
|
24
|
+
def to_iso_date(value)
|
|
25
|
+
return nil if value.nil?
|
|
26
|
+
return value.strftime("%Y-%m-%d") if value.respond_to?(:strftime)
|
|
27
|
+
return parse_date(value).strftime("%Y-%m-%d") if value.is_a?(Integer) || value.to_s.match?(/\A\d{8}\z/)
|
|
28
|
+
|
|
29
|
+
value.to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Converts a Time/DateTime or String to an ISO `YYYY-MM-DDTHH:MM` String.
|
|
33
|
+
def to_iso_datetime(value)
|
|
34
|
+
return nil if value.nil?
|
|
35
|
+
return value.strftime("%Y-%m-%dT%H:%M") if value.respond_to?(:hour)
|
|
36
|
+
return value.strftime("%Y-%m-%dT00:00") if value.respond_to?(:strftime)
|
|
37
|
+
|
|
38
|
+
value.to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Recursively drops nil values from a params Hash (JSON-RPC rejects explicit nulls).
|
|
42
|
+
def compact_params(params)
|
|
43
|
+
case params
|
|
44
|
+
when Hash
|
|
45
|
+
params.each_with_object({}) do |(key, value), memo|
|
|
46
|
+
next if value.nil?
|
|
47
|
+
|
|
48
|
+
memo[key.is_a?(Symbol) ? key.to_s : key] = compact_params(value)
|
|
49
|
+
end
|
|
50
|
+
when Array then params.map { |entry| compact_params(entry) }
|
|
51
|
+
else params
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Parses an Untis `YYYYMMDD` date (Integer or String) into a Date.
|
|
56
|
+
def parse_date(value)
|
|
57
|
+
Date.strptime(value.to_s.rjust(8, "0"), "%Y%m%d")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Parses an Untis `Hmm` time (Integer or String) into a `[hour, minute]` pair.
|
|
61
|
+
def parse_time(value)
|
|
62
|
+
digits = value.to_s.rjust(4, "0")
|
|
63
|
+
[digits[0, 2].to_i, digits[2, 2].to_i]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Base64-encodes a String without newlines.
|
|
67
|
+
def base64(value)
|
|
68
|
+
[value.to_s].pack("m0")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Decodes a base64url segment such as a JWT payload.
|
|
72
|
+
def base64url_decode(value)
|
|
73
|
+
padded = value.tr("-_", "+/")
|
|
74
|
+
padded += "=" * ((4 - (padded.length % 4)) % 4)
|
|
75
|
+
padded.unpack1("m0")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def untis_digits?(digits)
|
|
79
|
+
Date.strptime(digits, "%Y%m%d")
|
|
80
|
+
true
|
|
81
|
+
rescue Date::Error
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/webuntis.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
require_relative "webuntis/version"
|
|
10
|
+
require_relative "webuntis/errors"
|
|
11
|
+
require_relative "webuntis/util"
|
|
12
|
+
require_relative "webuntis/element_type"
|
|
13
|
+
require_relative "webuntis/http"
|
|
14
|
+
require_relative "webuntis/cookie_jar"
|
|
15
|
+
require_relative "webuntis/school"
|
|
16
|
+
require_relative "webuntis/totp"
|
|
17
|
+
require_relative "webuntis/session"
|
|
18
|
+
require_relative "webuntis/rpc"
|
|
19
|
+
require_relative "webuntis/rest"
|
|
20
|
+
require_relative "webuntis/client"
|
|
21
|
+
|
|
22
|
+
# A Ruby client for the WebUntis JSON-RPC API and the modern REST API of the
|
|
23
|
+
# WebUntis web app.
|
|
24
|
+
module WebUntis
|
|
25
|
+
# Builds a {Client}; see {Client#initialize} for the options.
|
|
26
|
+
def self.client(**)
|
|
27
|
+
Client.new(**)
|
|
28
|
+
end
|
|
29
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: webuntis-api
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Meow the Cat
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: A dependency-free Ruby client for the WebUntis JSON-RPC API and the modern
|
|
13
|
+
/WebUntis/api/rest/view endpoints used by the WebUntis web app.
|
|
14
|
+
email:
|
|
15
|
+
- meowdakat@gmail.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- LICENSE.txt
|
|
22
|
+
- README.md
|
|
23
|
+
- examples/class_teachers.rb
|
|
24
|
+
- lib/webuntis.rb
|
|
25
|
+
- lib/webuntis/client.rb
|
|
26
|
+
- lib/webuntis/cookie_jar.rb
|
|
27
|
+
- lib/webuntis/element_type.rb
|
|
28
|
+
- lib/webuntis/errors.rb
|
|
29
|
+
- lib/webuntis/http.rb
|
|
30
|
+
- lib/webuntis/http/fake.rb
|
|
31
|
+
- lib/webuntis/http/net_http.rb
|
|
32
|
+
- lib/webuntis/rest.rb
|
|
33
|
+
- lib/webuntis/rpc.rb
|
|
34
|
+
- lib/webuntis/school.rb
|
|
35
|
+
- lib/webuntis/session.rb
|
|
36
|
+
- lib/webuntis/totp.rb
|
|
37
|
+
- lib/webuntis/util.rb
|
|
38
|
+
- lib/webuntis/version.rb
|
|
39
|
+
homepage: https://github.com/Meow/webuntis-api
|
|
40
|
+
licenses:
|
|
41
|
+
- MIT
|
|
42
|
+
metadata:
|
|
43
|
+
source_code_uri: https://github.com/Meow/webuntis-api
|
|
44
|
+
changelog_uri: https://github.com/Meow/webuntis-api/blob/main/CHANGELOG.md
|
|
45
|
+
rubygems_mfa_required: 'true'
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.2'
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 3.7.1
|
|
61
|
+
specification_version: 4
|
|
62
|
+
summary: WebUntis API client
|
|
63
|
+
test_files: []
|