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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebUntis
4
+ VERSION = "0.1.0"
5
+ 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: []